2017-05-19 A well-typed suspension calculus

When implementing interpreters for languages based on the lambda-calculus one quickly moves away from naively substituting variables when evaluating function application. This is mostly due to the fact that terms can grow very big very fast, since when substituting variables for some term said term will be duplicated everywhere the variable is used.

In other words, if we have some term

(\x -> foo x x) SomeBigTerm

SomeBigTerm will be duplicated when substituting x for SomeBigTerm .

The solution is to not substitute eagerly. In most languages it is quite easy to do so, for example using a CEK machine.

However, things get a bit tricky when one needs to compute with free variables. This is the case in many dependently typed languages, such as Agda or Coq. Agda perfomance in particular has long suffered also because it substitutes terms too eagerly.

This problem is also relevant in other languages that need to implement higher-order unification, such as lambda-Prolog. The implementors of lambda-Prolog solved this problem by developing a “suspension calculus”, which allows substitutions to be delayed until we really need them.

The substitution calculus works with de Bruijn indices. As everybody who implemented algorithms involving de Bruijn indices knows, it is extremely easy to make mistakes and break the invariants required for the indices to be well-formed. The suspension calculus is no exception, and up to now I had trouble justifying its rewrite rules.

However, it is possible to encode what scope we’re working on at the type level, thus making operations involving de Bruijn indices much safer. This short article is about implementing the suspension calculus using the same techniques, so that we can implement its rewrite rules with much more confidence.

The article is a literate Haskell file, you can save it and load it using

$ stack --resolver lts-8.11 ghci Prelude> :l suspension.lhs

You can also see the file on GitHub.

Let’s get started.

Boring preamble

First of all, a few boring LANGUAGE extensions and import. Most of the imports are needed for the pretty printing and parsing, which are not really relevant to the article.

Variables and expressions

As I have mentioned, we will have our variables to be well-typed, in the sense that the “depth” of the scope of terms will be tracked at the type level. This approach goes back at least to de Bruijn notation as a nested datatype by Bird and Paterson. You can refer to that paper for details, but the core idea is quite simple.

We first define the type of variables for our terms:

This looks an awful lot like Maybe , but we define our own type for nicer naming. The role of B and F will hopefully become more clear as we define expressions, but intuitively B refers to the most recently bound variable (like 0 if we were using normal de Bruijn indices), and F refers to a variable in the scope without the most recently bound variable.

Then we define Syntax and Exp , in tandem. Syntax specifies the constructs of our lambda calculus: variables, applications, lambda functions, and let bindings:

We will define Exp shortly, but for now you can pretend it’s Syntax .

Note that the argument of Syntax indicates the scope of the term. For example if we have a term of type

Syntax (Var (Var Text))

we know that it’s a term with two bound variables (the two Var ) and some free variables represented by the top-level Text . In such a term, we might have

F (F "someTopLevelDefinition") F B -- The variable bound by the second most recent lambda or let B -- The variable bound by the most recent lambda or let

Also note that we store a piece of Text in Lam and Let to easily pretty print terms.

That said, Exp is either a piece of Syntax , or a suspended piece of Syntax :

Env b a is some data structure containing information to turn a term with scope b into a term in scope a . For example, something of type

Env (Var (Var Text)) (Var Text)

contains information on how to remove one free variable out of a term. We will define Env shortly, but first let’s define a couple of shortcuts to form Exp s quickly:

Environments

We can now get to the tricky part: defining environments that let us delay substitution. We first define a GADT to specify an increase in scope depth:

If we have Weaken from to , to it’s going to be of the form Var (Var ... (Var from)) : it specifies an increase of scope depth from from .

Then, a canonical environment is either just a weakening, or an existing environment added with an expression containing the value for a bound varible:

Similarly to Lam and Let , we store the name of the variable that EnvCons is referring to for easy pretty printing.

We call this form of environments canonical because we will reduce all environments to this form. However, we can also form environments by composition:

To recap:

EnvNil wk weakens terms by the amount specified by wk . For example, applying

EnvCanonical (EnvNil (WeakenSucc WeakenZero)) :: Env a (Var a)

to a term of type Exp a will result in a term of type Exp (Var a) , which will be implemented by applying all variables in the term to F .

EnvCons n e env will replace the first bound variable with e , and then apply the environment env . For example, applying

EnvCanonical (EnvCons n e (EnvCanonical (EnvNil WeakeZero))) :: Env (Var a) a

to a term of type Exp (Var a) will result in a term of type Exp a , where the first bound variable is replaced with e .

EnvComp env1 env2 will apply first environment env1 and then environment env2 .

Let’s define some shortcuts to construct environments and suspensions:

Then, we can define a function to remove delayed composition of environments. Although our environments differ quite significantly from the ones in the original lambda-Prolog paper, this is the trickiest part where the well-typed scope are a really big help.

The only surprising rule is the one dropping the substitution when a weakening is composed with an environment – the intuition is that if we’ve just weakened a term it surely can’t refer to the first bound variable.

Note that while these rules feel quite natural (and in fact are pretty much forced by the types), they are natural because of how we formulated environments, and that formulation was also guided by the types. When pushing more property of the code in the types, this often happens: data structures are largely driven by making your programs type check more easily. As Conor McBride put it, types are lamps, not lifebuoys.

Once we have this function “evaluating” environments, we can write a function taking a variable and looking up into an environment:

Evaluating

Finally, we can evaluate expressions to their head normal form (if they have one).

Normalized terms are either a lambda or a free variable applied to some terms (a neutral term):

Then we define a function to push environments substitution down the expression, giving us back a Syntax :

Then, evaluating a term is just a matter of creating the right suspension and using removeSusp :

And we’re done! In the rest of the article I implement a parser and pretty printer, which means that you can do

$ stack --resolver lts-8.11 ghci Prelude> :l suspension.lhs Main> repl >>> \x -> x Evaluated expression: \x -> x Evaluated expression (no suspensions): \x -> x >>> (\x -> x) foo Evaluated expression: foo Evaluated expression (no suspensions): foo >>> let x = foo; x Evaluated expression: foo Evaluated expression (no suspensions): foo

Things get interesting when we evaluate expressions that contain delayed substitutions:

>>> (\a b -> a) foo Evaluated expression: \b -> $susp ($cons (b_1 := b) ($comp ($cons (a := foo) ($nil 0)) ($nil 1))) a Evaluated expression (no suspensions): \b -> foo >>> :{ | let x = \y -> x y; | x foo | }: Evaluated expression: x ($susp ($cons (y := $susp ($cons (x := \y -> x y) ($nil 0)) foo) ($nil 0)) y) Evaluated expression (no suspensions): x foo

The first printed expression shows the full suspension including the environment, where $susp env a represents a Susp , $cons (n := e) env an EnvCons n e env , and $nil i a EnvNil wk where wk weakens by i .

We also print the expression with all the suspensions removed.

Node that this approach can be paired with other performance improvements, such as graph reduction (sharing common subexpressions and evaluating them all at once), or more eager evaluation of function arguments.

Thanks for listening, comments on reddit.

Appendix

Pretty printing

Parsing

REPL