F-omega -- the workhorse of modern compilers:
Before growing the langauge with more "realistic" features, such as
say recursive functions, references, etc. it's worth noting that we
can expand the type-level of the language by providing support for
abstraction at the type level. In particular, it is somewhat natural
to introduce functions at the type level (and applications.) For
instance, in ML, a definition like:
datatype 'a Exn = Fail | Succeed of 'a
is really defining Exn as a type-level function which, when applied to
a type, yields a type. That is, Exn is not a type, but T Exn is a
type for any type T. (It's just unfortunate that in ML, application
of type constructors is backwards.)
So we might augment our type-level with lambdas and applications. Of
course, as soon as we do this, then we need to introduce some "types"
for the types to ensure that we only apply type-level functions to
arguments of the right "type". The classifiers for types are called
*kinds* and we might as well take this as our definition for kinds:
(kinds) k ::= * (the kind of types)
| k1 -> k2 (a function kind)
Then we can define constructors as follows (we don't call them types
anymore because only some of the constructors correspond to types):
(primitive
constructors) C ::= -> | x | 1 | + | 0 | All_k | Exists_k
(constructors) t ::= C | a | \a:k.t | t1 t2
Notice that the constructor language is actually pretty small and
corresponds exactly to the simply-typed lambda calculus plus a few
constants. Factoring the type system in this fashion is a great idea
for implementors because you can keep the type system relatively small
and compact.
The constants have kinds as follows:
-> : * -> * -> *
x : * -> * -> *
1 : *
+ : * -> * -> *
0 : *
All_k : (k -> *) -> *
Exists_k : (k -> *) -> *
and of course we can give kinding rules to the constructor language as
follows, where D is a kind context mapping type variables to kinds.
D |- a : D(a)
D,a:k1 |- t : k2
-----------------------
D |- \a:k1.t : k1 -> k2
D |- t1 : k'->k D |- t2 : k'
-----------------------------
D |- t1 t2 : k
So for instance, we can write the Exn constructor as follows:
\a:*.(+ 1) a
and verify that it has kind * -> *. That is, it takes a type and
delivers a type. The System-F type "All a.a -> a" can be written
as follows:
All_* (\a:*.(-> a) a)
That is, the All_* constructor takes as an argument a (type-to-type)
function and yields a type. The same is true for the Exists
constructor. Notice that this very nicely allows us to have
exactly one form of binding for type variables in the language
and to treat issues such as alpha-variance and capture-avoiding
substitution uniformly.
The term language looks exactly as before except that we'll support
abstraction over *any* kind of constructor, not just constructors of
kind * (i.e., types):
e ::= c | x | \x:t.e | e1 e2 | 1 | (e1,e2) | #i e |
inl_t e | inr_t e | (case e of inl(x)=>e1 | inr(x)=>e2) |
/\t:k.e | e t | pack[t,e] as t' | unpack [a,x] = e1 in e2
The typing rules for terms is also relatively straightforward and
I'll only highlight a few of them:
D |- t1 : * D;G,x:t1 |- e : t2
--------------------------------
D;G |- \x:t1.e : -> t1 t2
Notice that we require that t1 is actually a well-formed type.
Before, we only had to check that the free type variables were
in scope, but now we have the possiblity that t1 has the wrong
kind (e.g., it could be of kind *->* instead of *.)
For type abstraction, we have:
D,a:k;G |- e : t
-------------------------------
D;G |- /\a:k.e : All_k (\a:k.t)
and for type application we have:
D;G |- e : All_k t1 D |- t2 : k
-----------------------------------
D;G |- e t2 : t1 t2
Notice that t1 might not be a lambda. It could be a variable or an
application instead. However, we know that *semantically* t1 must be
a function from kind k to * (i.e., types). So we simply apply t1 to
t2 to get out that type.
The typing rules for existentials are similar:
D |- t1 : k->* D;G |- e : t1 t2
---------------------------------
D;G |- pack[t2,e] : Exists_k t1
D;G |- e1 : Exists_k t1 D,a:k;G,x:t1 a |- e2 : t2 D |- t2 : *
-------------------------------------------------------------------
D;G |- unpack [a,x] = e1 in e2 : t2
The first rule is somewhat subtle -- the way to think of it is that if
e has type T[t2] where T is some type with holes in it that have been
plugged by t2, then we can rewrite the type of e as (\a:k.T[a]) t2 and
note that this will reduce to T[t2].
The second rule allows us to open up an existential and introduces a
type variable with the abstracted kind, as well as a term variable
which is given the type t1 a (think T[a]) while running the expression
e2. Note that the return type (t2) cannot have a free occurrence of a
in it.
There's one more rule to add, and this is one is crucial or we might
never be able to use All or Exist-types:
D;G |- e : t1 D |- t1 == t2 : *
-----------------------------------
D;G |- e : t2
This is a rule of "definitional equality" which says that if we can
prove that two types t1 and t2 are equal, and if we can assign e the
type t1, then we can also assign e the type t2. This is a lot like
the subsumption rule in a subtype-based system.
And of course, we now need to say what constitutes equality for types.
In particular, we can use the equational theory that we generated for
the simply-typed lambda calculus!
D |- t : k
(refl) ----------------
D |- t == t : k
D |- t1 == t2 : k
(symm) -----------------
D |- t2 == t1 : k
D |- t1 == t2 : k D |- t2 == t3 : k
(tran) ---------------------------------------
D |- t1 == t3 : k
D |- t1 == t1' : k'->k D |- t2 == t2' : k'
(app) -------------------------------------------
D |- t1 t2 == t1' t2' : k
D,a:k1 |- t1 == t2 : k2
(lam) ----------------------------------
D |- \a:k1.t1 == \a:k2.t2 : k1->k2
D |- (\a:k'.t1) : k'->k D |- t2 : k'
(beta) ---------------------------------------
D |- (\a:k'.t1) t2 = t1[t2/a]
D |- t1 : k1->k2
(eta) ----------------------
D |- (\a:k1.t1 a) = t1
In practical terms, the way you implement a type-checker (not type
inferencer!) for the language is to normalize types at all points and
then compare then up to alpha-equivalence. It's often useful to use a
"variable-less" representation (say deBruijn indices) so that the
comparison devolves to just a structural comparison. It's actually
a little trickier than this because we need to eliminate that pesky
definitional equality (in the same way that we eliminated subtyping)
because it can be applied anywhere. So formally, we need to define
an "algorithmic" set of typing rules where we bake definitional
equality into the rules and only use normal forms. For instance,
we might have the following for the application rule:
D;G |- e1 : t1 D;G |- e2 : t2
D |- NF(e1) = -> ta tb
D |- NF(e2) = tc
ta =alpha= tc
--------------------------------
D;G |- e1 e2 : tb
and of course, we're obligated to show that the algorithmic rules
are both sound and complete with respect to the more declarative
rules given above. But none of that is very hard since we've really
worked out all of the theory for doing this already.
This language is a variant of what is called F-omega and is quite
close to the internal representation of a number of type-preserving
compilers (including GHC, TIL and TILT.) At this point, GHC has
exported most of the functionality to the source level, but for
the sake of inference, has a number of restrictions on types.
(See the papers by Mark Shields and Simon Peyton Jones for details
on the inference algorithm.)
In my experience, F-omega is an extremely powerful and useful
foundation for programming languages. Advanced features, such as
Haskell's type classes, (the key parts of) SML and O'caml's module
systems, and even cutting edge features such as GADT's can be encoded
directly into this language. And, as we'll see, it's crucial for
understanding type systems for OO languages. Semantically, F-omega is
not that much harder to handle than System-F. Again, life is much
easier if you stratify it so that it's predicative, but it's
relatively easy to construct a semantic model similar to the one
that we did for system F, even for the impredicative case.
On the surface, it may not appear all that useful to be able to
abstract type constructors, but let me assure you, you can do some
damned cool things with this...
To give you a feel, I would like to informally show you how both
ML modules and Haskell-style type-classes can be encoded in F-omega.
First, let's consider what is a module? If we ignore types, then
a module is just a record of values. For instance, the structure
struct
val origin = (0,0)
fun bumpx (x,y) = (x+1,y)
fun bumpy (x,y) = (x,y+1)
fun dist((x1,y1),(x2,y2)) = ...
end
is really just a record of values:
{origin = (0,0), bumpx = ..., bumpy = ..., dist = ...|}
with type:
{origin:int*int, bumpx:int*int->int*int, ...}
and as we argued earlier, we can think of type abstraction as an
application of existentials. For instance, if we want to introduce
an abstract type of points:
struct
type point = int*int
fun bumpx (x,y) = ...
....
fun dist (...) = ...
end
and "seal" the structure, we can express this in System-F as:
Exists a.
{origin:a, bumpx:a->a, bumpy:a->a, dist:a*a->int}
Now if we want to abstract some kind of container, such as a stack
or a queue, then we want a signature that looks like this:
Exists C:*->*.
{empty: All a:*.C a,
insert: All a:*.a -> C a -> C a,
remove: All a:*.C a -> 1 + (a * C a)}
Notice that here, we want to abstract a *function* from types
to types not just a type. So the ability to abstract constructors
is generally crucial for building polymorphic containers.
What about functors? Consider the following ML functor which
is intended to turn a generic functional container (with a signature
similar to the one above) into one that is imperative:
signature Container =
sig
type C : * -> *
val empty : All a:*.C a
val insert: All a:*.a -> C a -> C a
val remove: All a:*.C a -> 1 + (a * C a)
end
signature MutableContainer =
sig
type M : * -> *
val empty : All a:*.1 -> M a
val insert: All a:*.a -> M a -> 1
val remove: All a:*.M a -> 1 + a
end
functor Mutable(X : Container):> MutableContainer =
struct
type M = \a:*.ref (X.C a)
val empty = /\a:*.\x:1.newref (X.empty())
val insert = /\a:*.\x:a.\m:M a.m := (X.insert x !m)
val remove = /\a:*.\m:M a.
case (X.remove !m) of
inl(_) => inl()
| inr(v,c) => (m := c; v)
end
We can define the Mutable functor as a function in F-omega (suitably
extended with refs and so forth) which takes an
existentially-quantified Container and returns and existentially
quantified mutable container. I'll leave that as an exercise.
The advantage of doing things this way is that modules (existentials)
and functors (just normal functions!) become first class.
There are disadvantages to encoding modules in this fashion which
we'll hopefully talk about later. Still, I hope that I'm giving
you the impression that the bulk of ML's module system can actually
be represented faithfully within F-omega.
What about a type-class? Consider the following class definition:
class Show a
method show :: a -> string
In Haskell, when you use a method, such as show above, the type
inference imposes a constraint that there must be an instance of the
show method defined for the type at which you use the method. For
instance, if I write:
show true
then this generates a requirement that the boolean type (the type of
true) has a show method. What about a polymorphic thing like:
\a -> \b -> "(" ++ show a ++ "," ++ show b ++ ")"
The type generated for such a function is:
forall a,b.Show a, Show b => a -> b -> string
That is, any caller of the function has to ensure that the constraints
are met (i.e., a and b are types that implement the show method.)
The way GHC implements such a function is to compile it to something
like this:
forall a,b.Show a -> Show b -> a -> b -> string
where the constructor Show a is defined as:
Show a = {show : a -> string,...}
In general, a type-class (such as Show) is represented by a dictionary
of methods representing evidence that the type is in the class. This
sort of "evidence passing" interpretation is actually quite similar to
what happens with a coercion-based interpretation of subtyping.
Now consider that Haskell's treatment of effects depends crucially upon
monads and that monads are such a useful structuring mechanism, we'd
really like to define a class for Monads once and for all. But,
the signature for a monad shows that Monads are in general type
constructors, not types. That is, we want "constructor" classes,
not just type classes. In particular, we'd like to define:
class Monad (M:*->*)
return :: All a:*.a -> M a
bind :: All a,b:*.M a -> (a -> M b) -> M b
In short, I would claim that the ability to abstract constructors,
not just types, is crucial for building re-usable containers and
abstractions. By using F-omega as a foundation, we get a substrate
that is relatively simple and well-understood, but surprisingly
powerful.