Foundations of Functional Programming/Simply typed λ-calculus
The simply typed λ-calculus is a typed λ-calculus with a monomorphic type system. It comes in an implicitly typed version, and an explicitly typed version. The difference between these two systems is that in the explicitly typed version, arguments to λ-abstractions must be annotated with types, whereas in the implicitly typed version, the types need not be specified and can be inferred.
Syntax for types
[edit | edit source]In the simply typed λ-calculus there are two kinds of type expressions: type variables, and function types. We use Greek letters to denote type variables. We use boldface letters to denote arbitrary type expressions. We use to denote the type of functions from to .
The syntax for type expressions is as follows:
In type expressions we can use parentheses to disambiguate scope, and is right associative, as usual.
The interpretation of type variables is tricky, and is unfamiliar relative to languages like Haskell and ML. A type variable does not denote a polymorphic type. is not the type of a function which takes an object of an arbitrary type, and returns an object of the same type. Rather, it is the type of a function which takes an object of type , and returns an object of type .
A type variable denotes a single, monomorphic type, such as integer, boolean, etc. The simply typed λ-calculus does not say what types the type variables represent. As far as the λ-calculus is concerned, they can denote any types; but each denotes a single, fixed, monomorphic type.
From here, the two versions of the simply typed λ-calculus — the implicitly typed version and the explicitly typed version — diverge. We will introduce each version in turn, beginning with the implicitly typed version.
Implicitly simply typed λ-calculus
[edit | edit source]Syntax for values
[edit | edit source]In the implicitly simply typed λ-calculus, the syntax for value expressions is the same as in the untyped λ-calculus. Value expressions are variables , applications , or λ-abstractions .
Statements, declarations, and contexts
[edit | edit source]A "statement" is a piece of syntax stating that a given value expression has a given type. Statements are of the form , meaning that has the type . is called the "subject" of the statement, and is called the "predicate."
A "declaration" is a statement where the subject is a variable. That is, a declaration is a statement of the form . To get some initial intuition for why declarations are special, consider that the type assertions that appear in the top level of a program are declarations. They predicate, of a particular global name, that it has a given type.
A "context" is a set of declarations. In a context, each variable must occur as the subject of at most one declaration. That is, you must not declare the type of a variable twice. In programming terms, a context is a little bit like a C header file, giving the types of some names without their definitions. However, for us it will be more useful to think of a context as a set of assumptions. What this means will become clear momentarily.
We denote contexts by upper case Greek letters
Type entailments
[edit | edit source]We write to express that under the assumptions in the context , has type . This syntactic item is called a "type entailment."
What does it mean to say that has type under the assumptions in ? To give a trivial example, under the assumption that has type , it follows that has type . That is, . To give a less trivial example:
That is, if has type , and has type , then has type .
In the case that is the empty set, we can write just , which just states that has type . For example, it is true that for any type , .
What is the distinction between writing and ? The difference is that the former is just a piece of syntax which expresses that has the type , and the latter is an assertion which says that actually does have type .
Rules for type entailments
[edit | edit source]The following rules describe what type entailments are valid.
(axiom) | if is a member of . | |
(→-elimination) | ||
(→-introduction) |
I will explain how to read this table. The first entry says that if is one of the declarations in , then the derivation is valid. This is a very trivial point.
The horizontal bar notation used in the second two rules means that if the stuff above the bar is true, then the stuff below the bar is true. So for example, the second rule says that if , and , then . This is the rule giving the type of a function application.
The third rule gives the type of a λ-abstraction. The notation means the context consisting of all the declarations in plus the declaration . This rule says that if assuming and assuming that has type , it follows that has type , then it follows that has type .
The valid type entailments are exactly the type entailments that can be derived using these rules.
Example type derivation
[edit | edit source]Let us give an example of using these rules to derive a type of a function. We will show that the function has the type .
- (axiom)
- (axiom)
- (→-elimination, from 1 and 2)
- (→-introduction, from 3)
- (→-introduction, from 4)
Note that this is just one of the many types that this function has. also has, for example, the type , and the type . All of these are distinct types. It also has, for example, the type , and in general any type of the form . All of these facts can be proven using the same derivation we just gave.
Algorithms for type checking
[edit | edit source]Note that the rules given above are not an algorithm for computing whether an object has a given type (under a given set of assumptions). They are, instead, declarative rules which define what it is for an object to have a given type (under a given set of assumptions). That is, an expression has the type under the assumptions just in case is derivable from the above rules. These rules do not, however, say anything about how to determine algorithmically whether an object has a given type.
It does follow immediately from the rules above that the problem of whether an object has a given type is semidecidable (i.e., computably enumerable). One can write a computer program which repeatedly applies all of the above rules to print out a list of all true statements of the form . Such a program allows one (in principle) to verify that an object has a given type, provided that it actually does; but it does not always allow one to verify that an object does not have a given type. One can watch the output of such a program as long as one wants, but how does one know that a given output won't ever appear in it?
In fact, however, in the case of the simply typed λ-calculus, type checking is decidable. That is, there is an algorithm which determines whether or not a given statement is true. The problem of typability is also decidable. That is, there is an algorithm which determines whether a value expression can be assigned a type in a given context: whether, given and , there is a type such that .
β-reduction and type preservation
[edit | edit source]The rules for β-reduction of value expressions in the implicitly simply typed λ-calculus are identical to the rules in the untyped λ-calculus. β-reduction has the desirable property of preserving type. That is, if and β-reduces to , then .
Interestingly, the converse is not true. That is, it is not the case that if β-reduces to , and , then .
One type of counterexample to this arises from the fact that not every λ-expression can be assigned a type in the simply typed λ-calculus. For example, cannot be assigned a type. Then, for example, we have
but not
even though the latter β-reduces to the former. The reason is that the latter expression cannot be assigned a type. There are also examples of expressions and such β-reduces to , and both expressions can be assigned a type, but they cannot be assigned the same type. See Barendregt (1992), Observation 3.1.12.
Turing incompleteness and normalization
[edit | edit source]The simply typed λ-calculus is not Turing complete. Not every λ-expression can be assigned a type in the simply typed λ-calculus, and the λ-expressions that can be assigned a type cannot represent all Turing computable functions.
The simply typed λ-calculus satisfies the strong normalization property. This means, in particular, that all functions in the simply typed λ-calculus halt. In fact, the simply typed λ-calculus has very little computational power; see this Stack Exchange answer.
At first blush, this makes it look like the simply typed λ-calculus is not a suitable basis for a functional language. However, it can be extended to be more powerful, e.g. by adding a primitive recursion operator; so the conclusion is not so immediate as it might appear. (Of course, the simply typed λ-calculus lacks polymorphism, and so it fails to be a suitable basis for a functional language for other reasons.)
Explicitly simply typed λ-calculus
[edit | edit source]So far we have been discussing the implicitly simply typed λ-calculus, where the arguments of λ-abstractions do not have type annotations. In the explicitly simply typed λ-calculus, the arguments of λ-abstractions do have type annotations. This gives rise to some fairly significant differences. For example, in the implicitly simply typed λ-calculus, expressions which can be assigned any type can be assigned more than one type. In the explicitly simply typed λ-calculus, if an expression can be assigned a type, then it can be assigned only one type.
Perhaps the most important difference between the implicitly and explicitly simply typed λ-calculi is that the explicitly simply typed λ-calculi can ultimately be developed into more sophisticated and capable type theories. For example, when one moves up to the second-order λ-calculus, type checking is undecidable in the implicitly typed version, but is decidable in the explicitly typed version.
Syntax for values
[edit | edit source]The syntax for values in the explicitly simply typed λ-calculus is the same as that for the untyped λ-calculus, except that λ-abstractions include type annotations: so a λ-abstraction is an expression of the form , where is a type expression.
Type theory
[edit | edit source]Statements, declarations, and contexts for the explicitly simply typed λ-calculus are defined as in the implicitly simply typed λ-calculus. The valid type entailments are produced by the following rules, which are identical to the rules for the implicitly simply typed λ-calculus, except for the presence of a type annotation in the λ-abstraction of the third rule:
(axiom) | if is a member of . | |
(→-elimination) | ||
(→-introduction) |
Despite being nearly notationally identical, there are some significant differences between these two type theories. In the implicitly simply typed λ-calculus, an expression could have many different types. In the explicitly simply typed λ-calculus, an expression always has at most one type.
That said, the systems are very closely related. Whenever is derivable in the explicitly typed system, the result of dropping the type annotations from λ-abstractions is derivable in the implicitly typed system. Similarly, if is derivable in the implicitly typed system, there is a way of adding type annotations to such that the result is derivable in the explicitly typed system.
Computational properties
[edit | edit source]The rules for β-reduction of value expressions in the explicitly simply typed λ-calculus are identical to the rules in the untyped λ-calculus, modulo the obvious notational variation. As with the implicitly simply typed λ-calculus, β-reduction preserves type, but β-expansion (i.e., reversed β-reduction) does not.
As with the implicitly simply typed λ-calculus, the problems of type checking and typability are decidable for the explicitly simply typed λ-calculus.
The explicitly simply typed λ-calculus is not Turing complete, and it satisfies the strong normalization property, so that all typable functions halt.
Algebraic data types
[edit | edit source]You can help expand this section. |
Algebraic data types are familiar from languages such as ML and Haskell. We assume the reader is familiar with algebraic data types as they exist in such a language, and give a formal treatment of the notion of algebraic data types in the explicitly simply typed λ-calculus.
In the simply typed λ-calculus, an algebraic data type definition consists of: (1) a name for the data type; and (2) declarations for its constructors. Here are three simple examples of algebraic data type definitions in Haskell:
data Pair a b = Pair a b
data Either a b = Left a | Right b
data List a = Empty | Cons a (List a)
The first of these three polymorphic type declarations declares the type of pairs of instances of a
and instances of b
. The second declares the type which is the disjoint union of a
and b
. The third declares the type of lists of instances of a
. Recall that a
and b
are variables standing for arbitrary types.
In type theory, the declarations of algebraic data types look a little different, being more similar to the generalized algebraic data type (GADT) declarations in Haskell. In Haskell, the above types would be declared using GADT syntax as follows:
data Pair a b where Pair :: a -> b -> Pair a b
data Either a b where Left :: a -> Either a b Right :: b -> Either a b
data List a where Empty :: List a Cons :: a -> List a -> List a
In GADT syntax, one declares a constructor by specifying its type. The result type of the constructor must be an instance of the ADT being declared. Sometimes other restrictions are also imposed.
The constructors of ADTs tell us how to turn component data of an ADT into an ADT. How do you reverse the process, to recover the component data from a given ADT? In functional programming languages, this is usually done with pattern matching. Here we will start by describing a simpler mechanism for recovering the component data of an ADT, known as "elimination constants." Elimination constants and pattern matching are, in general, interdefinable notions; pattern matching can be described in terms of elimination constants, or vice versa. We take elimination constants as our primitive notion, and define pattern matching in terms of elimination constants, because elimination constants are mathematically simpler.
An elimination constant is a function which can be defined from the definition of a given ADT. It enables one to "destructure" instances of the ADT, serving a function similar to that of pattern matching. We will start by giving the types, and definitions in terms of pattern matching, of the Haskell elimination constants for each of the above data types, to give a feel for the concept:
elimPair :: (a -> b -> p) -> Pair a b -> p elimPair f (Pair x y) = f x y
elimEither :: (a -> p) -> (b -> p) -> Either a b -> p elimEither f g (Left x) = f x elimEither f g (Right y) = g y
elimList :: p -> (a -> List a -> p) -> List a -> p elimList empty cons Empty = empty elimList empty cons (Cons x xs) = cons x xs
In each case, the elimination constant gives a way to turn an instance of the type under question into an arbitrary type, p
. It does this by taking functions, which we will call "destructor functions," which say what to do with each constructor of the type. Each such function takes as arguments the arguments to the constructor it corresponds to.
The scheme we have described here for elimination constants is one of the simplest versions of the scheme. There can be various other complications. We will discuss one of them right now.
In some λ-calculi, including the simply typed λ-calculus, general recursion is not possible. In these systems, it would not be possible to do recursion over algebraic data types unless special provisions were made for this in the definition of the elimination constants. In the case of List
, the one recursive data type we discussed above, to allow for recursion the elimination constant becomes:
elimList :: p -> (a -> List a -> p -> p) -> List a -> p elimList empty cons Empty = empty elimList empty cons (Cons x xs) = cons x xs (elimList empty cons xs)
The destructor for Cons
, the one recursive constructor, gains an additional argument of type p
which gets set to the result of recursively applying the elimination constant with the same function arguments to the recursive argument of the constructor. If there were multiple recursive arguments to the constructor, there would be one additional argument to the destructor for each recursive argument. For example, consider the data type:
data Tree a where Leaf :: a -> Tree a Branch :: a -> Tree a -> Tree a -> Tree a
This data type represents binary trees holding data of type a
. Its elimination constant is as follows:
elimTree :: (a -> p) -> (a -> Tree a -> p -> Tree a -> p -> p) -> Tree a -> p elimTree f g (Leaf x) = f x elimTree f g (Branch x t1 t2) = g x t1 (elimTree f g t1) t2 (elimTree f g t2)
Basic system
[edit | edit source]You can help expand this section. |
Here we present a basic λ-calculus augmenting the explicitly simply typed λ-calculus with algebraic data types.