1 Introduction
The correspondence between Curry’s type-free lambda calculus and Schönfinkel’s combinatory algebras is among the oldest known and the most aesthetically pleasing facts about the lambda calculus.Peter Selinger, The lambda calculus is algebraic, Journal of Functional Programming, 12(6), 549–566.
This paper explores the connection between the lambda calculus and combinatory logic (Schönfinkel, Reference Schönfinkel1924; Curry et al., Reference Curry, Feys, Craig, Hindley and Seldin1958). The terms of the lambda calculus are defined by the following grammar:
Evaluating and manipulating lambda terms require a careful treatment of variable binding. Combinatory logic, on the other hand, is a language without variable binding:
Here, lambda abstractions have been replaced by three combinators: ${\textsf{S}}$ , ${\textsf{K}}$ , and ${\textsf{I}}$ . Each combinator has its own reduction behaviour, given by the following rewrite rules:
It is not so hard to define a translation from combinatory logic to lambda terms that preserves reduction behaviour. The following three lambda terms correspond to the combinators ${\textsf{S}}$ , ${\textsf{K}}$ , and ${\textsf{I,}}$ respectively.
Interestingly, there is also a translation in the other direction, from lambda terms to combinatory logic. The key ingredient in this translation scheme is known as bracket abstraction or combinatory abstraction. Given a variable x and term in combinatory logic t, we can define the term $\Lambda \; x \; . \; t$ by means of the following three cases:
As its name suggests, the term in combinatory logic computed in this fashion simulates the reduction behaviour of a lambda abstraction in combinatory logic.
These translations are typically defined on untyped lambda terms. In this pearl, we try a different tack and explore how to prove that the translation from the simply typed lambda calculus to combinatory logic preserves both types and semantics. This is not a new result, but rather than prove these properties post hoc, we ensure the translation is correct by construction using the dependently typed programming language Agda (Norell, Reference Norell2007).
2 Lambda calculus
To set the scene, we start by defining an evaluator for the simply typed lambda calculus. This evaluator features in numerous papers and introductions on programming with dependent types (McBride, Reference McBride2004; Norell, Reference Norell2009, Reference Norell2013; Abel, Reference Abel2016), yet we include it here in its entirety for the sake of completeness.
Types
The types of our lambda calculus consist of a single base type () and functions between types, denoted using the function space operator ( ${{\Rightarrow}}$ ):
We can map these types to their Agda counterparts.
Here the interpretation of the base type, , is mapped to some type ${\textsf{A}\;\mathbin{:}\;\textsf{Set}}$ , which we pass as a parameter to this development; the functions and proofs that follow do not depend on the interpretation of our base type in any meaningful way.
Before defining lambda terms, we need one last definition. We will represent contexts or type environments as lists of types:
Typically, we will use variable names drawn from the Greek alphabet to refer to types (such as $\sigma$ and $\tau$ ) and contexts ( $\Gamma$ and $\Delta$ ).
Terms
Before we define the terms of the simply typed lambda calculus, we need to decide on how to treat variables. We begin by defining the following inductive family, modelling valid references to a type ${{\sigma}}$ in a given context ${{\Gamma}}$ :
Erasing the type indices, we are left with the Peano natural numbers – corresponding to the typical De Bruijn representation of variable binding.
We can now define the datatype for well-typed, well-scoped lambda terms as follows:
Each constructor mirrors a familiar typing rule: applications require the function’s domain and argument’s type to coincide; lambda abstractions introduce a new variable in the context of the lambda’s body; the ${\textsf{var}}$ constructor may be used to refer to any variable that is currently in scope.
Evaluation
The dependent types in the definition of ${\textsf{Term}}$ pay dividends once we try to define an evaluator for lambda terms. Before we can do so, however, we need to introduce a datatype for environments:
An environment stores a value for each variable in the context $\Gamma$ , as witnessed by the following ${\textsf{lookup}}$ function:
Note that this function is total. The type indices ensure that there is no valid variable in the empty context; correspondingly, the ${\textsf{lookup}}$ function need never worry about returning a value when the environment is empty.
We can now define an evaluator for the simply typed lambda calculus:
That this code type checks at all is somewhat surprising at first. It maps ${\textsf{app}}$ constructors to Agda’s application and ${\textsf{lam}}$ constructors to Agda’s built-in lambda construct. Once again, the type indices ensure that the evaluation of the ${\textsf{lam}}$ construct must return a function (and hence we may introduce a lambda). Similarly in the case for applications, evaluating ${\textsf{t}_{1}}$ will return a function whose domain coincides with the type of the value arising from the evaluation of ${\textsf{t}_{2}}$ . Finally, the environment of type ${\textsf{Env}\;\Gamma}$ passed as an argument contains just the right values for all the variables drawn from the context $\Gamma$ .
3 Translation to combinatory logic
Before we can define the translation from lambda terms to combinators, we need to fix our target language. As a first attempt, we might try something along the following lines, turning the grammar from the introduction into an Agda datatype:
Yet if we aim for our translation to be type-preserving, the very least we can do is decorate our combinators with the same type information as our lambda terms:
The types of both the ${\textsf{app}}$ and ${\textsf{var}}$ constructors are the same as we saw for the lambda terms. The types of the primitive combinators are determined by their desired reduction behaviour. Note that – as our ${\textsf{Comb}}$ lacks lambdas and cannot introduce new variables – the context is now a parameter rather than an index as we saw for the ${\textsf{Term}}$ datatype. This is the essence of combinatory logic: a language with variables but without binders.
Yet we will strive to do even better. We will define a translation that preserves both the types and dynamic semantics of our lambda terms. To achieve this, we index our combinators with both their types and their intended semantics, given by a function of type ${\textsf{Env}\;\Gamma\;\to \;\textsf{Val}\;\textsf{u}}$ . This will enable us to define a translation from a lambda term to a term in combinatory logic that has the same semantics as its input lambda term. This yields the final version of our datatype for combinatory logic:
Here the type of each base combinator ( ${\textsf{S}}$ , ${\textsf{K}}$ , and ${\textsf{I}}$ ) contains both its type and semantics. For example, the ${\textsf{I}}$ combinator has type ${{\sigma}\;\Rightarrow\;\sigma}$ and corresponds to the lambda term ${\lambda\;\textsf{x}\;\rightarrow\;\textsf{x}}$ . None of the combinators rely on the additional environment parameter ${\textsf{env}}$ . This environment is used in the ${\textsf{var}}$ constructor; just as we saw in our evaluator for lambda terms, this environment stores a value for each variable. Finally, the ${\textsf{app}}$ constructor applies one combinator term to another. The type information for both the ${\textsf{var}}$ and ${\textsf{app}}$ constructors coincides with their counterparts from the ${\textsf{Term}}$ data type; their intended semantics can be read off from the evaluator for lambda terms, , that we defined previously.
The key difference between lambda terms and SKI combinators is the lack of lambdas in the latter. To handle the bracket abstraction translation from the introduction, we define the ${\textsf{abs}}$ function that maps one combinator term to another:
This behaviour of the ${\textsf{abs}}$ function should be clear from its type: given a ${\textsf{Comb}}$ term of type ${\tau}$ using variables drawn from the context ${{\sigma}\;\textsf{::}\;\Gamma}$ , the ${\textsf{abs}}$ function returns a combinator of type ${{\sigma}\;\Rightarrow\;\tau}$ using variables drawn from the context ${{\Gamma}}$ . Essentially, any occurrences of the ${\textsf{var}\;\textsf{Top}}$ are replaced with the identity ${\textsf{I}}$ ; the new argument is distributed over applications using the ${\textsf{S}}$ combinator; any other variables or base combinators discard this new argument by introducing an additional ${\textsf{K}}$ combinator.
With this definition in place, we can now define our type-preserving correct-by-construction translation. That is, we aim to define a translation with the following type:
Here a lambda term of type ${{\sigma}}$ in the context ${{\Gamma}}$ is mapped to a combinator of type ${{\sigma}}$ using variables drawn from the context ${{\Gamma}}$ in such a way that the evaluation of ${\textsf{t}}$ and semantics of the combinator are identical, namely . The definition of this translation is now entirely straightforward.
To see why this code type checks, note that both the (dynamic) semantics of both the ${\textsf{app}}$ and ${\textsf{var}}$ constructors of the ${\textsf{Comb}}$ datatype coincide precisely with their semantics as lambda terms, and respectively. Finally, if translating the body of a lambda produces some ${\textsf{Comb}}$ term ${\textsf{f}}$ , the ${\textsf{abs}}$ function produces a combinator term with the semantics ${\lambda\;\textsf{env}\;\textsf{x}\;\;\textsf{f}\;(\textsf{Cons}\;\textsf{x}\;\textsf{env})}$ . The similarity between the type of the ${\textsf{abs}}$ function and the ${\textsf{lam}}$ branch of our evaluator is no coincidence.
There is a subtle difference between this translation scheme and the one presented in the introduction. In particular, when a variable does not occur anywhere, the bracket abstraction sketched in the introduction immediately introduces a ${\textsf{K}}$ combinator, whereas the ${\textsf{abs}}$ function will use the ${\textsf{S}}$ combinator in every application – even if the variable is unused in both branches. This may lead to unnecessarily large combinatorial terms. Furthermore, the SKI-combinators are not the only possible choice of combinatorial basis. In particular, the ${\textsf{S}}$ combinator always passes its third argument to the first two – even if it is unused in one of the branches. Can we do better?
4 An optimising translation
There is an alternative implementation of bracket abstraction, using two additional combinators ${\textsf{B}}$ and ${\textsf{C}}$ , that Turner (Reference Turner1979) attributes to Curry. The reduction behaviour of ${\textsf{B}}$ and ${\textsf{C}}$ is defined as follows:
In contrast to the ${\textsf{S}}$ combinator, the ${\textsf{B}}$ combinator only passes its third argument to its second argument. The ${\textsf{C}}$ combinator, on the other hand, only passes its third argument to its first argument. This avoids unnecessarily duplicating the third argument ${\textsf{x}}$ , when it is only used by one of the two terms in an application. When the variable is not used at all, we can introduce the ${\textsf{K}}$ combinator as suggested by the translation scheme from the introduction. As a result, normalising terms may require fewer reduction steps.
We can readily extend our ${\textsf{Comb}}$ datatype with new constructors for these two combinators:
When translating an application, we now need to select between four possible choices: ${\textsf{K}}$ , ${\textsf{B}}$ , ${\textsf{C,}}$ and ${\textsf{S}}$ , depending how variables are used. How can we make this choice, while still guaranteeing that types and semantics are preserved accordingly?
The key insight is that the translation scheme, implemented by the ${\textsf{abs}}$ function above, already informs us whether or not a variable is used: any variable occurrence or combinator that does not use the most recently bound variable starts with an application of the ${\textsf{K}}$ combinator. Rather than indiscriminately apply the ${\textsf{S}}$ combinator on subterms, we can instead differentiate where variables are actually used. To this end, we define the following specialised function for applying the ${\textsf{S}}$ combinator:
Unlike the previous naive translation, this definition avoids unnecessary occurrences of the ${\textsf{K}}$ combinator, simplifying the resulting definition whenever possible. Only the very last case, when neither ${\textsf{t}_{1}}$ nor ${\textsf{t}_{2}}$ start with an application of ${\textsf{K}}$ , introduces the ${\textsf{S}}$ combinator. The other cases introduce an outermost ${\textsf{K}}$ , ${\textsf{B,}}$ or ${\textsf{C}}$ combinator, depending on where the ‘bound’ variable occurs.
To complete the translation, we need to adapt the ${\textsf{abs}}$ function: adding new cases for ${\textsf{B}}$ and ${\textsf{C}}$ , and calling the ${\textsf{sapp}}$ function instead of applying ${\textsf{S}}$ directly.
The types and remaining cases definitions, however, remain unchanged.
5 Reflection
Although the translation schemes are reasonably straightforward, finding the implementation presented here was not. Writing dependently typed programs in this style – folding a program’s specification into its type – may feel like a bit of a parlour trick, where the right choice of definitions ensures the entire construction is correct. Yet reading through these definitions after the fact – like so often with Agda programs – does not tell the complete story of how they were constructed.
Verifying the type safe translation from lambda terms to ${\textsf{SKI}}$ combinators is a question I have set my students in the past. Proving this translation correct requires defining an evaluation function for combinatory terms and then proving that the translation is semantics preserving. Interestingly, this proof requires an axiom – functional extensionality – in the case for lambdas, as we need to prove two functions equal. Yet the structure of proof is simple enough: it relies exclusively on induction hypotheses and a property of the ${\textsf{abs}}$ function. It is this observation that makes it possible to incorporate the correctness proofs in the definitions themselves – where the required property of the ${\textsf{abs}}$ function is combined with its definition. This observation is an instance of the recomputation lemma of algebraic ornaments (McBride, Reference McBride2010). Extending the translation scheme to use the ${\textsf{B}}$ and ${\textsf{C}}$ combinators is a bit harder. The code accompanying this paper demonstrates how to use the ‘co-De Bruijn’ representation of variables to define the optimising translation (McBride, Reference McBride2018). Ralf Hinze suggested defining the translation directly using the ${\textsf{sapp}}$ function.
Historically, combinatory logic arose from the desire to find a foundation for mathematics that avoided the issues surrounding variable binding (Schönfinkel, Reference Schönfinkel1924; Curry et al., Reference Curry, Feys, Craig, Hindley and Seldin1958). The translation between between lambda calculus and combinatory logic is well documented in numerous textbooks (see Barendregt, Reference Barendregt1984, Chapter 7; Hindley & Seldin, Reference Hindley and Seldin1986, Chapter 2; Sørensen & Urzyczyn, Reference Sørensen and Urzyczyn2006, Chapter 5.4; Mimram, Reference Mimram2020, Chapter 3.6). There is a close connection between combinatory logic and Hilbert-style proof systems – cognoscenti will recognise the correspondence between the first three axiom schemes and the types that can be assigned to the three combinators above. Since then, Turner (Reference Turner1979) has explored how to compile functional programs to combinatory logic (see also Peyton Jones, Reference Peyton Jones1987, Chapter 16; Diller, Reference Diller1988). This idea has been extended further by Hughes (Reference Hughes1982) and many others, even leading to design of custom hardware for efficiently rewriting terms in combinatory logic (Stoye, Reference Stoye1983, Reference Stoye1985; Scheevel, Reference Scheevel1986). The lambda terms corresponding to the ${\textsf{S}}$ and ${\textsf{K}}$ combinators have made a recent reappearance as the operations defining the ${\textsf{Reader}}$ applicative functor (McBride & Paterson, Reference McBride and Paterson2008).
As our starting point, we have taken the ‘traditional’ simply typed lambda calculus. More recent work by Kiselyov (Reference Kiselyov2018) shows how a slight modification to the typing rules allows for a denotational semantics as combinators directly. Formalising this in a proof assistant, however, is left as an exercise for the reader.
Acknowledgments
I would like to thank Ralf Hinze for his encouragement to keep this pearl short. His suggestions helped to simplify Section 4 enormously. The anonymous JFP reviewers provided further constructive comments.
Conflicts of interest
None.
Discussions
No Discussions have been published for this article.