The C# language
Questions about the language itself that come up at every level: inheritance and interfaces, exceptions, extensions, generics, delegates, equality and pattern matching. Knowing the syntax is not enough — you need to explain what the compiler and the runtime do with your code.
Before this topic: Types and memory.
How is an interface different from an abstract class?
Section titled “How is an interface different from an abstract class?”What it is
- Interface — a set of members a type promises to implement:
IDisposable,IComparable<T>. - Abstract class — a class marked
abstract. You cannot create an instance of it, only inherit from it.
Why
- An interface lets you swap the implementation: in tests, through DI.
- An abstract class saves derived classes from repeating shared code.
How they differ
| Interface | Abstract class | |
|---|---|---|
| How many you can inherit | any number | one |
| Fields and constructors | no | yes |
| Usable by structs | yes | no |
Common mistake
Making an abstract class where only a contract is needed. Later a derived class needs a different base class, but the slot is already taken.
Takeaway: an interface describes what a type can do, an abstract class is a shared foundation for derived classes.
How it works
- An abstract class is a regular class: fields, constructors, any access modifiers,
abstractandvirtualmembers. - An interface holds no state:
{ get; set; }in it declares a property, not an auto-property. - Since C# 8 an interface member can have a body — a default interface method. A class does not inherit it; you can call it only through a reference of the interface type. It needs the .NET Core 3.0+ runtime.
- Since C# 11 (.NET 7) an interface can require static members and operators —
static abstract. You call them through a type parameter:T.Zero,T.Parse(...).
When to choose what
| Task | Choice |
|---|---|
| Contract for DI, mocks, structs | interface |
| Shared code and invariants of derived classes | abstract class |
| Numeric algorithm for any type | interface with static abstract |
| Public library | interface plus an abstract base class with a typical implementation |
Pitfalls
- A default method is not visible through a class variable:
rect.Describe()does not compile. - Diamond:
I1andI2both extendI0and both give a default implementation of its method. A class implementingI1andI2must provide its own, otherwise it is a compile error. - An explicit implementation
void IFoo.M()hides the method from the class API — it too is called only through the interface.
Rule of thumb
Start with an interface. Add an abstract base class when implementations start duplicating the same code.
Takeaway: an interface is a stateless contract with multiple implementation, an abstract class is a stateful foundation with single inheritance.
Under the hood
- A virtual method of a class is called through a slot in the method table — at a fixed offset.
- A call through an interface has to find the implementation for the concrete type. The runtime caches the result, but it is usually slightly more expensive than a virtual call.
- A
static abstractmember is not dispatched on an object. A call toT.Zerois resolved for the concrete type argument, and only through a type parameter, not through the interface itself.
Cost
- Casting a struct to an interface is boxing. To avoid it, use a generic with a
where T : IFooconstraint: the JIT generates separate code for each value type, and the call is direct. - In hot monomorphic call sites dynamic PGO (on by default since .NET 8) turns an interface call into a type check plus a direct call.
Trade-offs
- Adding a method to a published interface is a breaking change for every implementation. Default interface methods soften that, but they do not work on .NET Framework.
- An abstract class can grow a new virtual method without breaking derived classes, but it imposes a hierarchy.
- Generic math over
intanddoubleruns without boxing or virtual calls, but signatures withwhere T : INumber<T>are harder to read.
How to check in production
- Boxing and allocations show up in BenchmarkDotNet with
[MemoryDiagnoser]. [DisassemblyDiagnoser]shows how a direct call differs from a virtual and an interface call.
Takeaway: an interface gives flexibility but is harder to evolve; an abstract class is easier to evolve but ties you to a hierarchy.
var r = new Rect(2, 3);// r.Describe(); // error: the class does not inherit the default methodConsole.WriteLine(((IShape)r).Describe()); // Area: 6
public interface IShape{ double Area(); // default implementation (C# 8) string Describe() => $"Area: {Area()}";}
public abstract class Shape(string name) : IShape{ public string Name { get; } = name; // state and a constructor public abstract double Area();}
public sealed class Rect(double w, double h) : Shape("rectangle"){ public override double Area() => w * h;}using System.Numerics;
static T Sum<T>(IEnumerable<T> items) where T : INumber<T>{ T total = T.Zero; // static interface member foreach (var x in items) total += x; // operator from the interface return total;}
Console.WriteLine(Sum(new[] { 1, 2, 3 })); // 6Console.WriteLine(Sum(new[] { 1.5, 2.5 })); // 4- Can you call a default interface method through a class variable? — No, only through a reference of the interface type.
- What happens with a diamond of interfaces that have default implementations? — A compile error until the class provides the most specific implementation.
- Can an abstract class have no abstract methods? — Yes,
abstractonly forbids creating instances. - Why use explicit interface implementation? — To hide a member from the class API or to separate identical signatures from two interfaces.
- Why couldn’t
INumber<T>exist before C# 11? — Interfaces could not require static members and operators.
What is the difference between virtual/override, new and sealed?
Section titled “What is the difference between virtual/override, new and sealed?”What it is
virtual— a base class method that a derived class may replace.override— replacing a virtual method in a derived class.new— a new method with the same name that hides the base one.sealed— a ban: on a class, on inheriting; on anoverride, on overriding further.
How they differ
Call Base b = new Derived(); b.M(); |
What runs |
|---|---|
M is overridden with override |
Derived.M — by the object’s type |
M is hidden with new |
Base.M — by the variable’s type |
How to use
abstractis the same asvirtualbut without a body: the derived class must override it.base.M()calls the base implementation from the overriding one.
Common mistake
Forgetting virtual in the base class and writing a method with the same name in the derived class. You get hiding, not overriding, and the compiler issues a warning.
Takeaway: override picks the method by the object, new by the variable.
How it works
- The compiler sees the call
b.M()and finds the method by the static type ofb. - If the method is virtual, it emits a virtual call: the address comes from the object’s method table at run time.
- If the method is non-virtual or hidden with
new, the call is bound to the static type’s method at compile time.
When to choose what
| Goal | Modifier |
|---|---|
| Derived classes must change the behavior | virtual or abstract + override |
| Stop the chain of overrides | sealed override |
| The class is not meant to be inherited | sealed class |
| A new version of the base class added a method with the same name | new |
Pitfalls
- A method with the same name and no modifier is hiding with warning CS0108 (or CS0114 if the base method is virtual).
- A virtual method called from the base class constructor lands in the derived
overridebefore the derived constructor body runs. The fields it sets are still empty (analyzer CA2214). - A method that implements an interface is not virtual in C#. To let derived classes change it, declare it
virtual. - You cannot override a
staticmethod or change accessibility in anoverride.
Rule of thumb
Make only extension points virtual, and mark classes without derived classes sealed.
Takeaway: override is late binding by the object’s type, new is early binding by the variable’s type.
Under the hood
- Every type has a method table with slots for its virtual methods.
overrideputs its implementation into the same slot,new virtualcreates a new one (newslotin IL). - A virtual call loads the address from the slot of the object’s table and jumps to it. The JIT cannot inline such a call.
Cost
- The indirect jump itself is cheap. What costs more is that it blocks inlining and further optimization of the calling code.
- If the JIT knows the exact type — a
sealedclass or method, a freshly created object — it calls the method directly and can inline it. - Dynamic PGO (on by default since .NET 8) does guarded devirtualization: “if the type is the most common one, call directly, otherwise call virtually”.
- For a
sealedclass,ischecks and casts boil down to comparing a method table pointer.
Trade-offs
- An unsealed class with virtual methods is a public contract for derived classes. It is hard to change without breaking someone else’s code (the fragile base class problem).
- Analyzer CA1852 suggests sealing
internaltypes that have no derived classes. - If a class is open, document which methods may be overridden and in what order they are called.
How to check in production
- Measure the effect of
sealedwith BenchmarkDotNet. - What the JIT devirtualized and inlined shows up in the disassembly:
[DisassemblyDiagnoser]or theDOTNET_JitDisasmenvironment variable.
Takeaway: sealed both protects the design and helps the JIT; openness to inheritance has to be designed.
Animal a = new Dog();Console.WriteLine(a.Speak()); // Woof: chosen by the object's typeConsole.WriteLine(a.Kind()); // animal: chosen by the variable's typeConsole.WriteLine(((Dog)a).Kind()); // dog
class Animal{ public virtual string Speak() => "..."; public string Kind() => "animal";}
class Dog : Animal{ public override string Speak() => "Woof"; // overriding public new string Kind() => "dog"; // hiding}
class Puppy : Dog{ public sealed override string Speak() => "Yip"; // cannot be overridden further down}- What does calling a hidden method through a base class reference print? — The base version:
newdoes not take part in a virtual call. - Why is calling a virtual method from a constructor dangerous? — The derived
overrideruns before its fields are initialized. - Can you override a
staticmethod? — No, static methods are not virtual; you can only hide one withnew. - Why seal classes if nobody plans to inherit from them? — An explicit contract plus devirtualization and cheaper type checks.
- What are covariant return types? — Since C# 9 an
overridecan return a type derived from the base method’s return type.
How should you handle exceptions?
Section titled “How should you handle exceptions?”What it is
An exception is an object derived from Exception that describes an error. After throw, execution stops and the runtime looks for the nearest matching catch up the call stack.
Why
You cannot ignore an exception by accident the way you can ignore an error code: if nobody catches it, the process ends.
How to use
try— code that may fail.catch (ExceptionType ex)— handling. Order the blocks from most to least derived:FileNotFoundExceptionbeforeIOException.finallyalways runs — put resource cleanup there. ForIDisposable,usingis simpler.throw;insidecatchrethrows the same exception.
Common mistake
throw ex; instead of throw;. The stack trace then starts at the current method, and the place of the error is lost.
Takeaway: catch where you can fix the situation, rethrow with throw;, and release resources in finally or using.
How it works
throwcreates the exception and records the stack.- The runtime searches up the stack for a
catchof a matching type whosewhenfilter, if present, returnedtrue. - Every
finallyon the way to that handler runs. - If there is no handler, the process terminates.
When to choose what
| Situation | What to do |
|---|---|
| You can retry or use a fallback | catch of a specific type |
| You need to add context | a new exception with innerException |
| You need to roll back and rethrow | catch, roll back, throw; |
| There is nothing you can do | do not catch; let it reach the global handler |
Pitfalls
finallydoes not run onEnvironment.FailFast,StackOverflowExceptionor when the process is killed.- An exception thrown from
finallyreplaces the original one (CA2219). - An exception from an
asyncmethod is stored in theTaskand rethrown atawait. Catch cancellation asOperationCanceledException, notTaskCanceledException.
Rule of thumb
First look for a suitable built-in type: ArgumentException, InvalidOperationException. A custom exception derives from Exception, ends with Exception and has three constructors: parameterless, with a message, and with a message and an innerException. For argument checks use ArgumentNullException.ThrowIfNull(x) (.NET 6+).
Takeaway: catch only what you can handle, and do not lose the cause — neither the stack nor the InnerException.
Under the hood
- The first pass looks for a handler and runs the
whenfilters. The stack is not unwound yet; the locals of the original frame are still alive. - The second pass unwinds the stack and runs
finallyblocks. - That is why
catch (Exception ex) when (Log(ex)), whereLogreturnsfalse, logs without catching. ExceptionDispatchInfo.Capture(ex).Throw()rethrows an exception elsewhere or on another thread with the original stack — this is howawaitrethrows a task’s exception.
Cost
- A throw is an allocation, stack capture and a walk over the frames: orders of magnitude more than a normal return.
- In .NET 9 CoreCLR switched to the NativeAOT exception handling model: 2–4 times faster in micro-benchmarks. Exceptions are still expensive for validation and “not found” cases.
Trade-offs
- Unlike a return code, an exception cannot be skipped silently. But on hot paths it is replaced with the
Trypattern or a result type. - An unhandled exception on any thread and
StackOverflowExceptionterminate the process. Catch-all belongs once at the boundary: middleware, a global handler. - The
(SerializationInfo, StreamingContext)constructor is obsolete since .NET 8 (SYSLIB0051) — do not add it to new exceptions.
How to check in production
dotnet-counters monitorshows the number of exceptions (thedotnet.exceptionsmetric since .NET 9). Growth means exceptions have become part of normal control flow.dotnet-traceand theAppDomain.FirstChanceExceptionevent show who throws them.
Takeaway: an exception is an expensive error signal, not a way to drive logic; filters and throw; keep the context for diagnostics.
using System.Data.Common;using System.Net;
try{ ProcessOrder(id);}catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound){ // catch only 404; the rest keeps going without unwinding the stack throw new OrderNotFoundException($"Order {id} not found", ex); // the cause goes into InnerException}catch (DbException){ tx.Rollback(); throw; // the stack is preserved; throw ex; would restart it here}finally{ tx.Dispose(); // runs in any case}
public sealed class OrderNotFoundException : Exception{ public OrderNotFoundException() { } public OrderNotFoundException(string message) : base(message) { } public OrderNotFoundException(string message, Exception inner) : base(message, inner) { }}- How does
throw;differ fromthrow ex;? —throw ex;overwritesStackTrace, so the place of the error is lost. - When does a
whenfilter run, and why does it matter? — Before the stack unwinds: the original stack stays intact for a dump and the debugger. - Does
finallyrun iftrycontainsreturn? — Yes; it does not run only if the process dies, onFailFastor on stack overflow. - How do you rethrow an exception from another thread with its original stack? —
ExceptionDispatchInfo.Capture(ex).Throw(). - Why shouldn’t you drive logic with exceptions? — Throwing is expensive, and the control flow becomes non-obvious.
How do extension methods work, and what did extension members change in C# 14?
Section titled “How do extension methods work, and what did extension members change in C# 14?”What it is
A static method you call with a dot, like an instance method. The compiler rewrites s.WordCount() as StringExtensions.WordCount(s).
Why
To add a convenient method to a type you cannot change: string, a BCL interface, a class from a third-party library.
How to use
- Declare a
staticclass — not nested and not generic. - Add a
staticmethod whose first parameter isthis TypeBeingExtended. - Import the class’s namespace with
usingwhere you need the method.
In C# 14 you can write the same with an extension(string s) block and also declare a property inside: public bool IsBlank => string.IsNullOrWhiteSpace(s);.
Common mistake
Thinking an extension can override a class method or see its private fields. If the signatures match, the type’s own method is called.
Takeaway: an extension is a static method with convenient call syntax, not a real member of the type.
How it works
- The compiler looks for a matching member on the type itself.
- If none is found, it looks for extensions in all imported namespaces.
- The static type of the expression decides: for
object o = "x", extensions onstringare not visible. - The call becomes a plain static call, so an extension on a
nullreceiver does not throwNullReferenceExceptionat the call site.
What C# 14 added
An extension block is declared in a non-nested, non-generic static class. The block can have its own type parameters and constraints: extension<T>(IEnumerable<T> source) where T : IEquatable<T>.
| Syntax | What you can declare |
|---|---|
this parameter (C# 3) |
instance methods |
extension(T x) (C# 14) |
instance methods and properties, static members, operators |
extension(T) without a name (C# 14) |
static members only: IEnumerable<int>.Identity |
Pitfalls
- A struct receiver is passed by value, so changes to the copy are not visible. You need
this reforextension(ref Point p). - Extensions have no fields, so an extension property is always computed.
- No
virtual,override,protectedorinitaccessors. Indexers in extensions arrive only in C# 15.
Rule of thumb
If the type is yours, add the method to it. Extensions are for types and interfaces you do not own, in a separate namespace.
Takeaway: an extension is chosen at compile time, and only if the type has no suitable member of its own.
Under the hood
- A method in an
extensionblock becomes a static method with the[Extension]attribute. The block’s type parameters and receiver come first — just like a classic method. - A property
Pgets implementation methods such asget_P(receiver). - The block’s signature is encoded in nested helper types. The compiler and the IDE use them to recover extensions from a compiled assembly.
Cost
- It is a static call: no virtual dispatch, easy to inline.
- An extension on an interface boxes a struct receiver. For structs, write a generic extension with a constraint:
extension<T>(T value) where T : struct, IFoo.
Trade-offs
- Moving a library to
extensionblocks does not break consumers: for methods the IL is the same, so compatibility is both binary and source. - Static extensions and operators add factories and
+to types you do not own. That is powerful, but behavior starts to depend on the set ofusingdirectives. - All blocks in one static class share one declaration space: identical signatures for the same receiver are an error, even in different blocks.
How to resolve conflicts
- If two imported classes provide the same extension, the call is ambiguous. The way out is an explicit call
E1.M(x), orE1.get_P(x)for a property. - What exactly was generated shows up in an IL decompiler (ILSpy, ildasm).
Takeaway: C# 14 extended the syntax, not the model: extensions are still stateless static methods.
Console.WriteLine("one two three".WordCount()); // 3Console.WriteLine(" ".IsBlank); // TrueConsole.WriteLine(string.Dash); // —
public static class StringExtensions{ // classic extension method (C# 3) public static int WordCount(this string s) => s.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
// extension block (C# 14): s is the receiver extension(string s) { public bool IsBlank => string.IsNullOrWhiteSpace(s); // extension property }
// no receiver name — static members only extension(string) { public static string Dash => "—"; }}- What gets called if the type has a method with the same signature as an extension? — The type’s method: extensions are considered only after the type’s own members.
- Does calling an extension on
nullthrow? — The call itself does not, it is a static method; it throws if the method touches the receiver. - Can an extension add a field or an auto-property with state? — No, extensions have no storage; use
ConditionalWeakTableor a wrapper. - Does moving to
extensionblocks break binary compatibility? — No, the same IL is generated for methods. - How do you call a static extension? — Through the extended type’s name:
IEnumerable<int>.Identity.
What are generic constraints, covariance and contravariance?
Section titled “What are generic constraints, covariance and contravariance?”What it is
- Constraint — a requirement on a type parameter:
where T : IComparable<T>. - Covariance (
out) —IEnumerable<string>can be used asIEnumerable<object>. - Contravariance (
in) —Action<object>can be used asAction<string>.
Why
Without constraints nothing is known about T — only object members are available. Variance lets you pass collections and handlers without copying or casting.
How to use
where T : ISomething— you can call the interface’s methods.where T : new()— you can writenew T().where T : classorstruct— only reference types or only value types.
Common mistake
Expecting List<string> to convert to List<object>. It does not: otherwise you could add a number to a list of strings.
Takeaway: constraints say what you can do with T, variance says what you can substitute for T.
How it works
- Constraints on the kind of type:
class,class?,struct,notnull(C# 8),unmanaged(C# 7.3),default(C# 9),allows ref struct(C# 13). - Constraints on capabilities:
new()(must come last), a base class, an interface,EnumandDelegate(C# 7.3), another parameterwhere T : U. out Tcan appear only in return values,in Tonly in parameters.
Where this is in the BCL
Covariant (out) |
Contravariant (in) |
|---|---|
IEnumerable<out T> |
Action<in T> |
IReadOnlyList<out T> |
IComparer<in T> |
Func<out TResult> |
IEqualityComparer<in T> |
When to choose what
- If a method only reads a collection, accept
IEnumerable<T>orIReadOnlyList<T>: they are covariant. - Mark your own source interface
outand your handler interfacein.
Pitfalls
List<T>andIList<T>are invariant:Tis both an input (Add) and an output (the indexer).- Classes and structs are never variant:
class Box<out T>does not compile. IEnumerable<int>does not convert toIEnumerable<object>: variance is for reference types only.
Rule of thumb
Accept the most general variant interface you actually need, not List<T>.
Takeaway: out is for producers, in is for consumers, and anything that both reads and writes is invariant.
Under the hood
- The compiler checks positions:
out Tcannot be a parameter,in Tcannot be returned. So a variant conversion is the same reference, with no copying and no checks. - The JIT uses one shared implementation of generic code for all reference-type arguments and a separate one for each value type.
Cost
- An interface constraint plus a value type gives a direct call without boxing (a constrained call).
- In shared code for reference types, some operations go through a lookup in the generic dictionary — slightly more expensive.
string[]has been assignable toobject[]since .NET 1.0. The price is a type check on every store into an array of reference-type elements, and the JIT cannot always remove it.
Trade-offs
- Variance exists only for reference types:
inttoobjectis boxing into a new object, not the same reference. - Array covariance is convenient, but the
ArrayTypeMismatchExceptionshows up only at run time. where T : INumber<T>(static abstract, C# 11, .NET 7) gives youT.Zeroanda + bfor every numeric type. The price is complex signatures that are harder to read.
How to check in production
- Boxing in generic code shows up in BenchmarkDotNet with
[MemoryDiagnoser]and in an allocation profiler. [DisassemblyDiagnoser]shows the machine code generated for a specific type argument.
Takeaway: variance is safe because it is checked at compile time; arrays are the exception you pay for at run time.
IEnumerable<string> names = new List<string> { "a", "b" };IEnumerable<object> objects = names; // covariance: out T
Action<object> printAny = o => Console.WriteLine(o);Action<string> printString = printAny; // contravariance: in TprintString("ok"); // ok
// List<object> list = new List<string>(); // error: List<T> is invariant
object[] array = new string[1]; // array covariance// array[0] = 42; // compiles, but ArrayTypeMismatchException
Console.WriteLine(Max(3, 7)); // 7
static T Max<T>(T a, T b) where T : IComparable<T> => a.CompareTo(b) >= 0 ? a : b; // the constraint gives CompareTo- Why doesn’t
IEnumerable<int>convert toIEnumerable<object>? — Variance is for reference types only:intwould have to be boxed. - Why can’t
IList<T>be covariant? —Talso appears in input positions (Add, the indexer setter). - Where is contravariance in the BCL? —
Action<in T>,IComparer<in T>,IEqualityComparer<in T>, the input parameters ofFunc. - What does the
unmanagedconstraint give you? — A guarantee the type has no references: you can take a pointer, usesizeof, work with memory directly. - Why
allows ref struct? — Since C# 13 it lets you passSpan<T>and otherref structtypes as type arguments.
How is a delegate different from an event, and what is a closure?
Section titled “How is a delegate different from an event, and what is a closure?”What it is
- Delegate — a type for a reference to a method. Built-in ones:
Func<...>returns a value,Action<...>does not. - Event — a delegate with restricted access:
public event EventHandler? Changed;. - Closure — a lambda that uses local variables of the enclosing method.
How to use
- Put a method or a lambda into a delegate:
Func<int, int> square = x => x * x;. - Subscribe with
obj.Changed += Handler;, unsubscribe with-=. - Only the class itself can raise the event:
Changed?.Invoke(this, EventArgs.Empty).
Common mistake
Subscribing to an event of a long-lived object and never unsubscribing. The publisher holds a reference to the subscriber, and the garbage collector cannot collect it.
Takeaway: a delegate is a reference to a method, an event is a delegate that outside code can only subscribe to and unsubscribe from.
How it works
+=callsDelegate.Combineand creates a new immutable delegate with an invocation list.- Handlers run in order. An exception stops the chain, and a
Funcreturns the last handler’s result. If you need something else, iterate overGetInvocationList(). - A lambda captures the variable, not the value: changes made after the lambda was created are visible to it.
When to choose what
| Public delegate field | Event | |
|---|---|---|
| Subscribe and unsubscribe from outside | yes | yes |
| Raise or null out from outside | yes | no |
Thread-safe add/remove |
no | yes, generated by the compiler |
Pitfalls
forloop. The variableiis shared by the whole loop, so after it all lambdas see the last value. Fix it with a local copy inside the body.foreachloop. Since C# 5 each iteration gets its own variable, so there is no problem.- Unsubscribing a lambda.
x.Changed -= (s, e) => ...removes nothing: it is a new delegate that is not equal to the subscribed one.
Rule of thumb
Raise an event with Changed?.Invoke(...): the field is read once, so unsubscribing from another thread cannot cause a NullReferenceException.
Takeaway: an event protects the subscriber list, and a closure holds the variable, not its value.
Under the hood
- Captured variables become fields of a generated class, and the lambda becomes its method.
- The object is created when the variable’s scope is entered, not when the lambda is created. The allocation happens even if the branch with the lambda never runs.
- Lambdas in the same scope share one display class. A small lambda can extend the life of a heavy object captured by its neighbor.
Cost
- A lambda without captures is cached in a static field and created once.
- With captures, every pass creates a new display class and a new delegate.
- On hot paths, use
staticlambdas (C# 9), overloads with explicit state (GetOrAdd(key, factory, arg)onConcurrentDictionary) and static local functions.
Trade-offs
Delegate.Targetis a strong reference to the subscriber. Unsubscribing inDisposeis reliable but needs discipline.- Alternatives are weak events (
WeakEventManagerin WPF) and subscriptions that return anIDisposable, as inIObservable<T>. - A delegate is immutable, so a raise works on a snapshot of subscribers. A handler that unsubscribed from another thread may still be called once more.
How to check in production
- Memory growth shows up in
dotnet-counters, the heap contents indotnet-gcdump. dotnet-dumpwith thegcrootcommand shows the chain from a root to the subscriber — the publisher’s delegate will be in it.- Closure allocations show up in BenchmarkDotNet with
[MemoryDiagnoser].
Takeaway: a closure is an allocation and a subscription is a strong reference; both extend object lifetimes.
var actions = new List<Action>();for (int i = 0; i < 3; i++) actions.Add(() => Console.Write(i)); // one variable i for the whole loopactions.ForEach(a => a()); // 333Console.WriteLine();
actions.Clear();foreach (var n in new[] { 0, 1, 2 }) actions.Add(() => Console.Write(n)); // since C# 5, a new n on every iterationactions.ForEach(a => a()); // 012Console.WriteLine();
var counter = new Counter();counter.Changed += (_, _) => Console.WriteLine("changed");counter.Increment(); // changed// counter.Changed = null; // error: only += and -= from outside
class Counter{ public event EventHandler? Changed; public void Increment() => Changed?.Invoke(this, EventArgs.Empty);}- What does a multicast
Func<int>with three methods return? — The last method’s result; the others are lost. - Why is
Changed?.Invoke()safer than anullcheck followed by a separate call? — The field is read once, so unsubscribing between the check and the call does no harm. - Where are local variables captured by a lambda stored? — In fields of a display class object on the heap.
- What does the
staticmodifier on a lambda forbid? — Capturing locals andthis; you get a compile error instead of a hidden allocation. - How do you find a leak caused by an event subscription? — Take a memory dump and run
gcrooton the subscriber: the publisher’s delegate will be in the chain.
How does equality work in C#: ==, Equals, ReferenceEquals, records?
Section titled “How does equality work in C#: ==, Equals, ReferenceEquals, records?”What it is
| Method | What it checks |
|---|---|
ReferenceEquals(a, b) |
whether it is the same object; cannot be overridden |
a.Equals(b) |
whether they are equal in meaning; for classes, references by default |
a == b |
an operator; for classes without an overload, references |
How to use
string,intandDateTimecompare values with both==andEquals.- A
recordcompares all properties automatically:new Point(1, 2) == new Point(1, 2)istrue. - For your own struct,
Equalscompares fields, while==without an overload does not compile.
Common mistake
Comparing instances of your own class with == and expecting a field-by-field comparison.
Takeaway: by default classes compare by reference, records and string by value.
How it works
==is chosen by the static types of the operands:object a = "aaa", b = new string('a', 3);givesa == basfalseanda.Equals(b)astrue.Equalsis a virtual method: even through a variable of typeobject, the call reaches the actual type’s implementation.- Records generate
Equals(R?),GetHashCode,==,!=,IEquatable<R>andEqualityContract. Fields are compared withEqualityComparer<T>.Default, and records of different types are not equal.
How to implement it by hand
IEquatable<T>.Equals(T)— the main comparison, with no cast and no boxing for structs.override Equals(object)— delegates to it.override GetHashCode()— over the same fields:HashCode.Combine(X, Y).==and!=— consistent withEquals.
Pitfalls
- The contract: if
a.Equals(b), the hashes are equal. Break it and the object gets “lost” in aDictionary. Equalsdoes not throw and returnsfalsefornull.- For
T a, T bwithout constraints,a == bdoes not compile — useEqualityComparer<T>.Default.Equals(a, b).
Rule of thumb
Write null checks as x is null: it does not call an overloaded ==.
Takeaway: Equals and GetHashCode are overridden only together, and == consistently with them.
Under the hood
DictionaryandHashSetpick a bucket by hash, then callEquals.- Change a field that feeds the hash on an object inside a
HashSetand it “disappears”:Containsreturnsfalse, and you cannot remove it. EqualityComparer<T>.DefaultpicksIEquatable<T>when the type implements it, and the JIT devirtualizes that call.
Cost
Equals(object)on a struct boxes the argument.IEquatable<T>removes the boxing.- Compare strings with an explicit
StringComparison:Ordinalis faster than culture-aware comparison and does not depend on the locale.
Trade-offs
- Inheritance breaks symmetry:
point.Equals(colorPoint)can returntruewhile the reverse call returnsfalse. The fix is asealedclass or an exact-type check (GetType(),EqualityContract). - Records compare shallowly: an array or
List<T>property is compared by reference. You need your ownEqualsandGetHashCode. - Records do not suit EF Core entities: EF Core relies on reference equality.
double.NaN == double.NaNisfalseper IEEE 754, butdouble.NaN.Equals(double.NaN)istrue, otherwiseNaNcould never be found in a dictionary.
How to check in production
- The symptom of a broken contract is duplicates in a
HashSetorTryGetValuemisses for “the same” key. A unit test catches it: equal objects, equal hashes. - A poor hash with many collisions turns a dictionary into a linear search — in
dotnet-traceit shows up as time spent inEquals.
Takeaway: equality is a contract for collections: immutable keys, consistent Equals and GetHashCode, and a different rule goes through IEqualityComparer<T>.
object a = "aaa";object b = new string('a', 3);Console.WriteLine(a == b); // False: the object operator compares referencesConsole.WriteLine(a.Equals(b)); // True: virtual string.EqualsConsole.WriteLine(object.ReferenceEquals(a, b)); // False
Console.WriteLine(new Point(1, 2) == new Point(1, 2)); // True: a record compares valuesConsole.WriteLine(new Tags(["x"]) == new Tags(["x"])); // False: the array is compared by reference
public record Point(int X, int Y);public record Tags(string[] Items);
public sealed class Money(decimal amount, string currency) : IEquatable<Money>{ public decimal Amount { get; } = amount; public string Currency { get; } = currency;
public bool Equals(Money? other) => other is not null && Amount == other.Amount && Currency == other.Currency; public override bool Equals(object? obj) => Equals(obj as Money); public override int GetHashCode() => HashCode.Combine(Amount, Currency);}- Why must you override
GetHashCodewhen you overrideEquals? — Otherwise equal objects land in different buckets of the hash table. - Why is
x is nullbetter thanx == null? — It does not call a user-defined==operator. - Are two records with identical arrays inside equal? — No, the array is compared by reference.
- Why is a mutable object dangerous as a dictionary key? — Its hash changes, and the object can no longer be found or removed.
- What is
EqualityContractfor? — So that records of different types in a hierarchy are never considered equal.
What can pattern matching do in modern C#?
Section titled “What can pattern matching do in modern C#?”What it is
A pattern is a condition on the shape of a value. If the value matches, the pattern can declare a variable of the right type right away.
Why
Without patterns you write as, a null check and a cast. A pattern does it in one expression.
How to use
- Type:
if (shape is Circle c)— check and cast at once. null:if (x is null),if (x is not null).- Range:
if (age is >= 18 and < 65). switchexpression: pattern on the left, result on the right, arms separated by commas._means “everything else”.
Common mistake
Forgetting the _ arm. The compiler warns, and an unexpected value crashes the program with an exception.
Takeaway: a pattern checks the shape of a value and pulls data out of it at the same time.
How it works
| Pattern | Example | Version |
|---|---|---|
| Type with declaration | Circle c |
C# 7 |
| Property and positional | { Length: > 0 }, (var x, 0) |
C# 8 |
| Relational and logical | >= 0 and < 10, not null |
C# 9 |
| Extended property | { Address.City: "Kyiv" } |
C# 10 |
| List and slice | [1, .., > 5] |
C# 11 |
A positional pattern calls Deconstruct. A list pattern works with arrays, List<T>, Span<T> and anything that has Length or Count and an indexer.
When to choose what
- You need a value — a
switchexpression. You need actions in the arms — aswitchstatement with patterns incase. - Several inputs — a tuple:
(state, command) switch { (Open, Close) => Closed, ... }.
Pitfalls
- No arm for some value — warning CS8509, and at run time a
SwitchExpressionException. - An
enumneeds_: the variable can hold any number. - List patterns do not produce exhaustiveness warnings.
- An arm fully covered by previous arms is a compile error.
Rule of thumb
Check for null with is null and is not null: a pattern does not call a user-defined operator ==.
Takeaway: a switch expression is a compact “shape → result” table, and a trailing _ protects you from unhandled values.
Under the hood
- All arms are folded into a decision tree (decision DAG). Shared tests — the type,
Length, property values — run once. - The evaluation order of subpatterns is not guaranteed. Properties used in patterns should be cheap and free of side effects.
Cost
- The compiler turns a
switchover constants into jump tables or a comparison tree — usually no slower than hand-writtenifs. - A type test is an ordinary
isinstinstruction; for a sealed type it is a pointer comparison.
Trade-offs
- A class hierarchy is open: the compiler does not know all derived types, so
_is required. C# 15 (.NET 11) introducesuniontypes — for them exhaustiveness is checked without_;closedclasses with the same goal are only proposed so far. - Pattern matching is a functional style: data on one side, operations in a
switch. Adding an operation is easy; adding a case means visiting everyswitch. Polymorphism is the opposite (the expression problem). - Property patterns nested four levels deep read worse than a couple of
ifs. Move complex conditions intowhenor a method.
How to check in production
- Make warning CS8509 an error (
WarningsAsErrors): then a new case cannot pass the build without a new arm. - A
SwitchExpressionExceptionin the logs means an unhandled value; theUnmatchedValueproperty tells you which one.
Takeaway: pattern matching is for data with a closed set of cases, polymorphism is for behavior that will keep growing.
Console.WriteLine(Classify(5)); // digitConsole.WriteLine(Classify(new Order("paid", 5000, ["a", "b"]))); // large paid orderConsole.WriteLine(Classify(new[] { 1, 2, 3 })); // array 1..3
static string Classify(object? input) => input switch{ null => "null", int n when n < 0 => "negative", int and >= 0 and <= 9 => "digit", string { Length: 0 } => "empty string", Order { Status: "paid", Total: > 1000m } => "large paid order", Order { Items: [var single] } => $"single item: {single}", int[] and [var first, .., var last] => $"array {first}..{last}", _ => "something else",};
public record Order(string Status, decimal Total, string[] Items);- What happens if no arm of a
switchexpression matches? — ASwitchExpressionExceptionat run time. - Why does an
enumneed a_arm even if every value is listed? — Anenumcan hold any number of its underlying type. - Why is
is not nullbetter than!= null? — It does not call an overloaded operator and reads unambiguously. - How do you match the first and last elements of an array? — The list pattern
[var first, .., var last](C# 11). - When is pattern matching worse than polymorphism? — When new cases are added often: you have to edit every
switch.