How to implement a recursive function in lambda calculus using a subset of Clojure language?
Asked Answered
D

2

8

I'm studying lambda calculus with the book "An Introduction to Functional Programming Through Lambda Calculus" by Greg Michaelson.

I implement examples in Clojure using only a subset of the language. I only allow:

  • symbols
  • one-arg lambda functions
  • function application
  • var definition for convenience.

So far I have those functions working:

(def identity (fn [x] x))
(def self-application (fn [s] (s s)))

(def select-first (fn [first] (fn [second] first)))
(def select-second (fn [first] (fn [second] second)))
(def make-pair (fn [first] (fn [second] (fn [func] ((func first) second)))))    ;; def make-pair =  λfirst.λsecond.λfunc.((func first) second)

(def cond make-pair)
(def True select-first)
(def False select-second)

(def zero identity)
(def succ (fn [n-1] (fn [s] ((s False) n-1))))
(def one (succ zero))
(def zero? (fn [n] (n select-first)))
(def pred (fn [n] (((zero? n) zero) (n select-second))))

But now I am stuck on recursive functions. More precisely on the implementation of add. The first attempt mentioned in the book is this one:

(def add-1
  (fn [a]
    (fn [b]
      (((cond a) ((add-1 (succ a)) (pred b))) (zero? b)))))

((add zero) zero)

Lambda calculus rules of reduction force to replace the inner call to add-1 with the actual definition that contains the definition itself... endlessly.

In Clojure, which is an application order language, add-1 is also evaluated eagerly before any execution of any kind, and we got a StackOverflowError.

After some fumblings, the book propose a contraption that is used to avoid the infinite replacements of the previous example.

(def add2 (fn [f]
            (fn [a]
              (fn [b]
                (((zero? b) a) (((f f) (succ a)) (pred b)))))))
(def add (add2 add2))

The definition of add expands to

(def add (fn [a] 
           (fn [b]
             (((zero? b) a) (((add2 add2) (succ a)) (pred b)))))) 

Which is totally fine until we try it! This is what Clojure will do (referential transparency):

((add zero) zero)
;; ~=>
(((zero? zero) zero) (((add2 add2) (succ zero)) (pred zero)))
;; ~=>
((select-first zero) (((add2 add2) (succ zero)) (pred zero)))
;; ~=>
((fn [second] zero) ((add (succ zero)) (pred zero)))

On the last line (fn [second] zero) is a lambda that expects one argument when applied. Here the argument is ((add (succ zero)) (pred zero)). Clojure is an "applicative order" language so the argument is evaluated before function application, even if in that case the argument won't be used at all. Here we recur in add that will recur in add... until the stack blows up. In a language like Haskell I think that would be fine because it's lazy (normal order), but I'm using Clojure.

After that, the book go in length presenting the tasty Y-combinator that avoid the boilerplate but I came to the same gruesome conclusion.

EDIT

As @amalloy suggests, I defined the Z combinator:

(def YC (fn [f] ((fn [x] (f (fn [z] ((x x) z)))) (fn [x] (f (fn [z] ((x x) z)))))))

I defined add2 like this:

(def add2 (fn [f]
            (fn [a]
              (fn [b]
                (((zero? b) a) ((f (succ a)) (pred b)))))))

And I used it like this:

(((YC add2) zero) zero)

But I still get a StackOverflow.

I tried to expand the function "by hand" but after 5 rounds of beta reduction, it looks like it expands infinitely in a forest of parens.

So what is the trick to make Clojure "normal order" and not "applicative order" without macros. Is it even possible? Is it even the solution to my question?

This question is very close to this one: How to implement iteration of lambda calculus using scheme lisp?. Except that mine is about Clojure and not necessarily about Y-Combinator.

Disown answered 5/9, 2017 at 18:36 Comment(6)
i can help you when i wake up tomorrow; if you still need itCharleen
You either have to use a trampoline or use recur to avoid stack overflows.Structure
@JohannesKuhn : I will look in that direction in coming days.Disown
@naomik If you have a solution ... any help is welcomed, thanks :)Disown
@DemeterPurjon i just woke up; feeling very sick today; don't worry tho i'm making a write-up for youCharleen
@DemeterPurjon i provided an answer below but i'm almost certain it will fuel more questions; don't hesitate to ask for clarification; this has been my favourite topic of study in the computer sciences so i'm happy to help ^_^Charleen
C
5

