5 Formalism & Types
Concept checks, are designed to take ~30min, and can be re-submitted as many times as you’d like, at no penalty. The only requirement is that they will be due by tomorrow at midnight. All core-concepts checks accounts for 10% of your grade.
The final exam date did not align with Roosevelt’s policy, I’ve updated the Schedule, which also entails moving the midterm which is in 3 weeks.
On the midterm: it will be a pen-and-paper exam designed to take 1 hour to 1 hour 30min and will include multiple choice, short answer, and writing both derivation trees and plait code. The midterm is worth 30% of your grade.
hw3 is due on Thursday (hw4 will be released Wednesday/Thursday).
5.1 Formalism (continued)
Previously, we defined a mathematical notation to formally describe the steps of a definitional interpreter and our small-stepping interpreter. This notation was called the big-step and small-step semantics (respectively). Now we will wrap up this discussion and define our final formalism to define a type checker.
5.1.1 Structural Induction
To start this discussion, it is first important to realize that the reason why our inference rules allow us to argue carefully about what programs do. Suppose I want to establish the following seemingly obvious fact: all CalcLang programs terminate. This seems quite obviously true (as long as the abstract syntax trees of programs we consider are finite in size, which is an assumption we always make). How might we go about proving such a thing is true?
Let’s translate this statement into something about derivation trees. The depth of a derivation tree is length of the longest chain of inference rules in the tree. Concretely, an axiom has depth 0, and each time an inference rule is used it increases the depth by 1. We will argue that all CalcLang programs terminate by showing that all derivation trees have finite depth.
\dfrac{~}{\texttt{n} \Downarrow \texttt{n}}~\text{(B-Num)}\dfrac{\texttt{e1} \Downarrow v_1 \quad \texttt{e2} \Downarrow v_2 }{\texttt{(+ e1 e2)} \Downarrow v_1 + v_2}~\text{(B-Add)}
We want to assert that all CalcLang programs will terminate, which is the same as making the following statement:
Theorem: All CalcLang programs e have finite-depth big-step derivation trees.
We will prove this is true by structural induction on program syntax. Proofs by structural induction on syntax have two components:
A base case, which establishes the theorem holds for syntactic terms without sub-terms.
An inductive case, which proves that the theorem holds for syntactic terms with sub-terms, but can inductively assume that the theorem holds for those sub-terms.
The base cases in CalcLang are the numbers. Our theorem clearly holds for these numbers, since they are axioms and by definition their derivation trees have depth 0 which is finite.
The math-inclined reader may notice here a subtle difference between this notion of induction and the one you may have seen in your math classes. In math class, you typically make inductive arguments using the natural numbers, like "first, show the theorem holds for objects of size 1. Then, assuming the theorem holds for objects of size n, show that the theorem holds for objects of size n+1. Then, we can conclude that the theorem holds for all objects of size greater than 1." Here, we are not inducting on the naturals; we instead inducting on our abstract syntax tree. This changes the shape of our inductive hypothesis.
For the inductive case. The only kinds of programs with subterms in our language are addition, (+ e1 e2). Our inductive hypothesis states that e1 and e2 have finite big-step derivation trees. We know, by inspecting our big-step rules, that there is only one inference rule we can apply to this program, (E-Add). The (E-Add) rule increases the depth of the tree by 1, so we can conclude that the derivation tree for (+ e1 e2) is also finite since 1 added to any finite number is by definition finite.
5.1.2 Errors and Being Stuck
Finally it is important to note what happens as our language grows. For instance, if we are to add Booleans to CalcLang’s small-step semantics, we would end up with the following grammar:
‹expr›
::=
( + ‹expr› ‹expr› )
|
‹true›
|
‹false›
|
( if ‹expr› ‹expr› ‹expr› )
|
‹n›
Let’s define our semantics so that we have the usual IfLang semantics where the guards of if-expressions are expected to be Booleans. Here is what the rules look like for this:
\dfrac{~}{\texttt{(+ n1 n2)} \rightarrow \texttt{(+ n1 n2)}}~\text{(E-Add1)}\dfrac{\texttt{e1} \rightarrow \texttt{e1{}’} }{\texttt{(+ e1 e2)} \rightarrow \texttt{(+ e1{}’ e2)}}~\text{(E-Add2)}\dfrac{\texttt{e2} \rightarrow \texttt{e2{}’} }{\texttt{(+ n e2)} \rightarrow \texttt{(+ n e2{}’)}}~\text{(E-Add3)}\dfrac{~}{\texttt{(if true e1 e2)} \rightarrow \texttt{e1}}~\text{(E-IteT)}\dfrac{~}{\texttt{(if false e1 e2)} \rightarrow \texttt{e2}}~\text{(E-IteF)}\dfrac{\texttt{e1} \rightarrow \texttt{e1{}’}} {\texttt{(if e1 e2 e3)} \rightarrow \texttt{(if e1{}’ e2 e3)}}~\text{(E-IteStep)}
Note what happens if we try to step the program (if 10 true false): there is no rule that applies to this program!
Stuck terms cause runtime errors in interpreters when they are evaluated. In our previous interpreters, we raised a runtime error when this happened.
5.1.3 Small-step Semantics for the Lambda Calculus
Finally, we can see the small-step semantics for the lambda calculus. For this, we need just a bit more notation. If e_1 is a lambda calculus program, then we denote substituting x for e_2 in e_1 as e_1[x \mapsto e_2]. We will use v as a shorthand for value; in the case of the lambda calculus, all values are lambda abstractions. Then, we can write the small-step semantics for the call-by-value lambda calculus:
\dfrac{e_1 \rightarrow e_1{}’}{(e_1 ~ e_2) \rightarrow (e_1{}’~e_2)}~\text{(E-App1)}\dfrac{e_2 \rightarrow e_2{}’}{(v_1 ~ e_2) \rightarrow (v_1~e_2{}’)}~\text{(E-App2)}\dfrac{~}{((\lambda x . e) ~ v_2) \rightarrow e[x \mapsto v_2]}~\text{(E-Beta)}
Figure 11: Small-step semantics for the call-by-value lambda calculus.
We can see some examples of stepping specific lambda terms:
\dfrac{~}{(\lambda x . x) ~ (\lambda y . y) \rightarrow (\lambda y . y)}~\text{E-Beta}
Another one:
\dfrac{\dfrac{~}{(\lambda x . x) ~ (\lambda y . y) \rightarrow (\lambda y . y)}~\text{(E-Beta)}} {((\lambda x . x)~(\lambda y . y))~(\lambda z . z) \rightarrow (\lambda y . y)~(\lambda z . z)}~\text{(E-App1)}
Another one:
\dfrac{\dfrac{~}{(\lambda x . x) ~ (\lambda y . y) \rightarrow (\lambda y . y)}~\text{(E-Beta)}} {(\lambda z . z)~((\lambda x . x)~(\lambda y . y)) \rightarrow (\lambda z . z)~(\lambda y . y)}~\text{(E-App2)}
This will be the basis of most of our languages moving forward.
5.2 Types
Well-typed programs can’t go wrong.
Milner, Robin. "A theory of type polymorphism in programming." Journal of computer and system sciences 17.3 (1978): 348-375.
#include <stdio.h> |
#include <stdint.h> |
|
int main() { |
uint64_t my_num = 10; |
printf("Hello, World! %ld\n", my_num); |
return 0; |
} |
A type is a collection of values. In the above C program, the type uint64_t is the collection of all possible unsigned 64-bit integer values.
Types restrict the kinds of programs that you can write. For instance, in Plait, you cannot write this program:
(+ "hello" 1)
If you try, you will get an error that says something like "typecheck failed: Number vs. String". A type system is the set of rules that a program must follow in order to be well-typed. A type checker is a program that checks whether or not your program is well-typed.
Physical reasons. In order to run programs we need to run it on a low-level CPU that doesn’t know about strings, Booleans, etc. Types tell the compiler what shape data has so it can know how to map it into something the CPU understands. This can make writing compilers much easier, and it was the initial motivation for introducing types into programs.
Logical reasons. Types convey a lot of information about how programs behave to the compiler and to other programmers beyond how that information is represented physically on the computer. Types are used as a means to (1) find and prevent certain kinds of bugs, (2) provide checked specifications for how programs behave, (3) aid in the creation of developer tools, and more.
Our goal in this module is to explore the design and implementation of type systems for various programming languages. This is a rich and deep topic within programming languages research and practice; we will only scratch the surface here.
In order to be useful, a type checker should satisfy the following key properties:
Soundness. If a program is well-typed, then it should be free of whatever kind of error the type system is trying to prevent.
Expressivity. It should be possible to write interesting well-typed programs.
Usability. A programmer should be able to write well-typed programs without too much trouble.
Your type checker should not try to solve world peace or the Halting problem.
Efficiency. It should be faster to run the type-checker than it is to run the program. This is partially motivated by the typical use-case of type systems: we want to run our type-checking algorithm every time the user changes their code, and programs that users write might take a very long time to run.
As we design our type checkers for our languages, we will seek to ensure that each type checker satisfies the above four properties.
5.3 Designing a Typesystem for IfLang
Let’s see an example of designing a typechecker for the IfLang language we have been studying. First, we recall the abstract syntax and definitional interpreter for IfLang:
> (define-type IfLang (numI [value : Number]) (addI [e1 : IfLang] [e2 : IfLang]) (boolI [value : Boolean]) (ifI [guard : IfLang] [thn : IfLang] [els : IfLang]))
> (define-type Value (numV [v : Number]) (boolV [v : Boolean])) > (interp-ite : (IfLang -> Value))
> (define (interp-ite e) (type-case IfLang e [(numI n) (numV n)] [(boolI b) (boolV b)] [(addI e1 e2) (let ([v1 (numV-v (interp-ite e1))] [v2 (numV-v (interp-ite e2))]) (numV (+ v1 v2)))] [(ifI guard thn els) (if (boolV-v (interp-ite guard)) (interp-ite thn) (interp-ite els))]))
Currently, it is possible for IfLang programs to cause runtime errors:
> (interp-ite (ifI (numI 10) (numI 15) (numI 30))) boolV-v: contract violation ...
The first step in designing a typechecker is to establish the soundness guarantees of that typechecker, i.e., which kinds of bugs ought to be prevented. Here, we will say that our IfLang typechecker is sound if it rejects all IfLang programs that cause runtime errors in our definitional interpreter.
Let’s pause on this point: can’t a silly type checker just reject all programs? Such a typechecker is surely sound, but it is not useful, because we still want to be able to write programs. So, a second requirement on type systems is that they are expressive: we can’t just reject all programs, including the valid ones.
Using a Number in a position where a Boolean is expected, like (ifI (numI 10) (numI 15) (numI 30))
Using a Boolean in a position where a number is expected, like (addI (boolI #t) (numI 10))
Now we are ready to make our type checker. Like our interpreters, our type checker will work by checking that each subprogram is itself well-typed. We can write a function called type-of that associates each IfLang program with its type. The type of type-of will be (IfLang -> IfType).
What is our "type of types"? In this instance, we have two possible types: numbers and Booleans.
> (define-type IfType [numT] [boolT])
Now we will introduce typing inference rules, which give us a concise and efficient way of describing type systems and how they behave. We will use these inference rules to give type systems for the IfLang.
5.3.1 Typing Rules and Derivations for IfLang
Similar to the situation when we introduced inference rules and derivation trees to efficiently and concisely describe the semantics of programs, it is once again useful to introduce a system of notation for efficiently describing typing rules. Here are the rules for the type system for IfLang that captures the above typechecker:
\dfrac{~}{n : \texttt{numT}}~\text{(T-Num)} \qquad \dfrac{~}{b : \texttt{boolT}}~\text{(T-Bool)}\dfrac{\texttt{e1 : numT} \quad \texttt{e2 : numT}} {\texttt{(+ e1 e2)} : \texttt{numT}}~\text{(T-Add)}\dfrac{\texttt{g : boolT} \quad \texttt{e1} : \tau \quad \texttt{e2} : \tau} {\texttt{(if g e1 e2)} : \tau}~\text{(T-If)}
For example, the (T-Num) rule says that any syntactic number n has type Num; we read the relation e : \tau as "term e has type \tau".
Once again, to understand how these rules work, it’s helpful to give some example derivation trees:
\dfrac{\dfrac{\dfrac{~}{1 : \texttt{numT}}~\text{(T-Num)} \quad \dfrac{~}{2 : \texttt{numT}}~\text{(T-Num)} } {\texttt{(+ 1 2) : \texttt{numT}}}~\text{(T-Add)} \quad \dfrac{~}{3 : \texttt{numT}}~\text{(T-Num)} } {\texttt{(+ (+ 1 2) 3) : \texttt{numT}}} ~\text{(T-Add)}
What happens if we try to give a type to an ill-typed term? Then, we get stuck: there are no valid derivation rules to apply. For instance:
\dfrac{???} {\texttt{(+ 1 \#t)} : ~?}
There are no rules to apply here since the second argument does not have type Num, so we would conclude that this term is not well-typed according to our type system rules.
5.3.2 Implementing the type checker: typeof
Now we are ready to implement our type checker. First, let us define the typing relation above:
e : \tau aka: "term e has type \tau".
to be the function:
(type-of : (IfLang -> Number)) (define (type-of e) (type-case IfLang e [(numI n) (numT)] [(boolI b) (boolT)] [(addI e1 e2) (if (and (equal? (type-of e1) (numT)) (equal? (type-of e2) (numT))) (numT) (error 'type "expected int"))] [(ifI g thn els) ???]))
Notice how the addI case checks that both e1 and e2 are the correct type before returning numT as the resulting type of the expression.
Now, how do we typecheck ifI? Certainly we should ensure that the guard is a Boolean:
(type-of : (IfLang -> Number)) (define (type-of e) (type-case IfLang e [(numI n) (numT)] [(boolI b) (boolT)] [(addI e1 e2) (if (and (equal? (type-of e1) (numT)) (equal? (type-of e2) (numT))) (numT) (error 'type "expected int"))] [(ifI g thn els) (if (equal? (type-of g) (boolT)) ??? (error 'type "expectec bool") )]))
Now, what do we do for the ???? Here we encounter a conondrum, because we can have a program like this:
(ifI <very complex expression> (boolI #t) (numI 10))
Depending on what <very complex expression> evaluates to, this program could be have type boolT or numT. How do we resolve this?
We can give up some expressivity in the name of efficiency: we can reject some programs that do not cause runtime errors for the sake of a faster typechecker.
We can expand our set of types to include types like "unknown" or "bool+int". This may compromise soundness, depending on how we deal withg these new types in our typechecker.
We can give up on efficiency and run <very complex expression> to a value. Depending on if that value is #t or #f, we can output the type of corresponding branch.
We choose the first option. This is a design decision because it involves tradeoffs between design objectives. Most languages that have type checkers make this decision, including Plait. Plait requires that both branches of an if expression have the same type, or it will raise a type error:
(if #t #t #f) - Boolean #t (if #t 10 #f) typecheck failed: Number vs. Boolean sources: 10 #f
We will make the same compromise as Plait and require that both branches of an if-expression return the same type:
> (type-of : (IfLang -> IfType))
> (define (type-of e) (type-case IfLang e [(numI n) (numT)] [(boolI b) (boolT)] [(addI e1 e2) (if (and (equal? (type-of e1) (numT)) (equal? (type-of e2) (numT))) (numT) (error 'type "expected int"))] [(ifI g thn els) (if (equal? (type-of g) (boolT)) (let [(t-thn (type-of thn)) (t-els (type-of els))] (if (equal? t-thn t-els) t-thn (error 'type "expected thn and els to have same type"))) (error 'type "expected bool"))]))
Finally, we can test our typechecker. We will have two kinds of tests: soundness tests that check for type errors and expressivity tests that ensure that valid programs do not raise type errors.
First, some expressivity tests:
> (test (type-of (addI (numI 1) (numI 2))) (numT))
- Void
good (type-of (addI (numI 1) (numI 2))) at line 9
expected: (numT)
given: (numT)
> (test (type-of (addI (addI (numI 1) (numI 3)) (numI 2))) (numT))
- Void
good (type-of (addI (addI (numI 1) (numI 3)) (numI 2))) at line 10
expected: (numT)
given: (numT)
> (test (type-of (ifI (boolI #t) (numI 10) (numI 20))) (numT))
- Void
good (type-of (ifI (boolI #t) (numI 10) (numI 20))) at line 11
expected: (numT)
given: (numT)
Next, some soundness tests:
> (test/exn (type-of (addI (boolI #t) (numI 2))) "type")
- Void
good (type-of (addI (boolI #t) (numI 2))) at line 12
expected: "type"
given: "type: expected int"
> (test/exn (type-of (ifI (boolI #f) (numI 10) (boolI #t))) "type")
- Void
good (type-of (ifI (boolI #f) (numI 10) (boolI #t))) at line 13
expected: "type"
given: "type: expected thn and els to have same type"
5.3.2.1 Implementing the type checker: type-check?
Now that we’ve made a type-of function, we can use this function to create a type-check? function that returns #t if a IfLang program is well-typed and #f otherwise:
> (type-check? : (IfLang -> Boolean))
> (define (type-check? e) (try (begin (type-of e) #t) (λ () #f)))
> (test (type-check? (addI (numI 1) (numI 2))) #t)
- Void
good (type-check? (addI (numI 1) (numI 2))) at line 16
expected: #t
given: #t
> (test (type-check? (addI (boolI #t) (numI 2))) #f)
- Void
good (type-check? (addI (boolI #t) (numI 2))) at line 17
expected: #f
given: #f
5.3.3 The Typechecker Design Recipe
"In sum, teaching mathematics insisted on these points:
the solution process
the ability to provide justifications for each step
a clean presentation that would imply a justification if it was omitted."
Matthias Felleisen, The Design Recipe
Create examples that demonstrate all the soundness bugs that you want your typechecker to prevent.
Create a datatype for representing the kinds of types your programs have. You can build this datatype by thinking about what kinds of values your program has and how you may need to sort them into buckets; think of each type like a bucket of values.
Create a function type-of that associates each term of your language with its type. This program should raise a type error if no type for a term exists, and be efficient to run on programs.
Test your type-of function for expressivity by making sure well-typed programs pass, and test it for soundness by making sure ill-typed programs fail.
5.4 Simply-Typed Lambda Calculus (STLC)
Now we will revisit the lambda calculus and observe the consequences of simple types in this context: that it is not possible to give a type derivation for self-application. Later, we will see how brittle non-termination is: we will see how adding a seemingly orthogonal feature to termination – mutable state and references – results in a language that has nontermination, despite the fact that we did not introduce any explicit looping or recursion construct.
Let’s take our new recipe and apply it to a more interesting language: the lambda calculus extended with numbers. The STLC has the following abstract syntax:
> (define-type LType [NumT] [FunT (arg : LType) (body : LType)])
> (define-type LExp [varE (s : Symbol)] [numE (n : Number)] [lamE (arg : Symbol) (typ : LType) (body : LExp)] [appE (e : LExp) (arg : LExp)])
We can give the following surface syntax description of for this abstract syntax:
‹expr›
::=
( ‹expr› )
|
λ ‹ident› : ‹t› . ‹expr›
|
‹expr› ‹expr›
|
‹ident›
‹t›
::=
( ‹t› )
|
Num
|
‹t› -> ‹t›
Figure 13: Surface syntax for the simply-typed lambda calculus.
Some examples of parsing terms:
Surface syntax |
| Abstract syntax datastructure |
\lambda x : \texttt{Num} . x |
| (lamE ’x (NumT) (identE ’x)) |
\lambda x : \texttt{Num -> Num} . (x~5) |
| (lamE ’x (FunT (NumT) (NumT)) (appE (identE ’x) (numE 5))) |
> (subst : (LExp Symbol LExp -> LExp))
> (define (subst e1 x e2) (type-case LExp e1 [(varE s) (if (symbol=? s x) e2 (varE s))] [(numE n) (numE n)] [(lamE id typ body) (if (symbol=? x id) (lamE id typ body) (lamE id typ (subst body x e2)))] [(appE e1App e2App) (appE (subst e1App x e2) (subst e2App x e2))]))
> (define (interp e) (type-case LExp e [(varE s) (error 'runtime "unbound symbol")] [(lamE id typ body) (lamE id typ body)] [(numE n) (numE n)] [(appE e1 e2) (letrec [(e1V (interp e1)) (body (lamE-body e1V)) (id (lamE-arg e1V)) (argV (interp e2))] (interp (subst body id argV)))]))
Following our type design recipe, our goal is to design our type system to eliminate runtime errors. We can identify the following kinds of runtime errors we would like to eliminate:
Calling a non-function, programs like: (10~20)
Calling a function with the wrong argument type, like \lambda x : ((\texttt{Num} \rightarrow \texttt{Num}) \rightarrow \texttt{Num}) . (x~5)
5.4.1 Typing Rules for STLC
Now let’s give typing rules for the simply-typed lambda calculus. Notice that the type-of function has an extra argument, the environment env, that is necessary for typechecking lambda terms and variables. So, our typing judgment needs to also include this environment as part of its definition. We will call the typing environment \Gamma. You can think of \Gamma as the math version of a hashtable: it maps variables to their types.
The symbol \vdash is called the turnstile symbol. Traditionally, it is used in mathematics and logic to define a ternary relation: it is notation for defining a relation between premises, contexts, and conclusions. We will see, through examples, how it is used, and clarify this point a bit later.
Using \Gamma, we will define a typing judgment of the form \Gamma \vdash e : \tau, which says "in context \Gamma, the term e has type \tau".
\dfrac{~}{\Gamma \vdash n : \texttt{Num}}~\text{(T-Num)}\dfrac{ x : \tau \in \Gamma}{\Gamma \vdash x : \tau}~\text{(T-Var)}\dfrac{\Gamma, x : \tau_1 \vdash e : \tau_2} {\Gamma \vdash \lambda x : \tau_1 . ~ e : \tau_1 \rightarrow \tau_2}~\text{(T-Abs)}\dfrac{\Gamma \vdash e_1 : \tau_1 \rightarrow \tau_2 \qquad \Gamma \vdash e_2 : \tau_1} {\Gamma \vdash e_1 ~ e_2 : \tau_2}~\text{(T-App)}
Figure 14: Typing rules for the simply-typed lambda calculus with numbers.