Chapter 02 Csharp 2 Generics · How it works

22 min read
Mid-level4 min read
Rapid overview

How it works


2.1 Generics

2.1.1 Collections before generics (why they mattered)

The pre-generics world commonly used object-based APIs:

  • You lose compile-time safety (casts everywhere).
  • You can pay runtime costs (boxing/unboxing for value types).
  • Errors show up late (invalid casts) and are harder to refactor safely.

Generics fix this by letting you keep strong typing across APIs.

2.1.2 Generics save the day (what you should be able to explain)

Interview-ready framing:

  • Generics let you write one algorithm/data structure that works for many types without sacrificing type safety.
  • They help the compiler enforce contracts (e.g., “this collection contains Customer only”).

2.1.3 What can be generic?

Common generic targets:

  • Classes/structs (e.g., List<T>, Dictionary<TKey, TValue>)
  • Interfaces (e.g., IEnumerable<T>)
  • Methods (e.g., T Parse<T>(string input))
  • Delegates (e.g., Func<T>, Action<T>)

2.1.4 Type inference for method type arguments

Method type inference reduces ceremony:

  • You often don’t need to spell out <T> if it can be inferred from arguments.

Interview nuance:

  • Inference can fail with overload resolution ambiguity; know when to make the type explicit.

2.1.5 Type constraints (encoding “allowed shapes”)

Constraints let you express requirements like:

  • where T : class (reference type)
  • where T : struct (non-nullable value type)
  • where T : new() (public parameterless ctor)
  • where T : SomeBase / where T : ISomeInterface

Why interviewers care:

  • Constraints are a way to push correctness into compile time.
  • They also affect what you can do inside the generic code (new T(), calling members, etc.).

2.1.6 default(T) and typeof(T)

default(T):

  • Returns the “zero” value for the type parameter.
  • For reference types: null
  • For non-nullable structs: all fields defaulted

typeof(T):

  • Gives you the runtime Type for the generic parameter.
  • Useful for diagnostics, caching per-type, reflection-based behavior (with caution).

2.1.6a Open vs closed constructed types

A generic declaration like List<T> is a generic type definition. Supplying type arguments constructs a type from it. The spec's vocabulary:

  • Unbound generic type: List<> / Dictionary<,>. No type arguments at all; only legal inside typeof(...).
  • Open constructed type: still mentions a type parameter somewhere, e.g. List<T> inside class Foo<T>, or Dictionary<string, T>. It only exists inside generic code and cannot be instantiated as-is.
  • Closed constructed type: every type argument is a concrete type, e.g. List<int>, Dictionary<string, Customer>, List<List<int>>. This is a real runtime type: you can create instances of it.
Type unbound = typeof(Dictionary<,>);                   // generic type definition
Type closed  = typeof(Dictionary<string, int>);         // closed constructed type
Type built   = unbound.MakeGenericType(typeof(string), typeof(int));

Console.WriteLine(unbound.IsGenericTypeDefinition);     // True
Console.WriteLine(closed.ContainsGenericParameters);    // False -> fully closed
Console.WriteLine(built == closed);                     // True: same runtime type

Why it matters:

  • Each closed constructed type is its own type at runtime: List<int> and List<string> have separate static fields, separate static constructors, and typeof(List<int>) != typeof(List<string>).
  • Code sharing: the JIT shares one native code body across all closed types whose arguments are reference types (List<string>, List<Customer>), but generates specialised code for each value-type argument (List<int>, List<double>), which is why value-type generics avoid boxing.
  • DI and reflection: containers register an open definition (services.AddScoped(typeof(IRepository<>), typeof(Repository<>))) and close it per request (IRepository<Order>) with MakeGenericType.
Q: What is a closed constructed type in C#?

A: A type built from a generic type definition where every type argument is concrete, e.g. List<int> or Dictionary<string, Customer>. Unlike an open type (List<T>, List<>), it can be instantiated, and each closed constructed type gets its own static fields and static constructor at runtime.

2.1.7 Generic type initialization and state

Things to keep clear:

  • “Generic static” state is typically per closed constructed type (e.g., Cache<int> vs Cache<string>).
  • This can be powerful (type-specific caches) but can surprise you (memory growth across many closed types).