For strict languages, you need the Z combinator instead of the Y combinator. It's the same basic idea but replacing (x x) with (fn [v] (x x) v) so that the self-reference is wrapped in a lambda, meaning it is only evaluated if needed.

You also need to fix your definition of booleans in order to make them work in a strict language: you can't just pass it the two values you care about and select between them. Instead, you pass it thunks for computing the two values you care about, and call the appropriate function with a dummy argument. That is, just as you fix the Y combinator by eta-expanding the recursive call, you fix booleans by eta-expanding the two branches of the if and eta-reduce the boolean itself (I'm not 100% sure that eta-reducing is the right term here).

(def add2 (fn [f]
            (fn [a]
              (fn [b]
                ((((zero? b) (fn [_] a)) (fn [_] ((f (succ a)) (pred b)))) b)))))

Note that both branches of the if are now wrapped with (fn [_] ...), and the if itself is wrapped with (... b), where b is a value I chose arbitrarily to pass in; you could pass zero instead, or anything at all.

Cowen answered 5/9, 2017 at 18:47 Comment(7)
Hello, I already got this solution from the other question I mention, but I couldn't make something of it. I know it's a little pushy to ask this but could you enhance your solution with a code example ? Many thanks.Disown
@DemeterPurjon Does this help?Cowen
Yes ! It works ! But in that case, that means I can just forget all about Y or Z combinators and go straight with a naive implementation : (def naive-add (fn [a] (fn [b] ((((zero? b) (fn [_] a)) (fn [_] ((naive-add (succ a)) (pred b)))) 42))))Disown
But now you've cheated: your var definition is no longer just for convenience, since naive-add refers to itself. The point of the Y and Z combinators is to avoid such circular definitions.Cowen
So that means that my first definition, add-1, is not LC either ?Disown
I mean, you're the one who gets to decide how you map the concepts in lambda calculus to Clojure semantics. But LC does not admit self-reference, and so typically people doing this exercise do not permit self-reference in Clojure either. The problem would be more obvious if instead of def you used let for all your "convenience" vars, because let does not allow self-reference.Cowen
if we permit ourselves the use of self-referential definitions, the Z itself could be defined quite simply in Clojure as (defn Z [g] (g (fn [x] ((Z g) x)))). This would be like treating Z as an external primitive, from the LC code's point of view.Coulee
C
5

