When we left off, we were starting to write our interpreter for LetLang, and we got to the following case:
[(letE var assignment body) ...]
Informally, what we want to do is:
assignment to a value: in this case, a number nn for instances of var in bodyBut we stopped short of defining what it means to “substitute n for var in body”. Let’s think through some examples. We’ll use some notation here: [n/x]e means “substitute n for x in e.”
[7/x] x
7[7/x] (x + x)
7 + 7[7/x] (x + y)
7 + y[7/x] x + (let y = 4 in y + x)
7 + (let y = 4 in 7 + x)[7/x] 4
4[7/x] (let x = 3 in x + x)
let x = 3 in x + x (note: shadowing!)These examples should give us enough to generalize into a definition. We define substitution by induction (case analysis) on the expression being substituted into:
To substitute n for x in:
- A variable y:
- if y = x, then (num n)
- otherwise, y
- A number (num n):
- Nothing to do: (num n)
- A sum (e1 + e2):
- recursively substitute, and re-form the sum: ([n/x]e1 + [n/x]e2)
- A let-expression (let y = e1 in e2)
- If y = x: substitute into e1, but do nothing to e2:
(let y = [n/x]e1 in e2) --- note: this is the shadowing case
- Otherwise: recursively substitute :
(let y = [n/x]e1 in [n/x]e2)
In class, we’ll translate this definition into Plait code and test it on the examples above.
Now we can use the definition to complete our LetLang definitional interpreter.
So far, our notion of a definitional interpreter has allowed us to say that a program’s meaning is the value it runs to when it’s done running. However, this is not a very useful definition in all circumstances: for example, if we have programs that can run forever, or that periodically play noises or accept user input, we don’t care about only the “final result”.
Small-step semantics is an approach that takes this concern into account be explaining how to perform a single step of computation at a time for a given expression. You can think of it like each line of a math problem where you are asked to “simplify” an expression. For example:
(2 + 1) + (3 + 1)
--->
3 + (3 + 1)
--->
3 + 4
--->
7
We can kind of get the gist of what we’re doing by example. Note that this example shows a sequence of steps taken that eventually get to a value, but our step function will implement just a single ---> step.
Together, we will write a “stepper” that implements this behavior for LetLang. Here’s a pseudocode specification:
x: error, unbound variable.e1 + e2:
e1 and e2 are both numbers num n and num m:
num (n + m)e1 can take a step to e1',
e1' + e2e2 can take a step to e2',
e1 + e2'.let x = e1 in e2:
e1 can take a step to e1',
let x = e1' in e2e1 must be a num n.
[n/x]e2num n: do nothing (return num n).A few things to note and discuss:
(e1 + e2), which sub-expression should we evaluate first? Both choices are valid, but in some languages might change the program behavior (can you think of why?)Intuitively, we expect to be able to “call step repeatedly” to take an expression to its eventual value. Can we try make this intuition precise?
First, let’s say what it means to “step e some number k times”:
(stepk : (Number LetLang -> LetLang))
(define (stepk k e)
(if (equal? k 0)
e
(stepk (- k 1) (step e))
)
)
The following specification describes our intended behavior:
Whenever (interp e) produces a number n, there exists k such that (stepk k e) produces (numE n).
We can think of this as a relationship between two definitions of our language, a statement that they are, in some sense, the same. This will be a recurring idea throughout the course.