Coursify

Compiler Design (CD)

Non-Imperative Programming Languages

35 mins

Explore the unique compilation challenges posed by functional and logic programming paradigms. Understand how concepts like lazy evaluation, higher-order functions, closures, and Prolog-style unification require fundamentally different compiler strategies.

Learning Goals

  • Describe the key differences between imperative, functional, and logic programming paradigms.
  • Explain how higher-order functions and closures are compiled using closure conversion.
  • Define lazy (call-by-need) evaluation and describe how it is implemented with thunks.
  • Describe tail-call optimization (TCO) and why it is essential for functional language compilers.
  • Explain the basics of logic programming compilation including unification and backtracking.

The Paradigm Divide

Most of this course has focused on compiling imperative languages (C, C++, Java) — languages where the programmer describes how to compute using sequences of statements, mutable variables, and explicit control flow.

Functional languages (Haskell, ML, Lisp/Scheme, Erlang) and logic languages (Prolog, Datalog) describe what the result should be, leaving the compiler to determine how to compute it. This fundamentally changes how the compiler works:

FeatureImperative (C/C++)Functional (Haskell)Logic (Prolog)
StateMutable variablesImmutable valuesUnification variables
Control flowLoops, branchesRecursion, pattern matchingBacktracking search
FunctionsProcedures with side effectsFirst-class, pureRelations (predicates)
Execution modelStep-by-stepExpression evaluationQuery and prove
Memory modelStack + heapHeavy heap (closures)Trail + choice points

The compiler for a non-imperative language cannot reuse the straightforward techniques from imperative compilation. Instead, it needs fundamentally different intermediate representations and transformations.

First-Class and Higher-Order Functions

In functional languages, functions are first-class values — they can be passed as arguments, returned from other functions, and stored in data structures:

1-- A function that takes another function as an argument 2applyTwice :: (Int -> Int) -> Int -> Int 3applyTwice f x = f (f x) 4 5-- A function that returns a new function 6adder :: Int -> (Int -> Int) 7adder x = \y -> x + y -- This creates a closure!

The critical challenge is that functions may capture variables from their enclosing scope — these are closures.

Example:

1let a = 5 in 2let f = \x -> x + a in -- f captures 'a' from the environment 3f 3 -- evaluates to 8

The compiler must ensure that a remains alive and accessible after the scope where it was defined has exited. This is impossible on a simple stack — the solution is closure conversion.

Closure Conversion: Compiling `\x -> x + a`

  1. 1
    Step 1

    In the lambda expression \x -> x + a:

    • x is a bound variable (it is the parameter)
    • a is a free variable (it comes from the enclosing scope)

    The compiler's job is to transform this lambda into a self-contained function that carries its own copy of a.

  2. 2
    Step 2

    The compiler creates a closure — a heap-allocated record containing:

    1. A function pointer to the generated code
    2. The values of all free variables (a = 5)
  3. 3
    Step 3

    The lambda \x -> x + a is rewritten to take an extra environment parameter:

    1// Original: \x -> x + a 2// After closure conversion: 3lambda_body(Environment* env, int x) { 4 int a = env->a; // Retrieve captured variable from env 5 return x + a; 6}

    This transformation is called lambda lifting or closure conversion — every lambda becomes a top-level function with an extra environment parameter.

  4. 4
    Step 4

    At the call site f 3, the compiler transforms the call:

    1-- Original: 2let f = closure(&lambda_body, {a=5}) in 3f 3 4 5-- Generated (conceptual C): 6Environment* env = alloc_env(a=5); 7Closure* f = alloc_closure(&lambda_body, env); 8lambda_body(f->env, 3)

    So every function call through a closure adds one parameter — the environment pointer. The overhead is minimal (one extra pointer parameter).

  5. 5
    Step 5

    Since closures may outlive the scope that created them, they (and their captured variables) must live on the heap, not the stack. This means functional language compilers are heavily dependent on garbage collection.

    In imperative languages, stack allocation is the default (fast, deterministic). In functional languages, heap allocation + GC is the default, and the compiler aggressively optimizes to detect when stack allocation is safe (escape analysis).

    C++ lambdas with [&] capture-by-reference std use the stack but are unsafe if the lambda outlives the scope. Haskell's runtime uses GC — closures are always safe but pay GC overhead.

Lazy Evaluation and Thunks

In eager evaluation (call-by-value), arguments are fully evaluated before the function is called. In lazy evaluation (call-by-need), arguments are evaluated only when their value is first needed, and the result is memoized (cached) so subsequent uses don't re-evaluate.

Haskell uses lazy evaluation by default. This is a radical departure from imperative languages and requires a fundamentally different execution model.

The mechanism: thunks.

A thunk is a heap-allocated object representing a suspended computation. When the compiler encounters a lazy expression, it generates code that creates a thunk instead of evaluating immediately:

1-- Haskell: this creates a thunk, does NOT compute 1+2 immediately 2let x = 1 + 2 in 3-- x is a thunk: { code: () -> 1+2, result: unevaluated } 4 5-- When x is first used (e.g., in print x): 6-- 1. Check if thunk is already evaluated 7-- 2. If not, run the code, store result in the thunk 8-- 3. Return the stored result 9-- 4. All future uses of x return the cached result

Compilation implications:

  • Every expression becomes a heap-allocated thunk (memory overhead)
  • Every value access requires a check-and-possibly-evaluate (time overhead)
  • The compiler performs strictness analysis to detect when an argument will definitely be needed — in those cases, it can safely use eager evaluation and skip the thunk entirely. This optimization is critical for performance.

Tail-Call Optimization (TCO)

Functional languages use recursion as the primary looping construct (no for or while loops in pure Haskell). A naive recursive function would consume stack space proportional to the recursion depth — for a loop that runs a million times, this would overflow the stack.

Tail-call optimization solves this. When a function's last action is to call another function (or itself), the compiler reuses the current stack frame instead of creating a new one:

1-- Tail-recursive factorial: the recursive call is the LAST operation 2factorial :: Int -> Int -> Int 3factorial 0 acc = acc 4factorial n acc = factorial (n - 1) (n * acc) -- Tail call!

Without TCO:

factorial(5, 1)
  factorial(4, 5)
    factorial(3, 20)
      factorial(2, 60)
        factorial(1, 120)
          factorial(0, 120) → 120

Stack depth: O(n) — each call adds a new frame.

With TCO:

factorial(5, 1)  →  overwrite frame with (4, 5)
factorial(4, 5)  →  overwrite frame with (3, 20)
factorial(3, 20)  →  overwrite frame with (2, 60)
...
factorial(0, 120)  →  120

Stack depth: O(1) — the same frame is reused.

How the compiler implements TCO:

1; Instead of: 2; CALL factorial ; pushes return address, creates new frame 3; RET 4 5; The compiler generates: 6; MOV [SP], new_n ; overwrite current frame's n parameter 7; MOV [SP+8], new_acc ; overwrite current frame's acc parameter 8; JMP factorial ; jump to start of function (no call/ret!)

The JMP instead of CALL is the key — it doesn't push a return address, so when the "recursion" finishes, it returns directly to the original caller.

Why TCO matters: Without it, functional programs would be impractical for any significant computation. TCO makes recursion as efficient as a while loop. This is why Haskell, Scheme, and Erlang guarantee TCO in their language specification.

Key compiler transformations for functional languages:

  1. Closure conversion: Transform lambdas into top-level functions with environment parameters
  2. Lambda lifting: Move nested functions to the top level, passing free variables explicitly
  3. Thunk creation: Wrap lazy expressions in thunk objects with memoization
  4. Tail-call optimization: Convert tail recursion to iterative jumps
  5. Strictness analysis: Detect when eager evaluation is safe (eliminate thunks)
  6. Garbage collection: Heap-allocated closures and thunks require automatic memory management

Intermediate representation: Functional compilers typically use Continuation-Passing Style (CPS) or A-Normal Form (ANF) as IR, rather than the Three-Address Code used in imperative compilers.

The Warren Abstract Machine (WAM) — A Brief Overview

The Warren Abstract Machine is the standard compilation target for Prolog, invented by David H.D. Warren in 1983. Understanding its architecture explains how logic programs execute:

WAM memory areas:

  1. Global stack (heap): Stores compound terms (structures like f(a, b))
  2. Local stack: Stores choice points (for backtracking) and environment frames
  3. Trail: Records variable bindings that must be undone on backtracking

WAM registers:

  • A1, A2, ...: Argument registers (pass arguments to predicates)
  • X1, X2, ...: Temporary variable registers
  • P: Program counter (points to current instruction)
  • S: Structure pointer (for unification)

Compiling a Prolog clause:

1ancestor(X, Y) :- parent(X, Y). 2ancestor(X, Y) :- parent(X, Z), ancestor(Z, Y).

The compiler generates WAM instructions:

  1. Try / Retry / Trust instructions to manage the choice point stack for the three clauses
  2. Unify instructions that perform unification of arguments
  3. Call instructions that invoke sub-predicates
  4. Proceed instruction for the last call (tail-call optimization in Prolog!)

The key insight: Prolog compilation is fundamentally about compiling unification and backtracking into efficient machine code, rather than compiling statement sequences like an imperative compiler.

Historical Influence on Modern Languages

Functional language compilation techniques have profoundly influenced modern imperative languages:

  • Lambdas/closures in Java 8+, C++11, C#, Python, JavaScript — all use closure conversion internally
  • Garbage collection — pioneered for Lisp in the 1960s, now standard in Java, Go, C#
  • Tail-call optimization — required by Scheme, available in some C compilers as an optimization
  • Lazy evaluation — generators in Python (yield), LINQ in C#, streams in Java 8+
  • Immutable dataval in Kotlin, record in Java 14+, readonly in C#

Understanding functional compilation isn't just academic — it explains features you use every day in mainstream languages.

Knowledge Check

Question 1 of 5
Q1Single choice

What is a closure in the context of functional language compilation?