2.2 Nullable value types

2.2.1 Aim: expressing “no value”

Use nullable value types when:

  • You need “missing/unknown” but the domain type is a value type (e.g., int?, DateTime?).

2.2.2 CLR and framework support: Nullable<T>

Key idea:

  • T? is syntax for Nullable<T> (when T is a non-nullable value type).

Interview nuance:

  • Nullables change operator behavior (“lifting”) and can complicate equality and comparisons if you don’t normalize values.

2.2.3 Language support

Patterns to be fluent with:

  • x.HasValue, x.Value
  • x ?? fallback
  • x?.Member (later versions, but conceptually related: propagate null safely)

2.2.4 Boxing (and how Nullable<T> boxes)

Boxing converts a value type to object (or to an interface it implements): the runtime allocates a heap object, copies the value into it and hands back a reference. Unboxing is the reverse: check that the object is a boxed value of exactly that type, then copy the value out.

int i = 42;
object o = i;      // box: heap allocation + copy
int j = (int)o;    // unbox: type check + copy
i = 7;             // o still holds 42; the box is a copy, not an alias

Costs to name in an interview: one allocation per box (GC pressure in hot loops), a copy each way, and an InvalidCastException when the unbox type is wrong ((long)o fails for a boxed int).

Nullable<T> gets special treatment from the CLR:

  • Boxing an int? that has a value produces a boxed int, not a boxed Nullable<int>.
  • Boxing an int? with no value produces a null reference; nothing is allocated.
  • A boxed int can be unboxed to int or to int?; a null reference unboxes to int? as "no value" but throws NullReferenceException when unboxed to int.
int? some = 5, none = null;
object a = some;           // boxed int (a.GetType() == typeof(int))
object b = none;           // null, so b.GetType() throws
int? back = (int?)a;       // 5
int? empty = (int?)b;      // null
int crash = (int)b;        // NullReferenceException
Q: What is boxing in C#?

A: Converting a value type to object or an interface type: the runtime allocates a heap object, copies the value into it and returns a reference. Unboxing checks the boxed type matches exactly and copies the value back out. Each box is an allocation and a copy, so boxing in hot paths adds GC pressure.

Q: What does boxing a Nullable<int> produce?

A: If it has a value, a boxed int (not a boxed Nullable<int>); if it has no value, a null reference. So ((object)(int?)5).GetType() is typeof(int).

2.2.5 The null literal

null is a literal with no type of its own. The compiler converts it to whatever the target needs: a null reference for a reference type, or the "no value" instance of Nullable<T> for a nullable value type.

string s = null;   // null reference
int? x = null;     // compiles to default(Nullable<int>): HasValue == false, no reference anywhere
bool none = x == null;   // compiles to !x.HasValue
var v = null;      // error CS0815: null has no type to infer

Consequences:

  • int? x = null is not a pointer to nothing; it is a struct whose HasValue flag is false.
  • M(null) is ambiguous when M(string) and M(int?) both exist; cast it (M((int?)null)) or use default(int?).
  • In a conditional, cond ? 5 : null did not compile before C# 9 because neither branch converts to the other's type; write cond ? 5 : (int?)null.
Q: What is a null literal in C#?

A: The null keyword: a literal with no type of its own that converts to any reference type (a null reference) or any nullable value type (Nullable<T> with HasValue == false). Because it has no type, var v = null; does not compile.

Q: What does int? x = null; compile to?

A: default(Nullable<int>): a struct with HasValue == false. No reference is involved, and x == null compiles to !x.HasValue.

2.2.6 Lifting and lifted operators

Lifting is the compiler taking an operator or conversion defined on a non-nullable value type T and providing a matching form on T?. You never write the lifted version; the compiler generates the HasValue checks around the original operator.

Lifted for T?: unary + ++ - -- ! ~, binary + - * / % & | ^ << >>, equality == !=, relational < > <= >=, and user-defined operators on your own structs, when the operand and return types are non-nullable value types.

The rules differ by operator family:

