Zig Control Flow

Control flow is a core concept in programming. This chapter will cover various control flow statements in Zig in detail.

if Statements

Basic if Statement

The if statement is used for conditional branching:

const std = @import("std");

pub fn main() void {
    const age = 18;
    
    if (age >= 18) {
        std.debug.print("You are an adult\n", .{});
    }
    
    const temperature = 25;
    if (temperature > 30) {
        std.debug.print("It's hot\n", .{});
    } else {
        std.debug.print("The weather is nice\n", .{});
    }
}

if-else if Chain

const std = @import("std");

pub fn main() void {
    const score = 85;
    
    if (score >= 90) {
        std.debug.print("Excellent\n", .{});
    } else if (score >= 80) {
        std.debug.print("Good\n", .{});
    } else if (score >= 70) {
        std.debug.print("Average\n", .{});
    } else if (score >= 60) {
        std.debug.print("Pass\n", .{});
    } else {
        std.debug.print("Fail\n", .{});
    }
}

if Expression

In Zig, if is an expression and can return a value:

const std = @import("std");

pub fn main() void {
    const x = 10;
    const y = 20;
    
    // if expression
    const max = if (x > y) x else y;
    std.debug.print("Maximum: {}\n", .{max});
    
    // Complex if expression
    const status = if (x > 0) 
        "positive" 
    else if (x < 0) 
        "negative" 
    else 
        "zero";
    
    std.debug.print("Number status: {s}\n", .{status});
}

if with Optional Values

if can be used to handle optional values:

const std = @import("std");

pub fn main() void {
    var maybe_number: ?i32 = 42;
    
    // Unwrap optional value
    if (maybe_number) |number| {
        std.debug.print("The number is: {}\n", .{number});
    } else {
        std.debug.print("No number\n", .{});
    }
    
    // Change to null and test again
    maybe_number = null;
    if (maybe_number) |number| {
        std.debug.print("The number is: {}\n", .{number});
    } else {
        std.debug.print("Now there's no number\n", .{});
    }
}

if with Error Union Types

const std = @import("std");

const ParseError = error{
    InvalidFormat,
    OutOfRange,
};

fn parseNumber(input: []const u8) ParseError!i32 {
    if (std.mem.eql(u8, input, "42")) {
        return 42;
    } else if (std.mem.eql(u8, input, "100")) {
        return 100;
    } else if (std.mem.eql(u8, input, "999")) {
        return ParseError.OutOfRange;
    } else {
        return ParseError.InvalidFormat;
    }
}

pub fn main() void {
    const inputs = [_][]const u8{ "42", "100", "999", "abc" };
    
    for (inputs) |input| {
        if (parseNumber(input)) |number| {
            std.debug.print("Parse success: {} -> {}\n", .{ input, number });
        } else |err| {
            std.debug.print("Parse failed: {} -> {}\n", .{ input, err });
        }
    }
}

switch Statements

Basic switch Statement

The switch statement is used for multi-way branching:

const std = @import("std");

pub fn main() void {
    const day = 3;
    
    switch (day) {
        1 => std.debug.print("Monday\n", .{}),
        2 => std.debug.print("Tuesday\n", .{}),
        3 => std.debug.print("Wednesday\n", .{}),
        4 => std.debug.print("Thursday\n", .{}),
        5 => std.debug.print("Friday\n", .{}),
        6, 7 => std.debug.print("Weekend\n", .{}),
        else => std.debug.print("Invalid day\n", .{}),
    }
}

switch Expression

switch can also be used as an expression:

const std = @import("std");

pub fn main() void {
    const grade = 'B';
    
    const score = switch (grade) {
        'A' => 90,
        'B' => 80,
        'C' => 70,
        'D' => 60,
        'F' => 0,
        else => -1,
    };
    
    std.debug.print("Grade {} corresponds to score: {}\n", .{ grade, score });
}

Range Matching

switch supports range matching:

const std = @import("std");

pub fn main() void {
    const number = 75;
    
    const category = switch (number) {
        0...59 => "Fail",
        60...69 => "Pass",
        70...79 => "Average",
        80...89 => "Good",
        90...100 => "Excellent",
        else => "Invalid score",
    };
    
    std.debug.print("Score {} is: {s}\n", .{ number, category });
}

