📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How does C# code become IL? Explain lowering with examples such as foreach, using, lock, string concatenation and switch.

Roslyn lowering of high-level C# constructs into simpler C# and IL, and what Debug versus Release changes.

Mid-Level Theory Advanced compiler il lowering roslyn

🎯 Key points

  • Roslyn parses, binds, analyses flow, lowers and then emits IL, metadata and PDBs
  • Lowering rewrites high-level constructs into a small core language
  • foreach, using and lock become loops and try/finally blocks; lock on System.Threading.Lock uses EnterScope
  • String concatenation becomes string.Concat; interpolation uses DefaultInterpolatedStringHandler
  • async methods and iterators become state machines; capturing lambdas become display classes
  • Roslyn optimises little; the JIT does most optimisation
  • SharpLab and ILSpy show lowered C# and IL

⏱ 30-second answer

Roslyn parses C# into syntax trees, binds names and types into a semantic model, then lowers high-level constructs into simpler ones before emitting IL. foreach over an array becomes an index loop; over `List<T>` it uses the struct enumerator in a try/finally; using becomes try/finally with Dispose; lock becomes Monitor.Enter(obj, ref lockTaken) / Monitor.Exit (or Lock.EnterScope() for System.Threading.Lock); a + b + c on strings becomes one string.Concat; a switch on strings may become a length or hash-based jump. async methods and iterators become state machines. Roslyn does little optimisation; the JIT does the heavy work.

🗣 Full answer (senior)

How I would answer this in an interview

Roslyn compiles C# in phases: parse to syntax trees, bind names and types (semantic analysis), lower high-level constructs into simpler ones, then emit IL. Lowering is where much of C#'s convenience syntax disappears: foreach, using, lock, string interpolation/concatenation, switch, lambdas, async, iterators, pattern matching all become plain loops, try/finally blocks, method calls and generated classes. Knowing the lowered forms explains performance and behaviour.

1. foreach

foreach (var line in orderLines) Process(line);   // List<OrderLine>

lowers to:

List<OrderLine>.Enumerator e = orderLines.GetEnumerator();   // struct enumerator - no allocation
try
{
    while (e.MoveNext()) { var line = e.Current; Process(line); }
}
finally { ((IDisposable)e).Dispose(); }
  • Arrays and spans lower to for loops with an index (no enumerator, bounds-check elimination).
  • Through IEnumerable<T>, the enumerator is an interface → boxed if struct, interface calls.
  • Since C# 5, the loop variable is declared inside the loop (fresh per iteration for closures).
  • await foreach lowers to GetAsyncEnumerator, await MoveNextAsync(), await DisposeAsync() in finally.

2. using

using var stream = File.OpenRead(path);
Process(stream);

lowers to:

FileStream stream = File.OpenRead(path);
try { Process(stream); }
finally { if (stream != null) ((IDisposable)stream).Dispose(); }

For struct disposables, the compiler calls Dispose without boxing (constrained call). await using calls DisposeAsync() and awaits it. Pattern-based Dispose works for ref structs.

3. lock

lock (_sync) { _balance -= amount; }

lowers to (for object locks):

object obj = _sync; bool lockTaken = false;
try
{
    Monitor.Enter(obj, ref lockTaken);
    _balance -= amount;
}
finally { if (lockTaken) Monitor.Exit(obj); }

In C# 13 / .NET 9, if the lock expression is a System.Threading.Lock, it lowers to using (_lock.EnterScope()) { ... } — the new dedicated lock type with a faster implementation. (Converting a Lock to object and locking on it falls back to Monitor — the compiler warns.)

4. String concatenation and interpolation

string a = first + " " + last;              // string.Concat(first, " ", last) - single allocation
string b = $"Order {id} total {total:C}";   // C# 10+: DefaultInterpolatedStringHandler

b lowers to:

var h = new DefaultInterpolatedStringHandler(literalLength: 13, formattedCount: 2);
h.AppendLiteral("Order ");
h.AppendFormatted(id);
h.AppendLiteral(" total ");
h.AppendFormatted(total, "C");
string b = h.ToStringAndClear();
  • No boxing of id/total (generic AppendFormatted<T>), pooled buffer, one final string.
  • Constant-only interpolations become constants.
  • += in loops still creates a new string each iteration (lowers to Concat per iteration) — use StringBuilder.
  • Custom handlers ([InterpolatedStringHandler]) let APIs like logging avoid work when disabled.

5. switch