Operator familyResult when an operand is null
Arithmetic, bitwise, unary (+ - * / % ~ ++)null (null propagates)
Equality (== !=)bool, not bool?: null == null is true, null == 5 is false
Relational (< > <= >=)false
bool? & and ``three-valued logic: null & false is false, `nulltrue is true, otherwise null`
int? a = 5, n = null;
int? sum = a + n;        // null
bool eq = n == null;     // true
bool lt = n < a;         // false
bool ge = n >= a;        // also false: < and >= are not complements on nullables
bool? t = n & false;     // false

The interview trap: on nullables, !(x < y) is not x >= y. With either side null, both comparisons are false, so code that sorts or filters with negated comparisons silently misbehaves.

Q: What are lifted operators in C#?

A: Nullable forms of operators the compiler derives from the non-nullable operator on a value type. For int?, a + b is the lifted form of int + int: it returns null if either operand is null, otherwise the sum. Equality returns bool (null == null is true), relational operators return false when either side is null.

Q: Why is !(x < y) not the same as x >= y when x and y are int??

A: Lifted relational operators return false when either operand is null, so with a null operand both x < y and x >= y are false, and !(x < y) is true while x >= y is false.

Q: What is (bool?)null & false?

A: false. bool? & and | use three-valued logic: false wins for &, true wins for |, and the result is null only when it genuinely depends on the unknown value.

2.2.7 The as operator with nullable types, and why it is slow

as works with reference types and, since C# 2, nullable value types. It never throws: it returns the converted value or null.

object o = 42;
int? n = o as int?;      // 42; null if o is not a boxed int
int x = o as int;        // error CS0077: int is not nullable, so as cannot return null

Jon Skeet measured o as int? in C# in Depth and found it far slower than the obvious alternative, if (o is int) { int v = (int)o; }, by an order of magnitude or more on the .NET Framework of the time. The reason: unboxing into Nullable<T> is not a plain unbox instruction. The JIT emitted a call to a general runtime helper (JIT_Unbox_Nullable) that handles the null case, checks the boxed type, builds the Nullable<T> and copies it out, instead of the inline type-check-and-load used for (int)o. For reference types as is a single cheap type test, which is why the slowness is surprising.

Guidance:

  • Treat it as a measured result for a given runtime, not a law; modern .NET JITs have narrowed the gap. Benchmark (BenchmarkDotNet) before optimising.
  • The modern form avoids the question entirely: if (o is int v) { … } does one type check and one unbox, with no Nullable<int> in between.
Q: Why is using the as operator with a nullable type surprisingly slow?

A: o as int? must unbox into a Nullable<int>, and the JIT implements that through a general runtime helper call (handling null, type-checking and constructing the nullable) rather than the inline type check plus load it uses for (int)o. Skeet's benchmarks showed it an order of magnitude or more slower than is followed by a cast; today o is int v is the clean, fast form.

Q: Why does o as int not compile?

A: as returns null when the conversion fails, and int cannot hold null. It only works with reference types and nullable value types, so use o as int? or o is int v.


2.3 Simplified delegate creation

Delegates let you pass behavior:

  • “method group” conversions remove ceremony when the target delegate type is known.

2.3.2 Anonymous methods

Anonymous methods are an early step toward lambdas:

  • Create inline delegates without a named method.
  • Enable capturing local variables (closures), which is powerful but can produce subtle bugs if you don’t understand capture semantics.

2.3.3 Delegate compatibility

Compatibility rules enable more assignments without manual wrappers, but can confuse overload resolution.


2.4 Iterators (yield)

Iterators let you implement IEnumerable<T> with lazy execution.

Key ideas to rehearse:

  • Code runs when enumerated, not when the iterator is created.
  • yield return produces values one-at-a-time.
  • finally blocks still matter: cleanup should run when enumeration ends (including early exit / exceptions).

IEnumerable vs IEnumerator: the book and the bookmark

  • IEnumerable<T> is a sequence you can iterate over. Think of it as a book.
  • IEnumerator<T> is a cursor inside that sequence. Think of it as a bookmark.
  • GetEnumerator() puts a new bookmark at the start of the book.
public interface IEnumerable<out T>
{
    IEnumerator<T> GetEnumerator();   // "give me a new bookmark"
}