switch with Enums

switch works very well with enums:

const std = @import("std");

const Color = enum {
    Red,
    Green,
    Blue,
    Yellow,
};

fn getColorName(color: Color) []const u8 {
    return switch (color) {
        .Red => "Red",
        .Green => "Green",
        .Blue => "Blue",
        .Yellow => "Yellow",
    };
}

pub fn main() void {
    const colors = [_]Color{ .Red, .Green, .Blue, .Yellow };
    
    for (colors) |color| {
        std.debug.print("Color: {s}\n", .{getColorName(color)});
    }
}

switch with Union Types

switch can handle union types:

const std = @import("std");

const Value = union(enum) {
    Integer: i32,
    Float: f64,
    String: []const u8,
    Boolean: bool,
};

fn processValue(value: Value) void {
    switch (value) {
        .Integer => |int| std.debug.print("Integer: {}\n", .{int}),
        .Float => |float| std.debug.print("Float: {d:.2}\n", .{float}),
        .String => |string| std.debug.print("String: {s}\n", .{string}),
        .Boolean => |boolean| std.debug.print("Boolean: {}\n", .{boolean}),
    }
}

pub fn main() void {
    const values = [_]Value{
        Value{ .Integer = 42 },
        Value{ .Float = 3.14 },
        Value{ .String = "Hello" },
        Value{ .Boolean = true },
    };
    
    for (values) |value| {
        processValue(value);
    }
}

Complex switch Patterns

const std = @import("std");

const Point = struct {
    x: i32,
    y: i32,
};

fn analyzePoint(point: Point) []const u8 {
    return switch (point) {
        .{ .x = 0, .y = 0 } => "Origin",
        .{ .x = 0, .y = _ } => "On Y-axis",
        .{ .x = _, .y = 0 } => "On X-axis",
        .{ .x = var x, .y = var y } => blk: {
            if (x == y) {
                break :blk "On diagonal";
            } else if (x > 0 and y > 0) {
                break :blk "First quadrant";
            } else if (x < 0 and y > 0) {
                break :blk "Second quadrant";
            } else if (x < 0 and y < 0) {
                break :blk "Third quadrant";
            } else {
                break :blk "Fourth quadrant";
            }
        },
    };
}

pub fn main() void {
    const points = [_]Point{
        .{ .x = 0, .y = 0 },
        .{ .x = 0, .y = 5 },
        .{ .x = 3, .y = 0 },
        .{ .x = 2, .y = 2 },
        .{ .x = 3, .y = 4 },
        .{ .x = -2, .y = 3 },
        .{ .x = -1, .y = -1 },
        .{ .x = 2, .y = -3 },
    };
    
    for (points) |point| {
        std.debug.print("Point ({}, {}) is at: {s}\n", 
                        .{ point.x, point.y, analyzePoint(point) });
    }
}

defer Statement

Basic defer

The defer statement executes when the current scope ends:

const std = @import("std");

pub fn main() void {
    std.debug.print("Start execution\n", .{});
    
    defer std.debug.print("This executes at function end\n", .{});
    
    std.debug.print("Middle execution\n", .{});
    
    // defer statement executes when function ends
}

Multiple defer Statements

Multiple defer statements execute in LIFO (Last In, First Out) order:

const std = @import("std");

pub fn main() void {
    std.debug.print("Start\n", .{});
    
    defer std.debug.print("First defer\n", .{});
    defer std.debug.print("Second defer\n", .{});
    defer std.debug.print("Third defer\n", .{});
    
    std.debug.print("Middle\n", .{});
    
    // Execution order: Third defer -> Second defer -> First defer
}

Practical defer Usage

defer is commonly used for resource cleanup:

const std = @import("std");

fn processFile(filename: []const u8) !void {
    std.debug.print("Opening file: {s}\n", .{filename});
    
    // Simulate file operation
    defer std.debug.print("Closing file: {s}\n", .{filename});
    
    std.debug.print("Processing file content\n", .{});
    
    // defer executes even if an error occurs
    if (std.mem.eql(u8, filename, "error.txt")) {
        return error.FileError;
    }
    
    std.debug.print("File processing complete\n", .{});
}

