📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

Explain the C# operators you use daily, including the null operators (?., ??, ??=), short-circuit evaluation, integer division and operator precedence. Where do they cause bugs?

Arithmetic, comparison, logical, null-handling, pattern and index/range operators in C# 14, with precedence rules and the bugs each one commonly causes.

Junior Theory Beginner csharp language-basics null-coalescing operators pattern-matching precedence

🎯 Key points

  • && and || short-circuit; & and | on bools always evaluate both sides
  • ?. and ?[] return null and skip the rest of the chain when the receiver is null; ?? supplies a fallback; ??= assigns only when null
  • C# 14 allows null-conditional assignment: customer?.Address = value
  • Integer division truncates toward zero and % takes the sign of the dividend
  • ?? has low precedence: a ?? b + c means a ?? (b + c); add parentheses when mixing operators
  • == on reference types is reference equality unless the type overloads it (string and records do)
  • is null / is not null cannot be overridden by an operator overload, unlike == null
  • Index and range operators: ^1 is the last element, a..b is a half-open range

⏱ 30-second answer

Day to day I use arithmetic, comparison, logical and the null operators. && and || short-circuit, which is what makes x != null && x.IsValid safe. ?. returns null instead of throwing, ?? gives a fallback, ??= assigns only when null, and in C# 14 ?. also works on the left of an assignment. The bugs come from integer division (5 / 2 is 2), precedence (?? binds looser than +), using & instead of &&, and == meaning reference equality on classes that do not overload it. I prefer is null because it cannot be hijacked by an overloaded ==.

🗣 Full answer (senior)

How I would answer this in an interview

Most operators are uncontroversial, so I focus on the ones that cause production bugs: the null operators, short-circuiting, integer division and remainder, precedence surprises, and equality.

1. Null-related operators

Operator Meaning Result type
a?.B Member access only if a is not null; otherwise the whole chain is null Lifted to nullable (int becomes int?)
a?[i] Null-conditional indexer Nullable
a ?? b a if not null, otherwise evaluate b Common type of both
a ??= b Assign b to a only if a is null Type of a
a! Null-forgiving: silences the compiler, no runtime effect Same type
x is null / x is not null Pattern test that cannot be hijacked by an overloaded == bool

Key behaviours:

  • ?. short-circuits the rest of the chain: in order?.Customer.Address.City, if order is null nothing to the right is evaluated. But if order is not null and Customer is null, you still get a NullReferenceException — you need ?. at each nullable step.
  • ?? evaluates its right-hand side only when needed, so value ?? throw new ArgumentNullException(nameof(value)) and cache ?? LoadExpensive() are cheap.
  • ??= is ideal for lazy initialisation: _items ??= []; — but it is not thread-safe.
  • C# 14 adds null-conditional assignment: customer?.Name = "Ada"; assigns only when customer is not null.

2. Short-circuit vs non-short-circuit logic

  • && and || short-circuit: user != null && user.IsActive never dereferences a null user.
  • & and | on bool always evaluate both sides. Using & by accident causes NREs or doubles side effects (Validate() & Save() saves even when validation fails).
  • ^ is logical XOR on bool.
  • On nullable bools, & and | use three-valued logic (null & false == false), while &&/|| are not allowed.

3. Arithmetic surprises

  • Integer division truncates toward zero: 7 / 2 == 3, -7 / 2 == -3. double ratio = done / total; is 0 for ints.
  • % is remainder, not modulo: -7 % 3 == -1. For a true modulo (e.g. circular buffer indexes) use ((a % n) + n) % n.
  • ++x vs x++: same effect on x, different expression value. Avoid using them inside larger expressions.
  • Compound assignment includes an implicit cast: byte b = 10; b += 300; compiles (and wraps), while b = b + 300; does not.
  • Shift counts are masked: 1 << 32 on an int is 1, not 0. >> is arithmetic for signed types; C# 11 added >>> for unsigned (logical) shift.

4. Precedence traps

Precedence from high to low (simplified): primary (x.y, x?.y, f(), a[i], x++) → unary (!, -, ~, ++x, casts) → * / % → + - → shifts → relational and is/as → == != → & → ^ → | → && → || → ?? → ?: → assignment and lambdas.

Bugs I have seen:

  • total = price ?? 0 + shipping; parses as price ?? (0 + shipping) because + binds tighter than ??. Write (price ?? 0) + shipping.
  • "Total: " + a + b concatenates a then b as strings ("Total: 12", not "Total: 3").
  • flags & Mask == Mask parses as flags & (Mask == Mask) — a compile error for ints, but a silent bug for bools. Parenthesise bitwise tests.
  • a ? b : c ? d : e is right-associative; nested ternaries need parentheses for readability.

