CFoundations

C Control Flow & Deterministic Logic: if, switch, Loops & Goto (C23)

TT
TopicTrick Team
C Control Flow & Deterministic Logic: if, switch, Loops & Goto (C23)

C Control Flow & Deterministic Logic: if, switch, Loops & Goto (C23)


Table of Contents


Deterministic Logic: What Sets C Apart

In dynamically typed languages, even a simple if check may trigger property lookups, type coercions, or garbage collector activity. In C, the execution model is deterministic: the compiler translates your control flow into a fixed set of assembly instructions — CMP, JE, JNE, JMP — and the CPU executes them directly.

This determinism is why C is mandated for real-time systems (car brake controllers, medical ventilators, aircraft flight management) where you need to mathematically prove the maximum execution time of a code path. There are no hidden allocations, no unexpected pauses — just predicable, measurable CPU cycles.


if-else: The Basics and Branch Prediction

The if-else statement is the most fundamental control flow construct. In C, any non-zero integer value is truthy; zero is false:

c
#include <stdio.h>
#include <stdbool.h>

void check_status(int error_code) {
    if (error_code == 0) {
        printf("Operation succeeded.\n");
    } else if (error_code == -1) {
        printf("Error: Permission denied.\n");
    } else if (error_code == -2) {
        printf("Error: Resource not found.\n");
    } else {
        printf("Error: Unknown code %d.\n", error_code);
    }
}

Understanding Branch Prediction

Modern CPUs are pipelined — they start fetching and decoding the next instruction before the current one finishes. When the CPU hits a conditional branch (if), it must predict which path to take. If it predicts correctly, the pipeline stays full and execution is fast. If it predicts wrong, the pipeline must be flushed and restarted — costing 10–20 CPU cycles.

This is called a branch misprediction penalty, and it is measurable. In tight loops processing millions of elements, poorly predicted branches can reduce throughput by 30-50%.


C23 Branch Prediction Hints: [[likely]] and [[unlikely]]

