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:
There are other ways to specify a language's semantics, but they
have shortcomings:
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:
Here is a very simple example for the language of binary numerals.
The meaning of a "program" is the number it represents.
B[[BD]] = (B[[B]] times two) plus D[[D]]
D[[0]] = zero
As noted above, objects inside [[ ]] are really abstract-syntax trees.
So for example, B [[BD]] is really:
A "program" in this language is: 101
Its AST is:
Denotational definitions for simple languages are simple.
Imperative language features, e.g.:
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:
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 [[C1;C2]] = λs. C [[C2]] (C [[C1]] s)
Note that Commands are really just statements, and that
the evaluation of an expression cannot change the store in
this simple language.
E [[E1+E2]] = λs. (E [[E1]] s) plus (E [[E2]] s)
B [[E1==E2]] = λs. (E [[E1]] s) equals (E [[E2]] s)
Now let's consider the meaning of our example program:
By the definition of P, this is:
after doing the beta-reduction (substituting two for n)
we have:
Note that
So we have:
Now using the definition of C for an assignment we get:
Using the definition of E for an identifier, we have:
Therefore, we have the (sub)expression
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:
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:
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:
We must also make the following changes to the denotational semantics:
Note that, by definition, a store with value ⊥ maps all
identifiers to ⊥.
By convention, we use un underlined lambda to indicate a
strict function.
So the new definitions of C are all of the form:
Why is it not necessary to make the valuation functions E and
B strict?
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:
C [[diverge]] = λc.λs. ⊥
C [[I=E]] = λc. λs. c (update [[I]] (E [[E]] s) s)
C [[C1;C2]] = λc.λs.C [[C1]] (C [[C2]] c) s
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 [[C.]] = λn. let s1 = (update [[A]] n newstore) in
let s2 = ((C [[C]] λs.s) s1) in
access [[Z]] s2
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.
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
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 ⊥.
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.
Here is how to change our previous direct semantics so that the meaning of
a program that executes a division by zero is bottom.
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
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.
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:
Note that we cannot simply define ? as:
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:
Note that (2)+(3) is the second argument to E, so it is an
expression continuation, and its type is:
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:
Steps (2)+(3)+(4) are the continuation that must be passed to
E to do step (1).
So the form of ? is:
Write the valuation function for division.
Remember that if the denominator is zero, the result should be bottom.
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:
Evaluate the program:
Motivation
Overview
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).
A Simple Example
B → BD | D
D → 0 | 1
Note that we use words "zero", "one" etc, to distinguish these
semantic objects from the corresponding syntactic objects
0, 1, etc.
B[[D]] = D[[D]]
D[[1]] = one
B(B)
/ \
B D
/\ /\
/t1\ /t2\
---- ----
which is:
(B(B) times two) plus D(D)
/\ /\
/t1\ /t2\
---- ----
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:
B[[101]] = (B[[10]] times two) plus D[[1]]
Continuing this process:
= ((B[[1]] times two) plus D[[0]]) times two) plus one
= ((D[[1]] times two) plus zero) times two) plus one
= ((one times two) plus zero) times two) plus one
= five
More Interesting Examples
make it more difficult to define a denotational semantics for a language.
We will look at how to handle some of those features.
Assignment
Z = A; if (Z == 0) then Z = 1.
Denotational semantics
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
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
E [[I]] = λs. access[[I]] s
E [[N]] = λs. [[N]]
B [[!B]] = λs. not ( B [[B]] s )
Example
Z=A; if Z==0 then Z=1 .
according to this denotational semantics.
In general, there are two interesting ways to apply a denotational semantics:
Let's try the first one, using the input two;
i.e., we want to know the value of:
P [[ Z=A; if Z==0 then Z=1. ]] (two)
( λn. let
s = ( update [[A]] n newstore ) in
let
s' = ( C [[ Z=A; if Z==0 then Z=1 ]] s ) in
access [[Z]] s'
) two
( let
s = ( update [[A]] two newstore ) in
let
s' = ( C [[ Z=A; if Z==0 then Z=1 ]] s) in
access [[Z]] s' )
update [[A]] two newstore
is the store that maps A to two, and maps all other Ids to zero.
Let's call this store s1 in what follows.
let s' = ( C [[ Z=A; if Z==0 then Z=1 ]] s1 ) in access [[Z]] s')
Using the definition of C for a sequence of statements we get:
let s' = ( C [[ if Z==0 then Z=1 ]] ( C [[ Z=A ]] s1 ) in access [[Z]] s')
let s' = ( C [[ if Z==0 then Z=1 ]] ( update [[Z]] ( E [[A]] s1 ) s1 ) in access [[Z]] s')
E [[A]] s1
which simplifies to:
access [[A]] s1
which (since s1 maps A to two) evaluates to two.
( update [[Z]] two s1 )
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:
let s' = (( C [[ if Z==0 then Z=1 ]] s2) in access [[Z]] s')
Using the definition of C for an if-then statement we get:
let s' = ( if (B [[Z==0]] s2) then (C [[Z=1]] s2) else s2 ) in access [[Z]] s'
Using the definition of B for an equality test,
and the fact that s2 maps Z to two,
we have:
let s' = if (false) then (C [[ Z=1 ]] s2) else s2 in access [[Z]] s'
which simplifies to:
let s' = s2 in access [[Z]] s'
which is the same as
access [[Z]] s2
which evaluates to two, our final result (and the meaning of our
example program applied to two).
P [[ Z=A; if Z==0 then Z=1 . ]]
Show how to transform this into:
λn. if n equals zero then one else n
using the valuation functions.
Runtime 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:
C → diverge
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.
C [[diverge]] = λs. ⊥
OLD - C: Command → store → store
NEW - C: Command → store⊥ → store⊥
diverge ; Z=0 .
to be ⊥ (for all inputs).
To achieve that we need to make C strict
(recall that a strict function evaluates to ⊥ if any of
its arguments is ⊥, even if it never actually uses that
argument).
&lambda s. ...
For example, the first one is:
C [[C1;C2]] = &lambda s. C [[C2]] (C [[C1]] s)
Continuation Semantics
where c is a continuation and s is a store.
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.
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.
Note that λs.s
is the initial, identity continuation (i.e., it maps the final
store to itself).
Example 1: Program without "diverge".
s3 = λi. if i==Z then two else (if i==A then two else zero)
which = two.
Example 2: Program with "diverge".
= ⊥
Errors in expressions
Changes to direct semantics
E: Expression → Store → Nat⊥
where both "let"s are strict (i.e., if either denom or
num is bottom, then the value of the whole expression is bottom).
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
Changes to continuation semantics
I=E; C
The evaluation sequence for this code is:
I=E is a command.
Its continuation is step 3 which needs only the updated store.
E [[E^2]] = λk. λs. ?
where the ? must do the following:
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.
E [[E]] (2)+(3) s
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'
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
Here is the new valuation function:
C [I=E] = λc.λs1.E [[E]] (λn.λs2.c(update [[I]] n s2)) s1
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.
B=A/0;...
with input two.