5. Equality operators

  • For reference types, == is reference equality unless the type overloads it (string, records do).
  • object a = "x"; object b = new string('x', 1); a == b is false — the overload is chosen at compile time based on the static type object.
  • Prefer is null over == null for null checks; it cannot be redirected by a custom operator ==.

6. A production example

A pricing service computed var discount = customer?.Tier.DiscountRate ?? 0m;. When customers without a tier arrived from a new import, customer was not null but Tier was, and the endpoint threw NREs for a subset of users. The fix was customer?.Tier?.DiscountRate ?? 0m, but the real fix was enabling nullable reference types on the project, which flagged Tier as Tier? and forced every caller to handle it. Similar classic: a percentage column showed 0% for weeks because it was completed / total * 100 with int operands — caught only when a product owner asked why the chart was always flat.

🔍 What the interviewer is checking

The interviewer checks everyday fluency and whether the candidate knows the traps, not just the symbols.

Green flags

  • Explains short-circuiting and why it makes null checks safe.
  • Spots the integer-division and ?? precedence bugs quickly.
  • Knows is null cannot be overridden and that ! does nothing at run time.
  • Mentions C# 14 null-conditional assignment or index/range operators.

Red flags

  • Thinks & and && are interchangeable for bools.
  • Believes ?. makes code safe everywhere without thinking about what null means.
  • Cannot predict 7 / 2 or -7 % 3.

📚 Learn in depth

1. What ?. and ?? compile to

?. evaluates the receiver once into a temporary, tests it, then continues:

// var city = order?.Customer?.City;
var t1 = order;
string? city;
if (t1 == null) city = null;
else
{
    var t2 = t1.Customer;
    city = t2 == null ? null : t2.City;
}

Because the receiver is evaluated once, GetOrder()?.Total calls GetOrder() only once. For value-type results the expression is lifted: order?.Total has type decimal?. That is why int count = list?.Count; does not compile and you need list?.Count ?? 0.

The null-conditional invocation handler?.Invoke(this, args) is the standard thread-safe event raise: the delegate is copied into a temporary before the null check, so another thread unsubscribing between the check and the call cannot cause an NRE.

a ?? b compiles to "evaluate a once; if not null use it (unwrapping Nullable<T> with GetValueOrDefault()), else evaluate b". The right side being lazily evaluated is what makes ?? throw legal — throw is an expression since C# 7.

2. Why ??= is not thread-safe

_cache ??= Load();
// is roughly
if (_cache is null) _cache = Load();

Two threads can both see null and both call Load(). For thread-safe lazy initialisation use Lazy<T>, LazyInitializer.EnsureInitialized, or Interlocked.CompareExchange(ref _cache, Load(), null) when duplicate creation is acceptable but you need one published winner.

3. Operator overload resolution is static

Operators are static methods resolved at compile time from the operands' static types:

string s1 = "hello";
string s2 = new string("hello".ToCharArray());
Console.WriteLine(s1 == s2);                 // True  - string.op_Equality
Console.WriteLine((object)s1 == (object)s2); // False - reference equality

The compiler warns (CS0252/CS0253) when you compare object with string this way. This is also why generic code where T : class comparing a == b uses reference equality even if T is string — use EqualityComparer<T>.Default.Equals(a, b) in generics.

is null compiles to a direct reference comparison, bypassing any user-defined ==. Some ORMs and Unity's UnityEngine.Object override == so that destroyed objects compare equal to null — that is the classic case where is null and == null disagree.

4. Short-circuit evaluation in IL

a && b becomes a branch: evaluate a; if false jump to "false" without evaluating b. a & b evaluates both and performs a bitwise AND. Short-circuiting is therefore also a performance tool: put the cheapest, most selective test first (if (span.Length > 0 && ExpensiveCheck(span))).

With bool?, the & (AND) and OR operators implement SQL-style three-valued logic:

a b AND result OR result
true null null true
false null false null
null null null null

5. Integer division, remainder and shifts at the CPU level

  • x64 idiv truncates toward zero and produces both quotient and remainder; C# defines % to match, so the remainder takes the sign of the dividend.
  • Division is one of the slowest integer instructions (tens of cycles). The JIT replaces division by a constant with multiply-and-shift, and x % 2^n for non-negative values can be x & (2^n - 1). Math.DivRem returns both results.
  • int.MinValue / -1 overflows and throws OverflowException even in unchecked code, because the CPU faults.
  • Shift counts are masked to 5 bits for 32-bit types and 6 bits for 64-bit, matching x86 hardware: x << 33 on an int equals x << 1.

