Control Flow Patterns

Control Flow Patterns

Topic: Programming Lesson: 4 of 16 Prerequisites: What Is Programming, Programming Paradigms, Data Types & Abstraction Objective: Master control flow mechanisms — branching, loops, recursion, iterators, error handling — and learn when to use each pattern.


Sequential Execution

By default, programs execute sequentially — one statement after another, from top to bottom.

Python:

print("Step 1")
x = 10
print("Step 2")
y = x + 5
print("Step 3")
print(f"Result: {y}")

# Output:
# Step 1
# Step 2
# Step 3
# Result: 15

JavaScript:

console.log("Step 1");
let x = 10;
console.log("Step 2");
let y = x + 5;
console.log("Step 3");
console.log("Result:", y);

Java:

System.out.println("Step 1");
int x = 10;
System.out.println("Step 2");
int y = x + 5;
System.out.println("Step 3");
System.out.println("Result: " + y);

This is the simplest form of control flow. But real programs need decisions and repetition.


Conditional Branching

Execute different code paths based on conditions.

If/Else

Python:

age = 18

if age >= 18:
    print("Adult")
elif age >= 13:
    print("Teenager")
else:
    print("Child")

JavaScript:

let age = 18;

if (age >= 18) {
    console.log("Adult");
} else if (age >= 13) {
    console.log("Teenager");
} else {
    console.log("Child");
}

Java:

int age = 18;

if (age >= 18) {
    System.out.println("Adult");
} else if (age >= 13) {
    System.out.println("Teenager");
} else {
    System.out.println("Child");
}

C++:

int age = 18;

if (age >= 18) {
    std::cout << "Adult" << std::endl;
} else if (age >= 13) {
    std::cout << "Teenager" << std::endl;
} else {
    std::cout << "Child" << std::endl;
}

Switch/Match

For multiple discrete cases.

Java (switch):

int day = 3;
String dayName;

switch (day) {
    case 1:
        dayName = "Monday";
        break;
    case 2:
        dayName = "Tuesday";
        break;
    case 3:
        dayName = "Wednesday";
        break;
    default:
        dayName = "Unknown";
        break;
}

System.out.println(dayName);  // Wednesday

JavaScript (switch):

let day = 3;
let dayName;

switch (day) {
    case 1:
        dayName = "Monday";
        break;
    case 2:
        dayName = "Tuesday";
        break;
    case 3:
        dayName = "Wednesday";
        break;
    default:
        dayName = "Unknown";
}

console.log(dayName);  // Wednesday

C++ (switch):

int day = 3;
std::string dayName;

switch (day) {
    case 1:
        dayName = "Monday";
        break;
    case 2:
        dayName = "Tuesday";
        break;
    case 3:
        dayName = "Wednesday";
        break;
    default:
        dayName = "Unknown";
        break;
}

std::cout << dayName << std::endl;  // Wednesday

Pattern Matching (Modern Languages)

Python (3.10+):

def describe(value):
    match value:
        case 0:
            return "zero"
        case 1 | 2 | 3:
            return "small"
        case int(x) if x < 0:
            return "negative"
        case int():
            return "positive integer"
        case str():
            return "string"
        case _:
            return "unknown"

print(describe(2))     # small
print(describe(-5))    # negative
print(describe("hi"))  # string

Rust (match):

fn describe(value: i32) -> &'static str {
    match value {
        0 => "zero",
        1..=3 => "small",
        x if x < 0 => "negative",
        _ => "positive",
    }
}

println!("{}", describe(2));   // small
println!("{}", describe(-5));  // negative

Benefits of pattern matching: More expressive, exhaustiveness checking (compiler ensures all cases covered).

Ternary Operator

Concise conditional expression.

Python:

age = 20
status = "adult" if age >= 18 else "minor"
print(status)  # adult

JavaScript:

let age = 20;
let status = age >= 18 ? "adult" : "minor";
console.log(status);  // adult

Java:

int age = 20;
String status = age >= 18 ? "adult" : "minor";
System.out.println(status);  // adult

C++:

int age = 20;
std::string status = age >= 18 ? "adult" : "minor";
std::cout << status << std::endl;  // adult

Guard Clauses

Early returns to reduce nesting and improve readability.

Before (nested):

def process_user(user):
    if user is not None:
        if user.is_active:
            if user.has_permission("admin"):
                print("Processing admin user")
                # ... complex logic ...
            else:
                print("No permission")
        else:
            print("Inactive user")
    else:
        print("No user")

After (guard clauses):

