Skip to content
Search lessons, topics, tests…
Esc

    ↑ ↓ moveEnter openEsc close

    Module 3 · 3. Control Flow, Methods, Delegates, and Functional Building Blocks · Lesson 7 of 24

    Branching and Loops That Make Invariants Obvious

    Learning outcomes

    Choose a guard clause or switch expression for a concrete decision, trace a loop invariant and its progress measure, and implement at most three attempts with cooperative cancellation.

    Guard the contract before doing the work

    A guard removes an invalid case before the main path proceeds. Keep related validation together; use early exits to reveal the contract rather than scattering decisions across a method.

    The existing SumApproved helper is retained below. Its name does not establish approval status: its actual rule is that every amount must be nonnegative. Null is a caller error; an encountered negative amount is a domain error. Empty input returns zero. It neither skips rejected values nor returns a partial total after failure.

    Each listing is a separate complete console program. With a .NET 10 SDK, create a console project targeting net10.0, replace Program.cs with one listing, and run it. Do not combine the listings into one file. All examples use fixed in-memory input.

    SumApproved.cs: guards and a prefix trace

    C#
    using System;
    using System.Collections.Generic;
    using System.Globalization;
    
    CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
    Show("empty", Array.Empty<decimal>());
    Show("prefix-1", new decimal[] { 8m });
    Show("prefix-2", new decimal[] { 8m, 0m });
    Show("prefix-3", new decimal[] { 8m, 0m, 5m });
    Show("null", null);
    Show("negative", new decimal[] { 8m, -1m, 5m });
    Show("maximum", new decimal[] { decimal.MaxValue });
    Show("overflow", new decimal[] { decimal.MaxValue, 1m });
    
    static void Show(string name, IEnumerable<decimal>? amounts)
    {
        try { Console.WriteLine($"{name}: {SumApproved(amounts!)}"); }
        catch (ArgumentNullException) { Console.WriteLine($"{name}: ArgumentNullException"); }
        catch (ArgumentOutOfRangeException) { Console.WriteLine($"{name}: ArgumentOutOfRangeException"); }
        catch (OverflowException) { Console.WriteLine($"{name}: OverflowException"); }
    }
    
    static decimal SumApproved(IEnumerable<decimal> amounts)
    {
        ArgumentNullException.ThrowIfNull(amounts);
        decimal total = 0;
        foreach (var amount in amounts)
        {
            if (amount < 0)
                throw new ArgumentOutOfRangeException(nameof(amounts), "Amounts cannot be negative.");
            total += amount;
        }
        return total;
    }

    Expected output:

    Code
    empty: 0
    prefix-1: 8
    prefix-2: 8
    prefix-3: 13
    null: ArgumentNullException
    negative: ArgumentOutOfRangeException
    maximum: 79228162514264337593543950335
    overflow: OverflowException

    The prefix calls expose the same accumulator states that the three-item call reaches: initially 0, then 8, 8, and 13. After each successful iteration, total is the result of adding the processed nonnegative prefix in order with decimal arithmetic. The negative-input case stops at -1 before adding it; 5 is never processed.

    Nonnegative input is not a promise that every total fits. Decimal overflow throws OverflowException, including outside a checked block. Decimal also has finite precision, so the invariant describes the computed accumulator, not an unlimited-precision mathematical sum. The null-forgiving marker in this test harness deliberately allows the null test; the helper still rejects null.

    The shown arrays are finite. A general IEnumerable can fail during enumeration or fail to finish; this helper does not impose an item limit on it. A successful call requires enumeration to finish and each addition to complete. It propagates enumeration failures too. Guarding the reference alone does not guarantee those properties.

    Make the decision and progress visible

    A switch expression selects one result from the first matching arm. Put narrower cases before an overlapping broader case. The final discard arm supplies a fallback; it does not prove that an input is valid.

    This invented queue policy labels a negative pending count invalid, zero idle, one through three small-batch, and larger counts queue. It returns labels rather than throwing because invalid is an intentional result in this example.

    SwitchAndBound.cs: switch boundaries inside a counted loop

    C#
    using System;
    
    int[] pendingCounts = { -1, 0, 1, 3, 4 };
    for (int index = 0; index < pendingCounts.Length; index++)
    {
        int pending = pendingCounts[index];
        Console.WriteLine($"index={index}; remaining={pendingCounts.Length - index}; pending={pending}; route={Route(pending)}");
    }
    Console.WriteLine("visited=5; remaining=0");
    
    static string Route(int pending) => pending switch
    {
        < 0 => "invalid",
        0 => "idle",
        <= 3 => "small-batch",
        _ => "queue"
    };

    Expected output:

    Code
    index=0; remaining=5; pending=-1; route=invalid
    index=1; remaining=4; pending=0; route=idle
    index=2; remaining=3; pending=1; route=small-batch
    index=3; remaining=2; pending=3; route=small-batch
    index=4; remaining=1; pending=4; route=queue
    visited=5; remaining=0

    At the start of an iteration, index entries have been visited, and Length - index remain. The array stays unchanged; index increases by one after the body. The remaining count therefore descends from 5 to 0. The five fixed inputs check both sides of the policy boundaries, including 3 versus 4. The <= 3 arm also matches -1 and 0, but their earlier arms have already won.

    Choose loop syntax that exposes the algorithm: foreach suits item-by-item consumption such as the sum; for puts this trace's index and bound together; while can express a state-dependent condition. These are design choices, not exclusive rules. The iteration reference describes their mechanics. A finite-looking counter does not make arbitrary work inside the body finish.

    Practice: at most three attempts

    Implement Retry with an operation kept in a separate method. Pass it the attempt number and cancellation token. Return true at the first success or false after three failed attempts. Treat false as the only retryable result. Let operation exceptions propagate. Check cancellation before each attempt and immediately after it returns; an observed cancellation at that second check takes precedence over a returned success. No fourth call is allowed.

    Test first-attempt success, third-attempt success, exhaustion, cancellation before any call, cancellation requested by a failed attempt, cancellation requested by a successful attempt, and an operation exception. Use fixed scripts instead of network calls or timing assumptions.

    Worked solution: BoundedRetry.cs

    C#
    using System;
    using System.Threading;
    
    Run("first", successAt: 1);
    Run("third", successAt: 3);
    Run("exhausted", successAt: 4);
    Run("pre-canceled", successAt: 1, preCanceled: true);
    Run("cancel-after-failure", successAt: 3, cancelAt: 1);
    Run("cancel-with-success", successAt: 1, cancelAt: 1);
    Run("fault", successAt: 3, faultAt: 2);
    
    static bool Retry(Func<int, CancellationToken, bool> operation, CancellationToken token)
    {
        ArgumentNullException.ThrowIfNull(operation);
        for (int attempt = 1; attempt <= 3; attempt++)
        {
            token.ThrowIfCancellationRequested();
            bool succeeded = operation(attempt, token);
            token.ThrowIfCancellationRequested();
            if (succeeded)
                return true;
        }
        return false;
    }
    
    static void Run(string name, int successAt, bool preCanceled = false,
        int cancelAt = 0, int faultAt = 0)
    {
        using var source = new CancellationTokenSource();
        var script = new ScriptedOperation(source, successAt, cancelAt, faultAt);
        if (preCanceled)
            source.Cancel();
        Console.WriteLine(name);
        try
        {
            bool succeeded = Retry(script.Execute, source.Token);
            Console.WriteLine($"result={succeeded}; calls={script.Calls}");
        }
        catch (OperationCanceledException error) when (error.CancellationToken == source.Token)
        {
            Console.WriteLine($"canceled; calls={script.Calls}");
        }
        catch (InvalidOperationException)
        {
            Console.WriteLine($"fault-propagated; calls={script.Calls}");
        }
    }
    
    sealed class ScriptedOperation
    {
        private readonly CancellationTokenSource source;
        private readonly int successAt;
        private readonly int cancelAt;
        private readonly int faultAt;
        public int Calls { get; private set; }
    
        public ScriptedOperation(CancellationTokenSource source, int successAt,
            int cancelAt, int faultAt)
        {
            this.source = source;
            this.successAt = successAt;
            this.cancelAt = cancelAt;
            this.faultAt = faultAt;
        }
    
        public bool Execute(int attempt, CancellationToken token)
        {
            token.ThrowIfCancellationRequested();
            Calls++;
            Console.WriteLine($"attempt={attempt}; same-token={token == source.Token}");
            if (attempt == faultAt)
                throw new InvalidOperationException("Scripted non-retryable fault.");
            if (attempt == cancelAt)
                source.Cancel();
            return attempt == successAt;
        }
    }

    Expected output:

    Code
    first
    attempt=1; same-token=True
    result=True; calls=1
    third
    attempt=1; same-token=True
    attempt=2; same-token=True
    attempt=3; same-token=True
    result=True; calls=3
    exhausted
    attempt=1; same-token=True
    attempt=2; same-token=True
    attempt=3; same-token=True
    result=False; calls=3
    pre-canceled
    canceled; calls=0
    cancel-after-failure
    attempt=1; same-token=True
    canceled; calls=1
    cancel-with-success
    attempt=1; same-token=True
    canceled; calls=1
    fault
    attempt=1; same-token=True
    attempt=2; same-token=True
    fault-propagated; calls=2

    Before attempt k starts, exactly k - 1 earlier calls have returned false without cancellation being observed. The remaining call budget is 4 - k. The loop advances after a failure and leaves after attempt 3; success, cancellation, or an exception exits earlier. successAt: 4 deliberately tests that the fourth success is never reached. The fault case proves the wrapper does not turn an exception into another retry.

    The scripted operation prints that it received the same token and requests cancellation synchronously in two cases. These tests do not depend on thread scheduling. The wrapper's second check observes each request before accepting a result or beginning another call.

    Cancellation is cooperative. Passing a token and checking it between attempts does not interrupt arbitrary blocking work. The operation must also observe the token while it works or pass it to a cancellation-aware dependency. A request arriving after the final check is not guaranteed to change an already selected outcome. This example bounds the number of calls, not elapsed time.

    ThrowIfCancellationRequested reports cancellation through OperationCanceledException associated with that token. The harness catches it only to print the test result; Retry does not swallow it. The scripted operation is short, bounded, and local. Its delay policy is explicitly no delay. It is a control-flow exercise, not a production network retry policy: real work also needs a decision about which failures are transient, whether repeating an operation is safe, and an appropriate delay and timeout design.

    Failure modes and interview check

    • A fallback that silently reclassifies invalid input: keep the negative-count arm explicit.
    • A retry that catches every exception: this solution retries false only and preserves fault and cancellation signals.
    • Changing a collection without checking its enumeration contract: avoid structural mutation during these item walks; behavior depends on the collection.
    • Many break or continue paths that obscure progress: identify what still advances on every repeating path.

    Question: When do guard clauses improve readability, and when can many early returns make cleanup or reasoning harder?

    Answer: A short boundary guard, such as the null check before summing, makes the valid path easier to follow. Scattered returns can hide which updates happened or bypass manually placed cleanup. Keep exit policy explicit and use structured cleanup for owned resources. In the retry harness, using disposes the token source when its scope exits, including an exceptional exit. Early returns do not inherently break structured cleanup.

    Analogy

    Everyday picture

    Imagine a clerk working through a fixed row of trays from left to right. Before recording a tray, the clerk checks its form and follows the first applicable rule on an ordered checklist. A marker separates finished trays from those still waiting.

    Mapping. Choosing a rule models branching. The finished side represents the processed prefix described by a loop invariant. Moving the marker one position reduces the number of trays left.

    Where it stops. The pictured row is finite. Arbitrary input enumeration or work inside a loop can fail or never finish. The retry example limits the number of calls; it does not impose a wall-clock deadline or interrupt uncooperative work.

    Cheat sheet (PDF)

    csharp-branching-loops-companion.pdf7 pages · 76 KB
    Every page, in this page.

    Practice

    Sign in to mark lessons done and keep your place in the course.Sign in