public interface IEnumerator<out T> : IDisposable
{
    bool MoveNext();   // move the bookmark to the next page; false = no more pages
    T Current { get; } // read the page the bookmark is on
}
What it isThe sequenceA position inside the sequence
StateUsually none, iterating does not change itMutable: MoveNext() changes its position
How manyOneAs many as you like, each independent
Key memberGetEnumerator()MoveNext(), Current

Moving one bookmark does not change the book or any other bookmark:

IEnumerable<string> book = new List<string> { "page 1", "page 2", "page 3" };

IEnumerator<string> bookmarkA = book.GetEnumerator();
IEnumerator<string> bookmarkB = book.GetEnumerator();

bookmarkA.MoveNext();
bookmarkA.MoveNext();
Console.WriteLine(bookmarkA.Current);   // page 2

bookmarkB.MoveNext();
Console.WriteLine(bookmarkB.Current);   // page 1  (B is unaffected by A)

A new enumerator starts before the first element, so you must call MoveNext() once before reading Current.

foreach is just this bookmark pattern written for you. This loop:

foreach (int value in sequence)
{
    Console.WriteLine(value);
}

compiles to roughly:

using (IEnumerator<int> e = sequence.GetEnumerator())   // new bookmark at the start
{
    while (e.MoveNext())                                // turn the page
    {
        int value = e.Current;                          // read the page
        Console.WriteLine(value);
    }
}                                                       // Dispose() runs here

With an iterator method, each GetEnumerator() call gives a fresh state machine, so two loops over the same CreateSimpleIterator() result each start from 10 and each run the body again.

Q: What is the difference between IEnumerable<T> and IEnumerator<T>?

IEnumerable<T> is the sequence (the book) and only hands out enumerators; IEnumerator<T> is a cursor in it (a bookmark) with mutable state that moves forward on each MoveNext().

Q: What does GetEnumerator() do, in the book and bookmark analogy?

It creates a new bookmark placed before the first page: a fresh IEnumerator<T> set up to iterate the sequence from the start.

Q: Two enumerators come from the same IEnumerable<T>; does moving one affect the other?

No. Each enumerator holds its own position, and moving it changes neither the sequence nor any other enumerator.

Q: Why must you call MoveNext() before reading Current on a new enumerator?

A new enumerator sits before the first element; MoveNext() moves it onto the first element and returns false if there is none.

Q: What does a foreach loop compile to?

GetEnumerator() once, then while (e.MoveNext()) reading e.Current each pass, all inside a using/try-finally that disposes the enumerator.

Worked example: a simple iterator

static IEnumerable<int> CreateSimpleIterator()
{
    yield return 10;
    for (int i = 0; i < 3; i++)
    {
        yield return i;
    }
    yield return 20;
}

Consume it with a regular foreach:

foreach (int value in CreateSimpleIterator())
{
    Console.WriteLine(value);
}

Output:

10
0
1
2
20

The compiler turns the method into a state machine. Each MoveNext() call runs the body from where it last stopped until the next yield return, sets Current, and pauses. The for loop's i lives as a field of that state machine, so it survives between calls. When the body reaches its end, MoveNext() returns false and the foreach stops.

What is a state machine?

A state machine is an object that remembers which step it is on, and on each call does the next step and records the new one. Three parts:

  • State: a number that says where it is ("before start", "paused after the first yield", "inside the loop", "finished").
  • Saved data: the variables it needs when it resumes (here, i).
  • Step method: one method that reads the state, runs the next piece of work, updates the state and returns.

Everyday picture: a traffic light. Its state is red, green or amber; each tick it looks at the current state, switches to the next and stays there until the next tick. It does not re-run from the start every time.

Why the compiler needs one: a normal method runs top to bottom and its local variables die when it returns. An iterator must return a value from the middle of its body and later carry on from that exact line with its locals intact. A method cannot do that, so the compiler rewrites the body into a class: locals become fields, the resume point becomes a state field, and the body becomes MoveNext().

The states of CreateSimpleIterator():

stateDiagram-v2 [*] --> BeforeStart BeforeStart --> After10: MoveNext() yields 10 After10 --> InLoop: MoveNext() sets i = 0, yields 0 InLoop --> InLoop: MoveNext() i++, yields i (while i < 3) InLoop --> After20: MoveNext() loop done, yields 20 After20 --> Finished: MoveNext() returns false Finished --> [*]