def process_user(user):
    if user is None:
        print("No user")
        return

    if not user.is_active:
        print("Inactive user")
        return

    if not user.has_permission("admin"):
        print("No permission")
        return

    print("Processing admin user")
    # ... complex logic ...

Benefits: Reduced nesting, clearer error handling, main logic at end.

JavaScript:

function processUser(user) {
    if (!user) {
        console.log("No user");
        return;
    }

    if (!user.isActive) {
        console.log("Inactive user");
        return;
    }

    if (!user.hasPermission("admin")) {
        console.log("No permission");
        return;
    }

    console.log("Processing admin user");
    // ... main logic ...
}

Loops

Repetition is fundamental to programming.

For Loop

Iterate a fixed number of times or over a collection.

Python:

# Range-based
for i in range(5):
    print(i)  # 0, 1, 2, 3, 4

# Iterating over collection
fruits = ["apple", "banana", "cherry"]
for fruit in fruits:
    print(fruit)

# With index
for index, fruit in enumerate(fruits):
    print(f"{index}: {fruit}")

JavaScript:

// Traditional for loop
for (let i = 0; i < 5; i++) {
    console.log(i);  // 0, 1, 2, 3, 4
}

// For-of (ES6)
let fruits = ["apple", "banana", "cherry"];
for (let fruit of fruits) {
    console.log(fruit);
}

// For-in (iterates over keys/indices)
for (let index in fruits) {
    console.log(index, fruits[index]);
}

Java:

// Traditional for loop
for (int i = 0; i < 5; i++) {
    System.out.println(i);  // 0, 1, 2, 3, 4
}

// Enhanced for loop (for-each)
String[] fruits = {"apple", "banana", "cherry"};
for (String fruit : fruits) {
    System.out.println(fruit);
}

C++:

// Traditional for loop
for (int i = 0; i < 5; i++) {
    std::cout << i << std::endl;  // 0, 1, 2, 3, 4
}

// Range-based for loop (C++11)
std::vector<std::string> fruits = {"apple", "banana", "cherry"};
for (const auto& fruit : fruits) {
    std::cout << fruit << std::endl;
}

While Loop

Repeat while a condition is true.

Python:

count = 0
while count < 5:
    print(count)
    count += 1

JavaScript:

let count = 0;
while (count < 5) {
    console.log(count);
    count++;
}

Java:

int count = 0;
while (count < 5) {
    System.out.println(count);
    count++;
}

Do-While Loop

Execute at least once, then repeat while condition is true.

Java:

int count = 0;
do {
    System.out.println(count);
    count++;
} while (count < 5);

// Executes at least once even if condition is false
int x = 10;
do {
    System.out.println("Runs once");
} while (x < 5);  // Still runs once

JavaScript:

let count = 0;
do {
    console.log(count);
    count++;
} while (count < 5);

C++:

int count = 0;
do {
    std::cout << count << std::endl;
    count++;
} while (count < 5);

Note: Python doesn't have a do-while loop. Use while True with break:

count = 0
while True:
    print(count)
    count += 1
    if count >= 5:
        break

Loop Control: Break and Continue

Break: Exit the loop immediately.

Python:

for i in range(10):
    if i == 5:
        break  # Exit loop when i is 5
    print(i)  # 0, 1, 2, 3, 4

Continue: Skip the rest of the current iteration, proceed to next.

Python:

for i in range(10):
    if i % 2 == 0:
        continue  # Skip even numbers
    print(i)  # 1, 3, 5, 7, 9

JavaScript:

for (let i = 0; i < 10; i++) {
    if (i === 5) break;  // Exit at 5
    console.log(i);  // 0, 1, 2, 3, 4
}

for (let i = 0; i < 10; i++) {
    if (i % 2 === 0) continue;  // Skip even
    console.log(i);  // 1, 3, 5, 7, 9
}

Loop Invariants

A loop invariant is a condition that is true before and after each iteration. Useful for reasoning about correctness.

Example: Finding maximum in array

Invariant: At the start of each iteration, max holds the maximum of all elements examined so far.

Python:

def find_max(numbers):
    if not numbers:
        return None

    max_val = numbers[0]  # Invariant: max_val is max of numbers[0:0+1]

    for i in range(1, len(numbers)):
        # Invariant: max_val is max of numbers[0:i]
        if numbers[i] > max_val:
            max_val = numbers[i]
        # Invariant maintained: max_val is max of numbers[0:i+1]

    return max_val

Understanding invariants helps you write correct loops and debug when things go wrong.


Recursion

A function that calls itself. Every recursive function needs: 1. Base case: Condition to stop recursion 2. Recursive case: Call itself with a simpler input

