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