7 Parametric Polymorphism
Homework 4 is due this Thursday at 11:59pm.
The midterm is next week during class on 10/20. It will cover everything from weeks 1-6 as well as homeworks 1-4.
You can expect grades for homework 1-3 this week to help you prepare for the midterm.
7.1 Introduction
So far we have only used simple, concrete types to run programs.
It is useful to write functions that work for a variety of types. For example, the identity function \lambda x . x should work for any type of x.
Type systems that allow a single piece of code to be used with multiple types are broadly referred to as polymorphic type systems, or sometimes simply polymorphism
There are many kinds of polymorphism, and we will explore some of them in this class. A particularly common form of polymorphism permits writing functions that are parametric in a type, meaning that the function itself takes a type argument (also called a type parameter) as part of its definition. This form of polymorphism is called parametric polymorphism, and it is what we will study first.
public class Main { |
public static <T> T identity(T x) { |
return x; |
} |
|
public static void main(String[] args) { |
System.out.println(identity("Hello World!")); |
} |
} |
The syntax <T> declares a type argument T. The identity function is not allowed to know what T is; it must work over all possible instantiations of T. The main functions calls identity with an argument of type string; this replaces T with string in identity.
Parametric polymorphism is essential for code reuse: it allows the programmer to avoid unnecessary duplication of code that should work regardless of the argument type.
The identity function: id : A → A
Mapping a function over a list: map : List A → (A → B) → List B
Currying: curry : (A × B → C) → (A → B → C)
We can write down typing derivations for expressions that leave these types as metavariables, but so far we have not had a way to internalize this kind of genericity-over-types into our languages. Doing so will introduce polymorphism, literally meaning "many forms," and we will be able to explicitly characterize values that can be instantiated with multiple types.
7.2 Main idea: Type abstraction
We’ve previously characterized functions λx.e as abstractions: instead of defining a concrete expression e (where its free variable is instantiated with a specific concrete expression), we abstract over some parameter. In this setting we say we have “expression abstraction”: the ability to abstract over expressions by binding variables that refer to arbitrary expressions.
We will now do the same for types by implementing type abstraction. An abstract type is written: \forall \alpha : \text{Type} . \tau
In practice, all type abstractions will be over type variables, so we will omit the : Type annotation. The values of this type can be thought of as functions that take a type as an argument, so we write them, by analogy with the λ calculus, as
\Lambda \alpha . e
This gives us a way to explicitly represent the type of, e.g., map, in a generic way:
\texttt{curry } : \forall \alpha . \forall \beta . \forall \gamma . (\alpha \times \beta \to \gamma) \to (\alpha \to \beta \to \gamma)
When we implement the curry function, we need to explicitly bind the type variables with type-level lambda. We can use the following concrete syntax:
curry = TLam a. TLam b. TLam c. lam (f : a * b -> c). lam (x : a). lam (y : b). f (x,y)
...to represent the following abstract (formal) syntax: \Lambda a . \Lambda b .\Lambda c . \lambda f . \lambda x . \lambda y . f (x , y)
Now let’s say we have a specific function we want to curry, such as this function that takes a pair of a number n and a string s, then concatenates s to itself n times:
(repeat : (Number String -> String)) (define (repeat n s) (if (= n 0) "" (string-append s (repeat (- n 1) s))))
We will need to instantiate the curry function with the types Number, String, and String in place of its variables α, β, and γ. To insantiate variables, we will “apply” the type-level function:
(curry Number String String) |
: (Number * String -> String) -> (Number -> String -> String) |
...and now it has the correct type to be applied to the repeat function.
7.3 System F
System F was introduced independently by Girard in the context of logic and Reynolds in the context of programming languages. Our development in this subsection is based on Chapter 23 of Types and Programming Languages.
Type abstraction. We need a way of designating a function that takes a type as an argument. This is called a type abstraction, and we use the surface syntax \Lambda X.e to denote it (the symbol \Lambda is a capital lambda). Using this syntax, we can write a polymorphic identity function: \Lambda X.(\lambda x : X . x) The identifier X above is a type parameter.
Type application. Now we need a way to instantiate type parameters. We do this using something that looks a lot like normal function application: (\Lambda X . (\lambda x : X . x)) ~ [\texttt{Num}]
We call the above syntax a type application: its semantics is to substitute the type parameter X for the argument \texttt{Num} in the body of the type abstraction. So, the above steps to (\lambda x : \texttt{Num} . x). We wrap type application in square brackets to syntactically distinguish it from term application.
Universal types. The term \Lambda X.(\lambda x : X . x) is a program waiting to receive a type as an argument. Such a program should itself have a type to make sure we don’t misuse it (for instance, by calling it with an argument that is not a type). We will write this type \forall X . t (read "for all X"), where X is a type identifier and t is a type that can refer to X. Now we can write a type for the identity function above:
\Lambda X.(\lambda x : X . x) : \forall X . X \rightarrow X Think of "\forall X . X \rightarrow X" like a lambda: the argument X is free in the body X \rightarrow X. Once application occurs, X will be substituted for a concrete type.
Given these intuitions, now we can define the complete surface syntax of System-F:
‹expr›
::=
λ ‹ident› : ‹t› . ‹expr›
|
‹expr› ‹expr›
|
‹expr› [ ‹t› ]
|
‹ident›
|
‹num›
|
‹ident›
‹t›
::=
Num
|
‹tident›
|
‹t› -> ‹t›
|
\forall ‹tident› . ‹t›
We can give abstract syntax to match this surface syntax:
> (define-type typ (funT [arg : typ] [ret : typ]) (identT [id : Symbol]) (numT) (forallT [id : Symbol] [body : typ]))
> (define-type expr (identF [id : Symbol]) (numF [n : Number]) (lamF [arg : Symbol] [t : typ] [body : expr]) (appF [e1 : expr] [e2 : expr]) (tlamF [id : Symbol] [body : expr]) (tappF [e1 : expr] [t : typ]))
Surface syntax |
| Abstract syntax datastructure |
\Lambda X . \lambda x : X . x |
| |
(\Lambda X . \lambda x : X . x)~[\texttt{Num}] |
|
7.3.1 Semantics of System-F
The semantics of System-F can be given in terms of substitution, just like STLC. Let’s run some example programs to get a feel for how programming in System-F looks.
First, the identity function evaluated on the number 10:
((\Lambda X . \lambda x : X . x)~[\texttt{Num}{}])~10 \xrightarrow{\text{Substitute }\texttt{Num}{}} (\lambda x : \texttt{Num}{} . x)~10 \xrightarrow{\text{Substitute }10} 10
Now let’s do a more interesting case. Let’s define a "call-twice" function that takes two arguments: a function f of type X \rightarrow X and an argument a of type X, and calls f twice with a as the initial argument:
\texttt{calltwice} = \Lambda X . \lambda f : X \rightarrow X . \lambda a : X . f~(f~a)
Let’s step through an example invocation of calltwice:
\begin{align*} &(((\Lambda X . \lambda f : X \rightarrow X . \lambda a : X . f~(f~a))~[\texttt{Num}])~(\lambda x : \texttt{Num} . x))~10 \\ &\xrightarrow{\text{substitute \texttt{Num}}} ((\lambda f : \texttt{Num} \rightarrow \texttt{Num} . \lambda a : \texttt{Num} . f~(f~a))~(\lambda x : \texttt{Num} . x))~10 \\ &\xrightarrow{\text{substitute }(\lambda x : \texttt{Num} . x)} ((\lambda a : \texttt{Num} . (\lambda x : \texttt{Num} . x)~((\lambda x : \texttt{Num} . x)~a)))~10 \\ &\xrightarrow{\text{substitute }10} ((\lambda x : \texttt{Num} . x)~((\lambda x : \texttt{Num} . x)~10)) \\ &\rightarrow (\lambda x : \texttt{Num} . x)~10 \\ &\rightarrow 10 \end{align*}
7.3.2 A Typechecker for System-F
\Gamma \cup \{x:\tau\} which extends \Gamma by associating x with type \tau, and
\Gamma \cup X which extends \Gamma with a new type variable X.
\dfrac{~}{\Gamma \vdash \texttt{n} : \texttt{Num}}~\text{(T-Num)} \qquad \dfrac{x \in \Gamma \qquad \Gamma(x) = t}{\Gamma \vdash x : t}~\text{(T-Ident)} \dfrac{\Gamma \cup \{x \mapsto t_1\} \vdash e : t_2}{\Gamma \vdash (\lambda x : t_1 . e) : t_1 \rightarrow t_2 }~\text{(T-Lam)} \qquad \dfrac{\Gamma \vdash e_1 : t_1 \rightarrow t_2 \qquad \Gamma \vdash e_2 : t_1} {\Gamma \vdash (e_1~e_2) : t_2}~\text{(T-App)} \dfrac{\Gamma \cup X \vdash e : \tau}{\Gamma \vdash \Lambda X . e : \forall X . \tau}~\text{(T-TLam)} \qquad \dfrac{\Gamma \vdash e_1 : \forall X . \tau_1 \qquad X \in \Gamma }{e_1 ~ [\tau_2] : \tau_1[X \mapsto \tau_2]}~\text{(T-TApp)}
As an example, here is a typing derivation tree:
\dfrac{ \dfrac{ \dfrac{ \dfrac{ \dfrac{x:X \in \{X, x:X\}}{ \{X, x:X\} \vdash x : X}~\text{(T-Var)} } {\{X\} \vdash \lambda x : X . x : X \rightarrow X}~\text{(T-Lam)} } {\{\} \vdash (\Lambda X . \lambda x : X . x) : \forall X . X \rightarrow X}~\text{(T-TLam)} } { \{\} \vdash ((\Lambda X . \lambda x : X . x)~[\texttt{Num}]) : \texttt{Num} \rightarrow \texttt{Num}}~\text{(T-Tapp)} \qquad \{\} \vdash 10 : \texttt{Num}} { \{\}\vdash ((\Lambda X . \lambda x : X . x)~[\texttt{Num}])~10 : \texttt{Num}}~\text{(T-App)}
There is a subtlety in these rules: the (T-App) rule implicitly requires comparing two types for equality. However, it is not trivial to check whether or not two types are equal in System-F. Consider the following two terms, annotated with their types:
\Lambda X . (\lambda x : X . x) : \forall X . (X \rightarrow X) \qquad \qquad \Lambda Y . (\lambda x : Y . x) : \forall Y . (Y \rightarrow Y)
7.3.2.1 Exercise: Working out inference rules
Fill in the blank for the following typing judgments so the resulting judgment holds, or indicate there is no way to do so:
\{\alpha\ ,\ \beta\ ,\ f:\forall\gamma.\gamma\to\beta \} \vdash \text{\_\_\_\_\_\_\_\_\_\_} : \alpha\to\beta
\{\gamma\ \} \vdash \text{\_\_\_\_\_\_\_\_\_\_} : \gamma\to\gamma
\{\alpha\ \} \vdash \text{\_\_\_\_\_\_\_\_\_\_} : \forall\beta.\alpha\to\beta
\{x:\forall\alpha. (\forall\beta.\beta\to\beta)\to\alpha,\ \gamma\ \} \vdash \text{\_\_\_\_\_\_\_\_\_\_} : (\gamma\to\gamma)\to\gamma
7.3.2.2 Alpha-equivalence and the type checker
Intuitively, these two terms should have the same type, since they differ only by their naming of the type parameter (in other words, they are \alpha-equivalent types). As a consequence, when implementing our typechecker, we will need to check type equality up to \alpha-equivalence.
We can implement our interpreter using a substitution on types that is similar to how we implement substitution for the lambda calculus:
> (define (tsubst t1 x t2) (type-case typ t1 [(funT arg ret) (funT (tsubst arg x t2) (tsubst ret x t2))] [(numT) (numT)] [(identT id) (if (equal? id x) t2 (identT id))] [(forallT id body) (if (equal? x id) (forallT id body) (forallT id (tsubst body x t2)))]))
Previously, we got away with alpha-equivalence by "just naming programs the same." This time we will encode a systematic change on our code by way of the rename-as-debruijn function.
> (number->symbol : (Number -> Symbol))
> (define (number->symbol n) (string->symbol (to-string n))) > (rename-as-debruijn : (typ Number -> typ))
> (define (rename-as-debruijn t depth) (type-case typ t [(numT) (numT)] [(funT t1 t2) (funT (rename-as-debruijn t1 depth) (rename-as-debruijn t2 depth))] [(identT x) (identT x)] [(forallT id body) (let* ([new-name (number->symbol depth)] [new-body (tsubst body id (identT new-name))]) (forallT new-name (rename-as-debruijn new-body (+ depth 1))))]))
> (define (type-eq? t1 t2) (equal? (rename-as-debruijn t1 0) (rename-as-debruijn t2 0)))
> (test (type-eq? (forallT 'X (identT 'X)) (forallT 'Y (identT 'Y))) #t)
- Void
good (type-eq? (forallT (quote X) (identT (quote X))) (forallT (quote Y) (identT (quote Y)))) at line 10
expected: #t
given: #t
> (test (type-eq? (forallT 'X (forallT 'Y (funT (identT 'X) (identT 'Y)))) (forallT 'Z (forallT 'W (funT (identT 'Z) (identT 'W))))) #t)
- Void
good (type-eq? (forallT (quote X) (forallT (quote Y) (funT (identT (quote X)) (identT (quote Y))))) (forallT (quote Z) (forallT (quote W) (funT (identT (quote Z)) (identT (quote W)))))) at line 11
expected: #t
given: #t
> (test (type-eq? (forallT 'X (forallT 'X (funT (identT 'X) (identT 'X)))) (forallT 'Y (forallT 'X (funT (identT 'X) (identT 'X))))) #t)
- Void
good (type-eq? (forallT (quote X) (forallT (quote X) (funT (identT (quote X)) (identT (quote X))))) (forallT (quote Y) (forallT (quote X) (funT (identT (quote X)) (identT (quote X)))))) at line 12
expected: #t
given: #t
The goal of rename-as-debruijn is to make \alpha-equivalent types look exactly the same.
For example,
\forall X . X \rightarrow X
and
\forall Y . Y \rightarrow Y
mean the same thing, even though the bound variables have different names.
The function walks through the type and renames each forall-bound variable according to its nesting depth:
∀X. ∀Y. X -> Y |
↓ |
∀0. ∀1. 0 -> 1 |
When we enter a forall, we replace its variable with the current depth, then increment the depth before processing nested binders.
After this normalization, type-eq? can use ordinary equal? to check \alpha-equivalence. Importantly, substitution handles shadowing, so two variables with the same original name can still refer to different binders.
7.3.3 Implementing our typing rules
Finally, we can implement our typing rules:
> (define-type-alias TEnvF (Hashof Symbol typ)) > (define mt-env-sysf (hash empty)) > (extend-sysf : (TEnvF Symbol typ -> TEnvF))
> (define (extend-sysf old-env new-name value) (hash-set old-env new-name value)) > (typeof-sysf : (TEnvF expr -> typ))
> (define (typeof-sysf env e) (type-case expr e [(identF x) (type-case (Optionof typ) (hash-ref env x) [(some t) t] [(none) (error 'type "unbound identifier")])] [(numF n) (numT)] [(lamF id t body) (funT t (typeof-sysf (extend-sysf env id t) body))] [(appF e1 e2) (let [(t-e1 (typeof-sysf env e1)) (t-e2 (typeof-sysf env e2))] (type-case typ t-e1 [(funT tau1 tau2) (if (type-eq? tau1 t-e2) tau2 (error 'type-error "invalid function call"))] [else (error 'type-error "invalid function call")]))] [(tlamF id body) (forallT id (typeof-sysf env body))] [(tappF e t) (type-case typ (typeof-sysf env e) [(forallT id body) (tsubst body id t)] [else (error 'type "invalid type application")])]))
> (define (type-check? e) (try (begin (typeof-sysf mt-env-sysf e) #t) (λ () #f)))
7.3.4 Consequences of System-F Types (extra content)
A natural question you might have is: is System F more expressive than the simply-typed lambda calculus? The answer is yes: we can give types to programs that are not typeable in STLC. An example is the self-application term:
\lambda x . x~x
We saw how we couldn’t give a type to this program in STLC because there was no way for the argument to a function to have the same type as the function itself. In System F, we can give a type to this program by making use of type application:
\lambda x : (\forall X . X \rightarrow X) . (x~[\forall X . X \rightarrow X])~x
Note that this program has type (\forall X . X \rightarrow X) \rightarrow (\forall X . X \rightarrow X) This seems to imply that System F is extremely expressive, perhaps even so expressive that it can write all programs that can be written in the untyped lambda calculus. But in fact, this is not the case: just like STLC, System F is strongly normalizing (meaning, all well-typed System F programs terminate).
How can this be if we just typechecked self application? It seems like we should now be able to write \Omega by chaining together \omega~\omega, just like we did in the untyped lambda calculus.
The observation that all System-F terms of type \forall X . X \rightarrow X must behave like the identity function is due to Reynolds, see Reynolds, J.C. (1983). "Types, abstraction, and parametric polymorphism". Information Processing. North Holland, Amsterdam. pp. 513–523. The introduction to this paper is hilarious.
Subsequently, these ideas were greatly expanded on by Wadler in his classic paper "Theorems for free!"
You should notice that they are all observationally equivalent to the identity function!
Why is this the case? Intuitively, all programs of type \forall X . X \rightarrow X must (1) consume some unknown type X, and (2) produce an inhabitant of some unknown type X \rightarrow X. Since this function can’t know anything at all about X – including how to construct values of type X – all it can do is return the argument to the caller.
Now we are starting to see how type structure can tell us rich and interesting details about how programs behave, and can even be so restrictive that there is exactly one program that inhabits a particular type.