string Describe(OrderStatus s) => s switch { OrderStatus.Paid => "Paid", OrderStatus.Shipped => "Shipped", _ => "Other" };
  • Dense integer/enum constants → IL switch jump table.
  • Sparse constants → binary search of comparisons.
  • Strings → compute a hash (or check length/characters) then compare (ComputeStringHash + string.op_Equality), or length-based dispatch in newer compilers.
  • Patterns → a decision DAG of type tests (isinst), property reads and comparisons, sharing tests between arms.
  • Switch expressions that aren't exhaustive get a throw of SwitchExpressionException.

6. Other lowerings worth knowing

  • Lambdas/closures → display classes and cached delegates.
  • async → state machine struct + AsyncTaskMethodBuilder.
  • Iterators → state machine classes.
  • Null-conditional/coalescing → temporaries and branches.
  • Records → generated members.
  • Collection expressions → arrays, spans with inline arrays, CollectionsMarshal operations.
  • params ReadOnlySpan<T> → inline arrays on the stack.

7. How to see it

SharpLab's "C#" output shows lowered C#; ILSpy with "show compiler-generated code" settings; IL view for exact instructions.

8. A production example

A hot loop built log keys with key += part over ~50 parts per item, allocating ~50 strings per item. SharpLab's lowered view made the repeated string.Concat obvious; switching to string.Create/StringBuilder cut allocations dramatically. In another case, a team locked on a Lock instance typed as object after a refactor; the C# 13 warning showed it had silently fallen back to Monitor, and fixing the field type restored the faster EnterScope path.

🔍 What the interviewer is checking

The interviewer checks you understand that C# features are compiler transformations with costs.

Green flags: names the pipeline stages, gives several correct lowerings including lock and foreach, pattern-based features, Roslyn versus JIT optimisation, tools to view lowered code.

Red flags: believes Roslyn produces optimised machine code; cannot describe what using or lock become.

📚 Learn in depth

1. Compiler phases

  1. Parsing: text → syntax trees (full fidelity).
  2. Declaration/binding: symbols, name lookup, overload resolution, type inference → bound trees with semantic information.
  3. Flow analysis: definite assignment, nullable analysis, reachability.
  4. Lowering ("rewriting"): bound trees rewritten into simpler bound nodes — local rewriter (foreach, using, lock, patterns, interpolation), lambda rewriter (closures), async/iterator rewriters (state machines).
  5. Emit: IL generation, metadata, PDBs.

2. Lowering order matters

Lambdas inside async methods are lowered into closures first, then the async rewriter hoists locals into the state machine. That's why captured variables in async methods live in display classes referenced from the state machine.

3. String concatenation details

