Lecture 4. What the values of a language are, and where names get their meaning.
Programming Languages Lab · University of Pisa
Vincenzo Ciancia, CNR-ISTI
vincenzo.ciancia@isti.cnr.it · github.com/vincenzoml/PLDW
Lecture 3, exercise 5
run("{1, 2, 3} | {3, 4}") # '{1, 2, 3, 4}'
run("{1, 2, 3} & {2, 3, 4}") # '{2, 3}'
run("{1, 2, 3} - {2}") # '{1, 3}'
run("{1, 2} | {3} & {3, 4}") # '{1, 2, 3}'
run("({1, 2} | {3}) & {3, 4}") # '{3}'
run("{} - {1}") # '{}'
run("{1, 2") # ValueError
A new language, built like the one of Lecture 3: grammar, AST, transform, evaluate.
| is union, & intersection, - difference. & binds tighter than the other two.
The solution is in code/lesson-03/sets.py.
grammar = r"""
?expr: expr UNIONOP inter -> bin
| inter
?inter: inter INTEROP atom -> bin
| atom
?atom: set | paren
paren: "(" expr ")"
set: "{" (NUMBER ("," NUMBER)*)? "}"
NUMBER: /[0-9]+/
UNIONOP: "|" | "-"
INTEROP: "&"
%import common.WS
%ignore WS
"""
The same structure as precedence.py: one rule per level of precedence.
expr is the outer level, with | and -. inter is the inner level, with &. So & is computed first.
Both rules are left-recursive, so both levels group from left to right.
As in Lecture 3, ? removes nodes with one child and -> bin gives both levels the same node name.
set: "{" (NUMBER ("," NUMBER)*)? "}"
| piece | meaning |
|---|---|
| "{" ... "}" | the braces |
| NUMBER ("," NUMBER)* | a number, then zero or more times a comma and a number |
| ( ... )? | all of the list is optional: {} is allowed |
The parse tree of {1, 2, 3} is a node set whose children are the three NUMBER tokens. The braces and commas are anonymous strings, so they do not appear in the tree.
* means "zero or more times" and ? after a group means "optional", as in regular expressions.
@dataclass(frozen=True)
class SetLiteral:
elements: frozenset[int]
def transform(tree: Tree | str) -> Expression:
match tree:
case Tree(data="paren", children=[subtree]):
return transform(subtree)
case Tree(data="set", children=numbers):
return SetLiteral(frozenset(
int(n) for n in numbers if isinstance(n, Token)))
case Tree(data="bin",
children=[left, Token(value="|" | "&" | "-" as op), right]):
return BinaryExpression(op, transform(left), transform(right))
case _:
raise ValueError(f"Unexpected parse tree node: {tree}")
children=numbers matches a list of any length, also empty.
The repeated 1 in {1, 1} disappears when the frozenset is built.
frozenset is a set that cannot be changed after it is built. AST nodes are frozen dataclasses, and their fields should not change either.
def evaluate(ast: Expression) -> frozenset[int]:
match ast:
case SetLiteral(elements):
return elements
case BinaryExpression(op, left, right):
left_value = evaluate(left)
right_value = evaluate(right)
match op:
case "|":
return left_value | right_value
case "&":
return left_value & right_value
case "-":
return left_value - right_value
case _:
assert_never(op)
case _:
assert_never(ast)
The evaluator of Lecture 3, with a different type of values: frozenset[int] instead of int.
Python's sets already have the three operations, with the same symbols.
show turns a value into the text of the examples, with the elements sorted: '{1, 2, 3}'.
from lark.exceptions import LarkError
def run(text: str) -> str:
try:
tree = parser.parse(text)
except LarkError as e:
raise ValueError(f"syntax error: {str(e).splitlines()[0]}") from e
return show(evaluate(transform(tree)))
Lark raises its own exceptions. LarkError is the class they all inherit from.
'{1, 2': syntax error: Unexpected end-of-input. Expected one of:
'{1,, 2}': syntax error: No terminal matches ',' in the current parser context, at line 1 col 4
'{1} + {2}': syntax error: No terminal matches '+' in the current parser context, at line 1 col 5
from e keeps Lark's exception attached to ours, so the traceback still shows where it came from.
Where the meaning lives
def evaluate(ast: Expression) -> int:
match ast:
case Number(value):
return value
case BinaryExpression(op, left, right):
left_value = evaluate(left)
right_value = evaluate(right)
match op:
case "+":
return left_value + right_value
case "-":
return left_value - right_value
...
What + means is written inside evaluate, in the match op: one case per operator.
evaluate.x, there is no place to put its value: evaluate receives only the tree.int only because we wrote left_value + right_value.Grammar, parse tree and AST stay as in Lecture 3. The evaluator is rewritten around two ideas:
SEMANTIC DOMAIN
The set of values of the language, written as Python types. For now, integers.
ENVIRONMENT
A function that maps names to what they stand for. + is a name too: the environment says it is addition.
The environment is a Python function, and some environments are built by other functions. First, a part on functions as values. The code is in code/lesson-04/.
Python
def double(x: int) -> int:
return 2 * x
f = double # no parentheses: the function itself
f(21) # 42
operations = [double, abs, str]
[op(-3) for op in operations] # [-6, 3, '-3']
double without parentheses is the function, not its result.
It can be given another name, put in a list, passed to another function, like a number or a string.
Lecture 1B showed this briefly. The examples of this part are in functions.py.
from collections.abc import Callable
def twice(g: Callable[[int], int], x: int) -> int:
return g(g(x))
twice(double, 5) # 20
list(map(double, [1, 2, 3])) # [2, 4, 6]
sorted(["pear", "fig", "banana"], key=len)
# ['fig', 'pear', 'banana']
Callable[[int], int] is the type of functions that take one int and return an int.
map applies a function to each element of a list. sorted takes a function key and sorts by its result: here, by length.
A function that takes or returns functions is called higher-order.
def make_adder(n: int) -> Callable[[int], int]:
def add_n(x: int) -> int:
return x + n
return add_n
add3 = make_adder(3)
add10 = make_adder(10)
add3(4) # 7
add10(4) # 14
add3(add10(0)) # 13
make_adder defines a function inside its body and returns it, without calling it.
Each call of make_adder builds a new function: add3 and add10 are two different functions.
When make_adder(3) returns, its variable n is still needed: add_n uses it.
Python keeps it. The returned function carries its code together with the variables it uses from outside. This is called a closure.
The code of add3 and add10 is the same; the value of n they carry is different.
lambda
add3_again = lambda x: x + 3
add3_again(4) # 7
sorted(["pear", "fig", "banana"], key=lambda w: w[-1])
# ['banana', 'fig', 'pear']: by the last letter
lambda x: x + 3 is a function without a name. Its body is one expression, and its value is the result.
It is useful when a short function is needed in one place only, as the key of sorted.
A lambda can use variables from outside, like a function defined with def: it is a closure too.
def area(width: int, height: int) -> int:
return width * height
calls = 0
def counted_area(width: int, height: int) -> int:
global calls
calls += 1
return width * height
log: list[str] = []
def logged_area(width: int, height: int) -> int:
log.append(f"area({width}, {height})")
return width * height
A function is pure if its result depends only on its arguments, and calling it changes nothing else.
area is pure. counted_area changes a global variable; logged_area changes a list it did not receive. They have side effects.
All three return the same number, but calling the last two twice is not the same as calling them once.
A pure function can be understood from its arguments alone: same arguments, same result, nothing else to look at.
Today's evaluator is pure, and so are the functions that build environments. Then the equations of the next part describe the code exactly, and each piece can be tested on its own.
Printing, reading input and changing memory are side effects. Lecture 6 adds memory to the language, and will keep the evaluator pure by passing the state in and out as a value.
Semantics
Write \( ⟦e⟧ \) for the value of the expression \( e \). In Lecture 3:
\( ⟦n⟧ = n \)
\( ⟦e_1 \mathbin{op} e_2⟧ = ⟦e_1⟧ \mathbin{op} ⟦e_2⟧ \)
A numeral means the number it writes. A binary expression means the operation applied to the values of the two sides.
The brackets separate the text of the program, inside, from its value, outside. On the left + is a symbol of our language; on the right it is addition.
Write \( \rho \) (the Greek letter rho) for the environment, and \( \rho(x) \) for what the name \( x \) stands for. \( ⟦e⟧\rho \) is the value of \( e \) in the environment \( \rho \).
\( ⟦n⟧\rho = n \)
\( ⟦e_1 \mathbin{op} e_2⟧\rho = \rho(op)\,(⟦e_1⟧\rho,\ ⟦e_2⟧\rho) \)
The second equation does not say what \( op \) is. It asks the environment, and applies the answer to the values of the two sides.
These two equations describe the whole language. The evaluator we write today is a translation of them into Python.
(1 + 2) * 3, using the equations
\( ⟦(1 + 2) * 3⟧\rho \)
\( = \rho(*)\,(⟦1 + 2⟧\rho,\ ⟦3⟧\rho) \)
\( = \rho(*)\,(\rho(+)\,(⟦1⟧\rho,\ ⟦2⟧\rho),\ 3) \)
\( = \rho(*)\,(\rho(+)\,(1,\ 2),\ 3) \)
\( = \rho(*)\,(3,\ 3) = 9 \)
The last two steps use the environment in which \( + \) is addition and \( * \) is multiplication. With another environment, the same steps give another number.
Each construct of the language is given a value, its denotation, by one equation. The value of a compound expression is computed from the values of its parts.
The equations follow the AST: one equation per kind of node, and recursion on the children. The evaluator has the same structure: one case per kind of node, and recursive calls on the children.
When a new construct is hard to write as an equation, it usually means that we have not decided yet what it should mean.
Semantic domains
\( ⟦n⟧\rho = n \qquad ⟦e_1 \mathbin{op} e_2⟧\rho = \rho(op)\,(⟦e_1⟧\rho,\ ⟦e_2⟧\rho) \)
A third set, the storable values, is what a memory cell can contain. Our language has no memory until Lecture 6.
| set | question | today |
|---|---|---|
| expressible | what can an expression compute? | integers |
| denotable | what can a name stand for? | integers and binary operators |
| storable | what can be kept in memory? | no memory yet |
An operator is denotable but not expressible: + has a meaning, but no expression of our language computes an operator. 1 + (+) is not a program.
C
A function has a name, so it is denotable. No expression computes a new function, so it is not expressible. A variable can hold a pointer to a function, not the function itself.
PYTHON
Functions are in all three sets: lambda computes one, f = double names one, a list can hold one.
Deciding which values go in which set is a large part of deciding what a language can do.
from collections.abc import Callable
type EVal = int # expressible values
type Operator = Callable[[EVal, EVal], EVal] # a function of two values
type DVal = EVal | Operator # denotable values
type Environment = Callable[[str], DVal] # names to denotable values
The last line is the \( \rho \) of the equations: a function that takes a name, a string, and returns what the name stands for.
type gives a name to a type, as in Lecture 2. The names EVal and DVal come from the textbooks on semantics.
In the extension with comparisons, 1 < 2 gives 1, so that values stay int. Returning True would change the domain:
type EVal = int | bool
Then each operator must say what it does with each kind of value. Is (1 < 2) + 1 equal to 2, or an error? Python says 2, because in Python True is the integer 1. Many languages say it is an error.
This is decided in the semantic domain and in the operators, not in the grammar: (1 < 2) + 1 parses in both cases.
Implementation
A dictionary from names to values would work. We use a function, because in the equations \( \rho \) is a function: the code then says the same thing as the equations.
There is a cost. A dictionary can be printed and its keys listed; a function can only be called. To know what is in an environment, we have to ask about one name at a time.
Exercise 3 at the end asks you to replace the function with a dictionary, changing three functions only.
def empty_environment() -> Environment:
def env(name: str) -> DVal:
raise ValueError(f"Undefined identifier: {name}")
return env
def lookup(env: Environment, name: str) -> DVal:
return env(name)
The empty environment knows no name: for every name, it raises an error.
lookup asks the environment about a name, by calling it.
lookup(env, name) and env(name) do the same thing. The separate function keeps the rest of the code independent of how environments are made.
bind: one more name
def bind(env: Environment, name: str, value: DVal) -> Environment:
def new_env(n: str) -> DVal:
if n == name:
return value
return env(n)
return new_env
bind returns a new environment that knows one more name.
Asked about name, the new environment answers value. Asked about any other name, it passes the question to the old environment env.
new_env is a closure, like add_n: it remembers env, name and value.
In the equations, bind(env, x, v) is written \( \rho[x \mapsto v] \).
e0 = empty_environment()
e1 = bind(e0, "+", add)
e2 = bind(e1, "*", multiply)
lookup(e2, "+"): e2 compares "+" with "*", different, and asks e1. e1 compares "+" with "+" and answers add.
lookup(e2, "-") goes through e2 and e1 and reaches e0: Undefined identifier: -.
bind does not change the old environment
lookup(e2, "*") # <function multiply at 0x...>
lookup(e1, "*") # ValueError: Undefined identifier: *
e1 is exactly as it was before e2 was built. Code that still uses e1 does not see *.
With a dictionary there are two options. Change it, env[name] = value, and the old version is lost. Or copy it, {**env, name: value}, and pay for a copy at each bind.
The closure keeps the old environment without copying anything.
In Lecture 5 the language gets local names:
let x = 3 in x + 1
The body x + 1 is evaluated in the environment extended with x. After the let, the program goes on in the environment it had before, where x is not defined, or has its old value.
With bind this needs no work: the extended environment is a new one, and the old one was never changed.
Looking up a name goes through the chain until it is found: with n names bound, up to n comparisons. A dictionary finds a name in about the same time however many names it has.
For the small programs of this course it does not matter. If it did, we would change how environments are made and keep the three functions, empty_environment, lookup and bind, with the same types. The evaluator would not change.
Implementation
def add(x: int, y: int) -> int:
return x + y
def subtract(x: int, y: int) -> int:
return x - y
def multiply(x: int, y: int) -> int:
return x * y
def divide(x: int, y: int) -> int:
if y == 0:
raise ValueError("Division by zero")
return x // y
def modulo(x: int, y: int) -> int:
if y == 0:
raise ValueError("Division by zero")
return x % y
Each operator of the language is an ordinary Python function of two integers: a value of type Operator.
The check for division by zero moves from the evaluator into divide and modulo. The evaluator no longer knows what division is.
def create_initial_env() -> Environment:
env = empty_environment()
env = bind(env, "+", add)
env = bind(env, "-", subtract)
env = bind(env, "*", multiply)
env = bind(env, "/", divide)
env = bind(env, "%", modulo)
return env
The environment in which every program starts: five names, five operators.
Each line builds a new environment from the previous one. The name env is reused, but the five environments are different values.
Python does the same: before your program runs, names such as print and len are already defined.
env = create_initial_env()
print(lookup(env, "+"))
times = lookup(env, "*")
if callable(times):
print(times(6, 7))
lookup(env, "x")
<function add at 0x10605d300>
42
ValueError: Undefined identifier: x
Asking the environment about + gives back a function, which can be called like any other.
callable is needed for mypy: a DVal can also be an int, and an int cannot be called.
Implementation
def evaluate(ast: Expression, env: Environment) -> EVal:
match ast:
case Number(value):
return value
case BinaryExpression(op, left, right):
operator = lookup(env, op)
if not callable(operator):
raise ValueError(f"{op} is not an operator")
return operator(evaluate(left, env),
evaluate(right, env))
evaluate has a second parameter, the environment.
The binary case looks up the operator in the environment, evaluates the two sides in the same environment, and applies the operator.
There is no match op any more: the evaluator does not know which operators exist.
| equation | Python |
|---|---|
| ⟦e⟧ρ | evaluate(e, env) |
| ⟦n⟧ρ = n | case Number(value): return value |
| ρ(op) | lookup(env, op) |
| ⟦e₁⟧ρ, ⟦e₂⟧ρ | evaluate(left, env), evaluate(right, env) |
| ρ(op)(v₁, v₂) | operator(...) |
| ρ[x ↦ v] | bind(env, x, v) |
The check callable(operator) has no equation: it handles a case the equations do not cover, a name bound to a number used as an operator.
(1+2)*3, step by step
evaluate(parse_ast("(1+2)*3"), env)
evaluate(BinaryExpression('*', BinaryExpression('+', 1, 2), 3), env)
lookup(env, "*") multiply
evaluate(BinaryExpression('+', 1, 2), env)
lookup(env, "+") add
evaluate(Number(1), env) 1
evaluate(Number(2), env) 2
add(1, 2) 3
evaluate(Number(3), env) 3
multiply(3, 3) 9
The calls made by evaluate, with their results on the right; the AST is written in short form. They follow the steps we did with the equations.
The environment could be a global variable, read by evaluate. We pass it as a parameter instead.
evaluate is pure.let is evaluated in an extended environment, and the rest of the program in the old one.
def power(x: int, y: int) -> int:
return x ** y
ast = parse_ast("2*3")
print(evaluate(ast, env))
print(evaluate(ast, bind(env, "*", power)))
6
8
The tree is the same and evaluate is the same. In the second environment, * stands for power.
evaluate_string("10/0")
evaluate(parse_ast("2-3"), bind(empty_environment(), "+", add))
evaluate(parse_ast("1+2"), bind(env, "+", 3))
ValueError: Division by zero
ValueError: Undefined identifier: -
ValueError: + is not an operator
THE OPERATOR FAILS
Raised by divide, not by the evaluator.
NAME NOT BOUND
The grammar accepts -; this environment does not know it.
NOT AN OPERATOR
+ is bound to the number 3.
# grammar: one more string for the terminal
OP: "+" | "-" | "*" | "/" | "%" | "^"
# environment: one more line
env = bind(env, "^", power)
In Lecture 3, a new operator needed a new case in evaluate. Now it needs the grammar and the environment.
evaluate does not change.
The precedence and associativity of ^ are a different matter: they are in the grammar, as in Lecture 3.
Another semantic domain
type EVal = frozenset[int]
def set_env() -> Environment:
env = empty_environment()
env = bind(env, "|", lambda a, b: a | b)
env = bind(env, "&", lambda a, b: a & b)
env = bind(env, "-", lambda a, b: a - b)
return env
other = bind(set_env(), "|", lambda a, b: a ^ b)
run("{1, 2} | {2, 3}", set_env()) # '{1, 2, 3}'
run("{1, 2} | {2, 3}", other) # '{1, 3}'
The sets of exercise 5, with the operators in the environment, written as lambdas.
The binary case of evaluate is the same as for integers. Only the literal case and the types change.
In other, | means "in one of the two sets, not in both". The code is in sets_env.py.
type Picture = tuple[str, ...] # rows, all of the same length
type Operator = Callable[[Picture, Picture], Picture]
type DVal = Picture | Operator
type Environment = Callable[[str], DVal]
def block(n: int) -> Picture:
return tuple("#" * n for _ in range(n))
block(2) # ('##', '##')
Grammar and AST of Lecture 3, unchanged. The semantic domain changes: a value is a picture made of characters, one string per row.
A numeral n means a filled square of side n.
The code is in pictures.py.
def width(p: Picture) -> int:
return len(p[0]) if p else 0
def beside(p: Picture, q: Picture) -> Picture:
height = max(len(p), len(q))
rows_p = p + (" " * width(p),) * (height - len(p))
rows_q = q + (" " * width(q),) * (height - len(q))
return tuple(a + b for a, b in zip(rows_p, rows_q))
def above(p: Picture, q: Picture) -> Picture:
w = max(width(p), width(q))
return tuple(row.ljust(w) for row in p + q)
beside puts two pictures side by side. The shorter one is filled with rows of spaces at the bottom.
above puts the first picture on top of the second. ljust fills each row with spaces on the right, up to the larger width.
def picture_env() -> Environment:
env = empty_environment()
env = bind(env, "+", beside)
env = bind(env, "*", above)
return env
show(evaluate(parse_ast("(1+2)*3"), picture_env()))
In evaluate, the numeral case becomes return block(value). The binary case does not change.
###
##
###
###
###
1+2: a square of side 1 beside one of side 2. *3: above a square of side 3.
evaluate(parse_ast("3-1"), picture_env())
ValueError: Undefined identifier: -
The grammar accepts 3-1; the picture environment has no meaning for -. The error comes only at evaluation.
In a real picture language we would remove - from the grammar too, so that the parser reports the error. Here the grammar is left as it is, to show that the environment decides which operators have a meaning.
Summary
The picture file is about forty lines, and its grammar was not written for pictures.
| decision | alternative | why |
|---|---|---|
| environment as a function | a dictionary | same as ρ in the equations; but it cannot be printed |
| bind returns a new environment | change it in place | the old environment survives, as let will need |
| environment as a parameter | a global variable | same tree and same environment give the same value |
| operators looked up by name | a match in evaluate | new operators without editing the evaluator |
errors as ValueError | Python's TypeError | the messages speak about our language |
The evaluator receives the environment as a parameter and is a translation of two equations. The meaning of each operator is in the environment.
The values of the language are a decision, written down as Python types. The same grammar gave integers, sets and pictures.
A program cannot add names to the environment yet: let x = 3 in x + 1 needs a new rule in the grammar, a new node and a new equation. That is Lecture 5, binding and scoping.
Exercises
evaluate_string("2^3") # 8
evaluate_string("2*3") # 6
Level 1 of 5
domains.py, add "^" to the OP terminal and bind it to power in create_initial_env.evaluate did not need any change.
evaluate_string("ten * 2") # 20
evaluate_string("ten + ten") # 20
evaluate_string("eleven") # ValueError
Level 2 of 5
ten to 10 in the initial environment: it is a DVal too.IDENTIFIER terminal, a rule for it in mono, a node Var(name) and its case in evaluate.Var first, then the code.+ + 1 and ten(1, 2) do? Which stage reports each error?
type Environment = dict[str, DVal]
def empty_environment() -> Environment: ...
def lookup(env: Environment, name: str) -> DVal: ...
def bind(env: Environment, name: str, value: DVal) -> Environment: ...
Level 3 of 5
Environment and the three functions. Nothing else.bind must not change the dictionary it receives: check that lookup(e1, "*") still fails after e2 = bind(e1, "*", multiply).
1+2 with bottom alignment:
##
###
Level 4 of 5
pictures.py, make + align the two pictures at the bottom, changing only the environment and the operators.-: p - q is p beside q mirrored left to right. Write its equation first.Level 5 of 5 · take home
EVal, Operator, DVal.