Tutorial
CS374: Principles of Programming Languages - Tutorial: Environments and Type Checking
Contents
- Trace how a name is found through a chain of nested scopes
- Tell define from assign in real code
- Reuse the same scope table to check types before a program runs
This is a walkthrough, and there is nothing to submit. It shows you the one data structure your interpreter needs for variables, and then uses that structure a second time to check types. Type the code into a file named scopes.py as you go, and run it, because watching it work is the point.
Part 1: The Scope Table
Python for Language Work covers the classes and exceptions used here.
A block can use the names of the blocks around it, and it can also hide them with its own. So each scope needs its own table of names and a link to the scope that encloses it. Here is a program after entering one block:
inner outer
+-----------+ _parent +---------------+ _parent
| x: 51 | -----------> | x: 2, y: 10 | -----------> None
+-----------+ +---------------+
Each box is one Environment object:
class LangNameError(Exception):
pass
class Environment:
def __init__(self, parent=None):
self._bindings = {}
self._parent = parent
def define(self, name, value):
self._bindings[name] = value
def lookup(self, name):
if name in self._bindings:
return self._bindings[name]
if self._parent is not None:
return self._parent.lookup(name)
raise LangNameError(f"Undefined variable '{name}'")
def assign(self, name, value):
if name in self._bindings:
self._bindings[name] = value
elif self._parent is not None:
self._parent.assign(name, value)
else:
raise LangNameError(f"Undefined variable '{name}'")
The difference between define and assign is the most important idea here, and it is the difference between let x = 1; and x = 1;. define always writes into this scope, which is how an inner x hides an outer one. assign walks outward and updates the nearest x that already exists, and it never creates one. lookup walks outward the same way, and stops at the first scope that has the name.
Now run a small program. Each let is a define, and entering a block makes a new Environment whose parent is the current one:
outer = Environment() # let x = 2; let y = 10;
outer.define("x", 2)
outer.define("y", 10)
inner = Environment(parent=outer) # {
inner.define("x", 51) # let x = 51;
inner.assign("y", inner.lookup("y") + 1) # y = y + 1;
print(inner.lookup("x")) # print x;
# }
print(outer.lookup("x")) # print x;
print(outer.lookup("y")) # print y;
Predict, then run. What do the three prints show? You should see
51,2, and11. The innerlet xmade a newxand left the outer one alone, andy = y + 1had nolet, so it changed the outery.
Here is the y lookup inside the block, one scope at a time:
inner.lookup("y")
inner holds {x: 51}. No y, so ask the parent.
outer.lookup("y")
outer holds {x: 2, y: 10}. y is here, so return 10.
Part 2: Type Checking with the Same Table
A static type checker decides whether each operation makes sense before any of the program runs. It never computes a value. It remembers types instead: x is a Num, s is a Str. To do that, it has to know the type of each name at each point in the program, which is the same question as Part 1: look in this block, and then in the enclosing blocks. So the checker stores types in the same Environment.
Types flow up the tree, so the checker types the parts before it judges the whole. This small checker represents an expression as a tuple, which keeps the idea in view:
def type_of(node, tenv):
kind = node[0]
if kind == "num":
return "Num"
if kind == "bool":
return "Bool"
if kind == "var":
try:
return tenv.lookup(node[1])
except LangNameError:
return None # undeclared: unknown, so the evaluator reports it
if kind == "+":
left = type_of(node[1], tenv)
right = type_of(node[2], tenv)
if left in ("Num", None) and right in ("Num", None):
return "Num"
raise TypeError(f"'+' requires Num operands, got {left} and {right}")
raise TypeError(f"no rule for {kind}")
None is the unknown type, which a name gets when nobody declared it. The checker cannot prove anything about that name, so it skips the check and leaves the error to the evaluator. The message names both types, because either one could be the mistake.
Try it:
tenv = Environment()
tenv.define("x", "Num") # let x: Num = 1;
print(type_of(("+", ("var", "x"), ("num", 1)), tenv))
print(type_of(("+", ("var", "z"), ("num", 1)), tenv))
try:
type_of(("+", ("var", "x"), ("bool", True)), tenv)
except TypeError as e:
print(e)
Predict, then run. You should see
Numtwice, and then'+' requires Num operands, got Num and Bool. The second line isNumeven thoughzwas never declared, because an unknown type passes the check.
A rule like the one in the + branch can also be written on paper, with what must be true above the line and what then holds below it:
e1 : Num e2 : Num
--------------------
e1 + e2 : Num
The Interpreter assignment uses this Environment, so you can carry the class straight into your work there. If you want to go further, the optional extensions trace lexical and dynamic scope and build a fuller checker.