Skip to content

Module 05.1: Environments, Scope, and Closures

Module 04.2 ended with an interpreter that could represent functions and apply them using substitution.

That was a major step. It also left us with several problems.

Our evaluator could apply:

FunDef "x"
  (BinOp (Var "x") PlusOp (Num 1))

to an argument by substituting the argument for x in the body. But substitution has an uncomfortable property: every function call may require us to walk through and rebuild a potentially large expression.

Even more importantly, we ended Module 04.2 with questions that substitution had not really settled:

  • Which binding should a variable refer to when scopes are nested?
  • What should happen when a function uses a variable that is not one of its parameters?
  • If a function is returned as a value and called later, what information needs to travel with it?

This module develops a different model.

Instead of repeatedly rewriting expressions, we will keep track of bindings in an environment.

That one move leads directly to two of the most important ideas in programming languages:

scope determines which binding a variable refers to, and a closure packages a function together with the environment it needs.

Along the way, we will also add let expressions to our language and see a useful language-design technique: sometimes new syntax does not require fundamentally new semantics.

By the end of this module, you should be able to:

  • represent a let expression in our abstract syntax,
  • explain how a let expression can be defined in terms of function definition and application,
  • explain why repeated substitution can create unnecessary work,
  • describe an environment as a mapping from variable names to values,
  • modify an evaluator so that variable lookup uses an environment,
  • explain why the choice of environment data structure affects performance,
  • use let to create nested bindings and to give functions names,
  • distinguish dynamic scope from static (lexical) scope,
  • explain why an environment-only evaluator loses information when functions escape their defining context,
  • define a closure as a function together with its defining environment, and
  • explain how closures give our interpreter static scope.

How to use this module

When you see a box labeled Gradescope question, answer that question in the Module 5.1 Completion assignment on Gradescope.

Other boxes labeled Pause and think, Try it, or Practice are there to help you understand the material. There is nothing to submit for those unless the box explicitly says Gradescope question.


Where We Are

Our language now has numbers, variables, binary operations, function definitions, and function applications:

type VarName = String

data Exp
  = Var    VarName
  | Num    Double
  | BinOp  Exp Op Exp
  | FunDef VarName Exp
  | Apply  Exp Exp
  deriving (Show, Eq)

A function definition contains a parameter name and a body:

FunDef "x"
  (BinOp (Var "x") PlusOp (Num 1))

and a function application contains a function expression and an argument expression:

Apply
  (FunDef "x"
    (BinOp (Var "x") PlusOp (Num 1)))
  (Num 5)

In Module 04.2, our first semantics for application was:

apply a function
      =
substitute the argument for the parameter
      +
evaluate the result

For example:

(x ↦ x + 1) 5

became:

x + 1
[x → 5]

and therefore:

5 + 1

which evaluates to 6.

That model gave us a concrete way to understand function application. Now we are going to ask whether physically rewriting the body is the best way to implement it.

