Polyglot
Languages C# operators
C# § operators

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 unchecked contexts and throws OverflowException in checked contexts. The default is implementation-configurable but typically unchecked for releases and checked for debug builds; the keywords checked and unchecked admit per-block override.
  • Integer division by zero throws DivideByZeroException.
  • Floating-point division by zero yields Infinity, -Infinity, or NaN per 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:

CategoryOperators
Unary+, -, !, ~, ++, --, true, false
Binary arithmetic+, -, *, /, %
Binary bitwise&, |, ^, <<, >>, >>>
Comparison==, !=, <, >, <=, >=
Conversionimplicit, 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 overriding Equals and GetHashCode; the compiler warns.
  • Defining < requires defining >, <=, >=; conventionally IComparable<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 → B and B → C does not enable A → 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:

LevelOperatorsAssociativity
1x.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, *xright
3x..y (range)left
4x switch …, x with { … }left
5* / %left
6+ - (binary)left
7<< >> >>>left
8< <= > >= is asleft
9== !=left
10& (bitwise)left
11^left
12|left
13&&left
14||left
15??right
16?:right
17=, +=, -=, *=, /=, %=, <<=, >>=, &=, ^=, |=, ??=, =>right

Three precedence facts worth memorising:

  1. ?? binds tighter than ?:. cond ? a : b ?? c is cond ? a : (b ?? c).
  2. is binds at relational-operator level (8); x is T && y works as expected.
  3. The pattern operators (switch expression, with) bind tightly enough that x 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.