Fall 98, CSE 520:
Lecture 11 (Oct 1)
The Language PCF
PCF (Programming [Language for] Computable Functions)
is an extension of
the Lambda Calculus with some primitive data types and operations.
Historically, this language was a part of LCF
(Logic of Computable Functions), introduced in 1969 by
Dana Scott,
with the purpose of investigating the definability of
computable functions on higher order types.
Later on, in 1977,
Gordon Plotkin,
considered the PCF sublanguage on its own
(and gave it this name),
studied its operational semantics, and introduced
the problem of "full abstraction"
(correspondence between the denotational and the operational
meanings of a language),
which has become one of the key issues
in the semantics of Programming Languages.
Our purpose here is to investigate the computational
models of (the functional part of) programming languages,
and therefore we are mainly interested in
the operational semantics of PCF. We will consider the two main
kinds of semantics, corresponding to the eager
(call-by-value) and to the lazy (call-by-name) evaluation stategies.
The first is the basis for languages like Lisp, Scheme
and ML. The latter is the basis for langauges like
Miranda, Askell, and Orwell.
The original PCF is a very tiny language, in the sense that
it only contains a few operators more than
the lambda-calculus:
successor, predecessor, test is-zero,
conditional and fixpoint.
We will consider a sligthly richer language as it is more
suitable to our purpose. Also, the original PCF is typed
a' la Church, in the sense that every variable is explicitly
decorated with a type. In order to be more general
(so to capture also the implicitly typed languages
like ML) we will not make this assumption.
Syntax
The syntax of (our extension of) PCF is the following:
Term ::= Var % variables
| Num | true | false % numerical and boolean constants
| Term Op Term % numerical ops and comparison
| if Term then Term else Term % conditional
| (Term,Term) | fst Term | snd Term % pairs and projections
| \Var.Term | Term Term % abstraction and application
| let Var = Term in Term % term with a local declaration
| fix % the fixpoint operator (Y)
Var and Num are syntactical categories which generate
the set of term variables and the set of
(representations of) integer numbers.
Op is a syntactical category which generates the numerical
operations +, *, - and /, and the comparison operation =.
Type system
The type system for the above language is the
one described in Assignment 2, plus the following rule
for the let construct:
G |- M : A G, x : A |- N : B
---------------------------------
G |- let x = M in N : B
This rule is the one which is usually considered in
(the extensions of) PCF, but it is only an approximation of
the real rule used, for instance, in ML.
The real one is more complicated and requires
notions that we have not introduced yet.
We will see it in future lectures.
Operational semantics
The importance of the operational semantics of PCF
comes from the fact that every real programming language
(not only the functional ones), has a "functional subset",
formed by the "expressions", i.e. syntactical entities which represent
a value (like for instance 4 + factorial(3), which represents
the value 10). The process of calculating the value of
an expression is called "evaluation".
The notion of evaluation is based essentially
on beta reduction. The way beta-reduction
is formalized in the lambda calculus,
however, is "too liberal", in the sense that
a given term usually gives rises to several
possible reductions, and it is not extablished
(except for the terms in normal form) when the
reduction process should end.
When specifying an operational semantics, we should
therefore fix:
- what reduction to allow, if more than one is possible
from a given term (reduction strategy), and
- what terms we want to consider as representing values.
These will be called "canonical terms".
We will consider a "big-step" semantics, in the sense that
we will consider statements of the form
M eval N
meaning: M reduces to the "value" N
according to the given evaluation strategy.
In contrast, in a "small-step" semantics N is not forced to be a
value, and usually we would need a sequence of (small) steps to
reach a value.
Independently from the above choices,
we want the definition of evaluation
to be sound with respect to beta reduction,
in the sense that, for a lambda calculus term M,
if M eval N then M ->> N (i.e. N can be obtained from M by
beta reduction). As for the reverse (completeness), we
do not want necessarily to impose it. We will see in fact that
the eager and the lazy strategies differ in this issue:
the lazy semantics is complete and the eager is not.
Eager operational semantics
In the eager semantics, the canonical terms (i.e. the values)
are defined inductively as follows:
- the numerical and boolean constants are canonical
- if M, N are canonical, then the pair (M,N) is canonical
- the lambda abstractions are canonical (they represent functional values)
The rules of the eager semantics are the following:
- The canonical terms evaluate to themselves:
---------- for any number n
n eval n
---------------- ------------------
true eval true false eval false
----------------
\x.M eval \x.M
- The numerical operators and comparison
evaluate to their semantic counterpart
M eval P N eval Q
------------------------- op is +,-,*,/,=.
(M op N) eval (P sop Q)
In the above, sop stands for the semantic counterpart of op.
For instance, if op is the symbol +, then 5 sop 3 is 8.
- The conditional statement has the usual behavior of
evaluating to the first branch or to the second depending on the value of the
condition:
M eval true N eval Q M eval false P eval Q
----------------------------- ------------------------------
(if M then N else P) eval Q (if M then N else P) eval Q
- The semantics of the pairing and the projections are as follows
M eval P N eval Q M eval (N,P) M eval (N,P)
--------------------- ---------------- ----------------
(M,N) eval (P,Q) (fst M) eval N (snd M) eval P
- The application (M N) follows the call-by-value discipline, i.e.
the argument N is first evaluated, and then its value is replaced in the
body of the function:
M eval (\x.P) N eval Q P[Q/x] eval R
------------------------------------------
(M N) eval R
- The value of a construct of the form let x = M in N is the value of N,
where x is replaced by the value of M
M eval Q N[Q/x] eval P
--------------------------
(let x = M in N) eval P
- Finally, the fixpoint rule is based on the idea that recursively
definded functions can be evaluated by unfolding, i.e. by substituting
the funcion call by its body. In other words: in order to evaluate
(fix M), we should evaluate M (fix M). However this does not work under
the call-by-value discipline, because the evaluation of the latter would
bring to the evaluation of (fix M) again, and we would end up in a loop.
To fix this problem, we require in the premise that
(fix M) is replaced in the body of M withouth being evaluated first.
By generalizing this principle, we get the following rule:
M eval (\x.P) P[(fix M)/x] eval Q
-------------------------------------
(fix M) eval Q
Note that even if this semantics is called "eager", there are
some rules which do not obey the call-by-value discipline.
These are the fixpoint and the conditional
rule. (The first is lazy, the second is mixed, i.e. partially
lazy on the second and on the third argument.)
If they were formalized in the call-by-value fashion,
then in this semantics it would not
be possible to define recursive functions.
(This is the reason why we consider an extended lambda calculus
for studying reduction strategies:
the fixpoint and the conditional operators
could be encoded in the lambda calculus,
but then their semantics would be determined by the
application rule.)
Lazy operational semantics
In the lazy semantics, the canonical terms
are defined as follows:
- the numerical and boolean constants are canonical
- the pairs (M,N) are canonical
- the lambda abstractions are canonical (they represent functional values)
The difference wrt the eager semantics is that pairs are canonical
independently from the form of the arguments.
The reason for this choice is that, if we want
to be able to define lazy functions,
we need some of the basic operators to be lazy as well.
The rules of the lazy semantics are the following
(we list only those which differ from the corresponding
rules in the eager semantics)
- Pairs and projections:
M eval (N,P) N eval Q M eval (N,P) P eval Q
------------------ ------------------------- -------------------------
(M,N) eval (M,N) (fst M) eval Q (snd M) eval Q
Note that, since M and N are not evaluated in (M,N), then
the rules for fst and snd have to be modified so to
force the evaluation of the selected component.
- The application (M N) follows the call-by-name discipline, i.e.
the argument N is replaced in the body of the function without being
evaluated first:
M eval (\x.P) P[N/x] eval R
-------------------------------
(M N) eval R
- The rule for the let construct:
some authors (see, for instance, [Winskell93])
specify a lazy rule for this construct.
We prefer to leave the let rule as in the early semantics,
following the practice of lazy programming languages.
(Having an eager semantics for let allows for more flexibility,
i.e. for expressing eager functions when desired.)
- The fixpoint rule in the lazy semantics can equivalently be formulated as
in the eager semantics, or can be simplified as follows:
(M (fix M)) eval N
--------------------
(fix M) eval N
Programming in lazy languages
We present here some examples of programs that can be written in a
lazy language like Askell. We will consider functions on streams, which
can be seen as "potentially infinite lists".
More precisely, streams can be formalized as a variant of lists, where the
cons constructor is lazy on the second argument.
We consider the following signature for streams:
- the cons constructor (denoted by ::):
(a :: s) is the stream whose first element is a and whose rest is s.
- the head selector (denoted by hd): (hd s) is the first element of the
stream s.
- the tail selector (denoted by tl): (tl s) is the rest of the
stream s (i.e. what we get by removing from s the first element).
The semantics of these operators is as follows:
M eval P M eval (N::P) M eval (N::P) P eval Q
-------------------- --------------- -------------------------
(M::N) eval (P::N) (hd M) eval N (tl M) eval Q
Example: the stream of natural numbers
A function which generates the stream of natural numbers starting from n
can be defined as follows:
fun nats n = n :: (nats (n+1));
Note that in a eager language like ML a call of nats would end up
in a loop (since recursion is unguarded).
On the contrary, in Askell, if we write for instance an expression like
hd (tl (nats 0));
its evaluation terminates and gives the value 1.
Example: the sequence of factorial numbers
A function which generates the stream of factorial numbers
(0!::1!::2!::3!...)
can be defined as follows:
fun facts = 1 :: (times (nats 2) facts);
where times is a function which takes two input steams of numbers and
outputs the stream of the pairwise products:
fun times (a::r) (b::s) = (a*b) :: (times r s);
Example: the sequence of prime numbers
The following is the definition of a function which generates
the stream of prime numbers starting from 2 with the
method called "Eratosthenes Sieve"
fun primes = sieve (nats 2);
where sieve is a function which outputs the first element a of the
input stream, and then creates a filter that
will let pass only those elements, in the
rest of s, which are not divisible by a.
Sieve can be defined as follows:
fun sieve (a::s) = a :: (sieve (filter a s));
And filter can be defined as follows:
fun filter a (b::s) = if (b mod a) = 0 then filter a s
else b :: (filter a s);
Appendix: Semantics of lists in ML
For completeness, we give here the semantics of lists in ML
(and in eager languages in general). The rules are as follows:
M eval P N eval Q M eval (N::P) M eval (N::P)
--------------------- --------------- ---------------
(M::N) eval (P::Q) (hd M) eval N (tl M) eval P