Control Flow and Semantics §
Control flow and semantics define what a program means: how statements execute, how variables are bound, how functions compose, and how expressions are evaluated. They are relevant whenever a program’s behavior must be predicted, reasoned about, or verified. This page covers the control-flow constructs (conditionals, loops, exceptions), the binding and scoping rules that give variables meaning, the evaluation strategies that determine when and how expressions are reduced, and the formal semantics frameworks that make all of this precise.
Authoritative sources
Sequencing, state, and assignment §
Every imperative language rests on two primitive rules. Skip () does nothing: it is the identity on state. Sequencing () composes two statements: evaluate to an intermediate state , then evaluate in . A state is a mapping from variables to values (), and assignment is a state update: it produces , the state identical to except that now maps to the value of .
Details
These three notions — state, skip, composition — are the foundation on which every other control-flow rule builds: if and while are just different ways of choosing which sequence of assignments to run.
The state: think of as a table with one row per variable currently in scope. There is exactly one cell per binding; assignment overwrites the cell’s content, it never creates a second cell. (Where those cells live physically — stack frame or heap — is covered in Memory Management.)
Assignment: is defined pointwise: it agrees with on every variable except , where it maps . All other bindings are untouched — this is what makes assignment local.
Skip: leaves the state unchanged: . It is the identity element of sequencing: and behave exactly like . This is why a for loop with an empty body is legal and means “repeat the condition test only”.
Composition: is evaluated left to right:
Specific example: take the program starting from the empty state :
- Evaluate : the state becomes .
- Evaluate in : look up (yields 3), compute 4, and the state becomes .
The order matters: had the program been , the lookup of in step 2 would fail (or yield a default), which is why sequencing is not commutative.
Authoritative source: The Formal Semantics of Programming Languages (Winskel, 1993) — chapters 1–3 build everything from exactly these rules.
Example problem
Starting from the empty state, what is the final state of ? Which single statement could you replace with skip without changing the result?
Solution
- gives .
- looks up 2, computes 4, gives .
- looks up 4 twice, gives .
The first statement can be replaced by skip only if already holds 2 in the initial state; from the empty state, no statement can be removed — each assignment feeds the next. This shows sequencing is genuinely order-dependent: removing or reordering any changes the intermediate states that later statements read.
Conditionals and loops §
if: executes if is true, else . while: repeats while is true. for: a bounded loop with three components — initialization, condition, and update — that desugars directly to a while loop.
Details
Conditionals and loops are derived rules: each one selects which sequence of assignments (the primitive from the previous section) to run, and how many times.
Short-circuit evaluation: evaluates only if is true; evaluates only if is false. This is a semantic choice, not a syntactic one.
The for loop desugared: the C-style for (init; cond; update) body is syntactic sugar for:
init;
while (cond) {
body;
update;
}
“Bounded” means the loop’s lifetime is controlled by three explicit parts: init runs once before the first test, cond is checked before every iteration, and update runs after each iteration. This contrasts with an unbounded while where the programmer must manage the counter manually.
Loop invariants: a predicate that holds before and after each iteration. Proving holds at loop exit (when is false) establishes the loop’s postcondition.
Tail position and tail recursion: a call is in tail position when no operation is pending after it — the caller’s frame has nothing left to do, so it can be discarded before the call executes. A function whose recursive calls are all in tail position is tail-recursive and can be compiled to a loop. Whether a runtime actually performs this tail-call optimization is a property of the implementation, not the language semantics; the mechanism (stack frames, stack overflow) is covered in Memory Management.
Example problem
In f(n) { if (n == 0) return 0; return 1 + f(n - 1); }, is the recursive call in tail position? What minimal rewrite makes it so?
Solution
No: the call’s result is used by the pending operation 1 + …, so the caller’s frame must survive the call. Moving the pending addition into an accumulator parameter — f(n, acc) { if (n == 0) return acc; return f(n - 1, acc + 1); } — leaves nothing pending after the call, putting it in tail position. See Memory Management for how this eliminates stack growth.
Variables, scope, and closures §
A variable is a named binding in an environment. Scope determines where a binding is visible. Closures capture the environment in which a function is defined, allowing the function to access variables that are no longer in scope. The physical layout of environments and stack frames is covered in Memory Management.
Details
Lexical scope (static): a variable’s scope is determined by its textual position. .
Dynamic scope: a variable’s scope is determined by the call stack. Rare in modern languages (Scheme’s dynamic-wind, R5RS+; Common Lisp uses unwind-protect instead).
The difference is easiest to see when the same program is resolved both ways:
Closures: . The inner function captures the mutable cell for . The environment cell is shared: every call to c() mutates the same cell.
Hoisting: in JavaScript, variable declarations (var) and function declarations are moved to the top of their scope before execution. let and const are hoisted but not initialized (temporal dead zone).
Connection to the formal model: this is the state-and-assignment model of the sequencing section with one addition — an environment layer mapping names to cells, so that two names may share one cell (closures) or one name may denote different cells in different scopes (let per iteration).
Authoritative source: ECMAScript Language Specification — Lexical Environments.
Example problem
Under dynamic scoping, what does the scope-nesting program above print when caller() runs? Why do most modern languages reject dynamic scoping?
Solution
It returns 3: inner is called from caller, so dynamic resolution picks caller‘s x. Dynamic scoping is rejected because a function’s meaning then depends on its callers — you cannot reason about a function in isolation, and renaming a variable in any caller can silently change the callee’s behavior. Lexical scope makes every function’s meaning fixed at its definition site, which is what enables local reasoning and separate compilation.
Functions, composition, and currying §
A function maps inputs to outputs. Recursion is a function that calls itself. Higher-order functions take functions as arguments or return functions: map, filter, reduce, fold. The type system that constrains which functions can be composed is studied in Language Theory.
Details
Composition: , type . Composition is associative — — which is why pipelines of transformations can be regrouped freely.
Partial application: fixing one argument of yields .
Currying: is equivalent to . . Currying is what makes partial application uniform: every function of several arguments is secretly a function of one argument returning a function.
Specific example: with add = a => b => a + b, the partially applied add(3) is the function , and map(add(3), [1, 2, 3]) yields [4, 5, 6] — no new machinery needed, just composition of an existing function with map.
Recursion schemes: catamorphisms (folds) and anamorphisms (unfolds) generalize recursion over inductive data types. A left fold (the default reduce in Python, JavaScript, and Haskell’s foldl):
A right fold (Haskell’s foldr):
The left fold is strict in the accumulator; the right fold can be lazy and may terminate on infinite lists.
Authoritative source: Haskell Report, section on folds.
Example problem
Why does foldr terminate on the infinite list [1, 2, 3, ...] for foldr (\x acc -> if x > 2 then acc else x : acc) [] while foldl with the same arguments never produces a result?
Solution
foldr is defined as : it produces the outermost application before forcing the rest of the list. Here returns x : acc without inspecting acc when is false… more precisely, for each it conses and only the tail remains unevaluated; once , returns acc without forcing the tail, so the infinite remainder is never demanded. foldl is : the recursive call on the rest of the list is the first argument, so any result requires the whole infinite list to be consumed first. This strictness asymmetry is exactly the call-by-need behavior discussed in the next section.
Functors, applicatives, and monads §
Functors, applicatives, and monads generalize plain function composition to computations wrapped in a context (optionality, lists, effects). They are the category-theory layer above the composition and currying of the previous section.
- Functor: — lifts a plain function over a structure (e.g.,
Option,List). - Applicative: , — applies a function in context to a value in context.
- Monad: , — chains computations where the next step depends on the previous result.
The progression: a plain function maps ; a functor maps that function over a wrapper ; an applicative applies a wrapped function to a wrapped value; a monad chains when the next step depends on the previous result.
Details
Global picture: each layer adds one degree of dependency. Functor: the function is total and independent per element. Applicative: the shape of the computation is fixed in advance (all arguments known up front). Monad: the shape may depend on intermediate results — which is why monads can model effects like exceptions (stop early) and state (thread through).
Specific example: parsing two integers where the second parse depends on the first requires a monad (>>=); parsing two independent integers needs only an applicative (<*>), which is why applicative parsers can be parallelized and monadic ones generally cannot.
Where to go next: the practical, pattern-level uses of these abstractions (Option chaining, Result propagation, promise chaining as a monad) with worked code are treated in Design Patterns; this page fixes only the type signatures and the progression.
Authoritative source: Applicative Programming with Effects (McBride & Paterson, 2008) and Monads for Functional Programming (Wadler, 1995).
Example problem
Given safeDiv : Int -> Int -> Maybe Int, why can safeDiv 6 3 be written with fmap but divAll : Int -> [Int] -> Maybe [Int] (divide n by every element, failing if any division fails) needs a monad?
Solution
safeDiv 6 3 applies a known function to known arguments; only the result is wrapped, so fmap suffices. In divAll, whether to continue with the next element depends on the result of the previous division — the structure of the computation depends on intermediate values. That dependency is precisely what >>= adds over <*>: applicative computations have a fixed shape, monadic ones do not.
Evaluation strategies §
An evaluation strategy specifies the order in which sub-expressions are reduced and when function arguments are evaluated. This is a semantic choice that affects both performance and expressiveness.
Details
Call-by-value (strict / eager): arguments are fully evaluated before the function body executes. This is the strategy of C, Java, Python, and most imperative languages. Side effects in arguments are visible to the callee.
Call-by-name (lazy): arguments are substituted unevaluated into the body and evaluated only when needed. This can avoid unnecessary computation but may re-evaluate the same expression multiple times.
Call-by-need (lazy with memoization): like call-by-name, but each argument is evaluated at most once and the result is cached. Haskell’s default strategy. This gives the efficiency of strict evaluation with the laziness of call-by-name.
A concrete comparison: take and call it with a side-effecting argument — a counter increment:
let counter = 0;
function inc() { counter++; return counter; }
function f(x) { return x + x; }
f(inc());
The comparison, rendered as raw HTML (LaTeXMarkdown does not render pipe tables):
| Strategy | What happens | Result | Final counter |
|---|---|---|---|
| Call-by-value | inc() evaluated once, value 1 passed to the body | 2 | 1 |
| Call-by-name | inc() substituted, giving inc() + inc(); each occurrence evaluated when needed | 1 + 2 = 3 | 2 |
| Call-by-need | inc() evaluated once (value 1), result cached and reused | 2 | 1 |
Call-by-name is the only strategy where the argument’s side effect happens twice — and it happens interleaved with the body’s execution rather than before it, which is why call-by-name programs with effects are notoriously hard to reason about. Call-by-need restores the “evaluated once” guarantee while keeping laziness.
Evaluation order: even in strict languages, the order in which arguments are evaluated is often unspecified (C) or left-to-right (Java, Python). This matters when arguments have side effects.
Short-circuit vs. full evaluation: and are inherently non-strict in their second operand — a special case of call-by-name embedded in an otherwise strict language.
The choice of strategy interacts with the type system (see Language Theory) and with the compiler’s code generation (see Compilation): a lazy language requires the runtime to allocate thunks (unevaluated expressions) on the heap.
Authoritative source: Report on the Algorithmic Language Haskell 2010 — semantics of lazy evaluation.
Example problem
Under call-by-name, f(inc()) above returns 3 and leaves counter = 2. What does g(x) = 1 return under each strategy when called as g(inc()), and what is counter afterwards?
Solution
All three strategies return 1, but the side effects differ. Call-by-value evaluates inc() before the call: counter = 1. Call-by-name substitutes the argument into the body 1 — the argument never appears, so it is never evaluated: counter = 0. Call-by-need also defers evaluation, and since the thunk is never demanded, counter = 0. This is the key expressiveness difference: non-strict strategies can discard unused arguments entirely, which strict evaluation cannot.
Exceptions and non-local exits §
Exceptions are a control-flow mechanism that transfers control from a throwing site to the nearest enclosing handler, bypassing intermediate frames. They generalize the break/return pattern to arbitrary nesting depths.
Details
try / catch / finally:
try {
S; // may throw
} catch (e: T) {
H; // handler for type T
} finally {
C; // cleanup, runs whether or not an exception was thrown
}
The finally block runs on normal completion, on exception, and on non-local exit (e.g., return inside try). This makes it the canonical place for resource cleanup.
Non-local exits: return, break, continue, and exceptions all transfer control out of the current frame without executing the remaining statements. The key semantic question is: which cleanup code runs? In most languages, finally blocks and destructors run in reverse order of entry as the stack unwinds.
Interaction with scope: an exception does not “break” scope — it triggers the same cleanup (variable deallocation, finally blocks) that a normal return would, but the control transfer target is the handler, not the caller.
Scheme’s dynamic-wind (R5RS+) and Common Lisp’s unwind-protect provide explicit before/after guards that run on any exit (normal or exceptional), making the cleanup contract explicit rather than implicit.
Checked vs. unchecked exceptions: Java distinguishes checked (must be declared or caught) from unchecked (runtime) exceptions. C++ uses noexcept specifications. Python has no checked exceptions. The choice affects the type system and the compiler’s code generation.
Authoritative source: R5RS Scheme — control features (dynamic-wind).
Example problem
In the stack-unwinding diagram above, suppose readFile’s finally block itself throws. Which handler catches that new exception, and what happens to the original exception from parse?
Solution
The new exception propagates from readFile’s frame upward — it is not caught by main’s catch in the sense of resuming the original flow; rather, the original exception from parse is discarded (in most languages: Java, JavaScript, Python), and the new exception continues unwinding from readFile. main‘s handler receives the second exception. This “exception during cleanup” problem is why some languages (e.g., Python’s raise ... from, Java’s suppressed exceptions in try-with-resources) provide a way to chain or suppress the original exception instead of losing it.
Operational and denotational semantics §
Formal semantics gives a mathematical definition of what a program means, independent of any particular implementation. The primitives it must define first are exactly the ones from the sequencing section: the state , assignment , skip, and composition .
Details
Operational semantics defines meaning as a sequence of state transitions:
- Big-step (natural) semantics: — expression in state evaluates in one (possibly compound) step to value .
- Small-step (structural) semantics: — one atomic transition. The reflexive-transitive closure gives the full evaluation. Small-step makes it easier to reason about intermediate states and to define concurrency.
Denotational semantics maps each syntactic construct to a mathematical object (usually a function between domains):
- A command denotes a function from state to state: .
- An expression denotes a function from environment to value: .
- The key property is compositionality: the denotation of a compound expression is determined solely by the denotations of its parts.
Axiomatic semantics (Hoare logic) specifies meaning as pre/postcondition triples: means “if holds before and terminates, then holds after.” This is the basis for formal verification.
The two primitive rules, formally:
skip is the identity on state; composes by evaluating to a state , then in . Every other rule (if, while, exceptions) is defined on top of these.
Worked example: take the tiny program from the empty state .
(a) Big-step derivation — one judgment per compound command, bottom-up:
where and . The premise on the left is discharged by the assignment axiom ; the right premise needs the expression sub-derivation before the assignment axiom applies.
(b) Small-step derivation — a linear trace of atomic transitions on configurations :
Each arrow is one rule application: the sequencing rule exposes first, the assignment axiom fires, then the same happens for , and finally the skip rule terminates the trace.
(c) Denotational mapping — no states are “reached”; the program is a function:
The three frameworks agree on the outcome (), but they expose different information: the big-step derivation shows why the result holds, the small-step trace shows each intermediate state (essential for concurrency), and the denotation shows the program as a composable function, enabling algebraic laws such as .
The three approaches are equivalent in expressive power for sequential programs but differ in what they make easy to reason about: operational → execution traces; denotational → algebraic laws; axiomatic → correctness proofs.
Authoritative source: The Formal Semantics of Programming Languages (Winskel, 1993).
Example problem
Write the small-step trace of from the empty state, down to the first three transitions and the final state.
Solution
(assignment axiom)
(while unrolls when the guard holds)
(assignment; the skip after the unrolled body is elided)
Now the guard is false, so the while rule yields skip, and . Final state: .
The interactive simulation below steps through a for loop’s phases (init, condition, body, update) and shows how two closures share one environment cell — the concrete counterpart of the sequencing and closure sections above.
Quiz §
Quiz
Problem: Explain why the following JavaScript code prints 3, 3, 3 and not 0, 1, 2:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
Solution
The code prints 3, 3, 3 because var is function-scoped (not block-scoped). All three closures capture the same binding of i. By the time the setTimeout callbacks execute (after 100 ms), the loop has already finished and i === 3.
In the terms of the Variables, scope, and closures section: the var binding creates one environment cell for the entire function scope, so all three closures capture that same cell; let creates a fresh cell per block iteration, so each closure captures its own. The loop’s update statement () is an assignment on that shared cell — exactly the state-update semantics of the sequencing section — and each callback reads the cell only after the loop has written 3 into it.
To print 0, 1, 2, use let (block-scoped): each iteration creates a new binding for i, and each closure captures its own copy:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// prints 0, 1, 2
Or use an IIFE to create a new scope per iteration:
for (var i = 0; i < 3; i++) {
(function(j) { setTimeout(() => console.log(j), 100); })(i);
}
// prints 0, 1, 2