Factorial

Mathematical definition: - factorial(0) = 1 (base case) - factorial(n) = n × factorial(n-1) (recursive case)

Python:

def factorial(n):
    if n == 0:
        return 1  # Base case
    else:
        return n * factorial(n - 1)  # Recursive case

print(factorial(5))  # 120 (5 × 4 × 3 × 2 × 1)

JavaScript:

function factorial(n) {
    if (n === 0) {
        return 1;
    } else {
        return n * factorial(n - 1);
    }
}

console.log(factorial(5));  // 120

Java:

public static int factorial(int n) {
    if (n == 0) {
        return 1;
    } else {
        return n * factorial(n - 1);
    }
}

System.out.println(factorial(5));  // 120

C++:

int factorial(int n) {
    if (n == 0) {
        return 1;
    } else {
        return n * factorial(n - 1);
    }
}

std::cout << factorial(5) << std::endl;  // 120

Fibonacci

Definition: - fib(0) = 0, fib(1) = 1 (base cases) - fib(n) = fib(n-1) + fib(n-2) (recursive case)

Python:

def fibonacci(n):
    if n <= 1:
        return n
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(6))  # 8 (0, 1, 1, 2, 3, 5, 8)

Note: This is inefficient (exponential time) due to repeated calculations. Use memoization or iteration for better performance.

Tree Traversal

Recursion shines with tree structures.

Python:

class TreeNode:
    def __init__(self, value, left=None, right=None):
        self.value = value
        self.left = left
        self.right = right

def inorder_traversal(node):
    """Left → Root → Right"""
    if node is None:
        return  # Base case

    inorder_traversal(node.left)
    print(node.value)
    inorder_traversal(node.right)

# Example tree:
#       1
#      / \
#     2   3
#    / \
#   4   5

root = TreeNode(1,
    TreeNode(2, TreeNode(4), TreeNode(5)),
    TreeNode(3)
)

inorder_traversal(root)  # Output: 4, 2, 5, 1, 3

Tail Recursion

A recursive call is tail-recursive if it's the last operation in the function. Some compilers optimize tail recursion into loops (no stack growth).

Not tail-recursive (factorial):

def factorial(n):
    if n == 0:
        return 1
    else:
        return n * factorial(n - 1)  # Multiplication AFTER recursive call

Tail-recursive (factorial with accumulator):

def factorial_tail(n, acc=1):
    if n == 0:
        return acc
    else:
        return factorial_tail(n - 1, n * acc)  # Recursive call is last operation

print(factorial_tail(5))  # 120

Languages with tail-call optimization: Scheme, Scala, some Rust, some JavaScript engines.

When to Use Recursion vs Iteration

Recursion is better when: - Problem is naturally recursive (trees, graphs, divide-and-conquer) - Code is clearer, more elegant

Iteration is better when: - Performance matters (avoid stack overhead) - Problem is naturally iterative (simple loops)

Example: Factorial iteratively

Python:

def factorial_iterative(n):
    result = 1
    for i in range(1, n + 1):
        result *= i
    return result

print(factorial_iterative(5))  # 120

More efficient (no recursive calls), but recursion is often clearer for complex problems.


Iterators and Generators

Iterators

Objects that produce a sequence of values one at a time.

Python:

# Lists are iterable
numbers = [1, 2, 3, 4, 5]
iterator = iter(numbers)

print(next(iterator))  # 1
print(next(iterator))  # 2
print(next(iterator))  # 3

JavaScript:

let numbers = [1, 2, 3, 4, 5];
let iterator = numbers[Symbol.iterator]();

console.log(iterator.next().value);  // 1
console.log(iterator.next().value);  // 2
console.log(iterator.next().value);  // 3