6. Precedence and associativity rules worth memorising

  • Assignment, ??, ??= and ?: are right-associative: a ?? b ?? c is a ?? (b ?? c), which is what you want.
  • Everything else binary is left-associative: a - b - c is (a - b) - c.
  • as and is sit with relational operators, so x as Foo ?? fallback works as expected, but !x is Foo parses as (!x) is Foo — write x is not Foo.
  • Lambdas bind loosest of all, which is why Func<int,int> f = x => x + 1; needs no parentheses.

7. Pattern-based operators

Modern C# turns many operator combinations into patterns, which are easier to read and harder to get wrong:

if (order is { Status: OrderStatus.Paid, Total: > 0m }) { ... }
if (code is >= 200 and < 300) { ... }
if (input is not (null or "")) { ... }

Patterns are evaluated left to right with short-circuiting, and they do not call user-defined == for constant patterns on most types.

Try it yourself

  1. In SharpLab, compile var x = a?.B?.C ?? 0; and read the lowered C#.
  2. Print -7 / 2, -7 % 3, 1 << 32 and int.MinValue / -1 (in a try/catch).
  3. Write decimal? price = null; decimal shipping = 5; var t = price ?? 0 + shipping; and print t, then add parentheses.
  4. Compare (object)"ab" == (object)string.Concat("a", "b") with string.Equals.

💻 Code

namespace Basics;

public sealed class Address { public string? City { get; set; } }
public sealed class Customer { public Address? Address { get; set; } }
public sealed record Point(int X, int Y);

public static class OperatorDemo
{
    private static List<string>? _cache;

    public static void Main()
    {
        Customer? customer = null;

        // Null-conditional + null-coalescing
        string city = customer?.Address?.City ?? "Unknown";
        Console.WriteLine(city);                       // Unknown

        // C# 14 null-conditional assignment: no-op when customer is null
        customer?.Address = new Address { City = "Perth" };

        // ??= lazily initialises once
        _cache ??= ["a", "b"];

        // Integer division trap
        int processed = 1, total = 3;
        Console.WriteLine(processed / total * 100);                     // 0
        Console.WriteLine((int)Math.Round(100.0 * processed / total)); // 33

        // Modulo sign follows the dividend
        Console.WriteLine(-7 % 3);                     // -1
        Console.WriteLine(IsOdd(-7));                  // True

        // Precedence
        int? n = null;
        Console.WriteLine(n ?? 0 + 5);                 // 5  (n ?? (0 + 5))
        Console.WriteLine((n ?? 0) + 5);               // 5, but intent is now clear

        // Value equality on records, reference equality on classes
        Console.WriteLine(new Point(1, 2) == new Point(1, 2)); // True

        // Index and range
        int[] values = [10, 20, 30, 40];
        Console.WriteLine(values[^1]);                 // 40
        Console.WriteLine(string.Join(",", values[1..3])); // 20,30
    }

    private static bool IsOdd(int x) => x % 2 != 0;
}

⚠ Common mistakes

1. Stopping the ?. chain too early

What happens: order?.Customer.Address.City is assumed to be null-safe. Why it is wrong: ?. only protects the receiver it is attached to. If Customer or Address can be null you still get a NullReferenceException. Do instead: apply ?. at every nullable step, and enable nullable reference types so the compiler tells you which steps can be null.

2. Precedence bugs with ??

What happens: var total = price ?? 0 + shipping; Why it is wrong: + binds tighter than ??, so this is price ?? (0 + shipping) — shipping is ignored whenever price has a value. Do instead: always parenthesise ?? inside arithmetic: (price ?? 0) + shipping.

3. Using ! to make warnings go away

What happens: user!.Name sprinkled wherever the compiler warns. Why it is wrong: the null-forgiving operator does nothing at run time; it just hides the warning, so the NRE still happens in production. Do instead: fix the flow (guard clause, ?? throw, [NotNullWhen]), and reserve ! for cases you can prove, such as values set by a framework after construction.

4. Using & or | instead of && or ||

What happens: if (user != null & user.IsActive) or if (Validate() | Save()). Why it is wrong: non-short-circuit operators evaluate both sides — dereferencing null or running side effects that should have been skipped. Do instead: use &&/|| for boolean logic; keep &/| for bit flags and deliberate eager evaluation.

5. Integer division producing zero