C23 introduces the [[likely]] and [[unlikely]] attributes, allowing you to give the compiler (and thus the CPU's branch predictor) explicit hints about which branch is statistically more common:

c
#include <stdio.h>

void process_packet(int is_valid) {
    if (is_valid) [[likely]] {
        // Fast path: most packets are valid
        printf("Processing valid packet.\n");
    } else [[unlikely]] {
        // Slow path: errors are rare
        handle_error();
    }
}

// In a tight loop context
void scan_data(int *data, int count) {
    for (int i = 0; i < count; i++) {
        if (data[i] < 0) [[unlikely]] {
            // Only ~0.1% of values are negative
            report_anomaly(data[i]);
        }
    }
}

The compiler uses these hints to arrange the machine code so the likely path requires no jump (falling through sequentially), while the unlikely path requires a jump. This improves instruction cache efficiency and helps the CPU branch predictor make better guesses.

[!TIP] Use [[likely]] only when you have evidence (profiling data, domain knowledge) that the branch is taken >90% of the time. Incorrect hints actually make performance worse.


The switch Statement: Jump Tables Under the Hood

A switch statement over integer values can be dramatically faster than a long if-else if chain because the compiler can generate a jump table — a static array of function pointers (code addresses) that the CPU jumps to directly:

c
typedef enum { IDLE, RUNNING, PAUSED, STOPPED } SystemState;

void handle_state(SystemState state) {
    switch (state) {
        case IDLE:
            initialize_system();
            break;
        case RUNNING:
            process_next_task();
            break;
        case PAUSED:
            flush_write_buffers();
            break;
        case STOPPED:
            shutdown_and_cleanup();
            break;
        default:
            log_error("Unknown state");
            break;
    }
}

Jump Table Mechanics

For a switch with n sequential cases, the compiler generates code roughly equivalent to:

c
void (*jump_table[])(void) = {
    initialize_system,
    process_next_task,
    flush_write_buffers,
    shutdown_and_cleanup,
};
jump_table[state](); // O(1) — same speed regardless of number of cases

A long if-else if chain is O(n) — on average, you check half the conditions before finding the right one. For a state machine with 20 states, a switch is ~10× faster.

switch Fallthrough

Without break, execution falls through to the next case. This is occasionally useful:

c
switch (command) {
    case 'q':
    case 'Q':
        // Both 'q' and 'Q' reach here
        quit_program();
        break;
    case 'h':
    case '?':
        show_help();
        break;
}

[!WARNING] Accidental fallthrough is one of the most common bugs in C. Always use break. In C23, the [[fallthrough]] attribute documents intentional fallthrough: [[fallthrough]]; // intentional.


for Loops: The Mechanics of Iteration

The C for loop has three clauses: init; condition; increment. All three are optional:

c
#include <stdio.h>
#include <stdint.h>

int main(void) {
    // Classic indexed loop over an array
    int32_t scores[5] = {90, 85, 78, 92, 88};
    int32_t total = 0;
    
    for (size_t i = 0; i < 5; i++) {
        total += scores[i];
    }
    printf("Average: %d\n", total / 5);
    
    // Reverse iteration
    for (size_t i = 4; i < SIZE_MAX; i--) { // SIZE_MAX wraps — common idiom
        printf("%d ", scores[i]);
    }
    
    // Multiple initialization and increment
    for (int low = 0, high = 9; low < high; low++, high--) {
        // Converging from both ends
    }
    
    return 0;
}

Loop Counter Types: int vs size_t

A subtle but important point: use size_t for indices into arrays and int for counters with meaning. size_t is the type returned by sizeof and strlen — it is guaranteed to be large enough to index any array on the current platform. Mixing signed and unsigned loop counters produces compiler warnings and subtle comparison bugs.


while and do-while: Event-Driven Service Loops

The while loop checks the condition before the loop body. The do-while executes the body at least once before checking:

c
#include <stdio.h>

// Classic service loop pattern used in servers, device drivers, and daemons
void run_event_loop(void) {
    while (server_is_running()) {
        Event event = poll_for_event();
        
        if (event.type == EVENT_NONE) {
            sleep_microseconds(100);
            continue;
        }
        
        dispatch_event(&event);
    }
    
    printf("Server shutting down cleanly.\n");
}

// do-while: good for "prompt and validate" patterns
void get_valid_input(void) {
    int value;
    do {
        printf("Enter a number 1-10: ");
        scanf("%d", &value);
    } while (value < 1 || value > 10);
    
    printf("Valid input: %d\n", value);
}

break, continue, and Loop Control

break exits the entire loop. continue skips the rest of the current iteration and moves to the next:

c
void process_records(Record *records, int count) {
    for (int i = 0; i < count; i++) {
        if (records[i].is_deleted) {
            continue;  // Skip deleted records, check the next one
        }
        
        if (records[i].is_critical_error) {
            log_critical(&records[i]);
            break;     // Stop processing — critical error found
        }
        
        process_record(&records[i]);
    }
}

For nested loops, break only exits the innermost loop. To break out of multiple levels, you need either a sentinel flag, a function return, or — intentionally — goto.


The Rehabilitation of goto: Centralized Error Cleanup

goto has a terrible reputation from the 1970s "spaghetti code" era. In high-level languages, it is rightly avoided. In C systems programming, however, goto serves one specific, legitimate purpose: centralized error cleanup.

Without goto, error handling in C leads to deeply nested or duplicated cleanup code:

c
// WITHOUT goto: duplicated cleanup
FILE *log = fopen("log.txt", "w");
if (!log) return -1;

char *buf = malloc(4096);
if (!buf) {
    fclose(log);       // Must close log here
    return -1;
}

int *data = malloc(sizeof(int) * 1000);
if (!data) {
    fclose(log);       // Must close log AND free buf here
    free(buf);
    return -1;
}
c
// WITH goto: clean, readable, DRY error handling
int result = 0;
FILE *log  = fopen("log.txt", "w");
if (!log) { result = -1; goto done; }

char *buf  = malloc(4096);
if (!buf)  { result = -1; goto close_log; }

int *data  = malloc(sizeof(int) * 1000);
if (!data) { result = -1; goto free_buf; }

// --- actual work ---
do_work(log, buf, data);

// --- Cleanup labels ---
    free(data);
free_buf:
    free(buf);
close_log:
    fclose(log);
done:
    return result;

This pattern is used extensively in the Linux kernel and is the idiomatic way to handle resource cleanup in C. The key rule: only jump forward, never backward. Backward goto creates the "spaghetti" problem.


Look-Up Tables: Replacing Logic with Data

One of the most powerful performance optimizations in C is replacing complex logic with a pre-calculated array. When the output depends only on the input with no side effects, a Look-Up Table (LUT) replaces 10+ CPU cycles of computation with a single memory access:

c
#include <stdio.h>
#include <stdint.h>

// WITHOUT LUT: computes sin() on every call — expensive floating point
double slow_wave(int degree) {
    return sin(degree * 3.14159265 / 180.0);
}

// WITH LUT: pre-computed, single array access
static const float SINE_TABLE[360] = {
    0.000000f, 0.017452f, 0.034899f, /* ... all 360 values ... */
};

float fast_wave(int degree) {
    return SINE_TABLE[degree % 360];  // O(1) memory access
}

LUTs are foundational in embedded systems (CRC checksums, sin/cos tables for motor control), audio processing (waveform generators), and network processing (protocol dispatch tables).


Loop Unrolling and Performance

When the compiler knows the iteration count, it can "unroll" the loop — replacing the loop with repeated copies of the body to reduce branch overhead:

c
// Original loop (4 iterations over 4 elements)
for (int i = 0; i < 4; i++) {
    result += data[i];
}

// Manually unrolled (no branch, no counter increment)
result += data[0];
result += data[1];
result += data[2];
result += data[3];

GCC and Clang perform this automatically with -O2 and -O3 for fixed-size loops. You can hint at this with #pragma GCC unroll N or by using SIMD intrinsics, which we cover in the Advanced C++ module.


Frequently Asked Questions

Is goto really used in professional C code? Yes, extensively. The Linux kernel source (6+ million lines of C) uses goto thousands of times for exactly the error-cleanup pattern described above. git grep "goto" drivers/ in the kernel returns tens of thousands of hits. The criticism of goto applies to jumping backward or across initialization — not to the forward-jump cleanup pattern.

What is "undefined behavior" in the context of control flow? Certain C control flow constructs trigger undefined behavior (UB): falling off the end of a non-void function without returning a value, accessing an array out of bounds in a loop condition, or modifying the same variable twice between sequence points. The compiler assumes UB never occurs and may optimize in ways that produce surprising — and dangerous — results.

When should I use switch vs if-else? Use switch when you are comparing a single integer variable against a set of known constant values — the compiler can generate a jump table and it reads more cleanly. Use if-else when the conditions are complex (ranges, multiple variables, non-integer types) or when the number of branches is very small (1-2).

Why does C have do-while when while exists? do-while guarantees at least one execution of the loop body, which is semantically important for "parse and validate" patterns. It also maps directly to a specific CPU instruction pattern where the branch check is at the bottom of the loop — this is slightly more efficient when you know the loop body will always execute at least once.

What is the difference between break in a loop vs break in a switch? In a loop, break exits the loop. In a switch, break exits the switch block. If you have a switch nested inside a loop, break exits only the switch — not the loop. Use a flag variable or goto to break out of the loop from inside a switch.


Key Takeaway

Control flow in C is Transparent. You can predict exactly which assembly instructions the compiler will generate. By understanding jump tables, branch prediction, and when to use goto vs nested conditions, you write code that is not only logically correct but physically efficient on the CPU.

The C programmer who understands branch prediction speaks the same language as the hardware. That is why C remains the language of choice for performance-critical systems, from operating system kernels to embedded real-time controllers.

Read next: Functions & The Call Stack: Stack Frames and Performance →


Part of the C Mastery Course — 30 modules from foundations to expert systems programming.