Semantic domains

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

Sets of integers

Exercise 5: sets of integers


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.

The grammar: two levels


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.

The set literal


set: "{" (NUMBER ("," NUMBER)*)? "}"
  
piecemeaning
"{" ... "}"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.

AST and transform


@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.

Evaluating sets


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}'.

Errors


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

The evaluator, again

The evaluator of Lecture 3


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.

  • To add a power operator, we have to edit evaluate.
  • To use a name such as x, there is no place to put its value: evaluate receives only the tree.
  • The values are int only because we wrote left_value + right_value.

What changes today

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

Functions are values

A function is a value


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.

Functions that take functions


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.

Functions that return functions


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.

A closure remembers

add3 x + n n = 3 add10 x + n n = 10

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.

Pure functions


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.

Why pure functions matter here

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

What an expression means

The value of an expression

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.

Adding the environment

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.

Denotational semantics

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

Three kinds of values

Two sets of values in the equations

\( ⟦n⟧\rho = n \qquad ⟦e_1 \mathbin{op} e_2⟧\rho = \rho(op)\,(⟦e_1⟧\rho,\ ⟦e_2⟧\rho) \)

  • \( ⟦e⟧\rho \) is always an integer. The values that an expression can produce are the expressible values.
  • \( \rho(+) \) is a function of two integers. The values that a name can stand for are the denotable values. Here, the operators; later, also numbers, with variables.

A third set, the storable values, is what a memory cell can contain. Our language has no memory until Lecture 6.

The three sets in our language

setquestiontoday
expressiblewhat can an expression compute?integers
denotablewhat can a name stand for?integers and binary operators
storablewhat 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.

Other languages decide differently

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.

The sets as Python types


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.

A question left open in Lecture 3

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

The environment as a function

A function, not a dictionary

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.

The empty environment, and lookup


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] \).

A chain of environments


e0 = empty_environment()
e1 = bind(e0, "+", add)
e2 = bind(e1, "*", multiply)
  
e2 "*" → multiply e1 "+" → add e0 error other names other names

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.

Why the old environment must survive

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.

The cost of a chain

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

Operators in the environment

Operators as Python functions


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.

The initial environment


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.

Operators are values


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

The evaluator

Two equations, in Python


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.

Equations and code, side by side

equationPython
⟦e⟧ρevaluate(e, env)
⟦n⟧ρ = ncase 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 is a parameter

The environment could be a global variable, read by evaluate. We pass it as a parameter instead.

  • The value of an expression depends on the tree and the environment, and on nothing else, as in \( ⟦e⟧\rho \). evaluate is pure.
  • Two parts of a program can be evaluated in two different environments. In Lecture 5, the body of a let is evaluated in an extended environment, and the rest of the program in the old one.

The same tree, two environments


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.

Three errors


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.

Adding an operator


# 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

The same syntax, other values

Exercise 5 with an environment


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.

Values are pictures


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.

Two operators on pictures


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.

The picture environment


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.

What the picture language does not have


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

Designing with domains

An order for your own language

  1. The expressible values: what does a program of your language compute? Images, sounds, sets, numbers.
  2. The denotable values: what can a name stand for? Operators, constants, later functions.
  3. The equations: one for each construct, saying what it computes from the values of its parts.
  4. Then the grammar, which can often start from the one of Lecture 3.

The picture file is about forty lines, and its grammar was not written for pictures.

The decisions of today

decisionalternativewhy
environment as a functiona dictionarysame as ρ in the equations; but it cannot be printed
bind returns a new environmentchange it in placethe old environment survives, as let will need
environment as a parametera global variablesame tree and same environment give the same value
operators looked up by namea match in evaluatenew operators without editing the evaluator
errors as ValueErrorPython's TypeErrorthe messages speak about our language

Where we are

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

Your turn

Exercise 1 · A power operator


evaluate_string("2^3")      # 8
evaluate_string("2*3")      # 6
    

Level 1 of 5

  1. In domains.py, add "^" to the OP terminal and bind it to power in create_initial_env.
  2. Check that evaluate did not need any change.

Exercise 2 · Named constants


evaluate_string("ten * 2")       # 20
evaluate_string("ten + ten")     # 20
evaluate_string("eleven")        # ValueError
    

Level 2 of 5

  1. Bind ten to 10 in the initial environment: it is a DVal too.
  2. Add an IDENTIFIER terminal, a rule for it in mono, a node Var(name) and its case in evaluate.
  3. Write the equation for Var first, then the code.
  4. What should + + 1 and ten(1, 2) do? Which stage reports each error?

Exercise 3 · A dictionary instead


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

  1. Change the type Environment and the three functions. Nothing else.
  2. bind must not change the dictionary it receives: check that lookup(e1, "*") still fails after e2 = bind(e1, "*", multiply).
  3. Which version would you rather debug? Which is faster with 1000 names?

Exercise 4 · Pictures


1+2 with bottom alignment:

 ##
###
    

Level 4 of 5

  1. In pictures.py, make + align the two pictures at the bottom, changing only the environment and the operators.
  2. Add an operator -: p - q is p beside q mirrored left to right. Write its equation first.
  3. Which parts of the program did you not touch?

Exercise 5 · At home: a domain of your own

Level 5 of 5 · take home

  1. Choose a domain for your project language: what does a program compute?
  2. Write the semantic domain as Python types: EVal, Operator, DVal.
  3. Choose two or three operators and write their equations.
  4. Implement them with the grammar of Lecture 3 and an environment, as for pictures.
  5. What did the arithmetic grammar force on you that your domain does not need?