pub fn main() void {
    processFile("normal.txt") catch |err| {
        std.debug.print("Error processing file: {}\n", .{err});
    };
    
    std.debug.print("---\n", .{});
    
    processFile("error.txt") catch |err| {
        std.debug.print("Error processing file: {}\n", .{err});
    };
}

errdefer Statement

Basic errdefer

errdefer only executes when the function returns an error:

const std = @import("std");

const ProcessError = error{
    InitializationFailed,
    ProcessingFailed,
};

fn processData(should_fail: bool) ProcessError!void {
    std.debug.print("Starting initialization\n", .{});
    
    errdefer std.debug.print("Initialization failed, performing cleanup\n", .{});
    
    if (should_fail) {
        return ProcessError.InitializationFailed;
    }
    
    std.debug.print("Initialization successful\n", .{});
    std.debug.print("Processing data\n", .{});
}

pub fn main() void {
    std.debug.print("=== Success case ===\n", .{});
    processData(false) catch |err| {
        std.debug.print("Error: {}\n", .{err});
    };
    
    std.debug.print("\n=== Failure case ===\n", .{});
    processData(true) catch |err| {
        std.debug.print("Error: {}\n", .{err});
    };
}

Combining errdefer and defer

const std = @import("std");

const ResourceError = error{
    AllocationFailed,
    InitializationFailed,
};

fn allocateAndInitialize(should_fail_alloc: bool, should_fail_init: bool) ResourceError!void {
    std.debug.print("Starting resource allocation\n", .{});
    
    if (should_fail_alloc) {
        return ResourceError.AllocationFailed;
    }
    
    // Resource allocation successful
    defer std.debug.print("Releasing resource\n", .{});
    errdefer std.debug.print("Allocation succeeded but initialization failed, releasing resource\n", .{});
    
    std.debug.print("Starting initialization\n", .{});
    
    if (should_fail_init) {
        return ResourceError.InitializationFailed;
    }
    
    std.debug.print("Initialization successful\n", .{});
}

pub fn main() void {
    std.debug.print("=== Allocation failure ===\n", .{});
    allocateAndInitialize(true, false) catch |err| {
        std.debug.print("Error: {}\n", .{err});
    };
    
    std.debug.print("\n=== Initialization failure ===\n", .{});
    allocateAndInitialize(false, true) catch |err| {
        std.debug.print("Error: {}\n", .{err});
    };
    
    std.debug.print("\n=== All successful ===\n", .{});
    allocateAndInitialize(false, false) catch |err| {
        std.debug.print("Error: {}\n", .{err});
    };
}

Block Expressions

Basic Block Expression

Blocks can be used as expressions:

const std = @import("std");

pub fn main() void {
    const result = blk: {
        const x = 10;
        const y = 20;
        break :blk x + y;
    };
    
    std.debug.print("Block expression result: {}\n", .{result});
}

Complex Block Expressions

const std = @import("std");

fn calculateGrade(score: i32) []const u8 {
    return blk: {
        if (score >= 90) {
            break :blk "A";
        } else if (score >= 80) {
            break :blk "B";
        } else if (score >= 70) {
            break :blk "C";
        } else if (score >= 60) {
            break :blk "D";
        } else {
            break :blk "F";
        }
    };
}

pub fn main() void {
    const scores = [_]i32{ 95, 85, 75, 65, 55 };
    
    for (scores) |score| {
        const grade = calculateGrade(score);
        std.debug.print("Score {} corresponds to grade: {s}\n", .{ score, grade });
    }
}

Conditional Compilation

comptime if

Use comptime if for conditional compilation:

const std = @import("std");
const builtin = @import("builtin");

