Denotational Semantics


Contents


Motivation

The motivation for denotational semantics is to be able to provide a precise definition of the semantics of a language. This is important in a number of different contexts:

Programmers:
Need a precise definition to be sure that their code does what they expect, and to reason about programs (e.g., to prove properties of their code).
Compiler writers:
Need a precise definition to implement a correct compiler. Without a precise definition, a program may do different things depending on which compiler is used.
Tool generator writers:
Given a way to specify a language semantics, it might be possible to write a code-generator generator (or an interpreter generator), just as we currently have parser generators that work using a precise definition of a language's syntax.

There are other ways to specify a language's semantics, but they have shortcomings:

English description:
For a non-trivial language, an English description is almost sure to be ambiguous, or to have some details omitted.
Operational semantics:
This means giving a compiler or interpreter for the language. The semantics of the language are implicitly defined by the behavior of the compiled/interpreted code. The problem is that it provides little help with reasoning about programs, and does not a provide an input language for a tool generator.
Axiomatic semantics:
This involves giving an axiom or a rule of inference for each construct in the language. It does form a good foundation for proving properties about a program, but does not necessarily specify the precise meaning of a program.

Overview

The basic idea of denotational semantics is, given a language L, define the meaning of L by supplying a valuation function for each construct. The valuation function for a construct is defined in terms of the valuation functions for the sub-constructs; thus, this is a kind of syntax-directed translation from a program's abstract-syntax tree to its meaning.

Given the valuation functions for a language L, we can determine the meaning of a program written in L: usually a function (from inputs to outputs), though it may be just a value for simple languages.

Every denotational definition includes:

  1. A definition of the language's abstract syntax (via a set of syntactic domains and a grammar; the elements in the syntactic domain are the nonterminals in the grammar).
  2. A definition of the relevant semantic algebras. Each semantic algebra includes:
    • A semantic domain (e.g., N, the natural numbers), and
    • A set of operators, including constants which can be thought of as nullary operators (e.g., zero, one, plus, times ...)
    A semantic domain can be primitive (i.e., we already understand it, like the domain of natural numbers) or compound (built from existing domains using operators like x, →, etc).
  3. Valuation functions: One function is defined for each "syntactic domain"; i.e., one function for each nonterminal in the grammar. Each function is defined by cases, with one case for each production associated with the nonterminal.

A Simple Example

Here is a very simple example for the language of binary numerals. The meaning of a "program" is the number it represents.

  1. Abstract Syntax:
    1. Syntactic Domains (nonterminals of the grammar that defines the abstract syntax):
      • B ε BinaryNumeral;
      • D ε BinaryDigit

    2. Grammar:
        B → BD | D
        D → 0 | 1

  2. Semantic algebras:
    1. Natural numbers
      Domain
      Nat = N
      Operators
      zero, one, two ... : Nat
      plus, times : (Nat x Nat)→Nat
      Note that we use words "zero", "one" etc, to distinguish these semantic objects from the corresponding syntactic objects 0, 1, etc.

  3. Valuation Functions (One for each syntactic domain D; each function is defined by cases on D with one case for each grammar rule with D on the left-hand side.) We use bold font for the names of the valuation functions to distinguish them from the grammar symbols, and we use double brackets ([[ and ]]) around the arguments to remind us that the arguments themselves are really the abstract-syntax tree representations.
    • B : BinaryNumeral → Nat

      B[[BD]] = (B[[B]] times two) plus D[[D]]
      B[[D]] = D[[D]]

    • D : BinaryDigit → Nat

      D[[0]] = zero
      D[[1]] = one

    As noted above, objects inside [[ ]] are really abstract-syntax trees. So for example, B [[BD]] is really:

                             B(B)
                              / \
                             B   D
                            /\   /\
                           /t1\ /t2\
                           ---- ----
        
    which is:
                            (B(B) times two) plus D(D)
                              /\                    /\
                             /t1\                  /t2\
                             ----                  ----
       

A "program" in this language is: 101

Its AST is:

                        B
                      /   \
                     B     D
                    / \    |
                   B   D   1
                   |   |
                   D   0
                   |
                   1
And its meaning is the B function applied to the root of the tree: B[[101]]. Since the root production is B→BD, where the right-hand-side B represents 10, and the right-hand-side D represents 1, we have: Continuing this process:

More Interesting Examples

Denotational definitions for simple languages are simple. Imperative language features, e.g.:

make it more difficult to define a denotational semantics for a language. We will look at how to handle some of those features.

Assignment

To handle assignment we need a data structure called a store, which maps program identifiers to their values. Some program statements modify the store, some access the store; thus, a store is a means of "communication" between statements. Note that the store is not a data structure defined and used by the programmer; instead, it is one of the semantic domains used to define the denotational semantics of an imperative language.

To see how assignment is handled, we use a simple, imperative language. A program in this language consists of a sequence of one or more statements, separated by semi-colons and ending with a dot. Statatements can be assignments, if-then, or if-then-else. Expressions can involve natural numbers or identifiers (single capital letters), with +, ==, and ! as the only operators.

By definition, a program in this language has a single input (a natural number) that is initially (implicitly) assigned to variable A, while all other variables are (implicitly) initialized to zero. The result of executing the program is the final value of variable Z.

Here's an example program in this language:

And here is the denotational semantics for this language:

Denotational semantics

  1. Abstract Syntax:
    1. Syntactic domains:
      • P ε Program
      • C ε Command
      • E ε Expression
      • B ε BooleanExpression
      • I ε Identifier
      • N ε Numeral

    2. Grammar:
        P C.
        C C1 ; C2
        | if B then C
        | if B then C1 else C2
        | I = E
        E E1 + E2
        | I
        | N
        B E1 == E2
        | ! B
        I IDENT
        N NUM

  2. Semantic algebras:
    1. Truth Values
      Domain
      Tr = bool
      Operators
      true, false: Tr
      not: Tr → Tr

    2. Identifiers
      Domain
      i ε Id = Identifier
      (this notation "i ε Id" means that i will be used later as an identifier)

    3. Natural Numbers
      Domain
      n ε Nat = N
      Operators
      zero, one, ... : Nat
      plus: (Nat x Nat) → Nat
      equals: (Nat x Nat) → Tr

    4. Store
      Domain
      s ε Store = Id → Nat (note that this is our first example of a compound domain!)
      Operators
      (1) newstore: Store = λi.zero (initially, a store maps all IDs to zero)
      (2) access: Id → (Store → Nat) = λi.λs.s i (the access operator lets you look up the value of an Id in a Store).
      (3) update: Id → Nat → Store → Store = λi.λn.λs.[ i |→ n ] s (This creates a function that is the same as s, except that when the input is i it returns n. The function can be written as: λj.if j == i then n else s j)

  3. Valuation Functions
    • P: Program → (Nat → Nat)

      P [[C.]] = λn. let s = (update [[A]] n newstore) in let s' = C [[C]] s in (access [[Z]] s')

      Note: Function update was declared to be of type Id→Nat→Store→Store; i.e., all of its arguments are semantic objects. Therefore, we really should define a valuation function I that maps syntactic Identifiers to to semantic Ids. However, that would add clutter, so we haven't done it (similarly, in the definition of the last valuation function for expressions below, we really should use a valuation function N to map a Numeral to a Nat).

      To understand this valuation function, note that s is set to be the initial store (in which A gets the value of the input number); s' is set to be the final store produced by the valuation function for the command C that represents the whole program (except for the dot). The result of applying this valuation function is the final value of Z (extracted via the use of the access function).

    • C: Command → (Store → Store)

      C [[C1;C2]] = λs. C [[C2]] (C [[C1]] s)
      C [[if B then C]] = λs. if (B [[ B ]] s) then (C [[C]] s) else s
      C [[if B then C1 else C2]] = λs. if (B [[ B ]] s) then (C [[C1]] s) else (C [[C2]] s)
      C [[I=E]] = λs. update [[I]] ( E [[E]] s ) s

      Note that Commands are really just statements, and that the evaluation of an expression cannot change the store in this simple language.

    • E: Expression → Store → Nat

      E [[E1+E2]] = λs. (E [[E1]] s) plus (E [[E2]] s)
      E [[I]] = λs. access[[I]] s
      E [[N]] = λs. [[N]]

    • B: BooleanExpression → (Store → Tr)

      B [[E1==E2]] = λs. (E [[E1]] s) equals (E [[E2]] s)
      B [[!B]] = λs. not ( B [[B]] s )

Example

Now let's consider the meaning of our example program:

according to this denotational semantics. In general, there are two interesting ways to apply a denotational semantics:
  1. To determine the meaning of a program given a particular input (in this case, that meaning would be the output value, a natural number).
  2. To determine the meaning of the program without an input (in this case, that meaning would be a function of type Nat → Nat).
Let's try the first one, using the input two; i.e., we want to know the value of:

By the definition of P, this is:

after doing the beta-reduction (substituting two for n) we have:

Note that

is the store that maps A to two, and maps all other Ids to zero. Let's call this store s1 in what follows.

So we have:

Using the definition of C for a sequence of statements we get:

Now using the definition of C for an assignment we get:

Using the definition of E for an identifier, we have:

which simplifies to: which (since s1 maps A to two) evaluates to two.

Therefore, we have the (sub)expression

which evaluates to the store that maps A and Z to two, and all other identifiers to zero. We'll call that store s2. Now we have: Using the definition of C for an if-then statement we get: Using the definition of B for an equality test, and the fact that s2 maps Z to two, we have: which simplifies to: which is the same as which evaluates to two, our final result (and the meaning of our example program applied to two).


TEST YOURSELF #1

Consider what the denotational semantics tells us about the meaning of our example program with no input (i.e., what function does it define?) Start with the definition of the meaning of the program:

Show how to transform this into: using the valuation functions.

solution


Runtime Errors

Next we will consider what modifications are needed to our denotational semantics given the possibility of runtime errors in commands and expressions. Note that there are many possible things that we might like to define as errors:

However, the simplest way to extend our language to include programs that cause runtime errors is simply to add an error statement (command) to the language. This means adding a new rule to the abstract syntax:

We want the semantics (the meaning) of a program that executes "diverge" to be a special error value. Therefore, we must also change our denotational definition by changing the type of P to include that special value:

Note: For any domain D, D is called a lifted domain; a domain that includes all members of D plus a special bottom element ⊥, meaning no value, no meaning, or error.

We must also make the following changes to the denotational semantics:


TEST YOURSELF #2

Why is it not necessary to make the valuation functions E and B strict?

solution


Continuation Semantics

Our previous approach to handling runtime errors uses a kind of denotational semantics called a direct semantics. While we were able to use a direct semantics to define the meaning of a program that executes "diverge" to be ⊥, it had one somewhat undesirable characteristic: To determine the meaning of a sequence of statements, we first use the initial store to compute the store produced by the first statement, then we use that store to compute the store produced by the second statement, and so on. Even if a statement produces bottom, we continue with the sequence, applying the valuation function for each statement to bottom, producing bottom.

