Operators
C# inherits the operator surface of C and Java and adds the null-conditional ?., the null-coalescing ?? and ??=, the index-from-end ^, the range .., the is operator with patterns, and the with expression introduced for records (C# 9). Most operators may be overloaded for user-defined types; the conventions for overloading them — which to define, what to return, how to interact with Equals and GetHashCode — are part of the language’s idiom. The operator precedence and associativity follow the C-family conventions, with C#-specific levels for the new operators.
Inherited operators
The arithmetic, comparison, logical, and bitwise operators are essentially those of C and Java. Their semantics are well-defined in C#:
- Integer arithmetic wraps in
uncheckedcontexts and throwsOverflowExceptionincheckedcontexts. The default is implementation-configurable but typicallyuncheckedfor releases andcheckedfor debug builds; the keywordscheckedanduncheckedadmit per-block override. - Integer division by zero throws
DivideByZeroException. - Floating-point division by zero yields
Infinity,-Infinity, orNaNper IEEE 754. - Right-shift of a signed integer is arithmetic (preserves the sign bit); right-shift of an unsigned integer is logical (fills with zero).
int a = 7, b = 2;
int q = a / b; // 3
int r = a % b; // 1
int max = a > b ? a : b;
int flags = 0b1010 | 0b0101; // 0b1111
int low = flags & 0b0011; // 0b0011
int xor = flags ^ 0b1100; // 0b0011
int notb = ~flags;
int sh = flags << 2;
Compound assignment (+=, -=, *=, etc.) and increment/decrement (++, --) work as in C.
Null-conditional and null-coalescing
The null-conditional ?. performs a member access only if the receiver is non-null; otherwise the entire expression yields null:
string? name = null;
int? len = name?.Length; // null, not a NullReferenceException
person?.Address?.Street?.ToUpper(); // chains; any null short-circuits
The mechanism extends to indexers (?[i]) and method calls (?.Method()). The receiver is evaluated once; the chain short-circuits on the first null.
The null-coalescing ?? returns its left operand if non-null, otherwise its right:
string display = name ?? "unknown";
C# 8 added the null-coalescing assignment ??=, which assigns only if the left is null:
private List<string>? cache;
public List<string> GetCache() {
cache ??= new List<string>();
return cache;
}
The combination of ?., ??, and ??= is the conventional null-handling toolkit.
Index-from-end and range operators
C# 8 added two operators for slicing:
int[] arr = { 1, 2, 3, 4, 5 };
int last = arr[^1]; // 5; ^1 is "1 from the end"
int penult = arr[^2]; // 4
int[] slice = arr[1..4]; // {2, 3, 4}; range from index 1 inclusive to 4 exclusive
int[] tail = arr[2..]; // {3, 4, 5}
int[] head = arr[..3]; // {1, 2, 3}
int[] copy = arr[..]; // shallow copy of all elements
int[] last2 = arr[^2..]; // last two elements
The ^ operator yields a System.Index; the .. operator yields a System.Range. Types that implement appropriate indexers and slicers (System.String, System.Span<T>, arrays) participate; user-defined types may opt in by exposing the right operations.
The is operator and pattern operators
The is operator tests a value’s type or pattern:
object o = "hello";
bool isString = o is string; // type test; true
bool isPositive = o is > 0; // relational pattern (only on integer-typed o)
if (o is string s) { // type pattern with binding
Console.WriteLine($"length: {s.Length}");
}
if (o is { Length: > 5 } str) { // property pattern
/* ... */
}
The pattern grammar is substantial; the full treatment is in Pattern matching.
The with expression
C# 9 admitted with expressions for records, creating a copy of a record with selected members modified:
public record Point(double X, double Y);
var p1 = new Point(3, 4);
var p2 = p1 with { Y = 0 }; // (3, 0)
The with expression copies all members of the source and overrides the named members. C# 10 extended with to value types (struct types declared with primary-constructor parameters) and to anonymous types.
Operator overloading
C# admits overloading of most arithmetic, comparison, and logical operators on user-defined types. The operators that may be overloaded:
| Category | Operators |
|---|---|
| Unary | +, -, !, ~, ++, --, true, false |
| Binary arithmetic | +, -, *, /, % |
| Binary bitwise | &, |, ^, <<, >>, >>> |
| Comparison | ==, !=, <, >, <=, >= |
| Conversion | implicit, explicit |
Overloads are declared as public static:
public struct Money {
public long Cents;
public static Money operator +(Money a, Money b)
=> new Money { Cents = a.Cents + b.Cents };
public static bool operator ==(Money a, Money b) => a.Cents == b.Cents;
public static bool operator !=(Money a, Money b) => !(a == b);
// Overriding == requires overriding Equals and GetHashCode:
public override bool Equals(object? obj)
=> obj is Money other && this == other;
public override int GetHashCode() => Cents.GetHashCode();
}
The conventions:
- Defining
==requires defining!=; the compiler enforces. - Defining
==requires overridingEqualsandGetHashCode; the compiler warns. - Defining
<requires defining>,<=,>=; conventionallyIComparable<T>is also implemented. - Defining
+should typically also define-and+=.
C# does not admit overloading of the assignment operator =, the conditional ?:, the logical short-circuit && and ||, or member access .. The logical operators && and || are derived from &, |, true, and false: defining the four with the conventional semantics enables && and || for the type.
Conversion operators
A type may define conversion operators to and from other types:
public struct Celsius {
public double Degrees;
public static implicit operator double(Celsius c) => c.Degrees;
public static explicit operator Celsius(double d) => new Celsius { Degrees = d };
}
Celsius c = (Celsius)3.14; // explicit
double d = c; // implicit (the conversion is invisible)
The conventional discipline:
- Define an implicit conversion only when no information is lost and no exceptions are possible.
- Define an explicit conversion when the conversion is potentially lossy or may fail.
- Conversions should be one-step: defining a conversion
A → BandB → Cdoes not enableA → C; users must spell out the chain.
The conditional operator
The ternary ?: operator selects between two expressions:
int max = a > b ? a : b;
string sign = n > 0 ? "positive" : n < 0 ? "negative" : "zero";
The two arms must have a common type (or one must be implicitly convertible to the other). C# 9 introduced target-typed conditional expressions: the two arms need not have a common type if both are convertible to the target:
int? value = condition ? 42 : null; // C# 9: target-typed
sizeof, typeof, default
Three compile-time operators:
int s = sizeof(int); // 4; only on unmanaged types
Type t = typeof(string); // System.String
int d = default(int); // 0
string? n = default(string); // null
sizeof works only on unmanaged types — types whose memory layout is fixed and which contain no managed references. For managed types, use Marshal.SizeOf<T>().
typeof(T) yields a System.Type object that the runtime uses for reflection; obj.GetType() returns the runtime type of an instance.
default(T) (or default with target typing) yields the default value of the type — zero for numeric types, null for reference types, an all-fields-default struct for value types.
nameof and is
nameof(expr) yields the source-code name of an entity as a string, evaluated at compile time:
public void Process(string input) {
if (input == null) throw new ArgumentNullException(nameof(input));
}
The mechanism is the conventional defence against typos in argument names, property names referenced in INotifyPropertyChanged, and similar string-named references that would otherwise be brittle.
Operator precedence
The full precedence table from highest to lowest:
| Level | Operators | Associativity |
|---|---|---|
| 1 | x.y, f(x), a[i], x++, x--, new, typeof, sizeof, default, nameof, checked, unchecked, delegate, stackalloc, x! | left |
| 2 | +x, -x, !x, ~x, ++x, --x, (T)x, await, &x, *x | right |
| 3 | x..y (range) | left |
| 4 | x switch …, x with { … } | left |
| 5 | * / % | left |
| 6 | + - (binary) | left |
| 7 | << >> >>> | left |
| 8 | < <= > >= is as | left |
| 9 | == != | left |
| 10 | & (bitwise) | left |
| 11 | ^ | left |
| 12 | | | left |
| 13 | && | left |
| 14 | || | left |
| 15 | ?? | right |
| 16 | ?: | right |
| 17 | =, +=, -=, *=, /=, %=, <<=, >>=, &=, ^=, |=, ??=, => | right |
Three precedence facts worth memorising:
??binds tighter than?:.cond ? a : b ?? ciscond ? a : (b ?? c).isbinds at relational-operator level (8);x is T && yworks as expected.- The pattern operators (
switchexpression,with) bind tightly enough thatx switch { … }may appear as a sub-expression.
When in doubt, parenthesise. C# does not penalise redundant parentheses; the alternative is to invite the bug.
Sequence points and unsequenced evaluations
C# has substantially less subtle evaluation-order behaviour than C. The standard specifies that:
- Subexpressions of binary operators are evaluated left-to-right.
- Function arguments are evaluated left-to-right.
- The two operands of
&&,||,??, and?:are evaluated in order with short-circuiting.
Unlike C, C# does not have undefined behaviour from unsequenced modifications. The expression i = i++ is well-defined: the post-increment evaluates to the original i, increments i, then the assignment overwrites with the post-increment’s result. The result is that i ends up unchanged, which is rarely what the programmer intended but is at least determined.