A slide titled "What does it mean to 'apply a function'?" showing Apply (FuncDef


Adding let Expressions

We have already used Haskell expressions like:

let x = 100
in x + 17

A let expression has three important pieces:

let x = 100 in x + 17
    ^   ^       ^
    |   |       |
 variable     body
        |
   bound expression

It says:

  1. associate x with the expression 100,
  2. evaluate the body x + 17 with that binding available.

The result is:

117

Our little language does not have let yet.

So first we need syntax.

Gradescope question: Let expressions in the abstract syntax

Submit this response on Gradescope.

How can you add let-expressions to our abstract syntax?

Before continuing, sketch what information a Let constructor would need to store.

Representing a let

A direct representation is:

data Exp
  = Var    VarName
  | Num    Double
  | BinOp  Exp Op Exp
  | Let    VarName Exp Exp
  | FunDef VarName Exp
  | Apply  Exp Exp
  deriving (Show, Eq)

Read:

Let varname boundExp body

as:

let varname = boundExp in body

For example:

Let "x"
    (Num 100)
    (BinOp (Var "x") PlusOp (Num 17))

represents:

let x = 100
in x + 17

Notice how the datatype follows directly from the structure of the language feature.

A let needs:

  • a variable name,
  • an expression to bind to that variable,
  • and the body in which the binding is available.

So far this gives us the abstract syntax of let.

We still need its semantics.


We Already Know How to Give let a Meaning

Think again about:

let x = 100
in x + 17

Informally, one way to describe its meaning is:

evaluate x + 17 with 100 in place of x.

But we already built another language feature with almost exactly that behavior.

Consider the anonymous function:

x ↦ x + 17

applied to:

100

The result should be the same.

In our abstract syntax:

Let "x"
    (Num 100)
    (BinOp (Var "x") PlusOp (Num 17))

can be understood in terms of:

Apply
  (FunDef "x"
    (BinOp (Var "x") PlusOp (Num 17)))
  (Num 100)

This gives us a useful implementation rule:

eval (Let varname boundExp body) =
  eval (Apply (FunDef varname body) boundExp)

Gradescope question: Semantics of let

Submit this response on Gradescope.

How can you define the semantics of let-expressions using functions?

The important idea is larger than let.

New syntax can sometimes be defined using semantics we already have.

Instead of inventing an entirely new evaluation mechanism for Let, we translate it into function definition and application, whose meaning we already know.

This kind of convenience syntax is often called syntactic sugar: a new way to write something that could already be expressed using more primitive language features.

Here:

let x = e in body

is syntactic sugar for something like:

(x ↦ body) e

The new syntax can still be extremely useful. "Syntactic sugar" does not mean "unimportant." It means the feature does not require a fundamentally new semantic mechanism.

A slide titled "Let Bindings" showing the extended Exp datatype with Let VarName Exp Exp added, next to the eval rule eval (Let varname exp letBody) = eval (Apply (FuncDef varname letBody) exp), captioned "Important Idea: create new abstract syntax, express its semantics using existing semantics!"


The Cost of Substitution

Our substitution semantics is conceptually simple:

apply function
  -> substitute argument through body
  -> evaluate

But consider a curried function with several parameters:

FunDef "a"
  (FunDef "b"
    (FunDef "c"
      (FunDef "d"
        (BinOp (Var "a") PlusOp
          (BinOp (Var "b") PlusOp
            (BinOp (Var "c") PlusOp (Var "d")))))))

and apply it to four arguments:

Apply
  (Apply
    (Apply
      (Apply fourArgFunction (Num 1))
      (Num 2))
    (Num 3))
  (Num 4)

After the first application, substitution has to traverse the remaining function body and replace occurrences of a.

After the second application, substitution traverses much of that structure again for b.

Then again for c.

Then again for d.

If the function body has size proportional to (N), and we perform (N) substitutions that each traverse an expression of roughly that size, we can end up doing work on the order of:

N substitutions
    ×
O(N) traversal per substitution
    =
O(N²) work

The exact cost depends on the expression and implementation, but the pattern is the important part:

We keep walking through large pieces of syntax just to remember what values some names should stand for.

Can we avoid doing that?

A slide titled "Adding more arguments" showing the fully curried four-parameter function FuncDef


A Different Idea: Remember the Bindings

Suppose we are evaluating:

Apply
  (Apply
    (Apply
      (Apply fourArgFunction (Num 1))
      (Num 2))
    (Num 3))
  (Num 4)

Instead of substituting:

a -> 1

through the whole remaining expression, we could simply record:

a = 1

Then, when we eventually encounter:

Var "a"

we look up its value.

After more applications, we might have accumulated:

a = 1
b = 2
c = 3
d = 4

Now evaluating:

BinOp (Var "a") PlusOp
  (BinOp (Var "b") PlusOp
    (BinOp (Var "c") PlusOp (Var "d")))

does not require us to rewrite the expression first.

We can leave the syntax alone and look up names only when we need them.

The data structure containing those bindings is called an:

environment

An environment is a mapping:

variable name  ->  value

For example:

"a" -> Num 1
"b" -> Num 2
"c" -> Num 3
"d" -> Num 4

This is the central move of the module:

substitution model:
    change the expression to replace variables

environment model:
    keep the expression
    + remember what the variables mean

A slide titled "A great idea: save the environment!" introducing an empty Var/Value table next to the fully nested four-argument application expression, captioned "Instead of doing the substitution every time, just 'remember' what you need to substitute when you finally need the values of the local parameters."

The same slide with the Var/Value table now holding a -> Num 1 and b -> Num 2, while the remaining expression to evaluate has shrunk to just the inner two applications and the body.

The same slide with the table fully populated (a -> 1, b -> 2, c -> 3, d -> 4) and the expression reduced to just eval (BinOp (Var

The final slide in the sequence, labeling the table "environment," writing the signature eval env, and noting: an Environment is a map (lookup table) between variables and values; pass the environment to recursive calls of eval; update the environment with (function param, arg) when applying functions.


Representing an Environment

For a first implementation, a list of pairs is enough:

type Env = [(VarName, Exp)]

For example:

[ ("a", Num 1)
, ("b", Num 2)
, ("c", Num 3)
]

We need two basic operations:

lookup: given a name, find its value
insert: add or update a binding

One straightforward implementation is:

envLookup :: VarName -> Env -> Exp
envLookup v [] =
  error ("unbound identifier: " ++ v)

envLookup v ((key, val) : rest) =
  if v == key
  then val
  else envLookup v rest

and:

envInsert :: VarName -> Exp -> Env -> Env
envInsert v val [] =
  [(v, val)]

envInsert v val ((key, oldVal) : rest) =
  if v == key
  then (v, val) : rest
  else (key, oldVal) : envInsert v val rest

This is not the only representation we could use. It is useful because the implementation makes the idea of an environment completely explicit.


Evaluating With an Environment

Our evaluator now needs another input.

Before:

eval :: Exp -> Exp

Now:

eval :: Env -> Exp -> Exp

The environment answers a question that the expression itself cannot answer:

If I encounter Var "x", what does "x" currently mean?

Numbers

Numbers are already values:

eval env (Num x) =
  Num x

The environment is irrelevant here.

Variables

A variable gets its meaning from the environment:

eval env (Var v) =
  envLookup v env

This is the key replacement for substitution.

We do not rewrite every Var "v" in advance.

We leave it in the expression until evaluation actually reaches it, and then we look up v.

Binary operations

Recursive evaluation simply passes the same environment along:

eval env (BinOp left op right) =
  evalOp (eval env left) op (eval env right)

Function definitions

For the moment, we will continue treating function definitions as values:

eval env (FunDef varname body) =
  FunDef varname body

There is a problem hiding in this rule.

We will deliberately leave it there for a little while.

Function application

To apply a function:

  1. evaluate the function expression,
  2. evaluate the argument expression,
  3. associate the parameter name with the argument value,
  4. evaluate the function body using the extended environment.
eval env (Apply fexp arg) =
  let FunDef varname body = eval env fexp
      argval = eval env arg
      newEnv = envInsert varname argval env
  in eval newEnv body

This version is using call-by-value, because it evaluates arg before adding the result to the environment.

Compare this with substitution.

Old idea:

substitute the argument into every relevant place in the body

New idea:

remember parameter -> argument value
and look it up only when a variable is evaluated

Did We Actually Make It Faster?

We introduced environments partly because repeated substitution could require repeated traversals of large syntax trees.

But our first environment is:

type Env = [(VarName, Exp)]

How expensive is:

envLookup

in the worst case?

It may have to scan the entire list.

How expensive is our envInsert?

It may also have to scan the entire list.

So if an environment grows to size (N), each environment operation can itself require (O(N)) work.

If we perform (O(N)) such operations, we can still end up with (O(N^2)) work.

Gradescope question: Did the environment improve the running time?

Submit this response on Gradescope.

Did we achieve our goal of improving the running time with this implementation so far?

The idea of an environment lets us avoid repeatedly traversing and rebuilding the whole program expression.

But performance also depends on the data structure we choose to implement that environment.

Gradescope question: A better environment data structure

Submit this response on Gradescope.

Can you think of a better data structure to use for the environment, besides a list of pairs of variable and value?

A dictionary/map is the natural abstraction.

For example, Haskell's Data.Map provides logarithmic lookup and insertion, while hash-table-style maps can provide average constant-time lookup and insertion.

The exact implementation is not the central point here. The important design lesson is:

Once the semantics say "look up a name in an environment," we can choose an appropriate data structure to implement that operation efficiently.

If lookup and insertion were (O(1)), then an evaluator doing a constant amount of environment work at each of (N) syntax nodes could be (O(N)), rather than repeatedly traversing whole subtrees.

Representation and semantics are different questions

The semantics of an environment is just a mapping from names to values.

Whether we implement that mapping with a list, balanced tree, hash table, or something else is a separate implementation decision.

A slide titled "An Attempt at Lookup and Insert for Environments" showing the eval function with envLookup v env and envInsert varname argval env boxed, annotated with an arrow reading "If these are O(1) then overall eval is O(N)."


let Gets Better With Environments

Recall that we defined:

Let varname boundExp body

in terms of function application:

Apply (FunDef varname body) boundExp

So our evaluator can still use:

eval env (Let varname boundExp body) =
  eval env (Apply (FunDef varname body) boundExp)

Now let naturally uses the environment machinery we just built.

For:

Let "x"
    (Num 10)
    (BinOp (Var "x") TimesOp (Num 2))

evaluation eventually extends the environment with:

"x" -> Num 10

and evaluates the body in that environment.

The expression itself does not have to be rewritten.


Nested lets and Shadowing

Environments also make nested bindings concrete.

Consider:

let y = 100 in
  let x = 10 in
    x + y

The corresponding abstract syntax is:

Let "y" (Num 100)
  (Let "x" (Num 10)
    (BinOp (Var "x") PlusOp (Var "y")))

While evaluating the inner body, the environment needs both bindings:

"y" -> Num 100
"x" -> Num 10

so the result is:

110

Now consider shadowing:

let x = 100 in
  let x = 3 in
    x

The occurrence of x in the innermost body should refer to the inner binding.

Conceptually, the environment visible there is the environment in which the newest relevant binding for x is 3.

When evaluation leaves that inner scope, the outer environment still exists unchanged. Haskell data is immutable, so extending an environment constructs a new environment rather than destructively modifying the old one.

This reconnects to Module 02.1

Earlier, we described nested Haskell scopes as resolving names from the innermost applicable scope outward.

We now have enough interpreter machinery to see how a language implementation can make that idea concrete.


let Can Give a Function a Name

Our FunDef values are anonymous:

FunDef "x"
  (BinOp (Var "x") PlusOp (Num 1))

But now let gives us a way to bind such a value to a name:

let f = (\x -> x + 1) in
  f 100

In our abstract syntax:

Let "f"
  (FunDef "x"
    (BinOp (Var "x") PlusOp (Num 1)))
  (Apply (Var "f") (Num 100))

The environment can record:

"f" -> function value

Then:

Var "f"

is looked up just like any other variable.

The result should be:

Num 101

This is a useful consequence of something we emphasized earlier:

Functions are values.

An environment does not need a completely different lookup mechanism for numbers and functions. Both can be values associated with names.

A slide titled "Naming functions with Let" showing the abstract syntax Let


Now Something Strange Happens

Consider this expression:

let v = 42 in
  let f x = v + x in
    f 100

There is only one visible binding for v, so we expect:

42 + 100 = 142

Now add another binding before the call:

let v = 42 in
  let f x = v + x in
    let v = 3 in
      f 100

What should this evaluate to?

There are two plausible answers:

103

or:

142

Why?

The function's body is:

v + x

When we eventually call:

f 100

we know where x comes from:

x = 100

But where should the value of v come from?

At the moment of the call, the nearest active binding is:

v = 3

But at the moment f was defined, the visible binding was:

v = 42

Those two choices define two different notions of scope.

Gradescope question: Why do we get two different results?

Submit this response on Gradescope.

How might you explain the difference between the two different results? How is it related to the implementation of eval?

A slide titled "Functions in the scope of Let" showing the abstract syntax Let


Dynamic Scope and Static Scope

The difference between 103 and 142 is not an arithmetic issue.

It is a language-design decision about scope.

Dynamic scope

Under dynamic scope, a free variable in a function body is resolved using bindings associated with the context in which the function is called.

For:

let v = 42 in
  let f x = v + x in
    let v = 3 in
      f 100

when f 100 runs, the most recent applicable binding for v is:

v = 3

so:

v + x
=
3 + 100
=
103

Our first environment-based evaluator naturally drifts toward this behavior because function application evaluates the body using the environment available around the call:

eval env (Apply fexp arg) =
  ...
  eval (envInsert varname argval env) body
                                  ^^^
                         caller's environment

That env is the crucial detail.

Static scope

Under static scope, also commonly called lexical scope, a free variable in a function body is resolved using the environment associated with where the function was defined.

When we create:

f x = v + x

the visible binding is:

v = 42

So that is the v the function should continue to use later, even if it is called inside another scope containing:

v = 3

The result becomes:

42 + 100 = 142

The distinction can be summarized as:

dynamic scope:
    free variables use the calling environment

static / lexical scope:
    free variables use the defining environment

Most modern general-purpose programming languages, including Haskell, use static/lexical scope.

Gradescope question: Which scope rule do you prefer?

Submit this response on Gradescope.

Do you prefer one version of the two options from the end of the video over the other? Why or why not?

There is no need to invent a universal philosophical answer. The useful exercise is to think about what each choice means for how a programmer reasons about a function.

With static scope, the meaning of:

f x = v + x

depends on the lexical context where that definition appears.

With dynamic scope, its meaning can change depending on the chain of function calls that leads to it.

A slide titled "Two kinds of scope" placing the same nested-let example side by side with its two possible outcomes: "103, yay!" labeled Dynamic Scope ("Use the closest enclosing scope to look up local variables") and "142, yay!" labeled Static Scope ("Use the closest scope at the time of function definition to bind local variables").


A Deeper Problem: Returning a Function Loses Its Environment

There is another place where our environment-only evaluator breaks.

Recall how we represent a two-argument function using currying:

FunDef "a"
  (FunDef "b"
    (BinOp (Var "b") PlusOp (Var "a")))

Apply it to two arguments:

Apply
  (Apply
    (FunDef "a"
      (FunDef "b"
        (BinOp (Var "b") PlusOp (Var "a"))))
    (Num 100))
  (Num 42)

We expect:

100 + 42 = 142

Look at the first application:

Apply
  (FunDef "a"
    (FunDef "b"
      (BinOp (Var "b") PlusOp (Var "a"))))
  (Num 100)

Our evaluator temporarily creates an environment containing:

"a" -> Num 100

and evaluates the body:

FunDef "b"
  (BinOp (Var "b") PlusOp (Var "a"))

But our rule for a function definition is currently:

eval env (FunDef varname body) =
  FunDef varname body

It throws the environment away.

So the result of the first application is merely:

FunDef "b"
  (BinOp (Var "b") PlusOp (Var "a"))

Where did:

"a" -> Num 100

go?

It disappeared.

When we later apply this returned function to 42, the evaluator eventually reaches:

Var "a"

and has no binding for a.

The result is an error such as:

unbound identifier: a

This reveals the real problem.

A function value is not always completely described by its parameter and body.

If its body refers to variables from an outer scope, then the function also needs the environment in which those variables received their meaning.

A slide titled "Remember this?" showing eval [] (Apply (Apply (FuncDef

A slide titled "Look at the eval function!" listing all the eval cases, with eval env (FuncDef varname body) = (FuncDef varname body) bolded and underlined to highlight that it discards env and returns only the bare function definition.


Free Variables Are the Clue

Consider:

FunDef "x"
  (BinOp (Var "x") PlusOp (Var "v"))

Inside the function body:

Var "x"

is associated with the function parameter.

But:

Var "v"

is not.

We call v a free variable of this function expression.

To understand the function later, we need to know what v meant when the function was created.

That suggests exactly what information a function value needs to carry:

parameter
+
body
+
environment from the point of definition

This package is called a:

closure


Closures

A closure combines:

  1. the function's parameter,
  2. the function's body,
  3. the environment that was active when the function was defined.

Conceptually:

closure
  = function code
  + saved environment

We can represent that in our interpreter as another runtime form:

data Exp
  = Var     VarName
  | Num     Double
  | BinOp   Exp Op Exp
  | Let     VarName Exp Exp
  | FunDef  VarName Exp
  | Apply   Exp Exp
  | Closure Env VarName Exp
  deriving (Show, Eq)

Closure is a runtime value

We are storing Closure in the same Haskell datatype as our abstract syntax because it is convenient for this small interpreter.

Conceptually, though, a programmer does not write a Closure directly in the source language. A closure is a value the evaluator creates when it evaluates a function definition.

Now the function-definition rule changes.

Before:

eval env (FunDef varname body) =
  FunDef varname body

After:

eval env (FunDef varname body) =
  Closure env varname body

The environment is no longer discarded.

It becomes part of the function value.


Applying a Closure

Function application changes too.

The function expression should now evaluate to:

Closure closureEnv varname body

Then we:

  1. evaluate the argument in the current calling environment,
  2. extend the closure's saved environment with the parameter binding,
  3. evaluate the body in that environment.
eval env (Apply fexp arg) =
  let Closure closureEnv varname body = eval env fexp
      argval = eval env arg
      bodyEnv = envInsert varname argval closureEnv
  in eval bodyEnv body

Look carefully at the two environments in this rule.

The argument is evaluated using:

env

the environment of the call.

But the function body is evaluated using:

closureEnv

the environment saved when the function was defined.

That one distinction implements static scope.

definition time:
    save environment in Closure

call time:
    evaluate argument in caller's environment
    evaluate body in saved defining environment

A slide titled "We can fix it!" showing the old rule eval env (FuncDef varname body) = (FuncDef varname body) struck through in red, the extended Exp datatype with Closure Env VarName Exp added, and the replacement rule eval env (FuncDef varname body) = Closure env varname body, annotated "We need to keep the env around!"


Revisit the Scope Example

Return to:

let v = 42 in
  let f x = v + x in
    let v = 3 in
      f 100

When f is defined, the evaluator has an environment containing:

"v" -> Num 42

So evaluating the function definition produces conceptually:

Closure
  saved environment: v = 42
  parameter: x
  body: v + x

Later we enter:

let v = 3 in ...

The calling environment now contains:

v = 3

But that does not replace the environment stored inside the closure.

When we call:

f 100

we extend the closure environment:

v = 42
x = 100

and evaluate:

v + x

there.

So we get:

142

The value 3 belongs to the calling context, but it does not change what the free variable v means inside f.

That is lexical scope made operational.


Revisit the Curried Function

Now reconsider:

FunDef "a"
  (FunDef "b"
    (BinOp (Var "b") PlusOp (Var "a")))

After applying the outer function to 100, evaluating the inner function definition no longer returns only:

FunDef "b"
  (BinOp (Var "b") PlusOp (Var "a"))

Instead it produces a closure that remembers:

"a" -> Num 100

So when that returned function is later applied to 42, its body is evaluated in an environment containing:

"a" -> Num 100
"b" -> Num 42

and we finally get:

Num 142

This is why closures matter so much for higher-order functions.

A function can be:

  • created in one scope,
  • returned from that scope,
  • stored somewhere,
  • passed to another function,
  • and called much later.

The lexical environment where it was created may no longer be the currently active environment.

The closure carries the necessary bindings along with the function.


Putting the Evaluator Together

Using our simple list environment, the core evaluator now looks like this:

type VarName = String
type Env = [(VarName, Exp)]

data Exp
  = Var     VarName
  | Num     Double
  | BinOp   Exp Op Exp
  | Let     VarName Exp Exp
  | FunDef  VarName Exp
  | Apply   Exp Exp
  | Closure Env VarName Exp
  deriving (Show, Eq)

envLookup :: VarName -> Env -> Exp
envLookup v [] =
  error ("unbound identifier: " ++ v)

envLookup v ((key, val) : rest) =
  if v == key
  then val
  else envLookup v rest

envInsert :: VarName -> Exp -> Env -> Env
envInsert v val [] =
  [(v, val)]

envInsert v val ((key, oldVal) : rest) =
  if v == key
  then (v, val) : rest
  else (key, oldVal) : envInsert v val rest

eval :: Env -> Exp -> Exp

eval env (Num x) =
  Num x

eval env (Var v) =
  envLookup v env

eval env (BinOp left op right) =
  evalOp (eval env left) op (eval env right)

eval env (Let varname boundExp body) =
  eval env (Apply (FunDef varname body) boundExp)

eval env (FunDef varname body) =
  Closure env varname body

eval env (Apply fexp arg) =
  let Closure closureEnv varname body = eval env fexp
      argval = eval env arg
      bodyEnv = envInsert varname argval closureEnv
  in eval bodyEnv body

There are many directions in which a production interpreter would make this more robust. For now, focus on the semantic structure.

The environment handles variables

eval env (Var v) =
  envLookup v env

Function definition captures the environment

eval env (FunDef varname body) =
  Closure env varname body

Function application uses the captured environment

eval env (Apply fexp arg) =
  ...
  eval bodyEnv body

where:

bodyEnv = envInsert varname argval closureEnv

Those three rules are the conceptual core of this module.


A Useful Way to Read the Whole Development

We started with substitution:

when applying a function:
    rewrite the body

Then we introduced environments:

when applying a function:
    record parameter -> value

when evaluating a variable:
    look it up

That gave us an efficient and flexible way to separate:

the syntax of an expression

from:

the current meanings of its variable names

But a single "current" environment was not enough for functions.

So closures added one more rule:

when evaluating a function definition:
    save the current environment with it

Now a function carries enough context to interpret its free variables later.

The progression is:

substitution
    ↓
environment
    ↓
scope question
    ↓
closure

Each step is motivated by a concrete problem in the previous one.


Check Your Understanding

Practice: Which environment?

Nothing to submit.

Consider:

let n = 10 in
  let f x = n * x in
    let n = 3 in
      f 4

Under dynamic scope, which binding of n would the body of f use?

Under static scope, which binding would it use?

What result would each scope rule produce?

Reveal

Dynamic scope uses the calling context, where the nearest n is 3, so the result is 12.

Static scope uses the defining context, where n was 10, so the result is 40.

Practice: What belongs in the closure?

Nothing to submit.

Consider:

let offset = 7 in
  let f x = x + offset in
    f

If evaluating this expression returns the function f as a value, what information must that function value remember so it can still be called correctly later?

Reveal

It needs the function's parameter (x), its body (x + offset), and an environment that preserves the relevant binding offset = 7.


What to Carry Forward

The central ideas are now connected.

let can be understood using functions

let x = e in body

can be interpreted as:

apply (x ↦ body) to e

This is an example of defining new syntax in terms of existing semantics.

An environment records bindings

Instead of rewriting a whole expression every time a function is applied, we can remember:

name -> value

and look up names when evaluation reaches them.

Representation affects performance

A list of pairs is easy to understand, but its lookup and insertion can be linear in the size of the environment.

The semantic notion of an environment does not force us to use that representation. We can choose a more appropriate mapping structure.

Scope decides which binding a variable means

Dynamic scope uses bindings from the calling context.

Static or lexical scope uses bindings from the context where the function was defined.

A function value needs context

A function body can contain free variables.

If that function can outlive the scope where it was created, it must carry the bindings those variables need.

That is what a closure does:

closure
=
function
+
defining environment

This is the mechanism that lets higher-order functions and lexical scope work together.


Where This Leaves Us

At the end of Module 04.2, function application meant:

substitute
then evaluate

Now our evaluator has a richer model:

variables get meaning from environments
functions capture environments
applications extend captured environments

The interpreter is no longer just manipulating syntax trees. It is explicitly representing the context in which expressions have meaning.

That is a major step toward a realistic language implementation.

It also gives concrete machinery for ideas that appeared much earlier in the course:

bindings
+
nested scopes
+
functions as values
+
higher-order functions
+
code as data
=
environments and closures

The next time you encounter a function that uses values from outside its parameter list, especially a function that is returned or passed somewhere else, you should now have a precise question to ask:

What environment does this function carry with it?


Finish the Module 05.1 Completion

Make sure you have submitted responses to the six substantive Gradescope questions that appeared at the relevant points above:

  1. How can you add let-expressions to our abstract syntax?
  2. How can you define the semantics of let-expressions using functions?
  3. Did we achieve our goal of improving the running time with this implementation so far?
  4. Can you think of a better data structure to use for the environment, besides a list of pairs of variable and value?
  5. How might you explain the difference between the two different results? How is it related to the implementation of eval?
  6. Do you prefer one version of the two options from the end of the video over the other? Why or why not?

Gradescope also asks:

Gradescope question: Time spent

Submit this response on Gradescope.

Approximately how much time did you spend on this module?

  • less than 30 minutes
  • 30 - 45 minutes
  • 45 - 60 minutes
  • 60 - 75 minutes
  • 75 - 90 minutes
  • more than 90 minutes

Gradescope question: Lingering questions

Submit this response on Gradescope.

What lingering questions or thoughts do you have about this module?