There is an exceptionally simple way to embed simply typed lambda calculus with de Bruijn indices in a functional host language (discussed by Carette, Kiselyov & Shan, and by Kiselyov). The construction here, expressed in Haskell, is from Kiselyov's paper:
z :: (a, b) -> a
z (x, _) = x
s :: (a -> b) -> (c, a) -> b
s v (_, h) = v h
lam :: ((a, b) -> c) -> b -> a -> c
lam e h x = e (x, h)
app :: (a -> b -> c) -> (a -> b) -> a -> c
app e1 e2 h = e1 h (e2 h)
eval :: (() -> a) -> a
eval e = e ()
So, for example eval (lam (lam (app z (s z)))) 2 succ
evaluates to 3
, as our lam
expression eval
's to the Haskell value \x -> \f -> f x
.
This embedding uses de Bruijn indices: to find the binder of a variable, count upward from the variable. I am curious about whether there is an analogous embedding of de Bruijn levels, where to find a variable's binder we count downward. Concretely, \x -> \f -> f x
would be represented in such an embedding as lam' (lam' (app' (s' z') z'))
.
The above embedding uses (as Kiselyov notes) nothing beyond the Hindley-Milner subset of Haskell 2010. It is not obvious to me whether de Bruijn levels can be implemented in such a simple type system. Indeed, it seems quite difficult: the environment tuples built by lam
would need to be turned inside-out, or our variables would need to behave differently depending on the depth of the environment tuple. If we only had variables of one type, we could use lists; given that our variables have multiple types, do we require HList
s?
======
Late edit to expand on the points in the last paragraph...
One way to think about de Bruijn levels is that lam
append
's (rather than cons
's) to the environment. This way, from the perspective of a variable, the head
of the environment (in a closed term) will always correspond to the highest lam
. In terms of homogeneous lists:
zL :: [a] -> a
zL (x:h) = x
sL :: ([a] -> b) -> [a] -> b
sL v (_:h) = v h
lamL :: ([a] -> b) -> [a] -> a -> b
lamL e h x = e (h++[x])
appL :: ([a] -> b -> c) -> ([a] -> b) -> [a] -> c
appL e1 e2 h = e1 h (e2 h)
evalL :: ([a] -> b) -> b
evalL e = e []
This models de Bruijn levels, in the sense that lamL (lamL (sL zL)) [] 2 3
evaluates to 3
. But it isn't simply typed lambda calculus, since it only allows variables of one type -- e.g., lamL (lamL (appL (sL zL) zL))
is a type error.
To allow variables of multiple types, we can use HList
:
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE TypeOperators #-}
import Data.HList
zH :: HList (a ': b) -> a
zH (HCons x h) = x
sH :: (HList a -> b) -> HList (c ': a) -> b
sH v (HCons _ h) = v h
lamH :: HAppendList a '[b] =>
(HList (HAppendListR a '[b]) -> c) -> HList a -> b -> c
lamH e h x = e (hAppendList h (HCons x HNil))
appH :: (HList a -> b -> c) -> (HList a -> b) -> HList a -> c
appH e1 e2 h = e1 h (e2 h)
Then lamH (lamH (appH (sH zH) zH)) HNil 2 succ
evaluates to 3
, as desired. But this is a significantly heavier-duty approach than the embedding we began with. Do de Bruijn levels require all this artillery?