Nearly every C# surprise reduces to one of two questions. What got copied? — because a variable either holds your data or holds a reference to it, and assignment means something different in each case. When did that run? — because queries, iterators and awaited methods build machinery now and execute later, somewhere else. The syntax is the easy part; a feature tour will not tell you which half you are looking at. This guide follows those two questions and the third behind them: who cleans up.
Each chapter opens with the short version. Tap one to read the detail.
Value semantics: what a variable holds
~3 min
Every C# type is a value type or a reference type. A value-type variable holds the data, so assignment copies it; a reference-type variable holds a reference, so both names then reach one object.
Structs, enums and the built-in numeric types are value types; classes, arrays, delegates and string are reference types. Assign a value type and the data is copied, leaving two independent variables. Assign a reference type and you copy the reference: both names reach the same object, so a change through one is visible through the other.
The seam between them is boxing: converting a value type to object, or to an interface it implements, allocates an object on the heap and copies the value into it. Boxing is implicit and easy to miss; unboxing is an explicit cast that must name the exact type, and a mismatch throws InvalidCastException.
int i = 123;
object o = i; // boxed — a separate heap copy
i = 456;
Console.WriteLine(o); // 123: the box kept the old value
int j = (int)o; // unboxes
// int k = (short)o; // InvalidCastException at run time
Design your own value type and the copying becomes yours to manage. A struct is copied on assignment, argument passing and return; readonly struct promises nothing mutates. Partial immutability is the trap: inside a readonly member, calling a member that is notreadonly makes the compiler copy the instance and call the method on the copy, so your mutation lands somewhere you cannot see. Separately, default(T) and new T[n] skip any parameterless constructor and produce the all-zero value.
Records reach the same goal from the reference side, generating Equals, GetHashCode, == and ToString from the declared members, so two instances holding equal data are equal; with copies and replaces named members, shallowly, so a reference-typed property is shared. Two details catch people: positional properties are init-only in a record class and a readonly record struct but read-write in a plain record struct, and a base record never equals a derived one, because the generated EqualityContract compares runtime types.
string then breaks the rule you started with: a reference type that acts like a value. It is immutable, so anything that appears to modify a string returns a new one while a second variable keeps the old text, and == compares characters rather than references. That convenience hides a documented inconsistency: ==, string.Equals and Contains compare ordinally by default, while IndexOf(string), StartsWith, EndsWith and Compare are culture-sensitive by default.
Rule: for every assignment ask whether it copies the data or the reference, and design your own value types to be immutable — a mutable struct is where the copies turn invisible.
Two different nulls that share a keyword
~2 min
int? is a real type, Nullable<int>, with runtime behavior. string? is an annotation the compiler reads and then erases — it changes what you get warned about and nothing else. Confusing the two is the whole difficulty.
A nullable value type is System.Nullable<T>, itself a struct. Reading .Value when there is no value throws InvalidOperationException, not a null reference, and so does an explicit cast such as (int)n. Operators are lifted: if either operand is null, the result is null.
The comparison rule is what produces real bugs. With <, >, <= or >=, a null operand makes the result false — both ways.
So !(a < b) is not a >= b once nulls are possible, and a range check written as a single negation quietly admits the null case.
Nullable reference types are a different mechanism wearing the same ?, and assuming they behave like the value-type kind is the usual mistake. They are entirely compile-time: your program's runtime behavior is unchanged, and string and string? are both System.String. The compiler tracks a null-state of not-null or maybe-null through assignments and null checks, and warns where that disagrees with your annotations. The null-forgiving ! declares an expression is not null; it asserts, it does not check, and each use is a place the compiler stopped protecting you.
The analysis has documented blind spots. default(SomeStruct) and new string[3] both leave non-nullable reference fields holding null with no warning, because nothing was ever assigned.
Rule: treat ? on a value type as a runtime fact and ? on a reference type as a conversation with the compiler — and keep real argument checks on public entry points, because callers may not compile with the feature on.
Classes: what you expose, and what a subclass really gets
~2 min
A property is a pair of methods, not storage, which is why it can gain validation later without breaking callers. override dispatches on the runtime type; new hides instead, so one object behaves differently through two references.
Properties look like fields but are accessor methods over a hidden backing field. That is the point: a property can be virtual, appear in an interface, restrict its setter (private set, init), or start validating input, without breaking a caller already writing person.FirstName.
Arguments are always passed by value. For a class that means the reference is copied: mutating a member of the argument is visible to the caller, but replacing the object the parameter points at is not. ref aliases the caller's variable, out demands the method assign it, and in passes a large struct by read-only reference to avoid the copy.
Inheritance offers two mechanisms that look alike and are not. A virtual member a subclass overrides dispatches on the object's runtime type. A member redeclared with newhides the base member, and dispatch follows the compile-time type — so one object answers differently depending on which variable you call it through. The compiler warns when you hide without saying new.
Override Equals and GetHashCode together: they are meant to agree, and a dictionary key whose hash disagrees with its equality becomes unfindable.
Interfaces are contracts with no instance state — no fields, no instance constructors. Implement a member explicitly and it is reachable only through an interface-typed reference, which is how one class — with one base class but any number of interfaces — satisfies two that declare the same signature:
public class Runway(double meters) : IMetric, IImperial
{
double IMetric.GetDistance() => meters;
double IImperial.GetDistance() => meters * 3.28084;
}
Interfaces suit capabilities that cut across unrelated hierarchies; an abstract class is the better home for shared state.
Rule: expose properties rather than fields, use override when you mean polymorphism, and treat a hiding warning as a design question rather than noise.
Pattern matching, and the switch that returns a value
~2 min
Modern C# tests the shape of a value, not its type alone. Arms are checked in text order and the compiler errors on an arm an earlier one already covers — but a switch expression that misses a case only warns, then throws at run time.
A switch statement directs control flow; a switch expression produces a value. Both draw on one vocabulary: type patterns with a binding (is Order o), constants, relational patterns (< 32), the combinators and, or and not, property patterns ({ Items: > 10 }), positional patterns over a Deconstruct method, list patterns, and when guards.
Order is a rule, not a style: arms are evaluated top to bottom, and the compiler errors when a lower arm can never be chosen because a higher one already matches everything it would.
Exhaustiveness is weaker than it looks, and this is the trap.
string Describe(int n) => n switch
{
< 0 => "negative",
0 => "zero",
// nothing for positive numbers: a warning, not an error
};
Miss a case and you usually get only a warning; at run time an unmatched input throws SwitchExpressionException on .NET Core 3.0 and later, or InvalidOperationException on .NET Framework. List patterns produce no warning at all. A discard arm (_ =>) is what actually makes an expression exhaustive.
In a switchstatement, control cannot fall from one section into the next: every non-empty section must end with break, return, throw or goto.
Rule: let the compiler prove your cases — order arms narrowest first, and add the discard arm deliberately rather than because a warning appeared.
Arithmetic, and the conversions you did not write
~2 min
Integer division truncates, integer overflow wraps silently unless you ask for checked, and compound assignment quietly inserts a narrowing cast. Floating-point arithmetic never throws — it hands you Infinity or NaN and carries on.
int / int is integer division: an integer result, rounded toward zero. 13 / 5 is 2, and because the operator is left-associative, 9 / 5 / 2 is 0. Cast one operand to double or decimal to get a fraction.
Integer overflow is unchecked by default — the result is truncated to the destination type's bits. Wrap the expression in checked and it throws OverflowException instead. Integer division by zero always throws DivideByZeroException, but float and double arithmetic never throws at all: 1.0 / 0.0 gives Infinity and 0.0 / 0.0 gives NaN.
Those rules combine in compound assignment, which looks like shorthand for a = a + b and is not: it inserts a cast back to the left operand's type when the promoted result would not fit.
byte a = 200, b = 100;
var c = a + b; // int, 300 — byte operands promote to int
a += b; // 44 — the compound form casts 300 back to byte
Then the choice of type. float carries roughly 6–9 significant digits and double roughly 15–17, both as binary approximations, so 0.1 has no exact representation and 3 * 0.1 == 0.3 is False. decimal holds 28–29 digits in base 10, represents 0.1 exactly, and is the type for money — at the cost of 16 bytes and no implicit conversion to or from the binary types.
Rule: pick the type for the arithmetic you actually need, then make every narrowing visible — if a conversion matters, write the cast yourself rather than letting += write it for you.
Generics, and which substitutions are allowed
~2 min
A type parameter is checked once and reused for every argument, which removes both casts and boxing. Constraints are what let you do anything with a T. Variance decides when Thing<Derived> may stand in for Thing<Base> — by default it may not.
Generics let you write one implementation the compiler checks per type argument. List<string> will not accept an int, and a List<int> stores its elements without boxing each one — the two problems an object-based container has.
Inside the method, an unconstrained T gives you only what object gives you. Constraints buy back capability: where T : class or struct, where T : new() to construct one, or a base type or interface to call its members.
static T Max<T>(T a, T b) where T : IComparable<T> =>
a.CompareTo(b) >= 0 ? a : b; // CompareTo needs the constraint
Variance answers a different question: given that Dog derives from Animal, when is Thing<Dog> usable as Thing<Animal>? By default never — the parameter is invariant, and writing is the reason. If List<Dog> were a List<Animal>, you could add a cat to it.
A type parameter marked out is covariant and may appear only in output positions, which is why IEnumerable<Dog> is an IEnumerable<Animal>. Marked in, it is contravariant and may appear only in input positions, which is why an Action<Animal> serves where an Action<Dog> is wanted — a handler for any animal certainly handles a dog. Only interfaces and delegates may declare variance.
Arrays are the exception: they are covariant, unsafely, so the compiler accepts object[] array = new string[10]; and the store array[0] = 10; fails at run time. Variance is defined over reference conversions, so it never reaches value types.
Rule: constrain a type parameter to exactly what the body needs, and read out as "only comes out" and in as "only goes in" — the direction is the permission.
Delegates, lambdas and what a closure captures
~2 min
A delegate is a type describing a signature; an instance points at a method. A lambda captures the variable, not its value at that moment, and keeps it alive as long as the delegate lives — which explains a whole class of leak.
Declaring a delegate declares a type. An instance can hold any accessible static or instance method with a compatible signature, and for delegates the return type is part of that compatibility. Func, Action and Predicate are the ready-made generic delegate types, and a lambda converts to whichever fits.
The event keyword wraps a delegate so outside code may only subscribe and unsubscribe, leaving the publisher in charge of raising it.
Capture deserves the attention. A lambda referring to an outer variable captures the variable itself, and that variable is not collected until the delegate referencing it is. Two lambdas closing over the same local therefore see each other's writes, and the local outlives the method that declared it:
int j = 0;
Action<int> set = x => j = x;
Func<int, bool> isJ = x => x == j;
set(10);
Console.WriteLine(isJ(10)); // True — both lambdas share one variable
That is the mechanism behind subscribers that never die: while a publisher holds a handler, the handler holds its captured state and its target object, and only unsubscribing releases them. For a guarantee rather than a habit, static x => ... forbids capture at compile time.
Rule: assume a lambda holds its variables, not their values — and unsubscribe from anything whose lifetime is shorter than the publisher's.
Deferred execution: iterators and queries
~2 min
Building a query executes nothing. yield return and the query operators in System.Linq both hand you a description of work; the work happens when something enumerates it, once per enumeration.
An iterator method — one containing yield return — is rewritten by the compiler into a state machine that resumes where it left off. Nothing in the body runs when you call it. It runs when the caller starts enumerating.
That delay swallows your argument checks, which is why the documented pattern splits the method in two: a normal method that validates, returning a private iterator.
public IEnumerable<int> Take(int count)
{
if (count < 0) throw new ArgumentOutOfRangeException(nameof(count));
return Iterator(count); // validation already ran, at the call
}
private IEnumerable<int> Iterator(int n)
{
for (int i = 0; i < n; i++) yield return i;
}
Query operators work the same way: a query is not executed until you iterate the query variable. Enumerate twice and the work happens twice, and changing the source or a captured variable in between changes the second answer. ToList, ToArray, Count, First and the aggregates force the work now — which is why starting a set of asynchronous calls with Select and forgetting ToList() starts nothing.
The last distinction is where the code runs. A query over IEnumerable<T> compiles its lambdas to delegates and runs them in memory. A query over IQueryable<T> compiles them to expression trees, which a provider inspects and translates into whatever its data source speaks. Same lambda, entirely different execution — and an expression the provider cannot translate fails there, not here.
Rule: name the moment your query executes, and force it with ToList() when anything it depends on is about to change.
async/await is not a thread
~2 min
await does not start a thread. It signs the rest of the method up as a continuation and returns control to the caller. Blocking on the resulting task with .Result or .Wait() is the classic deadlock, and async void throws exceptions nobody can catch.
The async and await keywords do not cause extra threads to be created. The compiler splits the method at each await into a state machine; awaiting something incomplete registers the rest as a continuation and returns control to your caller, leaving the thread free. Use Task.Run when the work is a computation that needs a thread — awaiting alone never makes one.
Exceptions travel differently. A failure inside an async method faults its task and is stored in Task.Exception, an AggregateException; awaiting rethrows the first inner exception, so try/catch around the await reads normally — but the failure surfaces at the await, not at the call that started the work.
Three failure modes account for most async pain.
async void cannot be awaited, and its caller cannot catch what it throws. It exists for event handlers; anything else should return Task.
Blocking on a task deadlocks. Awaiting normally resumes the continuation on the context that was captured — for a desktop app, the UI thread. Block that same thread with .Result or .Wait() and the continuation can never run. The documented replacements are direct: await instead of Task.Result, await Task.WhenAll instead of Task.WaitAll, await Task.Delay instead of Thread.Sleep. Library code with no business resuming on anyone's context should call ConfigureAwait(false).
And awaiting each call in turn is sequential. Start the operations first, then await:
var eggs = FryEggsAsync(2); // both already running
var toast = ToastBreadAsync(2);
await Task.WhenAll(eggs, toast);
Rule: await everything and block on nothing — a synchronous wait inside an async call stack is the bug, not the workaround.
Exceptions: unwinding, filters, and rethrowing
~2 min
The runtime walks up the call stack for the first matching catch, so clauses are checked top to bottom and the most derived type must come first. throw; preserves the original stack trace; throw e; overwrites it.
When an exception is thrown, the runtime looks for a catch clause in the current method, then in its caller, and so on up the stack; if none matches, the thread is terminated. Within one try, clauses are checked in order, at most one runs, and the first naming the exact type or a base type wins — so a catch (Exception) above a specific clause makes it unreachable.
Rethrowing is where diagnostic information is usually lost. A bare throw; preserves the original stack trace; throw e; updates the trace on e, replacing the origin with the line you just wrote. When you translate an exception rather than rethrow it, pass the original as the inner exception so the cause survives.
catch (IndexOutOfRangeException e)
{
throw new ArgumentOutOfRangeException("Index is out of range.", e);
}
Exception filters are the underused tool. A when clause is a boolean tested before the stack unwinds, so it can inspect the exception while the original frames and locals are intact. A filter that logs and returns false therefore observes every exception without handling any — something catch-and-rethrow cannot do without disturbing the trace.
finally runs whenever control leaves the try: normal completion, a return or other jump, or an exception propagating out. That guarantee is the foundation for using, which the compiler rewrites into try/finally.
Do not catch an exception you cannot handle: an empty catch (Exception) turns a crash you would have fixed into corrupted state you find much later.
Rule: catch the narrowest type you can genuinely recover from, rethrow with a bare throw;, and use a when filter when you only want to look.
Cleanup: the collector, IDisposable, and spans
~2 min
The collector frees memory by reachability from roots, not by counting references — so cycles are collected, and anything a static field or a live delegate can reach never is. It knows nothing about file handles, which is what IDisposable is for.
Liveness is reachability. The collector builds a graph from the application's roots — static fields, locals on thread stacks, processor registers, collection handles — and anything outside it is garbage. Because that is reachability rather than a reference count, two objects referencing only each other are still collected.
The heap is generational, on the observation that most objects die young: new objects start in generation 0, survivors are promoted to 1 and then 2, and collecting a generation also collects the younger ones — so generation 0 collections are cheap and a generation 2 collection is the full, expensive one.
A managed "leak" is therefore never uncollected garbage; it is something still reachable. Static collections, event subscriptions and captured closures are the usual roots.
The collector knows nothing about unmanaged resources — file handles, sockets, native memory — which is what IDisposable exists for. The using statement compiles to try/finally, so disposal survives both an exception and an early return. In your own type the shape is fixed:
public void Dispose()
{
Dispose(disposing: true); // release what this type owns
GC.SuppressFinalize(this); // no finalizer needs to run now
}
Write a finalizer only if you hold an unmanaged resource directly, and prefer a SafeHandle, which brings its own — finalization is nondeterministic.
Spans are the allocation-avoidance half of the story. A Span<T> is a view over memory that already exists — an array, a stackalloc buffer, native memory — so creating or slicing one copies nothing, and ReadOnlySpan<char> over a string replaces the allocation Substring makes. Because a span must live on the stack it cannot be a class field or used in asynchronous code; Memory<T> is the heap-storable counterpart.
Rule: let the collector handle memory and take personal responsibility for handles — a type implementing IDisposable is telling you it owns something the collector cannot see.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.