Operator Overloading & Generic Math
Key Points
- Define
operator ==andoperator !=together; same for</<=/>/>=. Mismatched operators produce subtle bugs. op_Implicitfor lossless widening (int → long);op_Explicitfor narrowing or domain conversions that can fail. Implicit conversions surprise readers — use sparingly.- Generic math (C# 11 / .NET 7+) introduced
INumber<T>,IAdditionOperators<TSelf, TOther, TResult>, andstatic abstractinterface members so you can write generic algorithms over numeric types. IParsable<T>+IFormattable+ISpanFormattableare the modern trio for round-tripping value types to/from strings without allocations.- Senior rule: overload operators only when they read like math in the domain (
Money + Money,Vector * scalar). For everything else, name the method.
Concepts (deep dive)
Operators are static methods
public readonly record struct Money(decimal Amount, string Currency)
{
public static Money operator +(Money a, Money b)
{
if (a.Currency != b.Currency) throw new InvalidOperationException("Currency mismatch");
return new Money(a.Amount + b.Amount, a.Currency);
}
public static Money operator *(Money a, decimal factor) => a with { Amount = a.Amount * factor };
}
Compiles to op_Addition, op_Multiply static methods. Languages other than C# (F#, VB) see them as named methods.
Pairs that must move together
| Operator | Pair |
|---|---|
== | != |
< | >, <=, >= |
+ | - (often) |
++ | -- |
true | false (rare; for short-circuit && \|\|) |
When you overload ==, also override Equals and GetHashCode for the contract.
User-defined conversions
public readonly struct Celsius(double v)
{
public double Value { get; } = v;
public static implicit operator Celsius(double v) => new(v); // dangerous? double → Celsius is a "tagging" conversion
public static explicit operator Fahrenheit(Celsius c) => new(c.Value * 9 / 5 + 32);
}
Celsius room = 22.0; // implicit
var f = (Fahrenheit)room; // explicit
Senior guideline: keep op_Implicit only when the conversion is lossless and obvious. int → long qualifies; string → SqlQuery doesn't.
Generic math (C# 11+)
static abstract interface members let you constrain generics on operators:
public static T Sum<T>(IEnumerable<T> values) where T : INumber<T>
{
T total = T.Zero;
foreach (var v in values) total += v;
return total;
}
Sum(new[] { 1, 2, 3 }); // int
Sum(new[] { 1.5m, 2.5m }); // decimal
Sum(new[] { 1.0, 2.0, 3.0 }); // double
INumber<T> chains IAdditiveIdentity, IComparisonOperators, IIncrementOperators, etc. Useful for algorithm libraries; overkill for app code.
IParsable<T> + ISpanFormattable
public readonly partial struct Sku : IParsable<Sku>, ISpanFormattable
{
public string Code { get; }
public Sku(string code) => Code = code;
public static Sku Parse(string s, IFormatProvider? p) => new(s);
public static bool TryParse(string? s, IFormatProvider? p, out Sku result)
{ result = new Sku(s ?? ""); return s is { Length: > 0 }; }
public bool TryFormat(Span<char> dest, out int written, ReadOnlySpan<char> fmt, IFormatProvider? p)
=> Code.AsSpan().TryCopyTo(dest) ? (written = Code.Length, true).Item2 : (written = 0, false).Item2;
public string ToString(string? format, IFormatProvider? p) => Code;
}
This makes the type a first-class citizen in int.Parse-style generic code.
op_CheckedAddition / unchecked operators
C# 11 introduced checked user-defined operators:
public static Money operator checked +(Money a, Money b) => /* throw on overflow */;
public static Money operator +(Money a, Money b) => /* wrap */;
The checked form is selected inside checked { … } blocks.
== on classes vs records vs structs
- Class without override → reference equality.
- Record class → structural value equality, generated.
- Record struct → structural, generated, no boxing.
- Struct without override → reflection-based field-by-field (slow); always implement
IEquatable<T>and==.
Code: correct vs wrong
❌ Wrong: implicit conversion that loses info
public static implicit operator string(Email e) => e.Address;
// Now Email becomes string in interpolation, logging, dictionary keys — ambiguous & accidental.
✅ Correct: explicit + named method
public override string ToString() => Address; // implicit cultural use
public string ToCanonicalString() => Address.ToLowerInvariant();
❌ Wrong: == without Equals
public class Money { public static bool operator ==(Money a, Money b) => a.Amount == b.Amount; /* ... */ }
new HashSet<Money>().Contains(...); // hashes by reference; equality contract broken
✅ Correct: full quartet
public sealed class Money : IEquatable<Money>
{
public bool Equals(Money? o) => o is not null && Amount == o.Amount && Currency == o.Currency;
public override bool Equals(object? o) => Equals(o as Money);
public override int GetHashCode() => HashCode.Combine(Amount, Currency);
public static bool operator ==(Money? a, Money? b) => a is null ? b is null : a.Equals(b);
public static bool operator !=(Money? a, Money? b) => !(a == b);
}
Design patterns for this topic
Pattern 1 — Strongly-typed quantity
public readonly record struct Bytes(long Value)
{
public static Bytes operator +(Bytes a, Bytes b) => new(a.Value + b.Value);
public static Bytes operator *(Bytes a, int n) => new(a.Value * n);
public override string ToString() => $"{Value} B";
}
Domain primitives that compose through math operators.
Pattern 2 — INumber<T>-constrained algorithm
public static T Average<T>(IEnumerable<T> xs) where T : INumber<T>
{
T sum = T.Zero;
int count = 0;
foreach (var x in xs) { sum += x; count++; }
return count == 0 ? T.Zero : sum / T.CreateChecked(count);
}
Pattern 3 — Round-trippable value via IParsable<T> + ISpanFormattable
Use when the type appears in CSV, JSON, query strings, or model binding — letting the framework reach the same Parse/TryFormat contract built-in numerics use.
Pattern 4 — op_Checked for currency/financial code
Banking domains want overflow to throw, not wrap. Define operator checked + and call within checked { … } blocks.
Pros & cons / trade-offs
| Choice | Pros | Cons |
|---|---|---|
| Operator overloads | Reads like math, fluent | Hard to discover, easy to misuse |
| Implicit conversion | Concise call sites | Hides bugs, ambiguous overload resolution |
| Explicit conversion | Intent is clear | More casts at call sites |
INumber<T> constraint | Reusable algorithms | Compile-time complexity, harder to debug |
When to use / when to avoid
- Define operators for domain types where math expressions are natural.
- Use
INumber<T>in libraries that genuinely operate on multiple numeric types (statistics, signal processing). - Avoid implicit conversions unless lossless and obvious; prefer explicit or named methods.
- Avoid generic math in app code where one or two concrete numeric types suffice.
Interview Q&A
Q1. Why are operators static? A. They participate in overload resolution that doesn't depend on this polymorphism — both operands must be visible to the compiler. Languages without operators (F#) call them by their op_* method name.
Q2. What does static abstract give you? A. Interface members callable from generic code without an instance: T.Zero, T.Parse. Enables generic math, parsable factories, etc.
Q3. Why does == on string not call op_Equality for object? A. The C# compiler picks string.operator == because both operands are typed string. With object operands, it falls back to reference equality unless you cast.
Q4. What's the difference between op_Implicit and op_Explicit ILwise? A. Both are static methods; the C# compiler emits different methods names and chooses based on cast syntax. Implicit is callable without a cast; Explicit requires (T)x.
Q5. When is INumber<T> overkill? A. App code that handles only decimal or int. Generic math shines in shared math libraries; in domain code it adds compile complexity for no benefit.
Q6. Why must op == and op != move together? A. The compiler enforces it (CS0216) — the consistency expectation is fundamental: a == b ⇔ !(a != b).
Q7. What's CreateChecked vs CreateTruncating on INumber<T>? A. CreateChecked throws on overflow when converting between numeric types; CreateTruncating wraps. Use Checked for financial; Truncating for low-level perf code.
Q8. How does record struct get == for free? A. The compiler synthesizes IEquatable<T>.Equals(T), Equals(object), GetHashCode, ==, != based on positional/declared members — no reflection at runtime.
Gotchas / common mistakes
- Implicit conversions in dictionary keys causing accidental matches.
- Overloading
+without thinking about+=(compiler synthesizes; ensure semantics make sense). op_EqualitywithoutEqualsoverride → reference equality leaks throughobject.Equals.- Forgetting that
INumber<T>requiresT.Zeroetc. — not every "numeric" type implements it. - Using operator overloading on entities (DDD) — equality should be by ID, not value.
- Generic-math constraints exploding in error messages — keep them on a small surface.