hacks

(tidier examples of) random scripts from throughout the years
Log | Files | Refs | README

stack.lisp (7249B)


      1 ;; Editor's note: this is a "letter" (file?) I sent to my sister in response to
      2 ;; her asking me why her Emacs Lisp function was hitting a recursion limit.
      3 ;; This was only a couple days after I had finished 15-150's CPS lab so the
      4 ;; timing was great for me to get nerdsniped into talking about CPS and stacks.
      5 ;; Written around 2024 spring.
      6 
      7 ;;
      8 ;; As you just discovered, some recursive functions are not "stack-safe"
      9 ;; 
     10 ;; I've mentioned to you before that when you call a function, a "stack frame"
     11 ;; containing the memory needed for its local variables and such is pushed to
     12 ;; a processes' stack and then popped when the function returns (this way the
     13 ;; preserves the state of the function that calls another function)
     14 ;;
     15 ;; The consequence of this is that the deeper a recursion goes, the more memory
     16 ;; is (traditionally) needed! This sorta blows since there's plenty of regular
     17 ;; recursive algorithms that we would like to work.
     18 ;;
     19 ;; One observation we can make is that we don't need to preserve the state of
     20 ;; a parent function if the recursive call is the _last_ thing we do (there's
     21 ;; nothing to come back to!). This is called "tail recursion" and we can perform
     22 ;; "tail call optimization" here by replacing the current stack frame with the
     23 ;; new one when we recurse instead of pushing a new stack frame (since we don't
     24 ;; need to come back to the old function!)
     25 ;;
     26 ;; Many languages sadly do not do this optimization (e.g. only some lisps like
     27 ;; SBCL do it). Let's see an example with a factorial function.
     28 
     29 (defun bad-factorial (x)
     30   (if (zerop x) 1
     31       (* x (bad-factorial (- x 1)))))
     32 
     33 ;; n * fact (n-1)
     34 ;; ^ problematic because we need to do something with the result of the recursion
     35 
     36 (bad-factorial 500004)
     37 
     38 ;; Calling this will murder SBCL!
     39 ;; CL-USER> (bad-factorial 500004)
     40 ;; Error: Control stack exhausted (no more space for function call frames).
     41 ;;
     42 ;; Anyways this is slow because it's not a tail call: we do multiplication
     43 ;; after the recursion and so we need to preserve the previous stack frames.
     44 ;;
     45 ;; One way of fixing this would be to add an accumulator argument like so:
     46 
     47 (defun tail-factorial (x &optional (acc 1))
     48   (if (zerop x) acc
     49       (tail-factorial (- x 1) (* acc x))))
     50 
     51 
     52 ;; This won't murder SBCL because it can reuse stack frames!
     53 (tail-factorial 50004)
     54 
     55 ;; It isn't always easy to write tail recursive functions though. Let's think
     56 ;; about a slightly harder problem: a tail recursive is-even function! As usual
     57 ;; we'll start with a bad implementation that will murder your stack:
     58 
     59 (defun bad-is-even (x)
     60   (case x
     61     (0 t)
     62     (1 nil)
     63     (otherwise (not (bad-is-even (- x 1))))))
     64 
     65 ;; Except we can't exactly add a nice accumulator argument (at least I don't
     66 ;; think so) since without modulo (cheating here) we can't work backwards
     67 ;; (we could earlier because multiplication is commutative) and our computation
     68 ;; must be done when coming back out of all the recursions. This is explained
     69 ;; a little weirdly but try and think through on your own why you can't use the
     70 ;; same strategy as before here.
     71 ;;
     72 ;; There are some clever tricks we can do though! The first is being clever about
     73 ;; some "mutual recursion" in which we define two recursive functions even? and
     74 ;; odd? that call each other. Sidenote: these are still tail calls because the
     75 ;; recursion is the last thing that happens!
     76 
     77 (defun odd? (x)
     78   (if (= x 0) nil (even? (- x 1))))
     79 
     80 (defun even? (x)
     81   (if (= x 0) t (odd? (- x 1))))
     82 
     83 ;; By using two functions we've nicely dodged our earlier problems. This is not
     84 ;; a generally applicable solution though, and by now you might despair that most
     85 ;; functions are not expressible as a tail recursive function. Yet this is not the
     86 ;; case! Observe the glory of Continuation Passing Style (what I was working on in
     87 ;; my functional programming pset all of last week).
     88 ;;
     89 ;; Some background: a continuation passing style function is a tail recursive function 
     90 ;; that additionally takes a lambda function of "what to do after this" (and calls it 
     91 ;; as a tail call. You can start to build up some sneakiness here by calling more CPS
     92 ;; functions and build up arbitrary logic. Let's see a simple example with a factorial
     93 ;; function again:
     94 
     95 (defun cps-fact (x k) ;; k is "what to do next" - we call k with our result when done
     96   (if (zerop x) (funcall k 0) 
     97       ;; Here we call ourselves again but change the continuation: now what we do with
     98       ;; the result of this recursive call is multiply it by x and then pass it to k.
     99       (cps-fact (- x 1) (lambda (y) (funcall k (* x y))))))
    100 
    101 ;; It'll help to see what this evaluates to for an example input. I use => to indicate
    102 ;; "evaluates to" in the following trace (also write-to-string is basically just str()
    103 ;; from Python but in Lisp.
    104 ;;
    105 ;; (cps-fact 3 write-to-string)
    106 ;; => (cps-fact 2 (lambda (y) (funcall write-to-string (* 3 y))))
    107 ;; => (cps-fact 1 (lambda (z) (funcall (lambda (y) (funcall write-to-string (* 3 y))) (* 2 z))))
    108 ;; => (funcall (lambda (z) (funcall (lambda (y) (funcall write-to-string (* 3 y))) (* 2 z))) 1)
    109 ;; => (funcall (lambda (y) (funcall write-to-string (* 3 y))) (* 2 1))
    110 ;; => (funcall (lambda (y) (funcall write-to-string (* 3 y))) 2)
    111 ;; => (funcall write-to-string (* 3 2))
    112 ;; => (write-to-string 6)
    113 ;; => "6"
    114 ;;
    115 ;; Along the way we used nothing but tail calls! Hopefully after some more inspection
    116 ;; you can sort of see how we've basically punted all state to these growing lambda
    117 ;; functions that encode what to do next.
    118 ;;
    119 ;; We can also do this for our is-even function! Here's how it would work: 
    120 
    121 (defun cps-even (x k)
    122   (case x
    123     (0 (funcall k t))
    124     (1 (funcall k nil))
    125     (otherwise (cps-even (- x 1) (lambda (y) (funcall k (not y)))))))
    126 
    127 ;; Try writing a trace out for yourself. Also we can call the function on big inputs!
    128 
    129 (cps-even 100003 'write-to-string)
    130 (cps-even 100006 'write-to-string)
    131 
    132 ;; The cooler thing about continuation passing style is that it can express *anything*!
    133 ;; Maybe I'll send you some more complicated examples like what I did in my homework to
    134 ;; show this, but CPS is a whole style of programming (I wrote a SAT solver (google it)
    135 ;; in SML exclusively doing things this way!).
    136 ;;
    137 ;; Anyways, yeah this has been your primer on avoiding stack overflows in functional
    138 ;; languages. We can also be evil and do this kind of stuff imperatively. We can use
    139 ;; setf to update variables from let clauses in our factorial function.
    140 
    141 (defun evil-fact (x)
    142   (let ((out 1))
    143     (dotimes (_ x)
    144       (setf out (* x out)
    145             x (- x 1)))
    146     out))
    147 
    148 ;; Never do this. It is evil. I mean sometimes it is motivated: Lisp is actually a
    149 ;; multi-paradigm language and so if functional ever starts being a chore you can
    150 ;; always just not bother (don't though, that's cringe).
    151 ;;
    152 ;; Alternatively we can channel the dark arts of the loop macro:
    153 
    154 (defun loop-fact (x)
    155   (loop with out = 1
    156         for i from 2 to x
    157         do (setf out (* out i))
    158         finally (return out)))
    159 
    160 ;; Or if we like loop (it's actually the only clean range() equivalent) and hate setf
    161 ;; (as one rightfully should) we could do a more functional implementation with reduce:
    162 
    163 (defun other-loop-fact (x)
    164   (reduce '* (loop for i from 1 to x collect i)))
    165 
    166 ;; Okay bye now it's 2 AM.
    167