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");
}
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
};
_ 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"
};
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.
| Construct | Checks | Best when |
while | Before body | Unknown count; may be zero |
do | After body | At least one iteration is required |
for | Before body | Counter based or index iteration |
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
}
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");
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();
}
catch without the original as InnerException loses context. Use throw; to rethrow or wrap with the original as inner detail.