string.Concat has overloads for 2–4 strings and ReadOnlySpan<char> (newer), avoiding intermediate strings. Mixed types ("a" + 42) call ToString() first (no boxing for value types since C# 8+ with Concat(string, string) and ToString calls inserted). Interpolation with $"" and string target uses DefaultInterpolatedStringHandler; with FormattableString target creates a FormattableString object (allocates, boxes).

4. lock and Lock

System.Threading.Lock (.NET 9) is a dedicated type; lock (myLock) with a Lock-typed expression calls EnterScope() returning a ref struct Scope disposed at the end. Benefits: no object header/sync block usage, faster uncontended path. C# 13 warns if a Lock is converted to another type and locked (would use Monitor).

5. Switch on strings

For many string cases, the compiler emits a hash-based dispatch (ComputeStringHash using FNV-1a) followed by equality checks; for few cases, sequential comparisons. Newer compilers may use length-and-character switching to avoid hashing.

Try it yourself

  1. Paste foreach over an array, a List<T> and an IEnumerable<T> into SharpLab and compare lowered C#.
  2. Compare lowered output for lock on object vs System.Threading.Lock.
  3. Inspect lowering of an interpolated string assigned to string vs FormattableString.
  4. View the decision DAG for a switch expression with property patterns.

💻 Code

public sealed class LoweringDemo
{
    private readonly object _gate = new();
    private readonly System.Threading.Lock _lock = new();   // .NET 9+, C# 13 lowers lock to EnterScope
    private int _count;

    public void Increment()
    {
        lock (_gate) { _count++; }           // Monitor.Enter(_gate, ref lockTaken) in try/finally
    }

    public void IncrementWithLock()
    {
        lock (_lock) { _count++; }           // using (_lock.EnterScope()) { ... }
    }

    public static string Join(string a, string b, string c) => a + b + c;   // string.Concat(a, b, c)

    public static int SumArray(int[] values)
    {
        int sum = 0;
        foreach (int v in values) sum += v;  // for (int i = 0; i < values.Length; i++)
        return sum;
    }

    public static int SumEnumerable(IEnumerable<int> values)
    {
        int sum = 0;
        foreach (int v in values) sum += v;  // IEnumerator<int> via interface; allocates for List<int>
        return sum;
    }

    public static string Describe(string status) => status switch
    {
        "new" => "Created",
        "paid" => "Paid",
        "shipped" => "On its way",
        _ => "Unknown"
    };
}

⚠ Common mistakes

1. Assuming syntax sugar is free

What happens: using += on strings in loops. Why it is wrong: lowers to repeated allocations. Do instead: StringBuilder/string.Create.

2. foreach over interfaces in hot paths

What happens: iterating IEnumerable<T> typed variables. Why it is wrong: boxed enumerators and interface calls. Do instead: concrete types or spans.

3. Locking on Lock via object

What happens: object _sync = new Lock();. Why it is wrong: falls back to Monitor; loses benefits. Do instead: keep the field typed as Lock.

4. Using FormattableString unintentionally

What happens: passing interpolated strings to APIs taking FormattableString (or object). Why it is wrong: allocations and boxing. Do instead: know the target type; use handlers where available.

5. Not understanding using scope

What happens: using var disposing later than expected (end of scope). Why it is wrong: resources held too long. Do instead: use block using for narrow scopes.

6. Assuming switch on strings is slow

What happens: replacing switches with dictionaries prematurely. Why it is wrong: compiled switches are efficient. Do instead: measure before changing.

7. Forgetting finally semantics

What happens: surprised that foreach disposes enumerators on break. Why it is wrong: lowering adds try/finally. Do instead: rely on it for iterator cleanup.

8. Reading only source to diagnose behaviour

What happens: debating what code does without checking lowered output. Why it is wrong: lowering clarifies closures, boxing and state machines. Do instead: use SharpLab/ILSpy.

🏛 Senior insight: trade-offs

Lowering knowledge lets you predict costs without a profiler, then confirm with one.

Construct Hidden cost to watch
foreach over interface Enumerator allocation, interface calls
Lambda capturing locals Display class allocation per call
async method State machine and possible Task allocation
String + in loops New string per iteration; use StringBuilder
params arrays Array allocation per call (C# 13 params spans avoid it)

Seniors use SharpLab in code reviews to settle debates with evidence.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says lowered code is emitted as IL Why: Checks they can read the resulting IL
    How to answer

    IL (Common Intermediate Language, CIL) is the CPU-independent, stack-based instruction set that C# compiles to. Instructions push and pop values on an evaluation stack: ldarg.0, ldarg.1, add, ret. Calls use metadata tokens (call, callvirt, newobj), and conversions such as box are explicit. You read it with ILSpy, SharpLab or ildasm-style tools. Reading IL helps you spot hidden allocations (boxing, closures), see how async, foreach or using are lowered, and settle "what does the compiler really do" arguments.

  2. When: Candidate mentions display classes Why: Probes closures and captured variable semantics
    How to answer

    A closure is a lambda or local function plus the variables it captures. The compiler moves those variables into fields of a hidden display class, so the method and the lambda share the same variable. That's why capture is by variable, not by value. In a for loop all lambdas share one loop variable, while foreach gets a fresh one per iteration since C# 5. Every lambda in a scope shares one display class, and capturing this keeps the whole object alive, which can leak memory through timers and caches. In hot paths I use static lambdas with explicit state.

  3. When: Candidate mentions iterators become state machines Why: Checks iterator and yield knowledge
    How to answer

    A method that uses yield return is an iterator, and the compiler rewrites it into a state machine class that implements IEnumerable<T> and IEnumerator<T>. Locals become fields, a state field remembers where it paused, and each MoveNext runs to the next yield return. yield break ends the sequence. Execution is lazy, and finally and using blocks run when iteration finishes or the consumer disposes early. Iterators can't have ref or out parameters or yield inside a catch, and async iterators add await. They're ideal for streaming and large or infinite sequences.

🛠 Real-world problems: how you handle them

Hidden enumerator allocations in a hot validation path
Situation
A validation library ran [thousands of rules per request] and allocation rate was high.
Signals
Memory profiler showed many `` `List<T>.Enumerator` `` boxes from rule loops.
Investigation
Viewed lowered C# in SharpLab: rule loops took `` `IEnumerable<T>` `` parameters, so `foreach` used interface enumeration.
Resolution
Changed hot-path signatures to `` `IReadOnlyList<T>` `` with index loops or `` `ReadOnlySpan<T>` ``.
Prevention
Added a BenchmarkDotNet test with the memory diagnoser for the validation pipeline.

How to tell it (STAR)

In [our validation library], allocations were high. I showed in SharpLab that foreach over IEnumerable boxed the list enumerator. We changed [N] hot methods to take spans or lists, and allocations per request dropped by [X%].

↩ Usually asked after

More in Runtime Execution

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me