pub fn main() void {
    comptime {
        if (builtin.os.tag == .windows) {
            std.debug.print("Compilation target is Windows\n", .{});
        } else if (builtin.os.tag == .linux) {
            std.debug.print("Compilation target is Linux\n", .{});
        } else if (builtin.os.tag == .macos) {
            std.debug.print("Compilation target is macOS\n", .{});
        } else {
            std.debug.print("Compilation target is other system\n", .{});
        }
    }
    
    const is_debug = comptime builtin.mode == .Debug;
    
    if (comptime is_debug) {
        std.debug.print("This is a debug build\n", .{});
    } else {
        std.debug.print("This is a release build\n", .{});
    }
}

Compile-time switch

const std = @import("std");

fn getOptimizationLevel(comptime mode: std.builtin.Mode) []const u8 {
    return comptime switch (mode) {
        .Debug => "No optimization",
        .ReleaseSafe => "Safe optimization",
        .ReleaseFast => "Speed optimization",
        .ReleaseSmall => "Size optimization",
    };
}

pub fn main() void {
    const optimization = comptime getOptimizationLevel(@import("builtin").mode);
    std.debug.print("Current optimization level: {s}\n", .{optimization});
}

Control Flow Best Practices

1. Keep Conditions Simple

const std = @import("std");

pub fn main() void {
    const user_age = 25;
    const has_license = true;
    const has_insurance = true;
    
    // ✅ Good practice: clear condition
    const can_drive = user_age >= 18 and has_license and has_insurance;
    
    if (can_drive) {
        std.debug.print("Can drive\n", .{});
    } else {
        std.debug.print("Cannot drive\n", .{});
    }
}

2. Prefer switch Over Long if-else Chains

const std = @import("std");

const Status = enum {
    Pending,
    Processing,
    Completed,
    Failed,
};

pub fn main() void {
    const status = Status.Processing;
    
    // ✅ Good practice: use switch
    const message = switch (status) {
        .Pending => "Pending",
        .Processing => "Processing",
        .Completed => "Completed",
        .Failed => "Failed",
    };
    
    std.debug.print("Status: {s}\n", .{message});
}

3. Use defer for Resource Management

const std = @import("std");

fn processWithCleanup() !void {
    std.debug.print("Acquiring resource\n", .{});
    
    // ✅ Good practice: set cleanup code immediately
    defer std.debug.print("Releasing resource\n", .{});
    
    std.debug.print("Using resource\n", .{});
    
    // Resource will be released regardless of how function exits
}

pub fn main() void {
    processWithCleanup() catch {};
}

Practice Exercises

Exercise 1: Grade Management System

const std = @import("std");

const Student = struct {
    name: []const u8,
    score: i32,
};

pub fn main() void {
    const students = [_]Student{
        .{ .name = "Alice", .score = 85 },
        .{ .name = "Bob", .score = 92 },
        .{ .name = "Charlie", .score = 78 },
        .{ .name = "David", .score = 65 },
    };
    
    // TODO: Use control flow statements to complete the following tasks:
    // 1. Assign grades to each student (A: 90+, B: 80-89, C: 70-79, D: 60-69, F: <60)
    // 2. Count the number of students in each grade
    // 3. Find the students with highest and lowest scores
}

Exercise 2: Simple Calculator

const std = @import("std");

const Operation = enum {
    Add,
    Subtract,
    Multiply,
    Divide,
};

pub fn main() void {
    // TODO: Implement a simple calculator
    // 1. Use switch to handle different operations
    // 2. Handle division by zero error
    // 3. Use defer for resource cleanup
}

Exercise 3: State Machine

const std = @import("std");

const State = enum {
    Idle,
    Running,
    Paused,
    Stopped,
};

const Event = enum {
    Start,
    Pause,
    Resume,
    Stop,
};

pub fn main() void {
    // TODO: Implement a simple state machine
    // 1. Based on current state and event, determine next state
    // 2. Use switch to handle state transitions
    // 3. Handle invalid state transitions
}

Summary

This chapter covered Zig control flow statements in detail:

  • if statements: conditional branching, supports expression form
  • switch statements: multi-way branching, powerful features
  • defer and errdefer: resource management and cleanup
  • ✅ Block expressions: using code blocks as expressions
  • ✅ Conditional compilation: compile-time control flow
  • ✅ Best practices and practical applications

Mastering control flow is fundamental to writing complex program logic. In the next chapter, we'll learn about Zig's error handling mechanisms.