Summary

Intro

So far in our labs, we’ve built interpreters and compilers for the “core” of a language:

Now, we’re starting a new, major unit: functions.

Functions are fundamental, but they introduce a crucial, complex question that we haven’t had to answer yet:

What happens when a function uses a variable that isn’t local and isn’t global?

This is called a “non-local reference”. Where does that variable “live”? How does the function find it?

The “Natural” Approach: Scheme & Lexical Scope

Let’s look at an example in Scheme, where functions are “first-class” and it’s easy to create non-local references.

;; A function that *creates* and *returns* another function
(define (make-tax-calculator tax-rate)
  (lambda (base-price)
    (* base-price (1 + (/ tax-rate 100.0)))))

Now, let’s use it:

;; Create a new function for MA sales tax (6.0%)
(define calc-md-tax (make-tax-calculator 6.0))

;; 'calc-md-tax' is now a function that "remembers" tax-rate = 6.0
(calc-md-tax 100.0) ; returns 106.0
(calc-md-tax 50.0)  ; returns 53.0

;; We can make another one for DE (0%)
(define calc-de-tax (make-tax-calculator 0.0))
(calc-de-tax 100.0) ; returns 100.0

;; Note that the MD tax rate still applies!
;; (indicating that it's not just using a global variable)
(calc-md-tax 10.0) ; returns 10.6

Think about this:

  1. We call (make-tax-calculator 6.0). The parameter tax-rate is set to 6.0.

  2. The lambda is created.

  3. make-tax-calculator finishes and returns the lambda. Its stack frame, including tax-rate, should be gone.

  4. …but when we call (calc-ma-tax 100.0), the function still knows that tax-rate is 6.0.

How? The lambda didn’t just capture the code (lambda (base-price) ...). It also captured the environment it needed to run, specifically the binding tax-rate = 6.0.

This “bundle” — a function + its captured referencing environment — is called a Closure.

In Scheme, this is the default, implicit, “natural” behavior. It’s a direct result of lexical scope (also called static scope). Lexical scope means that a function’s free variables (like tax-rate) are resolved based on where the function was defined in the code, not where it is called.

Explicit Closures in Modern Languages

Scheme and Lisp do this all implicitly. Most modern languages adopted this idea, but they often make the “capturing” more explicit and, in some cases, more “awkward” due to their underlying memory models (e.g., stack vs. heap).

C++ Lambdas

C++ (since C++11) has lambdas that look similar, but you must specify how to capture the environment in the [] capture clause.

// Returns a C++ lambda
auto make_tax_calculator(double tax_rate) {
    // [tax_rate] means "capture by-value"
    return [tax_rate](double base_price) {
        return base_price * (1 + tax_rate / 100.0);
    };
}

auto calc_md_tax = make_tax_calculator(6.0);
calc_md_tax(100.0); // returns 106.0

This is powerful, but dangerous. What if you capture a local variable by reference ([&tax_rate]) and the function that variable lived in returns? You’ve just returned a closure with a dangling reference. Scheme’s garbage collector saves you from this; C++ expects you to manage it.

Rust Closures

Rust, being focused on memory safety, also has closures but uses its borrow checker to prove they are safe.

fn make_tax_calculator(tax_rate: f64) -> impl Fn(f64) -> f64 {
    // note: '|args| value' is the rust syntax for creating a lambda function
    // the 'move' keyword says that the *ownership* of captured variables
    // is taken by the closure
    move |base_price: f64| base_price * (1.0 + tax_rate / 100.0)
}

fn main() {
    let md_tax = make_tax_calculator(6.0);
    println!("{}", md_tax(100.0)); // prints 106.0
}

The move keyword is explicit: it tells the compiler to move tax_rate into the closure’s environment, so it will live as long as the closure does. In this way, the rust compiler can ensure that no dangling reference errors are possible.

Java Lambdas

Java (since Java 8) has lambdas, which are just syntactic sugar for a more complex underlying pattern.

import java.util.function.Function;

class MyProgram {
    public static Function<Double, Double> makeTaxCalculator(double taxRate) {
        // This lambda 'captures' taxRate
        return (basePrice) -> basePrice * (1 + taxRate / 100.0);
    }

    public static void main(String[] args) {
        Function<Double, Double> calcMDTax = makeTaxCalculator(6.0);
        calcMDTax.apply(100.0); // returns 106.0
    }
}

Java has a specific rule: any captured local variable (like taxRate) must be final or effectively final. This means you can’t re-assign taxRate after makeTaxCalculator captures it. This simplifies the capture mechanism… which leads to the question: what is actually happening here?

Connecting to What You Already Know: OOP

Does this idea of “bundling data and behavior” sound familiar? It should. Let’s look “behind the curtain” at what Java is doing with its lambdas.

How would you write this make_tax_calculator program in Java without lambdas? You’d probably use a class.

// A class that "makes tax calculators"
class TaxCalculator {
    private double taxRate; // The "captured" data

    // The "factory"
    public TaxCalculator(double taxRate) {
        this.taxRate= taxRate;
    }

    // The "behavior"
    public double calculate(double basePrice) {
        // Uses the captured data
        return basePrice * (1 + this.taxRate / 100.0);
    }
}

It’s the same pattern!

A closure is essentially an object with one method.

In fact, this is almost literally what the Java compiler does. When it sees a lambda, it internally generates a class very similar to this one. The lambda syntax is just “sugar” for creating an object that implements a functional interface (like Function<Double, Double>).

(Historical Note: This is actually backward! The concept of closures and lexical scope (from Lisp/Scheme in the 60s/70s) is much older than the widespread adoption of OOP. But since you learned OOP first, it’s a powerful mental model.)