Roughly what the compiler writes (simplified; the real class also handles Dispose(), threads and GetEnumerator()):

class SimpleIteratorStateMachine : IEnumerator<int>
{
    private int state = 0;      // 0 = before start, 1 = after 10, 2 = in loop, 3 = after 20, -1 = finished
    private int i;              // the local variable, now a field so it survives between calls
    public int Current { get; private set; }

    public bool MoveNext()
    {
        switch (state)
        {
            case 0:                          // first call: run up to the first yield
                Current = 10; state = 1; return true;
            case 1:                          // resume after "yield return 10"
                i = 0;
                goto case 2;
            case 2:                          // resume inside the loop
                if (i < 3) { Current = i; i++; state = 2; return true; }
                Current = 20; state = 3; return true;
            default:                         // after 20, or already finished
                state = -1; return false;
        }
    }
    // Dispose(), Reset(), IEnumerator.Current omitted
}

Each MoveNext() jumps straight to the code for the saved state, runs until the next yield return, stores the value in Current, saves the next state, and returns true. That is how "pause and resume" works with ordinary method calls.

Rules and limits:

  • Calling the iterator method only creates this object (state 0); no body code runs until the first MoveNext().
  • Every GetEnumerator() returns a new object with its own state and i, so two loops never share a position.
  • Once finished (state -1), MoveNext() keeps returning false; it never restarts.
  • Dispose() uses the state to know which pending finally blocks to run.
  • async/await uses the same technique: the compiler turns an async method into a state machine whose MoveNext() resumes after each await.
Q: What is a state machine, in one sentence?

An object that stores which step it is on plus the data it needs, and on each call runs the next step and saves the new state.

Q: Why does the compiler turn an iterator method into a state machine?

A normal method cannot return from its middle and later resume there with its locals intact; storing the resume point in a state field and the locals in fields lets each MoveNext() call continue where the last one stopped.

Q: In the generated iterator class, what do the state field and the other fields hold?

state holds the resume point (before start, paused after a given yield, finished); the other fields hold the method's parameters and local variables so they survive between MoveNext() calls.

Q: Which other C# feature is compiled to a state machine the same way?

async/await: each await is a resume point, and the generated MoveNext() continues after it when the awaited task completes.

2.4.2 Lazy execution: same output, different execution

You could write the same method eagerly, building a list and returning it:

static IEnumerable<int> CreateSimpleList()
{
    var list = new List<int>();
    list.Add(10);
    for (int i = 0; i < 3; i++)
    {
        list.Add(i);
    }
    list.Add(20);
    return list;
}

The foreach loop prints exactly the same 10 0 1 2 20, but it does not execute the same way:

When the body runsAll of it, at the callPiece by piece, on each MoveNext()
MemoryEvery value stored at onceOne value at a time
Caller stops early (break, Take, First)All values were already computedRemaining values are never computed
Infinite sequenceNever returns (or runs out of memory)Works, the caller decides when to stop
Exceptions in the bodyThrown at the callThrown during the foreach
Enumerating twiceReads the same stored listRuns the body again

Add tracing to both to see the difference:

static IEnumerable<int> EagerTraced()
{
    var list = new List<int>();
    for (int i = 0; i < 3; i++)
    {
        Console.WriteLine($"producing {i}");
        list.Add(i);
    }
    return list;
}

static IEnumerable<int> LazyTraced()
{
    for (int i = 0; i < 3; i++)
    {
        Console.WriteLine($"producing {i}");
        yield return i;
    }
}

foreach (var x in EagerTraced()) Console.WriteLine($"consuming {x}");
foreach (var x in LazyTraced()) Console.WriteLine($"consuming {x}");

Eager output: all producing lines first, then all consuming lines.

producing 0
producing 1
producing 2
consuming 0
consuming 1
consuming 2

Lazy output: producer and consumer take turns.

producing 0
consuming 0
producing 1
consuming 1
producing 2
consuming 2

Laziness is what makes an infinite iterator usable:

static IEnumerable<int> Naturals()
{
    int n = 0;
    while (true)
    {
        yield return n++;
    }
}

foreach (var n in Naturals().Take(3)) Console.WriteLine(n);   // 0 1 2

The list version of Naturals() would never return.

