<< Home

Chapter 5: Flow Control

Flow control directs the order of execution in your program. In C# it includes conditional branches, pattern-based decisions, loops that repeat work, early exits from blocks, and structured error handling. The following sections build a practical foundation that you can apply in every project.

Using if, else if, else

The if family chooses between paths based on a Boolean expression. The condition is evaluated; if it is true the associated block runs, otherwise the next branch is considered. Braces improve clarity for single statements and avoid subtle bugs; it is best to keep them even when the body is a single line.

Basic branching with blocks

This example reads an integer and classifies it. The braces show clear structure and prevent ambiguity when new lines are added later.

int n = int.Parse(Console.ReadLine());
if (n > 0) {
  Console.WriteLine("positive");
} else if (n < 0) {
  Console.WriteLine("negative");
} else {
  Console.WriteLine("zero");
}

Combining conditions

Combine conditions with && (and), || (or), and ! (not). Group with parentheses for readability and to ensure the intended precedence.

string role = "admin";
bool active = true;
if ((role == "admin" || role == "owner") && active) {
  Console.WriteLine("has access");
}
💡 Prefer explicit comparisons such as if (flag == true) only when helpful for clarity. In many cases if (flag) reads better.

Guard clauses for early return

Use a short initial check to exit a method early when a precondition fails. This keeps the happy path aligned to the left and reduces nested indentation.

void Process(Order order) {
  if (order is null) { throw new ArgumentNullException(nameof(order)); }
  if (!order.IsValid) { return; }
  // … continue with processing
}

Handling switch expressions

The switch statement and the newer switch expression route control across many alternatives. Pattern matching adds expressive power; you can branch on constant values, types, properties, positional tuples, and relational conditions. The switch expression yields a value; it is often concise and easy to read.

Classic switch statement

Use labels and break to avoid fall-through. The default label handles everything that did not match earlier cases.

var ch = Console.ReadKey(intercept: true).KeyChar;
switch (ch) {
  case 'y':
  case 'Y':
    Console.WriteLine("yes");
    break;
  case 'n':
  case 'N':
    Console.WriteLine("no");
    break;
  default:
    Console.WriteLine("unknown");
    break;
}

switch expression with patterns

When you want a value from a decision, the expression form is neat. Combine patterns and guards with when for fine control.

static decimal Shipping(decimal weightKg, string region) => (weightKg, region) switch {
  (<= 0m, _)                 => throw new ArgumentOutOfRangeException(nameof(weightKg)),
  (<= 0.5m, "local")         => 2.50m,
  (<= 2m,   "local")         => 4.00m,
  (<= 2m,   "international") => 9.50m,
  var (w, r) when w > 2m     => 12.00m + (w - 2m) * 3m,
  _                          => 7.00m
};
💡 The _ pattern is a catch-all. Keep it last so earlier, more specific patterns are considered first.

Type, property, and relational patterns

Patterns can test types and drill into members. This keeps branching code declarative and avoids temporary variables.

string Describe(object obj)   => obj switch {
  null                        => "null",
  string s when s.Length == 0 => "empty string",
  string s                    => $"string({s.Length})",
  int < 0                     => "negative int",
  { Length: > 3 }             => "has length > 3",  // any type with Length
  _                           => "something else"
};
⚠️ Pattern order matters. The first matching arm is chosen. Place specific patterns before general ones to prevent unintentional matches.

Creating while, do, and for loops

Loops repeat work while a condition holds. Choose the construct that best expresses intent; the right choice improves readability and reduces off-by-one mistakes.

while and do basics

while checks the condition before each iteration. do runs the body once before checking. Use while for zero-or-more scenarios; use do when you need at least one pass.

// while
int i = 0;
while (i < 3) {
  Console.WriteLine(i);
  i++;
}
// do
string input;
do {
  Console.Write("Enter value (q to quit): ");
  input = Console.ReadLine();
} while (input != "q");

for with counters

for is ideal for counted loops where you initialize, test, and increment in one place. Keep loop variables scoped to the loop for clarity.

for (int j = 0; j < 5; j++) {
  Console.WriteLine($"j = {j}");
}

Choosing a loop construct

This compact summary helps you select the right tool.

ConstructChecksBest when
whileBefore bodyUnknown count; may be zero
doAfter bodyAt least one iteration is required
forBefore bodyCounter based or index iteration
💡 Keep loop bodies short; extract helper methods when bodies grow. Small loops are easier to reason about and test.

Working with foreach

foreach enumerates items from any type that exposes an appropriate enumerator. It expresses intent to visit each element and keeps index logic out of sight. Iterator methods with yield return produce sequences lazily; this can reduce memory usage and improve composability.

foreach over sequences

Enumerate arrays, lists, dictionaries, and any type implementing the required pattern. The loop variable is read-only for reference types and a copy for value types.

var words = new List<string> { "alpha", "beta", "gamma" };
foreach (var w in words) {
  Console.WriteLine(w);
}

Iterator methods with yield

Use yield return to produce values on demand. The state machine is generated by the compiler; you focus on the logic. Use yield break to stop early.

IEnumerable<int> Range(int start, int count) {
  for (int k = 0; k < count; k++) {
    yield return start + k;
  }
}

foreach (var n in Range(10, 3)) {
  Console.WriteLine(n); // 10, 11, 12
}
⚠️ Avoid mutating a collection while iterating it with foreach. Many collections throw InvalidOperationException when modified during enumeration.

Using break, continue, and goto

These keywords alter control flow in loops and switch constructs. Use them sparingly; clarity comes first. When structure alone conveys intent, prefer that to jumps.

break and continue

break exits the nearest enclosing loop or switch. continue skips to the next iteration of the nearest loop.

for (int i2 = 0; i2 < 10; i2++) {
  if (i2 % 2 == 1) { continue; }   // skip odd
  if (i2 > 6) { break; }            // stop once past 6
  Console.WriteLine(i2);
}

goto and labeled statements

goto can jump to a label or to a switch case. It is rarely needed. Prefer structured alternatives such as factoring code into methods or using return to exit a method.

int tries = 0;
retry:
tries++;
bool ok = TryConnect();
if (!ok && tries < 3) { goto retry; }
Console.WriteLine(ok ? "connected" : "failed");
💡 A well-named helper method with an early return is usually clearer than a label and goto.

Exception handling

Exceptions report unexpected conditions. Use try to delimit a risky operation, catch to handle specific failures, and finally to run cleanup regardless of success. Rethrow when you cannot handle the error usefully; do not swallow failures silently.

Catching and filtering

Catch the most specific exceptions first. Filters with when allow conditional handling without altering the exception stack.

try {
  using var stream = File.OpenRead(path);
  // … read and process
}
catch (FileNotFoundException ex) when (ex.FileName?.EndsWith(".cfg") == true) {
  Console.WriteLine("Missing configuration file.");
}
catch (UnauthorizedAccessException) {
  Console.WriteLine("Access denied.");
}
catch (IOException ex) {
  Console.WriteLine($"I/O error: {ex.Message}");
}

Cleanup with finally

The finally block always runs. It is ideal for releasing unmanaged resources or restoring state. The using statement usually handles disposal for you, however finally remains essential for custom cleanup.

Stream? s = null;
try {
  s = File.OpenRead(path);
  // … process
}
catch (Exception ex) {
  Console.Error.WriteLine(ex.Message);
  throw; // preserve the original stack
}
finally {
  s?.Dispose();
}
⚠️ Throwing a new exception inside catch without the original as InnerException loses context. Use throw; to rethrow or wrap with the original as inner detail.