The problem I'm seeing is that you have too strong of a coupling between your Clojure program and your Lambda Calculus program

  1. you're using Clojure lambdas to represent LC lambdas
  2. you're using Clojure variables/definitions to represent LC variables/definitions
  3. you're using Clojure's application mechanism (Clojure's evaluator) as LC's application mechanism

So you're actually writing a clojure program (not an LC program) that is subject to the effects of the clojure compiler/evaluator – which means strict evaluation and non-constant-space direction recursion. Let's look at:

(def add2 (fn [f]
            (fn [a]
              (fn [b]
                (((zero? b) a) ((f (succ a)) (pred b)))))))

As a Clojure program, in a strictly evaluated environment, each time we call add2, we evaluate

  1. (zero? b) as value1
  2. (value1 a) as value2
  3. (succ a) as value3
  4. (f value2) as value4
  5. (pred b) as value5
  6. (value2 value4) as value6
  7. (value6 value5)

We can now see that calling to add2 always results in call to the recursion mechanism f – of course the program never terminates and we get a stack overflow!


You have a few options

  1. per @amalloy's suggestions, use thunks to delay the evaluation of certain expressions and then force (run) them when you're ready to continue the computation – tho I don't think this is going to teach you much

  2. you can use Clojure's loop/recur or trampoline for constant-space recursions to implement your Y or Z combinator – there's a little hang-up here tho because you're only wishing to support single-parameter lambdas, and it's going to be a tricky (maybe impossible) to do so in a strict evaluator that doesn't optimise tail calls

    I do a lot of this kind of work in JS because most JS machines suffer the same problem; if you're interested in seeing homebrew workarounds, check out: How do I replace while loops with a functional programming alternative without tail call optimization?

  3. write an actual evaluator – this means you can decouple your the representation of your Lambda Calculus program from datatypes and behaviours of Clojure and Clojure's compiler/evaluator – you get to choose how those things work because you're the one writing the evaluator

    I've never done this exercise in Clojure, but I've done it a couple times in JavaScript – the learning experience is transformative. Just last week, I wrote https://repl.it/Kluo which is uses a normal order substitution model of evaluation. The evaluator here is not stack-safe for large LC programs, but you can see that recursion is supported via Curry's Y on line 113 - it supports the same recursion depth in the LC program as the underlying JS machine supports. Here's another evaluator using memoisation and the more familiar environment model: https://repl.it/DHAT/2 – also inherits the recursion limit of the underlying JS machine

    Making recursion stack-safe is really difficult in JavaScript, as I linked above, and (sometimes) considerable transformations need to take place in your code before you can make it stack-safe. It took me two months of many sleepless nights to adapt this to a stack-safe, normal-order, call-by-need evaluator: https://repl.it/DIfs/2 – this is like Haskell or Racket's #lang lazy

    As for doing this in Clojure, the JavaScript code could be easily adapted, but I don't know enough Clojure to show you what a sensible evaluator program might look like – In the book, Structure and Interpretation of Computer Programs, (chapter 4), the authors show you how to write an evaluator for Scheme (a Lisp) using Scheme itself. Of course this is 10x more complicated than primitive Lambda Calculus, so it stands to reason that if you can write a Scheme evaluator, you can write an LC one too. This might be more helpful to you because the code examples look much more like Clojure


a starting point

I studied a little Clojure for you and came up with this – it's only the beginning of a strict evaluator, but it should give you an idea of how little work it takes to get pretty close to a working solution.

Notice we use a fn when we evaluate a 'lambda but this detail is not revealed to the user of the program. The same is true for the env – ie, the env is just an implementation detail and should not be the user's concern.

To beat a dead horse, you can see that the substitution evaluator and the environment-based evaluator both arrive at the equivalent answers for same input program – I can't stress enough how these choices are up to you – in SICP, the authors even go on to change the evaluator to use a simple register-based model for binding variables and calling procs. The possibilities are endless because we've elected to control the evaluation; writing everything in Clojure (as you did originally) does not give us that kind of flexibility

;; lambda calculus expression constructors
(defn variable [identifier]
  (list 'variable identifier))

(defn lambda [parameter body]
  (list 'lambda parameter body))

(defn application [proc argument]
  (list 'application proc argument))

;; environment abstraction
(defn empty-env []
  (hash-map))

(defn env-get [env key]
  ;; implement
)

(defn env-set [env key value]
  ;; implement
)

;; meat & potatoes
(defn evaluate [env expr]
  (case (first expr)
    ;; evaluate a variable
    variable (let [[_ identifier] expr]
               (env-get env identifier))

    ;; evaluate a lambda
    lambda (let [[_ parameter body] expr]
             (fn [argument] (evaluate (env-set env parameter argument) body)))

    ;; evaluate an application
    ;; this is strict because the argument is evaluated first before being given to the evaluated proc
    application (let [[_ proc argument] expr]
                  ((evaluate env proc) (evaluate env argument)))

    ;; bad expression given
    (throw (ex-info "invalid expression" {:expr expr}))))


(evaluate (empty-env)
          ;; ((λx.x) y)
          (application (lambda 'x (variable 'x)) (variable 'y))) ;; should be 'y

* or it could throw an error for unbound identifier 'y; your choice

Charleen answered 7/9, 2017 at 20:26 Comment(7)
A reasonable sketch of an evaluator, but Clojure doesn't have match as a built-in like Scheme does, so you need to do something else there; typically cond is the construct used for implementing a lisp evaluator in lisp.Cowen
@Cowen it's mostly meant to be a pseudocode starting point for the learner – i'm sorry i don't know enough clojure to do it in a more idiomatic way – any edits that preserve simplicity are warmly welcomedCharleen
@Cowen thanks heaps ! in the case expressions, should we be matching (quoted) 'variable, 'lambda, etc instead of variable, lambda, ... ?Charleen
No. case can only match literal values, which in a way you can think of as being implicitly quoted. More verbosely, quotation is not needed there because forms in that context are never evaluated, and therefore you don't need quote to prevent evaluation.Cowen
Interesting. Thanks for the collaborative effort ^_^Charleen
@naomik Thank you for your great write-up ! I really, really, appreciate. This is a lot of information, I will make some time to digest it all.Disown
@naomik I accepted Alan's as an answer because my main purpose was to use "vanilla Clojure as lambda calculus". In your answer you perfectly list the caveats of this approach, and it helps a lot. You also provide 3 alternatives and even provide an actual evaluator ! That's a really great answer, I wish I could mark both as correct !Disown

© 2022 - 2024 — McMap. All rights reserved.