Benefits: Memory-efficient (don't need entire collection in memory), lazy evaluation.

Generators

Functions that yield values one at a time, pausing between yields.

Python:

def count_up_to(n):
    count = 1
    while count <= n:
        yield count  # Pause here, return count
        count += 1

# Create generator
gen = count_up_to(5)

print(next(gen))  # 1
print(next(gen))  # 2

# Or use in loop
for num in count_up_to(5):
    print(num)  # 1, 2, 3, 4, 5

JavaScript:

function* countUpTo(n) {
    let count = 1;
    while (count <= n) {
        yield count;
        count++;
    }
}

let gen = countUpTo(5);
console.log(gen.next().value);  // 1
console.log(gen.next().value);  // 2

// Or use in loop
for (let num of countUpTo(5)) {
    console.log(num);  // 1, 2, 3, 4, 5
}

Benefits: - Lazy evaluation: Values computed on-demand - Memory-efficient: Don't store entire sequence - Infinite sequences: Can represent infinite streams

Example: Infinite sequence

Python:

def infinite_count():
    count = 0
    while True:
        yield count
        count += 1

# Only compute as needed
gen = infinite_count()
print(next(gen))  # 0
print(next(gen))  # 1
print(next(gen))  # 2
# ... can continue forever

Coroutines and Async/Await

Coroutines: Functions that can pause and resume, enabling cooperative multitasking.

Async/Await Pattern

Python:

import asyncio

async def fetch_data(url):
    print(f"Fetching {url}...")
    await asyncio.sleep(2)  # Simulate network delay
    print(f"Done fetching {url}")
    return f"Data from {url}"

async def main():
    # Run concurrently
    task1 = fetch_data("https://api1.com")
    task2 = fetch_data("https://api2.com")

    result1, result2 = await asyncio.gather(task1, task2)
    print(result1, result2)

# Run
asyncio.run(main())

JavaScript:

async function fetchData(url) {
    console.log(`Fetching ${url}...`);
    await new Promise(resolve => setTimeout(resolve, 2000));  // Simulate delay
    console.log(`Done fetching ${url}`);
    return `Data from ${url}`;
}

async function main() {
    let task1 = fetchData("https://api1.com");
    let task2 = fetchData("https://api2.com");

    let [result1, result2] = await Promise.all([task1, task2]);
    console.log(result1, result2);
}

main();

Key difference from threads: Coroutines are cooperative (they explicitly yield control), not preemptive (OS can interrupt threads at any time).


Error Flow

How do you handle errors and exceptional situations?

Exceptions

Try/Catch/Finally

Python:

def divide(a, b):
    try:
        result = a / b
        return result
    except ZeroDivisionError:
        print("Error: Cannot divide by zero")
        return None
    finally:
        print("Cleanup (always runs)")

divide(10, 2)   # 5.0, "Cleanup"
divide(10, 0)   # "Error", None, "Cleanup"

JavaScript:

function divide(a, b) {
    try {
        if (b === 0) {
            throw new Error("Cannot divide by zero");
        }
        return a / b;
    } catch (error) {
        console.log("Error:", error.message);
        return null;
    } finally {
        console.log("Cleanup (always runs)");
    }
}

divide(10, 2);  // 5, "Cleanup"
divide(10, 0);  // "Error: Cannot divide by zero", null, "Cleanup"

Java:

public static Double divide(int a, int b) {
    try {
        return (double) a / b;
    } catch (ArithmeticException e) {
        System.out.println("Error: " + e.getMessage());
        return null;
    } finally {
        System.out.println("Cleanup (always runs)");
    }
}

C++:

double divide(int a, int b) {
    try {
        if (b == 0) {
            throw std::runtime_error("Cannot divide by zero");
        }
        return static_cast<double>(a) / b;
    } catch (const std::exception& e) {
        std::cout << "Error: " << e.what() << std::endl;
        return 0.0;
    }
}

Result/Either Types

Functional approach: return a type that represents success or failure.

Rust:

fn divide(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 {
        Err("Cannot divide by zero".to_string())
    } else {
        Ok(a / b)
    }
}

match divide(10, 2) {
    Ok(result) => println!("Result: {}", result),
    Err(error) => println!("Error: {}", error),
}

Benefits: Errors are explicit in the type signature. Compiler forces you to handle them.

Error Propagation

Rust's ? operator:

fn read_file_length(path: &str) -> Result<usize, std::io::Error> {
    let contents = std::fs::read_to_string(path)?;  // Propagate error if it occurs
    Ok(contents.len())
}

If read_to_string returns an Err, it's immediately returned from read_file_length. Otherwise, continue.

Java's throws:

public static String readFile(String path) throws IOException {
    return new String(Files.readAllBytes(Paths.get(path)));
}

// Caller must handle
try {
    String content = readFile("file.txt");
} catch (IOException e) {
    System.out.println("Error reading file");
}

Short-Circuit Evaluation

Logical operators && (AND) and || (OR) use short-circuit evaluation: they stop evaluating as soon as the result is determined.

Python:

def is_positive(x):
    print(f"Checking {x}")
    return x > 0

# AND: stops at first false
result = is_positive(5) and is_positive(10) and is_positive(-3)
# Output: Checking 5, Checking 10, Checking -3
# Result: False

# OR: stops at first true
result = is_positive(-5) or is_positive(10) or is_positive(20)
# Output: Checking -5, Checking 10
# Result: True (doesn't check 20)

JavaScript:

function check(x) {
    console.log(`Checking ${x}`);
    return x > 0;
}

let result = check(5) && check(10) && check(-3);
// Logs: Checking 5, Checking 10, Checking -3

let result2 = check(-5) || check(10) || check(20);
// Logs: Checking -5, Checking 10
// Doesn't log Checking 20 (short-circuited)

Use case: Avoiding null/undefined errors

JavaScript:

let user = getUser();
if (user && user.isActive && user.hasPermission("admin")) {
    console.log("Admin user");
}
// If user is null, doesn't try to access user.isActive (would error)

Structured Programming

Structured programming (1960s-70s) advocated for: - No goto: Use loops and functions instead - Single entry, single exit: Functions have one entry point and one return (though multiple returns are common now) - Top-down design: Break problems into smaller procedures

Bad (unstructured with goto):

// Don't do this
int i = 0;
start:
    printf("%d\n", i);
    i++;
    if (i < 5) goto start;

Good (structured with loop):

for (int i = 0; i < 5; i++) {
    printf("%d\n", i);
}

Modern consensus: Structured programming principles are good, but pragmatic use of early returns and break/continue improves readability.


Exercises

Exercise 1: Refactor with Guard Clauses

Refactor this nested code using guard clauses:

Python:

def process_order(order):
    if order is not None:
        if order.is_valid():
            if order.total > 0:
                if order.user.is_verified:
                    print("Processing order")
                else:
                    print("User not verified")
            else:
                print("Order total must be positive")
        else:
            print("Invalid order")
    else:
        print("No order")

Exercise 2: Recursion vs Iteration

Implement sum of an array both recursively and iteratively:

Recursive:

def sum_recursive(numbers):
    # Base case: empty array
    # Recursive case: first element + sum of rest
    pass

Iterative:

def sum_iterative(numbers):
    # Use a loop
    pass

Which is clearer? Which is more efficient?

Exercise 3: Generator

Write a generator that produces the Fibonacci sequence indefinitely:

def fibonacci_gen():
    # Yield 0, 1, 1, 2, 3, 5, 8, 13, ...
    pass

# Usage
gen = fibonacci_gen()
for i in range(10):
    print(next(gen))  # First 10 Fibonacci numbers

Exercise 4: Pattern Matching

If your language supports pattern matching (Python 3.10+, Rust, Scala), write a function that classifies a value: - If it's 0: "zero" - If it's 1-10: "small" - If it's 11-100: "medium" - If it's > 100: "large" - If it's negative: "negative" - Otherwise: "unknown"

Exercise 5: Error Handling

Write a function safe_divide(a, b) that: - Returns the result of a / b - Handles division by zero gracefully - Uses exceptions (try/catch) in one implementation - Uses a Result type (or similar) in another

Python:

# Exception version
def safe_divide_exception(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        return None

# Result version (using a tuple)
def safe_divide_result(a, b):
    if b == 0:
        return (False, "Cannot divide by zero")
    else:
        return (True, a / b)

# Usage
success, value = safe_divide_result(10, 2)
if success:
    print(f"Result: {value}")
else:
    print(f"Error: {value}")

Exercise 6: Tail Recursion

Rewrite the Fibonacci function to be tail-recursive:

Hint: Use helper function with accumulators.

def fibonacci_tail(n, a=0, b=1):
    # Base case: n == 0
    # Recursive case: call with updated accumulators
    pass

Summary

Control flow determines the order of execution:

  • Sequential: Default, top to bottom
  • Branching:
  • If/else, switch/match, ternary operator
  • Guard clauses: early returns for clarity
  • Loops:
  • For, while, do-while
  • Break/continue for control
  • Loop invariants for reasoning about correctness
  • Recursion:
  • Base case + recursive case
  • Tail recursion for optimization
  • Use when problem is naturally recursive
  • Iterators & Generators:
  • Lazy evaluation, memory-efficient
  • Produce values on-demand
  • Coroutines & Async/Await:
  • Cooperative multitasking
  • Pause/resume execution
  • Error Handling:
  • Exceptions: try/catch/finally
  • Result types: explicit error values
  • Error propagation: ? operator, throws
  • Short-circuit evaluation: && and || optimize logic
  • Structured programming: Avoid goto, use structured constructs

Key Insight: Choose the right control flow pattern for the problem. Guard clauses reduce nesting. Recursion shines for trees and graphs. Generators enable infinite sequences. Exceptions handle exceptional cases. Each pattern has its place.


← Previous: Data Types & Abstraction

to navigate between lessons