An alternative is to use a continuation semantics instead of a direct semantics. The idea is that valuation functions have an argument called a continuation. A continuation is a function that represents the effect of the rest of the program. If the construct the valuation function is handling terminates normally (no error), the continuation is applied to the resulting store. Otherwise (there was a runtime error), the continuation is ignored (which causes us to quit immediately; later statements are not evaluated at all. Continuations are also useful when defining a denotational semantics for a language with non-local control flow.

Here is how to change our previous direct semantics (for our example language including the "diverge" command) to a continuation semantics:

  1. The abstract syntax is unchanged.
  2. The semantic algebras are augmented with one new one:
    1. Command Continuations
      Domain
      c ε Cc = Store → Store
      Operators (none)

  3. The Valuation Functions are redefined as follows:
    • C : Command → Cc → Store → Store

      C [[diverge]] = λc.λs. ⊥
      where c is a continuation and s is a store.

      C [[I=E]] = λc. λs. c (update [[I]] (E [[E]] s) s)
      where c is a continuation and s is the old store. Note that

        update [[I]] (E [[E]] s) s
      is the new store to which the given continuation is applied.

      C [[C1;C2]] = λc.λs.C [[C1]] (C [[C2]] c) s
      Again, c is the continuation that represents the rest of the program (after C2), and s is the current store (used when evaluating C1). The expression

        C [[C2]] c
      is the continuation (of type Store → Store&perp) that represents the rest of the program after C1.

      C [[if B then C]] = λc.λs.if (B [[B]] s) then C [[C]] c s else c s

      C [[if B then C1 else C2]] = λc.λs.if (B [[B]] s) then C [[C1]] c s else C [[C2]] c s

    • P : Program → Nat → Nat

      P [[C.]] = λn. let s1 = (update [[A]] n newstore) in let s2 = ((C [[C]] λs.s) s1) in access [[Z]] s2
      Note that λs.s is the initial, identity continuation (i.e., it maps the final store to itself).

    • E and B (the valuation functions for expressions and boolean expressions) are unchanged, because there is no possibility they can produce a "diverge" command.

Below are two examples to illustrate the new semantics; the first is our original example (a program that does not include a "diverge" statement), and the second is a program with a "diverge". In each case, we show how the evaluation functions work by evaluating pieces of the current expression identified via subscripted underlining.

Example 1: Program without "diverge".

P [[Z=A; if Z==0 then Z=1.]] (two)1

(1) = let s1 = (update [[A]] (two) newstore) in let s2 = C [[Z=A; if Z==0 then Z=1]] (λs.s) s12 in access [[Z]] s2

(2) = C [[Z=A]] (C [[if Z==0 then Z=1]] (λs.s)) s1

= (λc. λs. c(update [[Z]] (E [[A]] s) s)) (C [[ if ... ]] λs.s) s1

Note that this is a function of 2 arguments (λc.λs. c(...)) applied to 2 arguments (C [[if ...]])λs.s and s1. After doing the beta-reductions (substituting the two actuals for c and s), we get:

(C [[if Z==0 then Z=1]] λs.s)(update [[Z]] (E [[A]] s1) s1)3

(3) is the store (which we'll call s3) that maps A and Z to two and everything else to zero; i.e.:

So (2) = if (B [[Z==0]] s34) then (C [[Z=1]] (λs.s) s3) else (λs.s) s3

(4) simplifies to false, so (2) = (λs.s) s3 = s3

This means that (1) = access [[Z]] s3
which = two.

Example 2: Program with "diverge".

P [[diverge; Z=A; Z=Z+1.]] (two)1

(1) = let s1 = (update [[A]] (two) newstore) in let s2 = C [[diverge; Z=A; Z=Z+1]] (λs.s) s12 in access [[Z]] s2

(2) = C [[diverge]] (C [[Z=A; Z=Z+1]] (λs.s)) s1
= ⊥

So (1) = access [[Z]] ⊥ = ⊥

As noted above, accessing any identifier in the ⊥ store produces ⊥.

Errors in expressions

What happens if the evaluation of an expression can lead to a runtime error? For example, suppose we extend the language to include integer division. Then division by 0 should be an error. We can handle this either by changing our direct semantics or by changing our continuation semantics.

Changes to direct semantics

Here is how to change our previous direct semantics so that the meaning of a program that executes a division by zero is bottom.

  1. First we must change the type of E, since the result of evaluating an expression can now be bottom.
      E: Expression → Store → Nat

  2. Second, we need a new valuation function for division.
    E [[E1/E2]] = λs. let denom = E [[E2]] s in
    if denom equals zero then
    else let num = E [[E1]] s in
    num divided-by denom
    where both "let"s are strict (i.e., if either denom or num is bottom, then the value of the whole expression is bottom).

  3. Finally, we must change all valuation functions that use E to check for a ⊥ result (i.e., to use a strict let). In particular, E [[E1+E2]], C [[I=E]], and B [[E1==E2]] must be changed.

Changes to continuation semantics

To change our continuation semantics, we'll change E, the valuation function for expressions, to take a continuation as an argument, and to produce a store as its final result, just as C, the valuation function for commands, does. Note however, that an expression continuation is different than a command continuation because an expression continuation needs a value as well as a store. To understand this, consider the two-statement sequence

The evaluation sequence for this code is:
  1. evaluate E
  2. update the store to map I to the value of E
  3. evaluate C, using the updated store
I=E is a command. Its continuation is step 3 which needs only the updated store.

E alone is an expression. Its continuation is steps 2 and 3. This continuation needs the value of E as well as the store to be updated. So expression continuations are passed values and stores, while command continuations are passed only stores.

Now we can define the changes needed to our continuation semantics.

  1. First, we need one new semantic algebra (for expression continuations):
    1. Expression Continuations
      Domain
      k ε Ec = Nat → Store → Store
      Operations (none)

  2. Second, we need new valuation functions for expressions (because now they must include continuations).
    • E: Expression → Ec → Store → Store

      E [[N]] = λk. λs. k [[N]] s

      E [[I]] = λk. λs. k (access[[I]] s) s

    We still need to define the valuation functions for E1+E2 and for E1/E2. It is a bit complicated to figure out what they should be; therefore, let's first consider an expression with a unary operator so that we only have to deal with one sub-expression. We'll assume that our language has been extended to include expressions of the form: E^2 (E squared).

    The valuation function will be of the form:

      E [[E^2]] = λk. λs. ?

    where the ? must do the following:
    1. evaluate E (using s) to produce a value v1
    2. compute v2 = v1 * v1
    3. apply continuation k to value v2 and store s

    Note that we cannot simply define ? as:

      k( E [[E]] s times E [[E]] s)
    because the types are wrong: E needs a continuation (not a store) as its second argument, and the result of applying E is now a Store, not a Nat, so we can't apply times to that result.

    Our ? clearly has to include E [[E]], and E needs a continuation and a store as well as an Expression argument. Thinking about the three steps given above, we can see that steps (2) and (3) form the continuation that needs to be used by E in step (1). So the form of ? is:

      E [[E]] (2)+(3) s

    Note that (2)+(3) is the second argument to E, so it is an expression continuation, and its type is:

      Nat → Store → Store
    This means that it must be of the form:
      λn.λs'. ??
    Remember that E is going to apply this continuation to the value of E; i.e., that is the value that will be used for argument n. Since ? is supposed to do steps (2) and (3) -- square the given value then apply k to the result and s' -- ?? should be:
      λn. λs'. k (n times n) s'

    So the valuation function for E^2 is defined as follows:

    E [[E^2]] = λk. λs. E [[E]] (λn. λs'. k (n times n) s') s

    Now we'll try addition; we'll use the same technique to reason about what the definitions should be.

    E [[E1+E2]] = λk. λs1. ?

    where ? must do the following:

    1. evaluate E1 (using s1), producing value v1
    2. evaluate E2 (using s1), producing value v2
    3. compute v3 = v1 + v2
    4. apply continuation k to v3 and s1

    Steps (2)+(3)+(4) are the continuation that must be passed to E to do step (1). So the form of ? is:

      E [[E1]] ( (2)+(3)+(4) ) s1

    (2)+(3)+(4) is an expression continuation with type Nat→Store→Store, so its form must be:
      λn1.λs2. ??

    ?? must evaluate E2 in the given store (s2), using a continuation that represents steps (3)+(4). So ?? is of the form:
      E [[E2]] ( (3)+(4) ) s2

    and (3)+(4) is of the form:
      λn2.λs3. ???

    ??? must be:
      k( n1 plus n2 ) s3

    So we finally have the definition of the valuation function for addition:
      E [[E1+E2]] = λk.λs1.E [[E1]] (λn1. λs2. E [[E2]] (λn2. λs3. k (n1 plus n2) s3) s2) s1


TEST YOURSELF #3

Write the valuation function for division. Remember that if the denominator is zero, the result should be bottom.

solution


Since expression evaluation can now produce runtime errors, we also need to change the way we handle assignments:

For I=E we must do the following:

  1. evaluate E, using a continuation that will:
  2. update the store to map I to the value of E, and
  3. evaluate the given command continuation (i.e. the rest of the program), using the new store.
Here is the new valuation function:
Similar changes must also be made to the valuation functions for boolean expressions (B), and for the if-then and if-then-else commands that use boolean expressions.


TEST YOURSELF #4

Evaluate the program:

with input two.

solution


Aliasing

To handle aliasing (where two syntactically different expressions can refer to the same location) we use a "two-level" store.

Valuation functions C and E take both an env and a store. To evaluate an identifier (i.e., to define E [I]) we must:

  1. look up the identifier's location (using env), then
  2. fetch the actual value from that location (using store)

To define assignment (C [I=E]):

  1. evaluate E
  2. look up I's location using env
  3. update that location in the store
Note that if another identifier is mapped to the same location, its value just got updated, too.

While Loops

We can define the semantics for loops using either a direct or a continuation semantics. Recall that for the direct semantics, we have:


and we define the valuation function for a loop as follows:
Note that the "then" part of the if says "execute the next iteration, using the store produced by this iteration". But this is a recursive definition; what does it really mean? The answer is to use our usual trick with fixed points:

For the continuation semantics, we have:


and we define the valuation function as follows:
Again, we can eliminate the recursion by using Y as we did above for the direct semantics.

Of course, we need to know that the fixed points exist and are what we want. Fortunately, Dana Scott showed that if semantic domains and valuation functions have certain properties (which we'll study next) then there is always a meaningful least fixed point, which is what you get by applying Y.