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

📚 Interview question

Walk through C# control flow: if, switch, for, foreach, while, break, continue and return. What does foreach compile to, and which loop bugs should you watch for?

C# selection, iteration and jump statements, how foreach is lowered for arrays, List<T> and IEnumerable<T>, and the classic loop bugs: closure capture, modifying during enumeration, off-by-one and multiple enumeration.

Junior Debugging Intermediate closures control-flow csharp foreach language-basics loops

🎯 Key points

  • Selection: if/else and switch; switch statements support patterns and when guards (C# 7+) and have no implicit fall-through
  • Iteration: for, foreach, while, do-while (do runs at least once)
  • Jumps: break, continue, return, goto, throw, and yield return / yield break in iterators
  • foreach on arrays becomes an index loop; on other types it calls GetEnumerator, MoveNext and Current, and disposes the enumerator in a finally so resources (readers, connections) are released even on break or exception
  • foreach is pattern-based: List<T> has a struct enumerator, so no allocation
  • Modifying a List<T> during foreach throws InvalidOperationException (version check)
  • Since C# 5 the foreach variable is fresh per iteration, but a for loop variable is shared, so closures capture its final value
  • Enumerating a lazy IEnumerable<T> twice runs the query twice; foreach over a null collection throws NullReferenceException

⏱ 30-second answer

C# has selection statements (if, switch), iteration (for, foreach, while, do) and jumps (break, continue, return, goto, throw, yield). foreach over an array compiles to an index loop; over anything else it calls GetEnumerator, loops on MoveNext/Current, and disposes the enumerator in a finally. The bugs I watch for are modifying a list while iterating (it throws), closures capturing a for loop variable (they all see the last value), off-by-one bounds, and enumerating a lazy LINQ query twice.

🗣 Full answer (senior)

How I would answer this in an interview

I cover the constructs quickly, then spend most of the time on two senior topics: what foreach actually compiles to (pattern-based, struct enumerators, disposal) and the loop bugs that reach production — modifying collections while iterating, closures, off-by-one, and multiple enumeration.

1. Branching

  • if / else if / else — conditions must be bool; there is no implicit conversion from int, so if (count) does not compile.
  • switch statement — works on any type with patterns (not just constants). C# forbids implicit fall-through between non-empty sections: each section must end with break, return, throw, continue or goto case. Empty sections can be stacked: case 1: case 2:.
  • Switch expression for value mapping (covered separately).

2. Loops

Loop Use when Note
for You need the index, a custom step, or reverse iteration Condition evaluated each iteration
foreach Iterating any sequence Pattern-based; read-only iteration variable
while Unknown number of iterations, condition first May run zero times
do...while Body must run at least once (retry, menu loop) Condition after the body
await foreach IAsyncEnumerable<T> (streams, EF Core async queries) Supports cancellation via WithCancellation

3. Jump statements

  • break — exits the innermost loop or switch only. To exit nested loops, extract a method and return, or use a flag (goto exists but is rarely justified).
  • continue — skips to the next iteration (in for, the iterator expression still runs).
  • return — exits the method; finally blocks still run.
  • yield return / yield break — produce values from an iterator lazily.

4. What foreach compiles to

foreach is pattern-based, not interface-based. The compiler looks for a public GetEnumerator() with MoveNext() and Current:

// foreach (var item in list) Console.WriteLine(item);
List<int>.Enumerator e = list.GetEnumerator();   // struct enumerator
try
{
    while (e.MoveNext())
    {
        int item = e.Current;   // fresh variable per iteration (C# 5+)
        Console.WriteLine(item);
    }
}
finally
{
    e.Dispose();
}
  • For arrays (and spans), it compiles to an index-based for loop with no enumerator at all.
  • List<T> returns a struct enumerator, so foreach over a List<T> variable allocates nothing. Iterating the same list through IEnumerable<T> boxes the enumerator and uses interface calls — measurably slower in hot paths.
  • The enumerator is disposed in finally, which is how iterator methods run their finally/using blocks when you break early.
  • The iteration variable is read-only; you cannot assign to it.

5. The loop bugs I watch for

  1. Modifying a collection during foreach → InvalidOperationException: Collection was modified. List<T> tracks a _version field that the enumerator checks on every MoveNext.
  2. Closures over the loop variable — fixed for foreach in C# 5, but still a bug for for loops: lambdas capture the single i variable.
  3. Off-by-one — i <= list.Count, or forgetting the last batch when chunking.
  4. Multiple enumeration of IEnumerable<T> — calling .Count() then foreach re-runs a LINQ pipeline or database query.
  5. Infinite loops — while loops whose exit condition depends on state that never changes, or retry loops without a cap or backoff.
  6. Awaiting inside a loop when work is independent — sequential await per item where bounded concurrency (Parallel.ForEachAsync) is appropriate, or the opposite: unbounded Task.WhenAll over 50,000 items.

6. A production example

A nightly job removed expired sessions with foreach (var s in sessions) if (s.Expired) sessions.Remove(s); — it worked in testing because the test data had a single expired session, then threw "Collection was modified" in production. The fix was sessions.RemoveAll(s => s.Expired), which is also O(n) instead of O(n²). In the same codebase, a retry loop while (!sent) { sent = TrySend(); } with no cap and no delay pinned a CPU core and hammered a downstream API during an outage; we replaced it with a Polly (Microsoft.Extensions.Resilience) retry policy with exponential backoff and a maximum attempt count.

🔍 What the interviewer is checking

This checks that the candidate can debug common loop failures and understands what the compiler generates.

Green flags

  • Explains the foreach lowering and the dispose in finally.
  • Immediately recognises "Collection was modified" and fixes it properly.
  • Knows the C# 5 foreach capture change and that for still shares the variable.
  • Mentions deferred execution and multiple enumeration.

Red flags

  • Fixes the modification exception with ToList() everywhere without understanding why.
  • Cannot explain why all closures print the same value.
  • Thinks switch cases fall through like C.

📚 Learn in depth

1. foreach is duck-typed

The compiler's rules for foreach (V v in x):

  1. If x is an array or span, emit an index loop (for (int i = 0; i < x.Length; i++)). The JIT can then remove bounds checks.
  2. Otherwise, look for an accessible instance (or, since C# 9, extension) GetEnumerator() whose return type has bool MoveNext() and a Current property. Use that — no interface required.
  3. Otherwise, fall back to IEnumerable<T> / IEnumerable.
  4. If the enumerator type implements IDisposable (or, for ref struct enumerators, has a Dispose method), wrap the loop in try/finally and dispose it.

This duck typing is why List<T>, Dictionary<TKey,TValue>, HashSet<T> and ImmutableArray<T> expose public struct enumerators: foreach over the concrete type avoids a heap allocation and allows the JIT to inline MoveNext. When the same collection is typed as IEnumerable<T>, GetEnumerator() returns IEnumerator<T>, the struct is boxed, and every MoveNext/Current is an interface call (though dynamic PGO in .NET 8+ can often guarded-devirtualise them).

2. The version check behind "Collection was modified"

// Simplified from List<T>
public void Add(T item) { _version++; ... }

public bool MoveNext()
{
    if (_version != _list._version) ThrowHelper.ThrowInvalidOperationException_InvalidOperation_EnumFailedVersion();
    ...
}

Every mutating method increments _version; the enumerator captured the version at creation and checks it. It is a best-effort check for single-threaded misuse, not a thread-safety mechanism — concurrent writers can still corrupt the list without throwing. Safe alternatives:

  • list.RemoveAll(predicate) — single O(n) compaction pass.
  • Iterate backwards with for when removing by index.
  • Iterate over a snapshot: foreach (var x in list.ToArray()).
  • Dictionary<TKey,TValue>.Remove during enumeration is allowed since .NET Core 3.0 (it does not bump the version), but Add is not.

3. Closure capture and the loop variable

Before C# 5, the foreach variable was declared outside the loop, so lambdas created in the loop all captured the same variable. C# 5 changed the spec so each iteration gets a fresh variable — one of the few breaking changes in C# history.

The for loop was deliberately not changed, because its variable is genuinely shared and mutated by the iterator expression:

var actions = new List<Action>();
for (int i = 0; i < 3; i++) actions.Add(() => Console.Write(i));
foreach (var a in actions) a();   // prints 333

The compiler hoists i into a closure class (a "display class") shared by all lambdas; by the time they run, i is 3. Fix: copy to a local inside the body (int copy = i;).

4. Iterators and early exit

An iterator method (yield return) compiles into a state-machine class implementing IEnumerable<T> and IEnumerator<T>. try/finally and using inside the iterator become part of the state machine's Dispose. If the consumer breaks out of foreach, the compiler-generated finally calls Dispose, which runs the iterator's pending finally blocks — so a using var reader = ... inside an iterator is closed correctly. If you enumerate manually with GetEnumerator() and forget Dispose, those resources leak.

Iterators are lazy and re-runnable: each foreach calls GetEnumerator() again and re-executes the method from the start. That is the root of the multiple-enumeration bug.

5. Loop performance basics

  • for over an array or span with i < arr.Length lets the JIT eliminate bounds checks. Caching the length in a local can prevent that optimisation for arrays in older runtimes — write the idiomatic form.
  • foreach over a List<T> is as fast as for in modern .NET; CollectionsMarshal.AsSpan(list) gives a span when you need the absolute fastest loop (and promise not to resize the list meanwhile).
  • Hoist invariant work out of loops (regex construction, DateTime.Now, LINQ Count() in the condition).
  • while (true) with a clear break is fine; what matters is a guaranteed exit and, for retries, a cap plus backoff with jitter.

6. switch statement details

  • Since C# 7, cases can be patterns with when guards, and order matters.
  • goto case X; and goto default; are the explicit fall-through mechanism.
  • A switch on dense integers or enums compiles to a jump table (switch IL opcode); on strings, the compiler computes a hash and then compares (or, in newer compilers, switches on length and characters) to avoid sequential comparisons.

Try it yourself

  1. Paste a foreach over List<int> and over IEnumerable<int> into SharpLab and compare the lowered code and allocations.
  2. Benchmark both with BenchmarkDotNet using [MemoryDiagnoser].
  3. Write an iterator with a try/finally that logs, break after the first item, and observe the log.
  4. Reproduce the for-loop closure bug and fix it.

💻 Code

namespace Basics;

public sealed record Session(string Id, DateTimeOffset ExpiresAt);

public static class Loops
{
    public static void Main()
    {
        // 1. Closure over a for variable
        var actions = new List<Func<int>>();
        for (int i = 0; i < 3; i++)
        {
            int copy = i;              // fix: per-iteration copy
            actions.Add(() => copy);
        }
        Console.WriteLine(string.Join(",", actions.Select(a => a()))); // 0,1,2

        // 2. Modifying during foreach: use RemoveAll instead
        var now = DateTimeOffset.UtcNow;
        var sessions = new List<Session>
        {
            new("a", now.AddMinutes(-5)),
            new("b", now.AddMinutes(10)),
            new("c", now.AddMinutes(-1))
        };
        int removed = sessions.RemoveAll(s => s.ExpiresAt < now);
        Console.WriteLine($"Removed {removed}, left {sessions.Count}");

        // 3. Reverse loop when you need the index
        var numbers = new List<int> { 1, 2, 3, 4, 5, 6 };
        for (int i = numbers.Count - 1; i >= 0; i--)
        {
            if (numbers[i] % 2 == 0) numbers.RemoveAt(i);
        }

        // 4. Breaking out of nested loops: extract a method
        Console.WriteLine(FindPair([1, 4, 6, 9], target: 10));

        // 5. Multiple enumeration: materialise once
        IEnumerable<int> expensive = Generate();
        var list = expensive.ToList();   // runs once
        Console.WriteLine($"{list.Count} items, first {list[0]}");
    }

    private static (int, int)? FindPair(int[] values, int target)
    {
        for (int i = 0; i < values.Length; i++)
            for (int j = i + 1; j < values.Length; j++)
                if (values[i] + values[j] == target)
                    return (values[i], values[j]);   // exits both loops
        return null;
    }

    private static IEnumerable<int> Generate()
    {
        Console.WriteLine("Generating...");
        for (int i = 0; i < 3; i++) yield return i * 10;
    }
}

⚠ Common mistakes

1. Removing items inside foreach

What happens: foreach (var x in list) if (cond) list.Remove(x); Why it is wrong: the enumerator's version check throws InvalidOperationException; even with a for loop, removing forward skips the next element, and repeated Remove is O(n²). Do instead: list.RemoveAll(cond), iterate backwards with for, or build a new list with LINQ Where.

2. Capturing the for loop variable in a lambda

What happens: tasks or callbacks created in a for loop all see the final value of i. Why it is wrong: for has one shared variable; the C# 5 fix only applies to foreach. Do instead: copy to a local inside the loop, or use foreach over Enumerable.Range.

3. Off-by-one boundaries

What happens: for (int i = 0; i <= items.Count; i++) or chunking code that drops the final partial batch. Why it is wrong: throws ArgumentOutOfRangeException or silently loses data. Do instead: use < with Count/Length, prefer foreach or Chunk(size) (.NET 6+), and unit-test boundaries (0, 1, exactly N, N+1).

4. Enumerating an IEnumerable<T> more than once

What happens: if (items.Any()) { var n = items.Count(); foreach (var i in items) ... } over a LINQ query or IQueryable. Why it is wrong: each enumeration re-runs the pipeline — repeated database queries, repeated expensive work, or different results if the source changed. Do instead: materialise once (ToList()/ToArray()), or accept IReadOnlyCollection<T> when callers already have a collection.

5. Unbounded retry or polling loops

What happens: while (!done) { done = TryCall(); } with no delay, cap or cancellation. Why it is wrong: burns CPU, amplifies load on a struggling dependency, and can run forever during an outage. Do instead: use a resilience pipeline (Polly / Microsoft.Extensions.Resilience) with exponential backoff, jitter, max attempts and a CancellationToken.

6. Breaking out of nested loops with flags everywhere

What happens: found booleans checked in every loop level. Why it is wrong: easy to get wrong and hard to read; break only exits the inner loop. Do instead: extract the nested loops into a method and return the result.

7. Sequential await in a loop over independent work — or unbounded parallelism

What happens: foreach (var id in ids) await client.GetAsync(id); for 1,000 IDs, or await Task.WhenAll(ids.Select(Fetch)) for 100,000. Why it is wrong: the first is needlessly slow; the second exhausts sockets, connection pools or downstream rate limits. Do instead: use Parallel.ForEachAsync with MaxDegreeOfParallelism, or SemaphoreSlim for bounded concurrency.

8. Expensive work in the loop condition

What happens: for (int i = 0; i < query.Count(); i++) or while (DateTime.Now < deadline) with heavy work. Why it is wrong: the condition is evaluated every iteration — Count() on a query re-executes it each time. Do instead: compute invariants once before the loop; use Stopwatch or a CancellationTokenSource with a timeout for time limits.

9. Manually enumerating without disposing

What happens: var e = source.GetEnumerator(); while (e.MoveNext()) ... with no using. Why it is wrong: iterator finally blocks and using resources (readers, connections) never run. Do instead: use foreach, or wrap manual enumeration in using var e = ....

10. Implicit fall-through expectations from other languages

What happens: developers coming from C or JavaScript expect a case without break to fall through. Why it is wrong: C# reports CS0163; when people "fix" it they sometimes add break in the wrong place and lose behaviour. Do instead: stack empty case labels, use or patterns (case 1 or 2:), or goto case when fall-through is truly intended.

🏛 Senior insight: trade-offs

Seniors treat loops as data-flow decisions: eager or lazy, mutate or project, sequential or parallel.

Need Prefer
Transform or filter LINQ (Select, Where) or a simple foreach building a new list
Remove in place List<T>.RemoveAll
Hot numeric loop for over an array or Span<T>
Stream large data yield return / IAsyncEnumerable<T> with await foreach
CPU-bound independent items Parallel.ForEach with care for shared state
I/O-bound items Parallel.ForEachAsync with MaxDegreeOfParallelism
  • Know the lowering: it explains allocations (boxed enumerators through interfaces), exceptions (version checks) and closure bugs.
  • Keep loop bodies small; extract methods so return can replace complex break and flag logic.
  • For async loops, avoid async void lambdas in ForEach; use await foreach or Task.WhenAll deliberately.

🔗 Follow-ups the interviewer may ask

  1. When: The candidate explains the for-loop closure bug. Why: Closure capture in loops leads naturally to how lambdas and local functions capture and allocate.
    How to answer

    A local function is a named method declared inside another method. Unlike a lambda it can be recursive, generic, an iterator or async, and called before it is declared. The big practical difference is allocation: if a local function captures variables and is only called directly, the compiler passes them in a struct by reference, so there is no heap allocation, while a capturing lambda always allocates a closure object and a delegate. A static local function cannot capture anything, which guarantees no hidden allocations. I use them for helpers used in one method and for the eager-validation pattern in iterators and async methods.

  2. When: The candidate describes long if/else or switch statement chains. Why: Tests whether the candidate can replace branching statements with exhaustive switch expressions.
    How to answer

    An expression produces a value, like a + b or order.Total > 100; a statement does something, like if, foreach, return or a declaration. C# has become more expression-oriented: a switch expression returns a value, cannot fall through and warns if it is not exhaustive, and patterns (type, relational, property, list, and/or/not) let you test shape and values in one place. Expression-bodied members use => for one-line members, and throw expressions let ?? and ?: throw. I use expressions to compute values without mutation and statements when there are steps or side effects.

↩ Usually asked after

More in Language Basics

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me