extra Explicit polymorphism
Consider the length
definition that we had — it is specific for
NumList
s, so rename it to lengthNum
:
{rec {lengthNum : (NumList -> Num)
{fun {l : NumList} : Num
{cases l
[{NumEmpty} 0]
[{NumCons x r} {+ 1 {call lengthNum r}}]}}}
{call lengthNum
{NumCons 1 {NumCons 2 {NumCons 3 {NumEmpty}}}}}}}
To simplify things, assume that types are previously defined, and that
we have an even more Racket-like language where we simply write a
define
form:
{fun {l : NumList} : Num
{cases l
[{NumEmpty} 0]
[{NumCons x r} {+ 1 {call lengthNum r}}]}}}
What would happen if, for example, we want to take the length of a list of booleans? We won’t be able to use the above code since we’d get a type error. Instead, we’d need a separate definition for the other kind of length:
{fun {l : BoolList} : Num
{cases l
[{BoolEmpty} 0]
[{BoolCons x r} {+ 1 {call lengthBool r}}]}}}
We’ve designed a statically typed language that is effective in catching
a large number of errors, but it turns out that it’s too restrictive —
we cannot implement a single generic length
function. Given that our
type system allows an infinite number of types, this is a major problem,
since every new type that we’ll want to use in a list requires writing a
new definition for a length function that is specific to this type.
One way to address the problem would be to somehow add a new length
primitive function, with specific type rules to make it apply to all
possible types. (Note that the same holds for the list type too — we
need a new type definition for each of these, so this solution implies a
new primitive type that will do the same generic trick.) This is
obviously a bad idea: there are other functions that will need the same
treatment (append
, reverse
, map
, fold
, etc), and there are other
types with similar problems (any new container type). A good language
should allow writing such a length function inside the language, rather
than changing the language for every new addition.
Going back to the code, a good question to ask is what is it exactly
that is different between the two length
functions? The answer is that
there’s very little that is different. To see this, we can take the code
and replace all occurrences of Num
or Bool
by some ???
. Even
better — this is actually abstracting over the type, so we can use a
familiar type variable, τ:
{fun {l : 〈τ〉List} : Num
{cases l
[{〈τ〉Empty} 0]
[{〈τ〉Cons x r} {+ 1 {call length〈τ〉 r}}]}}}
This is a kind of a very low-level “abstraction” — we replace parts of
the text — parts of identifiers — with a kind of a syntactic meta
variable. But the nature of this abstraction is something that should
look familiar — it’s abstracting over the code, so it’s similar to a
macro. It’s not really a macro in the usual sense — making it a real
macro involves answering questions like what does length
evaluate to
(in the macro system that we’ve seen, a macro is not something that is a
value in itself), and how can we use these macros in the cases
patterns. But still, the similarity should provide a good intuition
about what goes on — and in particular the basic fact is the same:
this is an abstraction that happens at the syntax level, since
typechecking is something that happens at that level.
To make things more manageable, we’ll want to avoid the abstraction over
parts of identifiers, so we’ll move all of the meta type variables, and
make them into arguments, using 〈...〉
brackets to stand for “meta
level applications”:
{fun {l : List〈τ〉} : Num
{cases l
[{Empty〈τ〉} 0]
[{Cons〈τ〉 x r} {+ 1 {call length〈τ〉 r}}]}}}
Now, the first “〈τ〉” is actually a kind of an input to length
, it’s
a binding that has the other τ
s in its scope. So we need to have the
syntax reflect this somehow — and since fun
is the way that we write
such abstractions, it seems like a good choice:
{fun {τ}
{fun {l : List〈τ〉} : Num
{cases l
[{Empty〈τ〉} 0]
[{Cons〈τ〉 x r} {+ 1 {call length〈τ〉 r}}]}}}}
But this is very confused and completely broken. The new abstraction is
not something that is implemented as a function — otherwise we’ll need
to somehow represent type values within our type system. (Trying that
has some deep problems — for example, if we have such type values,
then it needs to have a type too; and if we add some Type
for this,
then Type
itself should be a value — one that has itself as its
type!)
So instead of fun
, we need a new kind of a syntactic, type-level
abstraction. This is something that is acts as a function that gets used
by the type checker. The common way to write such functions is with a
capital lambda
— Λ
. Since we already use Greek letters for things
that are related to types, we’ll use that as is (again, with "〈〉"s),
instead of a confusing capitalized Lambda
(or a similarly confusing
Fun
):
〈Λ 〈τ〉 ; sidenote: similar to (All (t) ...)
{fun {l : List〈τ〉} : Num
{cases l
[{Empty〈τ〉} 0]
[{Cons〈τ〉 x r} {+ 1 {call length〈τ〉 r}}]}}〉}
and to use this length
we’ll need to instantiate it with a specific
type:
{call length〈Bool〉 {list #t #f}}}
Note that we have several kinds of meta-applications, with slightly different intentions:
-
length〈τ〉 is the recursive call, which needs to keep using the same type that initiated the
length
call. It makes sense to have it there, sincelength
is itself a type abstraction. -
List〈τ〉 is using
List
as if it’s also this kind of an abstraction, except that instead of abstracting over some generic code, it abstracts over a generic type. This makes sense too: it naturally leads to a generic definition ofList
that works for all types since it is also an abstraction. -
Finally there are
Empty〈τ〉
andCons〈τ〉
that are used for patterns. This might not be necessary, since they are expected to be variants of theList〈τ〉
type. But if we were doing this without pattern matching (for example, see the book) then we’d neednull?
andrest
functions. In that case, the meta application would make sense —null?〈τ〉
andrest〈τ〉
are the τ-specific versions of these functions which we get with this meta-application, in the same way that usinglength
needs an explicit type.
Actually, the last item points at one way in which the above sample calls:
{call length〈Bool〉 {list #t #f}}}
are broken — we should also have a type argument for list
:
{call length〈Bool〉 {list〈Bool〉 #t #f}}}
or, given that we’re in the limited picky language:
{call length〈Bool〉 {cons〈Bool〉 #t {cons〈Bool〉 #f null〈Bool〉}}}}
Such a language is called “parametrically polymorphic with explicit type parameters” — it’s polymorphic since it applies to any type, and it’s explicit since we have to specify the types in all places.
extra Polymorphism in the type description language
Given our definition for length
, the type of length〈Num〉
is
obvious:
but what would be the type of length
by itself? If it was a function
(which was a broken idea we’ve seen), then we would write:
But this is broken in the same way: the first arrow is fundamentally
different than the second — one is used for a Λ
, and the other for a
fun
. In fact, the arrows are even more different, because the two τ
s
are very different: the first one binds the second. So the first arrow
is bogus — instead of an arrow we need some way to say that this is a
type that “for all τ” is “List〈τ〉 -> Num”. The common way to write
this should be very familiar:
Finally, τ
is usually used as a meta type variable; for these types
the convention is to use the first few Greek letters, so we get:
And some more examples:
map : ∀α,β. (α->β) × List〈α〉 -> List〈β〉
where ×
stands for multiple arguments (which isn’t mentioned
explicitly in Typed Racket).
extra Type judgments for explicit polymorphism and execution
Given our notation for polymorphic functions, it looks like we’re
introducing a runtime overhead. For example, our length
definition:
〈Λ 〈α〉
{fun {l : List〈α〉} : Num
{cases l
[{Empty〈α〉} 0]
[{Cons〈α〉 x r} {+ 1 {call length〈α〉 r}}]}}〉}
looks like it now requires another curried call for each iteration through the list. This would be bad for two reasons: first, one of the main goals of static type checking is to avoid runtime work, so adding work is highly undesirable. An even bigger problem is that types are fundamentally a syntactic thing — they should not exist at runtime, so we don’t want to perform these type applications at runtime simply because we don’t want types to exist at runtime. If you think about it, then every traditional compiler that typechecks code does so while compiling, not when the resulting compiled program runs. (A recent exception in various languages are “dynamic” types that are used in a way that is similar to plain (untyped) Racket.)
This means that we want to eliminate these applications in the typechecker. Even better: instead of complicating the typechecker, we can begin by applying all of the type meta-applications, and get a result that does not have any such applications or any type variables left — then use the simple typechecker on the result. This process is called “type elaboration”.
As usual, there are two new formal rules for dealing with these abstractions — one for type abstractions and another for type applications. Starting from the latter:
———————————————————
Γ ⊢ E〈τ₂〉 : τ[τ₂/α]
which means that when we encounter a type application E〈τ₂〉 where E
has a polymorphic type ∀α.τ, then we substitute the type variable α with
the input type τ₂. Note that this means that conceptually, the
typechecker is creating all of the different (monomorphic) length
versions, but we don’t need all of them for execution — having checked
the types, we can have a single length
function which would be similar
to the function that Racket uses (i.e., the same “low level” code with
types erased).
To see how this works, consider our length use, which has a type of ∀α. List〈α〉 -> Num
. We get the following proof that ends in the exact
type of length
(remember that when you prove you climb up):
——————————————————————————————————————————————
Γ ⊢ length〈Bool〉 : (List〈α〉 -> Num)[Bool/α]
——————————————————————————————————————————————
Γ ⊢ length〈Bool〉 : List〈Bool〉 -> Num [...]
——————————————————————————————————————————————
Γ ⊢ {call length〈Bool〉 {cons〈Bool〉 ...}} : Num
The second rule for type abstractions is:
———————————————————
Γ ⊢ 〈Λ〈α〉 E〉 : ∀α.τ
This rule means that to typecheck a type abstraction, we need to check
the body while binding the type variable α — but it’s not bound to
some specific type. Instead, it’s left unspecified (or
non-deterministic) — and typechecking is expected to succeed without
requiring an actual type. If some specific type is actually required,
then typechecking should fail. The intuition behind this is that a
polymorphic function can be one only if it doesn’t need some specific
type — for example, {fun {x} {- {+ x 1} 1}}
is an identity function,
but it’s an identity that requires the input to be a number, and
therefore it cannot have a polymorphic ∀α.α type like {fun {x} x}
.
Another example is our length
function — the actual type that the
list holds better not matter, or our length
function is not really
polymorphic. This makes sense: to typecheck the function, this rule
means that we need to typecheck the body, with α being some unknown type
that cannot be used.
One thing that we need to be careful when applying any kind of
abstraction (and the first rule does just that for a very simple
lambda-calculus-like language) is infinite loops. But in the case of our
type language, it turns out that this lambda-calculus that gets used at
the meta-level is one of the strongly normalizing kinds, therefore no
infinite loops happen. Intuitively, this means that we should be able to
do this elaboration in just one pass over the code. Furthermore, there
are no side-effects, therefore we can safely cache the results of
applying type abstraction to speed things up. In the case of length
,
using it on a list of Num
will lead to one such application, but when
we later get to the recursive call we can reuse the (cached) first
result.
extra Explicit polymorphism conclusions
Quoted directly from the book:
Explicit polymorphism seems extremely unwieldy: why would anyone want to program with it? There are two possible reasons. The first is that it’s the only mechanism that the language designer gives for introducing parameterized types, which aid in code reuse. The second is that the language includes some additional machinery so you don’t have to write all the types every time. In fact, C++ introduces a little of both (though much more of the former), so programmers are, in effect, manually programming with explicit polymorphism virtually every time they use the STL (Standard Template Library). Similarly, the Java 1.5 and C# languages support explicit polymorphism. But we can possibly also do better than foist this notational overhead on the programmer.