Summary
- Lexical scope and object lifetime
- Non-local references
- Closures
- Connection to object-oriented programming
Intro
So far in our labs, we’ve built interpreters and compilers for the “core” of a language:
-
Data: Strings, booleans
-
Memory: Variables, assignment
-
Control Flow: Conditionals (
if), loops (while) -
Processing: Scanning, parsing, syntax analaysis, ASTs, and LLVM IR code generation
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)))))
-
make-tax-calculatoris a function that takes one argument,tax-rate. -
It returns a new, anonymous function (a
lambda) that takes one argument,base-price. -
This inner function calculates the total price.
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:
-
We call
(make-tax-calculator 6.0). The parametertax-rateis set to6.0. -
The
lambdais created. -
make-tax-calculatorfinishes and returns thelambda. Its stack frame, includingtax-rate, should be gone. -
…but when we call
(calc-ma-tax 100.0), the function still knows thattax-rateis 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
-
[=]would capture all used variables by value. -
[&]would capture all used variables by reference. -
[&tax_rate, y]would capturetax_rateby reference andyby value.
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!
-
The
TaxCalculatorobject is the closure. -
The
taxRatefield is the captured environment. -
The
calculatemethod is the function code. -
The
TaxCalculator(double taxRate)constructor is the factory function (likemake-tax-calculator).
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.)