Q: An eager List<int> method and a yield return iterator print the same values in a foreach; what is the difference?

The list version runs its whole body at the call and stores every value; the iterator runs only when enumerated, one step per MoveNext(), storing one value at a time, and stops computing when the caller stops.

Q: Why can an iterator return an infinite sequence while a list-building method cannot?

The iterator produces a value only when asked, so Take(3) or break ends it; the list method must finish building the whole list before it returns, which never happens.

Q: When is an exception inside an iterator body thrown: at the call or during enumeration?

During enumeration, on the MoveNext() that reaches it; the call itself only creates the state machine. A list-building method throws at the call.

Example: proving the execution is lazy

static IEnumerable<int> Traced()
{
    Console.WriteLine("start");
    yield return 1;
    Console.WriteLine("after 1");
    yield return 2;
    Console.WriteLine("end");
}

var seq = Traced();              // prints nothing
Console.WriteLine("created");
foreach (var x in seq)
{
    Console.WriteLine($"got {x}");
}

Output:

created
start
got 1
after 1
got 2
end

Calling Traced() only builds the state machine; the body starts on the first MoveNext(), and producer and consumer lines interleave.

Example: early exit runs finally

static IEnumerable<int> WithCleanup()
{
    try
    {
        yield return 1;
        yield return 2;
    }
    finally
    {
        Console.WriteLine("cleanup");
    }
}

foreach (var x in WithCleanup())
{
    Console.WriteLine(x);
    break;
}

Output: 1 then cleanup. break makes foreach call Dispose() on the enumerator, which runs the pending finally.

Q: What does foreach over CreateSimpleIterator() (yield 10, then 0..2 in a loop, then 20) print?

10, 0, 1, 2, 20, one per line: each yield return hands one value to the loop, and the body resumes after it on the next MoveNext().

Q: What happens when you call an iterator method but never enumerate the result?

Nothing in the body runs; you only get a state-machine object. The first statement executes on the first MoveNext().

Q: How does a local loop variable like i survive between yield return calls?

The compiler hoists it into a field of the generated state-machine class, together with a state number recording where to resume.

Q: Why does break inside a foreach over an iterator still run the iterator's finally block?

foreach disposes the enumerator when it exits, and the generated Dispose() runs any finally blocks that are still pending.

Disposing an iterator: who closes the file?

The rule: whoever calls GetEnumerator() must call Dispose() on what it gets back. foreach does it for you. If you call MoveNext() yourself, you do it.

Disposing an iterator means telling it: "I'm done with you, clean up what you hold." For a yield iterator, Dispose() runs the finally blocks it has not run yet. A using inside an iterator is a try/finally, so Dispose() closes that resource too.

Why it matters: an iterator can hold a file, a database connection or a lock while it is paused between two yield returns.

public IEnumerable<string> ReadLines(string path)
{
    using var reader = new StreamReader(path);   // file opens on the FIRST MoveNext()
    string line;
    while ((line = reader.ReadLine()) != null)
        yield return line;                       // paused here, file still open
}                                                // file closes at the end OR on Dispose()

What happens when the caller stops after one line:

How you iterateStops early with breakFile closed?
foreachyesYes: foreach calls Dispose()
MoveNext() by hand, no usingyesNo: the file stays open until the garbage collector runs a finalizer, if ever
MoveNext() by hand, inside usingyesYes: using calls Dispose()
Any style, read to the endnoYes: the method body reaches its end and the using closes the file

Read to the end, the iterator cleans up by itself. Stop early without Dispose() and the cleanup never runs. That is a leak (open file handle, open connection) or at best a delay until a finalizer runs.

using syntax: two ways to write the same thing. A using statement calls Dispose() on its object when the block ends, even if an exception is thrown.

// Option A: declare the variable inside using
using (TextReader reader = File.OpenText(path))
{
    reader.ReadToEnd();
}   // reader.Dispose() runs here; reader no longer exists after this line

// Option B: declare the variable before using
TextReader reader = File.OpenText(path);
using (reader)
{
    reader.ReadToEnd();
}   // reader.Dispose() runs here, but reader is still in scope

Both compile to the same try/finally:

