I was cleaning up a request handler and found the usual little wart: a null check wrapped around a single assignment.
if (metrics is not null)
metrics.Last = BuildSnapshot();
Three lines for one store. C# 14 has null-conditional assignment now, so I collapsed it to the thing I'd been wanting to write for years:
metrics?.Last = BuildSnapshot();
Compiled clean. Tests green. Then a counter I keep on BuildSnapshot — it tracks how often we build a snapshot, I watch it in a dashboard — dropped to zero in the environment where metrics were switched off. Not lower. Zero. The function just stopped running, and nothing complained.
What the ? actually guards
Here's the part I'd half-forgotten. ?. short-circuits the whole expression, and in an assignment the right-hand side is part of that expression. So metrics?.Last = BuildSnapshot() doesn't mean "compute the snapshot, then store it if metrics isn't null." It means "if metrics isn't null, compute the snapshot and store it." When metrics is null, BuildSnapshot() is never called.
That's genuinely different from what a lot of people (me, that afternoon) assume it's sugar for. The mental model is usually this:
var snap = BuildSnapshot(); // always runs
if (metrics is not null)
metrics.Last = snap;
But the real desugaring is closer to:
if (metrics is not null)
metrics.Last = BuildSnapshot(); // only runs when metrics != null
If BuildSnapshot were pure you'd never notice. Mine wasn't. It bumped a metric and warmed a cache as a side effect, and both of those quietly went away the moment I leaned on the ?.
I counted it
I didn't want to argue from memory, so I wrote the smallest thing that would show the gap. A metrics sink that might be null, a snapshot builder that increments a static counter every time it runs, and two loops of 1,000 iterations each with the target forced to null.
sealed class Snapshot
{
public static int BuildCount;
public long Value { get; }
private Snapshot(long value) => Value = value;
public static Snapshot Build(int seed)
{
BuildCount++; // the side effect we care about
long acc = seed;
for (int i = 0; i < 64; i++) // a little real work, not a no-op
acc = (acc * 1_000_003 + i) & 0xFFFFFF;
return new Snapshot(acc);
}
}
// what people think ?.= is sugar for
static void TwoStatement(MetricsSink? sink)
{
for (int i = 0; i < 1000; i++)
{
Snapshot snap = Snapshot.Build(i);
if (sink is not null) sink.Last = snap;
}
}
// what C# 14 actually does
static void NullConditional(MetricsSink? sink)
{
for (int i = 0; i < 1000; i++)
sink?.Last = Snapshot.Build(i);
}
I reset BuildCount before each run, called them with a null sink, and printed the count. This is a small Linux container, Release build, .NET 10 — I care about the counts here, not the microseconds.
--- target is null (1000 iterations) ---
var snap; if(x!=null) x.P=snap target=null Build() calls= 1000 1.14 ms
x?.P = Build() (C# 14) target=null Build() calls= 0 0.01 ms
--- target is live (1000 iterations) ---
var snap; if(x!=null) x.P=snap target=live Build() calls= 1000 0.86 ms
x?.P = Build() (C# 14) target=live Build() calls= 1000 0.86 ms
A thousand calls on one line, zero on the next, same null target. When the target is live the two forms agree exactly, which is the trap: every test I'd write against a configured sink passes, and the behavior only forks in the branch where the dependency is missing. The 0.01 ms is just the loop doing nothing a thousand times.
Compound assignment behaves the same way. sink?.Writes += Bump() never calls Bump() when sink is null, so if you were counting on that increment to happen, it won't.
So is it a footgun?
I don't think so, actually. Short-circuiting the right-hand side is the consistent choice — it's exactly what ?. does everywhere else, and the alternative (evaluate the RHS, then maybe throw it away) would be the weird one. The feature isn't wrong. My mental model was.
Where it bites is the refactor direction. You start with x.Prop = Compute() that always runs, you add a ? to make it null-safe, and without meaning to you've made Compute() conditional. If Compute() only produces a value, perfect — you just skipped pointless work for free. If it also logs, increments, enqueues, or caches, you've dropped that too, silently, in the one path hardest to cover with a test.
My rule now is boring: null-conditional assignment is for pure right-hand sides. The second the RHS does something I'd miss, I pull it out onto its own line where its execution isn't tied to whether the target happens to exist.
var snap = BuildSnapshot(); // runs no matter what
metrics?.Last = snap; // stored only if there's somewhere to put it
It's two lines again. That's fine. The ? was never the point — the side effect was.
Have you hit this yet, or found a spot where skipping the RHS was the whole reason you reached for it? I'd like to hear the other direction.
— still benchmarking the things nobody asked me to benchmark
Top comments (0)