What happens: double percent = done / total * 100; with int operands. Why it is wrong: done / total is computed in integer arithmetic and truncates to 0 before conversion. Do instead: convert first: 100.0 * done / total or (decimal)done / total.

6. Treating % as mathematical modulo

What happens: circular-buffer or day-of-week code uses index % n with possibly negative index. Why it is wrong: % keeps the sign of the dividend, so -1 % 7 == -1 and the array access throws. Do instead: use ((index % n) + n) % n, or keep indexes non-negative.

7. Comparing references through object

What happens: values stored as object (e.g. in a Dictionary<string, object>) are compared with ==. Why it is wrong: overloads are chosen by static type, so == becomes reference equality and equal strings or boxed numbers compare as different. Do instead: use Equals, object.Equals(a, b), or EqualityComparer<T>.Default in generic code.

8. Relying on ??= or ?. for thread safety

What happens: _client ??= CreateClient(); in a singleton accessed by many requests. Why it is wrong: check-then-assign is not atomic; multiple threads can create multiple instances, and some may observe a half-initialised object. Do instead: use Lazy<T>, LazyInitializer, or register the object as a singleton in DI.

9. Using ++ and -- inside bigger expressions

What happens: arr[i++] = arr[i] + 1; or if (count++ > max). Why it is wrong: readers misjudge whether the old or new value is used, and refactors change behaviour. Do instead: increment on its own line; clarity beats saving a line.

10. Unparenthesised bitwise checks

What happens: if (flags & Permission.Write == Permission.Write). Why it is wrong: == binds tighter than &, so it either fails to compile or silently tests the wrong thing for bool. Do instead: write (flags & Permission.Write) == Permission.Write, or flags.HasFlag(Permission.Write) (which the JIT now optimises well).

🏛 Senior insight: trade-offs

Seniors use operators to make intent explicit and avoid cleverness.

Situation Prefer Avoid
Null check is null / is not null == null on types that overload ==
Defaulting value ?? fallback nested ternaries
Guard clause ArgumentNullException.ThrowIfNull(x) x ?? throw ... repeated everywhere
Lazy init (single thread) ??= manual if/assign
Lazy init (multi-thread) Lazy<T> or LazyInitializer ??= (it is not atomic)
Percentages double maths then round integer division
  • ??= is not thread-safe: two threads can both see null and both assign.
  • ?. hides nulls; overusing it can turn a bug (a missing customer) into silent wrong data. Decide whether null is valid before reaching for it.
  • With nullable reference types enabled, the compiler's flow analysis understands is null, ?? and ?., so idiomatic operators also improve warnings.

🔗 Follow-ups the interviewer may ask

  1. When: The candidate mentions is not null or pattern matching. Why: Moves from individual operators to pattern operators and switch expressions.
    How to answer

    An expression produces a value, like a + b or order.Total > 100; a statement does something, like if, foreach, return or a declaration. C# has become more expression-oriented: a switch expression returns a value, cannot fall through and warns if it is not exhaustive, and patterns (type, relational, property, list, and/or/not) let you test shape and values in one place. Expression-bodied members use => for one-line members, and throw expressions let ?? and ?: throw. I use expressions to compute values without mutation and statements when there are steps or side effects.

  2. When: The candidate explains the integer division trap. Why: Probes the numeric type rules behind integer division and overflow.
    How to answer

    C# has integral types (byte to long, plus nint, Int128), binary floating point (float, double, Half) and decimal. I use int by default, long for IDs and counters that can pass 2.1 billion, double for measurements and science, and decimal for money because it is base-10 and exact for values like 0.1. Integer overflow is unchecked by default, so int.MaxValue + 1 silently wraps; I use checked where wrapping would be a bug. Doubles never throw: dividing by zero gives Infinity or NaN, and Math.Round defaults to banker's rounding.

  3. When: The candidate mentions the null-forgiving operator. Why: The null operators interact with nullable reference type flow analysis and the ! operator.
    How to answer

    Nullable reference types make nullability part of the declared type for static analysis: string means "should never be null" and string? means "may be null". The compiler tracks a null-state for every expression and warns when a maybe-null value is dereferenced or assigned to a non-nullable. It is compile-time only: there are no run-time checks, and ! merely silences a warning. Attributes like [NotNullWhen(true)] on TryGet methods or [MemberNotNull] on initialisers tell the analyser about contracts it cannot infer. Nulls can still arrive from deserialisation, arrays, default and unannotated code, so I still validate at the boundaries.

↩ Usually asked after

More in Language Basics

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me