TextReader reader = File.OpenText(path);
try
{
    reader.ReadToEnd();
}
finally
{
    if (reader != null)
        ((IDisposable)reader).Dispose();
}
Variable usable after the block?No, compile errorYes, but it is already disposed
Exception between create and using?ImpossiblePossible: the object leaks
When to useDefaultWhen the object comes from somewhere else, e.g. GetEnumerator() you called earlier

Trap in option B: code after the block can still call reader.ReadToEnd(); it compiles, then throws ObjectDisposedException at runtime. Option A makes that mistake a compile error.

Since C# 8 there is also the using declaration, using var reader = File.OpenText(path);, with no braces: Dispose() runs when the enclosing scope (method or block) ends. The ReadLines iterator above uses this form.

Manual iteration, done right (generic):

using (IEnumerator<string> e = ReadLines("test.txt").GetEnumerator())
{
    while (e.MoveNext())
    {
        Console.WriteLine(e.Current);
        break;                     // leaving the using block calls e.Dispose()
    }
}

Generic vs non-generic: why the book mentions both.

InterfaceExtends IDisposable?What you write
IEnumerator<T> (generic)Yesusing (var e = seq.GetEnumerator())
IEnumerator (non-generic, from .NET 1)Notry/finally + check e is IDisposable

The non-generic IEnumerator was designed before IDisposable mattered to iteration, so its type does not promise a Dispose() method. The object behind it may still have one, so you check at runtime:

IEnumerator e = collection.GetEnumerator();   // collection is a non-generic IEnumerable
try
{
    while (e.MoveNext())
        Console.WriteLine(e.Current);
}
finally
{
    if (e is IDisposable disposable)
        disposable.Dispose();
}

That is what the compiler generates for a foreach over a non-generic sequence. For a generic sequence it can call Dispose() directly because the interface guarantees it.

Allowed and forbidden:

  • Allowed: never disposing an iterator you read to the end; its body already ran its finally.
  • Allowed: calling Dispose() twice; the generated Dispose() does nothing the second time.
  • Forbidden: stopping a manual MoveNext() loop early with no using or try/finally.
  • Forbidden: using on a non-generic IEnumerator; it does not compile, because the type is not IDisposable.
Q: Who is responsible for disposing an enumerator?

The code that called GetEnumerator(). foreach does it automatically; a manual MoveNext() loop must dispose it itself with using or try/finally.

Q: What does calling Dispose() on a yield iterator actually do?

It runs the finally blocks (including the ones a using creates) that are still pending at the point the iterator is paused, so open files or connections get closed.

Q: An iterator reads a file with using var reader; you call MoveNext() once by hand and stop without disposing. What happens to the file?

It stays open: the using cleanup only runs at the end of the method body or on Dispose(), and neither happened, so the handle leaks until a finalizer runs, if ever.

Q: Why can you write using around IEnumerator<T> but not around the non-generic IEnumerator?

IEnumerator<T> extends IDisposable; the non-generic IEnumerator does not, so for it you use try/finally and dispose only if e is IDisposable.

Q: What is the difference between using (TextReader r = File.OpenText(p)) { } and declaring r first, then writing using (r) { }?

Both dispose r when the block ends; declared inside, r is out of scope afterwards, while declared before, r stays usable but is already disposed, so using it throws ObjectDisposedException.

Q: What does a using statement compile to?

A try/finally whose finally calls Dispose() on the object if it is not null, so cleanup runs even when the block throws.

  • Multiple enumeration can re-run the iterator body.
  • Capturing mutable state in iterators can cause surprising behavior if you reuse the enumerable concurrently.
Q: How does foreach dispose an enumerator from a non-generic IEnumerable?

It wraps the loop in try/finally and, in the finally, checks whether the enumerator implements IDisposable at runtime and calls Dispose() if it does.

Interview traps:


2.5 Minor features (still interview-relevant)

  • Partial types: split a type across files (often used by codegen + hand-written code).
  • Static classes: enforce “utility holder” semantics.
  • Different access modifiers for getters/setters: control encapsulation without extra backing fields.
  • Namespace aliases: disambiguate types with the same name or simplify long namespaces.
  • Pragmas / fixed buffers / InternalsVisibleTo: niche but useful to recognize (especially InternalsVisibleTo for testing).

See also