I have kind of grown to prefer Clojure's loop/recur construct, since it gives you something more or less akin to tail recursion but it doesn't pretend to be actually recursive.
I have kind of grown to prefer Clojure's loop/recur construct, since it gives you something more or less akin to tail recursion but it doesn't pretend to be actually recursive.
It is true that TCO messes up your stack traces. It is also true that loops mess up your stack traces, because they don't even create a stack trace. In many languages that support TCO, TCO can be turned off for debugging and enabled for production, so Guido's characterization of the issue is ridiculous.
I don't agree that being able to turn off TCO in debugging is enough to call Guido's characterization "ridiculous". I often have to debug running production systems, and those are (hopefully) not deployed with debug compilations.
You're not wrong about loops not creating a stack trace; I guess what bothers me (and maybe Guido) is that with loops there's no expectation of a stack trace, while with a function call there is.
No, because an indirect tail-call eliminates the calling frame. After, it looks like the caller of that function called a function it did not call. In fact, with tail-calls a whole pile of steps gets telescoped down to nothing[1]. This is not the case with a loop, it doesn't jump to itself indirectly. Loops don't jump into the middle of other loops in other functions. We can still follow the chain of control flow from the start of the main function, through (non-tail) calls, i.e. the stack, through the start of the loop, and then from some backedge in the loop body we're looking at.
Tail-calls are absolutely harder to debug in general than loops.
[1] In the limit, if the entire program was transformed into CPS with tail-calls everywhere, the original execution stack is now strewn throughout the heap as a chain of closures.
I like TCO because I often prefer the expressiveness of recursion over loops. When I need to debug, I turn off TCO. (I'm talking about Common Lisp.)
I agree TCO definitely makes the compiled code not look like the source, and this lossage doesn't happen with either regular recursion or with loops. But various other kinds of optimizations also have that problem.
If you're looking at the assembler output you'll see JMPs where there would have been JSRs without the optimization. So knowing that helps a bit. You just lose the automatic history preservation the stack gave you for free.
Time travel debugging might be an answer here but of course that can be much more expensive than simply keeping JSRs in place.
The expectation that iterative recursion ought to have a call stack is the problem here and wouldn't be up for debate if people had done their due diligence and read their SICP.
Notable exceptions to the above are python3 with generators, which I believe truly use O(1) memory with map and fold. Haskell has lists that are lazy by default, but if you fold or map over them, it still "forces the thunk" for each element and thus you still end up using O(N) memory.
My claim is that in most cases, such a function can be implemented efficiently by chaining together standard higher-order functions (i.e. combinators), rather than by writing a custom tail recursive solution from scratch.
* If you did mean it that way, I doubt Python can avoid forcing each element that goes through its generators. (Without, say, thunking up each element manually or using a generator of generators, etc.)
Here is Haskell using a fold and a map. It does not force the input, the output, or each element:
main =
let lazyInput = zip ([1..] :: [Int])
(repeat (error "boom"))
lazyOutput = foldr (:) [] lazyInput
in print (sum (map fst (take 10_000_000 lazyOutput)))
> 50000005000000
> 9 MiB total memory in use
> real 0m0.062s
Yeah this was a big help when I started F#. Basically "if you're using the rec keyword, you're probably missing something" and hell that even goes for a lot of uses of fold, from the beginners perspective.
That is my point. Modern functional languages do have a host of higher-order functions for exactly this sort of thing. For example, here is F#'s `Seq` module, for working with lazy sequences: https://fsharp.github.io/fsharp-core-docs/reference/fsharp-collections-seqmodule.html
> There are many cases, where you don't want to fold or map over the whole data structure and want to exit early with a result already. Writing tail recursive functions is still very common.
I think this can usually be handled more concisely by combining higher-order functions instead. For example, if you want to fold over a partial data structure, you can use `filter` to select only the elements you want, and then fold over that subset. Or, if you want to exit early from a map, you can use `takeWhile` and only map over what's left.
Real-world functional programming is usually about combining these built-in tools effectively, rather than writing new tools from scratch.
On the other hand one could argue, that one pass of those is checking some condition (index below some number, or element followed by element that satisfies a predicate, or similar things), and the other pass is then blindly applying some other function, without having to check the condition again. Maybe that is in the end equal in performance.
Are these the examples?
>> For example, if you want to fold over a partial data structure, you can use `filter` to select only the elements you want, and then fold over that subset. Or, if you want to exit early from a map, you can use `takeWhile` and only map over what's left.
These are done in a single pass. And if they weren't, I'd stop using them.
I actually like the compromise. I get to write safe functional code while getting all the benefits of a highly optimised iterative operation.
But most of the time you will want to express your program in terms of more restricted combinators, because restriction makes the readers job easier. Easier to understand, and easier to convince yourself that no weird things are happening.
Basically, you might already agree that restricting mutation is good. This is the same principle.
So when you see a 'fold' you know even without looking at the callback, that your program will run through the whole list and not exit early. When you see a 'map' you also know that no early exit will happen, but even more you know exactly how the return value will be constructed (whereas for fold that's arbitrary).
However you are right that 'fold' and 'filter' and 'map', just like 'for' and 'while', are not good as universal building blocks.
That's why you should define new combinators when you need them. Typically, that's when you define new data structures, eg when you define a tree you will also want to define a 'map' for it, and also various tree traversals and perhaps searches. Less typically, you also want new combinators for new algorithmic ideas, even on old data structures.
Eg matrix multiplication is an interesting combinator. You provide the meaning of 'addition' and 'multiplication' as call-backs, and depending on your choice you get an algorithm for finding shortest paths or for matching regular expressions etc (in addition to the obvious matrix-multiplication on real numbers, of course). See https://news.ycombinator.com/item?id=9751987
It's relatively seldom that you want to use 'naked' recursion. The main example I can think of is when implementing state machine or interpreters. See eg https://news.ycombinator.com/item?id=43076088 for the latter, and https://news.ycombinator.com/item?id=43076088 for the former.
I'm not sure Pascal does closure well?
A combinator heavy style works best in a language (and ecosystem) that welcomes it, like eg Haskell.
It is true that every tail recursive function can be converted into a semantically equivalent loop via a transformation like CPS (which the author mentions). However, for mutually tail-recursive functions, this conversion loses control flow information. This is because after the CPS transformation, calls to the other function become calls to a continuation; this call usually must be implemented as an indirect jump. On the other hand, mutually tail-recursive functions can call each other with direct/statically-known jumps.
This loss of information might appear trivial, but in practice it has some important consequences. Classic examples are interpreter loops. It is well-known that computed gotos can result in modest to large speedups for interpreters [1]. The reason why is that computed gotos create an indirect jump per opcode, so a branch predictor can take advantage of correlations between opcodes. For example, looking at Python disassembly, the header of a standard range for loop compiles down to three opcodes: GET_ITER, FOR_ITER, STORE_FAST in sequence [2]. A branch predictor can recognize that the target of the "FOR_ITER" indirect jump will likely be the "STORE_FAST" instruction pointer; it cannot predict this in the naive implementation where jumps for all instructions are "merged" into a single indirect jump / switch at the top of the loop body. In this case, computed goto is effectively equivalent to a CPS transformation whose closures require no storage on the heap.
Suppose, however, we know even more information about the instruction sequence; for example, we know ahead of time that every FOR_ITER opcode will be followed by a STORE_FAST opcode. We could completely replace the indirect jump with a direct jump to the instruction pointer for the STORE_FAST opcode. Because modern branch predictors are very good, this will have about the same performance in practice as the computed goto loop.
However, consider the limiting case where we know the entire instruction sequence beforehand. If we write our interpreter as many mutually tail-recursive functions, with one function for every instruction, an optimizing compiler can replace every indirect call with a direct (tail-recursive) call to the function that implements the next instruction's opcode. With a good enough optimizer / partial evaluator, you can turn an interpreter into a compiler! This is known as the first Futamura projection [3].
To see an example of this in action, I wrote a prototype of a Brainfuck compiler via the Futamura projection; it uses LLVM as a partial evaluator [4]. The main interesting function is `interpret`, which is templated on the program counter / instruction. That is, `interpret` is really a family of mutually tail-recursive functions which statically call each other as described above. For short Brainfuck programs, the LLVM optimizer is able to statically compute the output of the Brainfuck program. (The one in the Godbolt link compiles to a loop, likely because LLVM does not want to unroll the mutual recursion too much.) You can play around with different Brainfuck programs by modifying the `program` string on line 5.
[1] https://eli.thegreenplace.net/2012/07/12/computed-goto-for-efficient-dispatch-tables
[2] https://godbolt.org/z/rdhMvPo36
[3] https://en.wikipedia.org/wiki/Partial_evaluation#Futamura_projections
To the contrary, I'd argue that immutability isn't the only alternative to universal mutability: many newer imperative languages (such as Rust) have various forms of controlled mutability, where one must explicitly declare which variables may be modified by imperative assignments.
IME, controlled mutability captures just about all the benefit of immutable languages, without requiring any special data structures or sufficiently-smart compiler analyses to ensure good performance. I've never really understood the desire for tail-recursive versions of iterative algorithms, except for a prior commitment to functional programming.
It is a tradeoff one can make, and it lends itself to high performance but it does come at a cost.
I don't understand why someone would want to hold on nostalgically to restrictions we no longer face.
Controlled mutability is a step up, yes. Btw, even languages like Haskell give you plenty of mutability to play with, if you want to. (Eg MVars and TVars are a thing.)
You are right that replacing a loop one for one with a tail recursive version doesn't give you much benefit either way. (Though it does make my life easier, because you don't have to contort my brain around keeping track of mutation.)
However that's a bit of a straw man and misses the broader picture.
What you'd want to do is define combinators that encapsulate the specific pattern of computation you want to implement. The most commonly known combinators are things like 'map' and 'filter'. But there are plenty more useful ones you can define, like various tree traversals or whatever makes sense for use cases and data structures.
Whether those combinators are implemented with tail calls or with loops or gotos or whatever is an implementation detail that their users don't need to worry about.
I know of one major use case where you'd want to use tail recursion directly: state machines and interpreters. See https://news.ycombinator.com/item?id=43076088 for an example of the latter. See https://en.wikisource.org/wiki/Lambda:_The_Ultimate_GOTO for an example of the former.
On the other hand, I don't understand why someone would want to shoehorn all possible forms of control flow into "everything is a function object that can receive argument values that are substituted into parameter variables". I could just as easily say that all programs are a special case of cellular automata or string-rewriting systems or whatever other Turing tarpit.
You call imperative mutation "contorting your brain", but you always have to keep close track of which variables change and which stay the same, even if you shove them into function parameters. Local immutability comes at the cost of disguising which variables may have their values replaced over the course of the execution.
> What you'd want to do is define combinators that encapsulate the specific pattern of computation you want to implement.
Yes, I agree that control-flow combinators are a good model for many forms of programming. But they are mostly orthogonal to the broader imperative vs. functional style for sequential control flow, except for the historical factor of older imperative languages having poor support for capturing values into closures.
> I know of one major use case where you'd want to use tail recursion directly: state machines and interpreters. See https://news.ycombinator.com/item?id=43076088 for an example of the latter. See https://en.wikisource.org/wiki/Lambda:_The_Ultimate_GOTO for an example of the former.
I can accept that direct threading makes contingent sense in that Python case, given current implementations. It seems to mostly be an unfortunate limitation that the compilers have poor handling of dispatch loops. (Indeed, the author still had to use special implementation-specific contortions to get the compiler to generate acceptable register handling.)
But otherwise, I'd generally prefer structured programming, whether it be with imperative statements or with control-flow combinators, over the totally unstructured control flow offered by bare tail calls.
Huh? Where did you get that impression from?
I was suggesting to remove some special case handling of certain tail recursive constructs (commonly known as 'loops') and just handle them via the general mechanism (via function calls, which are already in practically any language, and just need to be properly compiled) and perhaps at most offer some syntactic sugar to define loops in terms of the underlying tail recursive constructs.
I was not suggesting to 'shoehorn all possible forms of control flow' into function calls. (Sorry, I don't know what a 'function object' is. I just assume you mean a function?)
For example, many languages offer exception handling and there's also async operations and multi-threading and call-with-currenc-continuation and (early) return etc. I have expressed no opinion on those, and I'm not even sure they can all be handled as function calls.
> But otherwise, I'd generally prefer structured programming, whether it be with imperative statements or with control-flow combinators, over the totally unstructured control flow offered by bare tail calls.
I am suggesting to use combinators in most cases, and mostly use (tail) calls to define new combinators, rarely directly. Your compiler (and language) should ideally make this pattern of usage fast (which modern compilers can generally do, because they can eg often in-line the whole combinator thing).
I am observing that the commonly accepted loops are just some special cases of (something like) combinators and shouldn't be raised on a pedestal. Ideally they should be defined in the standard library, and not on the built-in level.
Tail-recursion and iteration are broadly equivalent right? So picking one over the other is a matter of style and capability.
You can't use iteration if your language isn't capable of it, but no worries, you can use tail recursion.
Similarly, you can't use tail recursion if your language isn't capable of it, but no worries, you can use iteration, at least for iterative tail recursion. Otoh, there are uses of tail call elimination that aren't recursion (or aren't direct recursion) ... that can get akward if your language can't manage it.
Tail recursion is more closely related to goto statements. So one could argue that imperative iteration constructs, as with other forms of structured programming, are superior in giving the reader immediate knowledge of the broad control flow, while tail recursion (especially mutual tail recursion) could have all sorts of weird behaviors.
You can certainly argue about which of two forms is clearer, but I don't know that it's a decidable problem, so like ... do whatever you feel. I write in multiple programming languages and try to blend with the local idioms, so I write loops with foreach and with recursion, and they're both fine. If you have pattern matched function clauses, I don't find recursion to be lacking in indications of the broad control flow; if you're in a language that lacks that, then recursive loops look pretty ugly. But something like a loop to sum values would be (in Erlang)
sum(List) -> sum(List, 0).
sum([], Acc) -> Acc;
sum([H|T], Acc) -> sum(T, H + Acc).
Assuming you know the syntax and the idioms, this is very clear. If you don't know the idioms, Acc means 'accumulator', H means 'head', T means 'tail', so if you call sum(List), it calls into the recursive sum with an initial accumulator of 0, then one element at a time is taken off the front of the list, and added to the accumulator, when the list is empty, the accumulator is returned.the same thing in Rust would look like
fn sum (slice: &[i64]) -> i64 {
let mut sum = 0;
for i in slice {
sum += i;
}
sum
}
You could make it generic or whatever if you want. Is one form better at giving the reader immediate knowledge of the broad control flow than the other? I don't know. But the iterative version is 7 lines vs 4 lines in recursion. A list isn't really the same as an array slice of course, so that's a bit of a cheat. Using a tuple in Erlang is similar, but wordier: sum(Tuple) -> sum(Tuple, 1, 0).
sum(Tuple, N, Acc) when N > size(Tuple) -> Acc;
sum(Tuple, N, Acc) -> sum(Tuple, N + 1, element(N, Tuple) + Acc).
And you have to know that Tuples are 1 indexed, and that N indicates the Nth element of a Tuple. You could make sum/1 take a list or a tuple, but you do have two write both versions... you could make the Rust take an IntoIterator and benefit from Traits to not have to write a different version for each kind of container. You could argue about what's a better choice, but I won't :) Maybe you like type annotations, you could add those to the Erlang, but I won't do that either :PIf your iteration has more exciting work than summation, the line count difference doesn't matter so much... maybe the work it shorter in one language than the other anyway. But I don't think either form is obviously and objectively superior. I'd wager iteration has more mind share, but that doesn't indicate that it's superior.
What "weird behaviors" have you encountered with tail recursion?
- V8 JavaScript Engine
- Python
Tail calls being jumps is the key insight.
Not all tail calls are recursion!
Students are confused by recursion. Some may be confused by tail calls.
Don't mix two confusing things together when teaching.
(Tail calls get mixed up with recursion because recognizing and treating/optimizing recursive tail calls to create loops an important highlighted category. In a compiler that doesn't optimize tail calls, optimizing self tail calls is an important priority (if that is easier to do than a general treatment). Many common algorithms are expressible in just one function that calls itself, and there are cases in which some or all of those calls can be tail calls; e.g. binary tree traversal.)
The order should probably be: teach recursion first. Then when students have firmly wrapped their heads around it, introduce tail calls. Students will gain the skill of knowing what is a tail call and why it is important, and recognize which calls tail calls. Secondly, the harder skill of taking recursion that uses non-tail calls and restructuring it to make tail calls instead.
However, loops aren't the only use for tail recursion. Another classic example are state machines as described in the 'Lambda: the ultimate goto' paper. See eg https://en.wikisource.org/wiki/Lambda:_The_Ultimate_GOTO
[0] Well, recursion on functions. You can also have eg recursive data types or recursion in mathematics in general. But let's focus on functions.
What's an example of such a loop?
And the other way round: how do you (sanely) write a state machine as a loop?
See https://en.wikisource.org/wiki/Lambda:_The_Ultimate_GOTO for some examples.
I agree that (most) functions that only ever tail-call themselves can relatively easily be expressed as loops, if for some reason you really love loops. But state machines aren't like that.
With tail calls, you can represent each state as a function, and you can pass different parameters to different states. Very neat, and just works.
But since your example is relying on mutual tail recursion with functions for each state. Yeah, that is much harder to cleanly map to a simple loop. Like obviously making a giant function to cover all the state functions with a giant switch statement is possible, and even relatively common, but not especially clean.
If all state functions have the same parameters, then an approach with functions per state that return the new state and new parameter values, which get called from a loop using a state value to function dictionary can be relatively clean. But if states need different parameters, yeah that gets complicated.
> If all state functions have the same parameters, then an approach with functions per state that return the new state and new parameter values, which get called from a loop using a state value to function dictionary can be relatively clean. But if states need different parameters, yeah that gets complicated.
In a language like Rust, you can encode your state in an enum, and the different variants (= states) in a Rust enum can have different parameters.
Then your implementation basically becomes a loop around a match statement over that enum.
But all you've done here is manually implement a 'trampoline'. The trampoline is to a tail call what manually maintaining a (call) stack is for simulating non-tail calls. See https://en.wikipedia.org/wiki/Trampoline_(computing)#High-level_programming
Loops are merely a special case of recursion. The reason languages have loops is that reifying these cases to language constructs simplifies code generation. The downside is that it muddles the logic of computation.
Enthusiasm for tail recursion comes mostly from LISP and LISP-adjacent people - those who learned to program from SICP.[1] This is neither good nor bad. Even MIT doesn't use SICP any more, though.
(The classic 20th century "programming should be hard and programmers should suffer" books:
- Structure and Interpretation of Computer Programs, Abelson and Sussman.
- Fundamental Algorithms and Semi-Numerical Algorithms, Knuth.
- Algorithms + Data Structures = Programs, Wirth.
- A discipline of programming, Dijkstra.
These are mostly of historical interest now, but at one time, knowing those defined a real computer scientist. Today, it's more important that your LLM has been trained on them.)
"Love the lambda." - footer of original article.
I'll note that in modern imperative languages is harder than it looks to figure out if calls are really in tail position, things like exception handling, destructors etc. interfere with this, so even as a SICP fanboy I'll admit it's fair enough that some languages don't want to bother.
It does not, it sets low recursion limit which is a rather different thing.
No. The only thing that went in is moving Python frames off of the C stack, but since afaik the recursion limit did not change it has essentially no impact on user code unless you play with said recursion limit.
<whatever state the application is in here serves as the starting state>
foreach (i in range(n)) {
<the state of the application is updated with each iteration>
}
<the desired state of the application has been applied when the loop is done>
An equivalent left fold is invoked like <desired state> = foldl (
<state updating procedure>,
<starting state>,
range(n)
)
The difference is that the starting state is given explicitly to the loop, and the desired state is returned as a composite value. This makes it easier to troubleshoot than the implicit state transitions, but it's essentially the same still.
Tail recursion is meant to fix the latter. But what we mean to happen and what actually happens ain't ever exactly similar.
Tail recursion IME is a bigger foot gun than relying on someone to add a new conditional branch at the end of a block in an iterative algorithm without fucking it up in the process. And iteration responds generally better to Extract Function. And while I can think of counter cases easily enough, in the large iteration is less work and less vigilance. And you cannot scale a project up without the vigilance requirement amortizing basically to 0 per line of code.
At least in theory, a tail recursive call will be converted into a dumb jump, so there shouldn't be a performance penalty, but since from your code's perspective you're passing in the stuff for the next iteration, you can keep using the pretty and easy-to-reason-about structures without creating any kind of mutable reference.
I'm not 100% sold on tail recursion in a broader sense, but at least with Clojure's loop/recur stuff it is kind of cool to be able to keep using persistent data structures across iterations of loops.
Of course, this means that the same implementation could also be directly expressed logically in a way that is mutable/iterative.
func pow(uint base, uint n): n == 0 ? return 1 : return n * pow(base, n-1)
is just
func pow(uint base, uint n): uint res = 1; for(i=0; i<n; i++){ res *= n} return res
There is no real "advantage" to, or reason to "sell" anybody on tail call recursion if you are able to easily and clearly represent both implementations, IMO. It is just a compiler/runtime optimization, which might make your code more "elegant" at the cost of obfuscating how it actually runs + new footguns from the possibility that code you think should use TCO actually not (because not all immutable + recursive functions can use TCO, only certain kinds, and your runtime may not even implement TCO in all cases where it theoretically should).
As an aside, in C++ there is something very similar to TCO called copy-elision/return-value-optimization (RVO): [1]. As with TCO it is IMO not something "buy into" or sell yourself on, it is just an optimization you can leverage when structuring your code in a way similar to what the article calls "continuation passing style". And just like TCO, RVO is neat but IMO slightly dangerous because it relies on implicit compiler/runtime optimizations that can be accidentally disabled or made non-applicable as code changes: if someone wanders in and makes small semantic to changes to my code relying on RVO/TCO for performance they could silently break something important.
[0] EXCEPT in practice all implementation differences/optimizations introduce observable side effects that can otherwise impact program correctness or semantics. For example, a program could (perhaps implicitly) rely on the fact that it errors out due to stack overflow when recursing > X times, and so enabling TCO could cause the program to enter new/undesirable states; or a program could rely on a functin F making X floating point operations taking at least Y cycles in at least Z microseconds, and not function properly when F takes less than Z microseconds after enabling vectorization. This is Hyrum's Law [2].
[1] https://en.wikipedia.org/wiki/Copy_elision#Return_value_optimization
[2] https://www.hyrumslaw.com/
That said, just as I'd expect experienced C++ programmers to be able to recognize others' code using RVO and be careful not to restructure things to break it, I'd concede that experienced FP programmers might be unlikely to accidentally break others' TCO. It's just that ad absurdum you cannot expect every developer to be able to read every other developers' mind and recognize/workaround all implicit behavior they encounter.
I suspect you are only thinking of patterns that are basically equivalent to a loop. I might agree with that.
TCO really shines when you want to implement state machines. See eg https://news.ycombinator.com/item?id=43076088 for an example where using tail calls in Python's interpreter loop gives nice performance benefits. Similar also for LUA.
> [...] I'd concede that experienced FP programmers might be unlikely to accidentally break others' TCO.
Compiler (or linter) checkable annotations would help here. You are right that you want to make it possible for programmers to statically assert somehow that their function call is a tail call.
Btw this reminds me: recursion isn't just something you do in computation, but also in data structures (amongst other things). In eg Rust the members of your data structure are typically just laid out one after another, but when you have a recursive structure (and in certain other cases) you need to box it, otherwise you'd get an infinitely large data structure. Boxing is more or less equivalent to using indirection via a pointer.
However, unboxing isn't really like TCO. It's more like in-lining.
Yes, compilers exist.
> There is no real "advantage" to, or reason to "sell" anybody on tail call recursion if you are able to easily and clearly represent both implementations, IMO.
Avoiding mutation avoids headaches.
> [0] EXCEPT in practice all implementation differences/optimizations introduce observable side effects that can otherwise impact program correctness or semantics. For example, a program could (perhaps implicitly) rely on the fact that it errors out due to stack overflow when recursing > X times, and so enabling TCO could cause the program to enter new/undesirable states; or a program could rely on a functin F making X floating point operations taking at least Y cycles in at least Z microseconds, and not function properly when F takes less than Z microseconds after enabling vectorization. This is Hyrum's Law [2].
These programs are likely not standards compliant. (And that's not just true for the C++ standard but for basically any language with a standard.)
> And just like TCO, RVO is neat but IMO slightly dangerous because it relies on implicit compiler/runtime optimizations that can be accidentally disabled or made non-applicable as code changes:
Who says TCO has to be always implicit? In eg Scheme it's explicit in the standard, and in other languages you can have annotations.
(Whether a call is in tail position is more or less a property you can ascertain syntactically, so your annotation doesn't even have to be understood by the compiler: the linter is good enough. That would catch your 'accidental changes' part.
Things get more complicated, when you have implicit clean-ups happen after returning from the function. Like calling destructors in C++. Then the function call is arguably not in a tail position anymore, and so TCO doesn't apply. Whether this is detectable reliably at compile time depends on the details of your language.)
Loops need mutation to work. The mutation might be benign or it might be headache-inducing. Without further analysis you don't know. If there's no mutation, no need for analysis. Lowering the cognitive load.
Well, replacing loops with tail calls is one tool to get rid of some mutations.
It's basically the same reasoning people give you for not using goto in your program: yes, there are ways to use gotos responsible, and there's ways to end up with spaghetti code.
If you use goto, the reader has to analyse and figure out whether you made spaghetti (and the reader can never be quite sure she understood everything and didn't miss an important caveat). If you express yourself without goto, the need for that analysis largely goes away.
If you iterate by const reference over a const container, and you make every assign-once variable in the loop body const (or in Rust: just not mut), is there any advantage to tail recursion except someone on the internet said it's the proper functional style?
I think functional programming contains some great ideas to keep state under control, but there is no reason to ape certain superficial aspects. E.g. the praise of currying in Haskell tutorials really grinds my gears, I think it's a "clever" but not insightful idea and it really weirds function signatures.
Function calls can express all kinds of useful and interesting control flow. They are so useful that even people who love imperative programming use functions in their language. (Early and primitive imperative programming languages like very early Fortran and underpowered dialects of BASIC didn't have user defined functions.)
So we established that you want functions in your language anyway. Well, and once you properly optimise function calls, what's known as tail call optimisation, you notice that you don't need no special purpose loops (nor goto) built into your language. You can define these constructs as syntactic sugar over function calls. Just like you can define other combinators like map or filter or tree traversals.
See how in the bad old days, Go had a handful of generic functions and data structures built-in (like arrays), but didn't allow users to define their own. But once you add the ability for users to define their own, you can remove the special case handling.
And that's also one thing C++ does well: as much as possible, it tries to put the user of the language on the same footing as the designers.
When 'map' or 'filter' are the best construct to express what you want to say, you should use them. When a 'for'-loop is the best construct, you should use it. (And that for-loop could be defined under the hood as syntactic sugar on top of function calls.) The scenario your concocted is exactly one where a foreach-loop shines.
Though to be a bit contrarian: depending on what your loop does, it might be useful to pick an ever more constrained tool. Eg if all you do run one action for each item, with no early return and you are not constructing a value, you can use something like Rust's 'foreach' (https://docs.rs/foreach/latest/foreach/). If you transform a container into another container (and no early return etc), you can use 'map'. Etc.
The idea is to show the reader as much as possible what to expect without forcing them to dive deep into the logic. The transformation in a 'map' might be very complicated, but you know the shape of the result immediately from just spying that it's a 'map'.
When you see the for-loop version of the above, you have to wade through the (complicated) body of the loop just to convince yourself that there's no early return and that we are producing a new container with exactly the same shape as the input container.
> I think functional programming contains some great ideas to keep state under control, but there is no reason to ape certain superficial aspects. E.g. the praise of currying in Haskell tutorials really grinds my gears, I think it's a "clever" but not insightful idea and it really weirds function signatures.
Yes, that's mixing up two separate things. Haskell doesn't really need currying. All you need for Haskell to work as a language is a convenient way to do partial application. So if Haskell (like OCaml) used tuples as the standard way to pass multiple arguments, and you had a syntactically convenient way to transform the function (a, b, c) -> d into (b, c) -> d by fixing the first argument that would get you virtually all of the benefits Haskell gets from pervasive currying without the weird function signatures.
In practice, people tend to get used to the weird function signatures pretty quickly, so there's not much pressure to change the system.
Nitpick: that’s not tail recursive. Something like def pow(base, n, acc=1) = match n case 0 => acc; default => pow(base, n-1, acc*base)
`for (var i = 0; i < n, i++)`, for example, requires that `i` mutate in order to keep track of the value so that the loop can eventually terminate. You could also do something like:
There, the reference to `i` is being mutated.For tail recursion, it's a little different. If I did something like Factorial:
Doing this, there's no explicit mutation. It might be happening behind the scenes, but everything there from the code perspective is creating a new value.In something like Clojure, you might do something like
(stolen from [1])In this case, we're creating "new" structures on every iteration of the loop, so our code is "more pure", in that there's no mutation. It might be being mutated in the implementation but not from the author's perspective.
I don't think I'm confusing anything.
[1] https://clojure.org/reference/transients
Basically, just pretend the loop body is a lambda that you call for each run through the loop.
It might make sense to think of the common loops as a special kind of combinator (like 'map' and 'filter', 'reduce' etc.) And just like you shouldn't write everything in terms of 'reduce', even though you perhaps could, you shouldn't write everything in terms of the common loops either.
Make up new combinators, when you need them.
For comparison, in Haskell we seldom use 'naked' recursion directly: we typically define a combinator and then use it.
That often makes sense, even if you only use the combinator once.
That's rebinding. Mutation is when you change the state of an object. Variables are not objects. You can't have a reference (aka pointer) pointing to a variable.
What?
tombert's point (I think) is that in a functional setting, factorial(n) is always factorial(n). In an imperative setting, first it's 1, then 2, then 6, then 24, etc.
Here's factorial calculated imperatively in C#.
There are two results (one primitive and one object) to show that it doesn't matter. Maybe there's no such thing as a Reference-to-a-Variable in whatever language you have in mind, but in the above code ObserveVariable refers to a variable (while it mutates).If I were to write
Then yes, that’s technically rebinding and not directly mutating the value in memory, but from “do stuff 2’s” perspective the value has changed. It still acts more or less the same as if it had directly changed.Otherwise you're just burning cycles and a good compiler should dead-code eliminate the whole loop.
(see https://news.ycombinator.com/item?id=44873488)
The parameter essentially becomes a mutable reference.
If I pass a persistent structure into a tail call, it looks like I'm passing a copy into there, in the same way that I might if I did something like `Math.abs(myValue * 3)`; the compiler converts it to a mutable reference but I see it as the same as passing a value.
Of course, a compiler might do whatever shenanigans it has to do. But that's an implementation detail and doesn't change how the language works.
(And let's not pretend that CPUs execute something that resembles an imperative language like C under the hood. That might have been true during the PDP11 or a VAX days. These days with out-of-order execution, pipelining etc what's actually happening doesn't really resemble one-after-another imperative code much.)
EDIT: I re-read parent comment.
>> the biggest advantage to using tail recursion over vanilla loops is the ability to keep using persistent data structures without any (apparent) mutation.
Yes
>> at least with Clojure's loop/recur stuff it is kind of cool to be able to keep using persistent data structures across iterations of loops
There's the implied mutation.
In tail recursive algorithms, there is no stack, it's just a straight loop.
Is simply: If it's not tail recursive you introduce a stack, for instance a DFS on a binary tree: Note the insertion order is reversed from the recursive calls in a typical DFS. We want left to be searched first and then its children and then we "recur" back to right and deal with its children, so we need to push right into the stack first and then left.When you have multiple mutually recursive functions (as is likely the case in a recursive descent parser) then things become more complicated, but it's still feasible.
That's basically what continuations provide. Scheme, SML, and others provide them.
Before I knew about CPS and defunctionalization, I wrote a Python decorator that did exactly the transformation you describe. https://github.com/tylerhou/fiber. Now I know about CPS and defunctionalization, I realize that my work was not the best implementation (but it was a fun learning experience!).
https://github.com/jberthold/packman
Recursion isn't physically real, any book that teaches the abstraction before explaining either the call stack (for non-TCO recursion) or in the GOTO context is a book actively trying to gatekeeper CS and weed out readers. (Not that any of those old pascal turbo/boreland books from the 90s actually shipped code that compiled.)
I had several people on HN of all places try to "teach me" recursion after this simple line inside a larger comment:
> It's like acting like recursion is physically real (it's not) when it smuggles in the call stack.
Recursion IS real abstractly. It's just not physically real, it was developed before we knew how DNA/RNA encoding handles the same issues in a more performant way.
That was a sharp left turn -- how do you figure DNA/RNA are relevant here? I feel like iteration pre-dates our modern understanding of RNA in particular (though I could be mistaken) so I struggle to imagine how DNA/RNA were particularly informative in this regard.
Do you also believe that loops and functions should only be taught after the call stack and goto are taught? Neither of them are real either by your measure.
What a silly sentiment.
They don't smuggle anything in conceptually, their abstraction doesn't leave anything critical to their structure out. They are real and can be physicalized as stand alone objects.
I see you've never tried to teach a software class to children or other learners, historically recursion is _very_ poorly taught by those who already understand the concept, but I'm not saying you have to care about that, a lot of people think there are too many programers already.
Recursive functions are a mathematical concept, like the "imaginary" number, or "trascendental" numbers. Or negative numbers for that matter.
Simple example, the Fibonacci sequence. FIB(1) = 1 FIB(2) = 1 FIB(N) = FIB(N-1) + FIB(N-2)
There's no programming language or "physical" implementation needed in order to calculate FIB(N) for arbitrary N. Pencil and paper will do for small numbers
But in computer programing it is often hidden.
And then you are misleading people about recursion and not helping them to build mental models that map to the physical/hardware process.
This isn't a big deal or anything, it's hardly worth talking about but it is literally a true thing many don't realize and that does empirically negatively effect education of the concept.
Recursive functions were mathematically defined well before the callstack or the von Neumann architecture was a thing. Godel, Church and Kleene did alot of work on them in the 1930s (and I believe even prior to that infinite series were defined recursively although functional theory had not be worked out), this was before ENIAC (1945) which would be just barely recognizable as a general purpose computer and didn't have anything like CALL instruction.
So I don't understand this notion of "hiding the callstack" comes from. If one cannot understand recursion without invoking the callstack, well thats just them. But I don't see how its absolutely necessary or some universal
I'm being quite clear that I take issue with how the concept has been taught in many programming books and courses.
You can see the ignorance and confusion in this very thread.
Math majors cover hundreds of algorithms per semester often using recursion without thinking much of it. For that matter, same with "higher order functions" (e.g. derivative operators, or even just translation/scaling of a graph). Even in introductory calculus, students cover things like fixed points (e.g. I remember discussing d^4/(dx)^4 sin = sin almost immediately after introducing derivatives).
Thinking in terms of physical machines is useful for reasoning about performance, but seems like a distraction for learning to think logically/correctly, which seems like a more important first step?
That only applies for non-primitive recursive functions, though. Most people rarely encounter those. For primitive recursive functions, it's all down to the nature of the function and what's idiomatic for your language. If you're using Scheme, for example, recursion is the name of the game.
When you use one to process the other, you get into trouble. For example, you need to manage a stack to do iteration on your binary tree. Or you need to construct slices to recurse on your arrays.
It's all about ergonomics.
Is a binary tree recursive from the perspective of a type declaration? Yes.
Is it recursive from the point of view of search? Depends. If you're doing depth-first search, then you need a stack and a recursive algorithm seems natural. If you're doing breadth-first search, then you need a queue and the algorithm is less obviously recursive.
In either case the program could be expressed recursively or as an explicit loop. When a stack is needed, the underlying hardware always provides automatic stack management so recursion feels natural in that case.
When a queue is needed it's more of a tossup since hardware and programming languages don't generally provide automatic queue management, so you're going to need to manage that data structure manually anyway. In which case whether you choose to use recursion or not is more of a personal preference.
Kinda shows that recursive data structures are usually better dealt with recursion. Worst case you end up with the same level of difficulty as iteration.
Just look at myriad ways that people implement something like checking win conditions in Rock, Paper, Scissors. Switch statements, conditional hell, etc.
...you can just do something as simple as stick Rock, Scissors, Paper into a circular list and compare to the next element. It's practically readable as plain English. No programming skill required.
If you need to code golf you can use modulo arithmetic, for a different kind of "simple", but then you lose readability.
The circular list seems a bit too cute for me. Sure, it's possible, but seems like overkill.
Heck, you could easily write it using Terraform built-in functions.
Not true at all. Eg Risc-V provides no stack management at all. But compilers and interpreters exist, so it doesn't make a difference to how your high level code 'feels'.
Recursion around trees can get you into trouble with the stack. Consider:
The second recursive call to traverse is in tail position and is a candidate for elimination, but the first one isn't and will _always_ eat stack space. This is fine if you know what you're doing, but can bite you in the arse if you're expecting TCO to save you.Linked lists are recursive, but you can use loops just fine to deal with them.
Similarly, it depends on what you do with your trees. If you want to go down a single path (eg to find an element in a search tree), loops aren't too bad.
And arrays don't necessarily ask for loops. Eg implementing quicksort or heap sort with loops is possible (and depending on your language, compiler and target machine might have performance benefits), but I'd hardly call it ergonomical.
Despite all my criticism you are hinting at a good point though: it's often useful to define your data structures together with combinators that work on them. So eg you want to define various tree traversals together with your tree. (Most people are familiar with the combinators 'map' and 'filter' for lists but that's a very small pool.)
Whether those combinators are implemented with recursion or loops is an implementation detail that the user of the combinator doesn't need to worry about too much.
Self recursive code takes on a fractal nature - at any call stack you cannot determine the call depth. You are in a twisty little maze.
When you layer calls horizontally, different tasks at different depths, it’s dead obvious where you are in the calculation. If all you can manage though is iteration, you still have the local variables to tell you where you are.
In contrast, I remember plenty of problems stemming from mutation (and lack of static typing etc).
and yeah, as others said, mutation is often the source of more problems.
Generally though I don't like hard "don't use X" rules. There's a time and place for everything -- it all comes down to what you're prioritizing at a given time. I try not to be dogmatic about anything.
I fixed two bugs I knew about, four bugs nobody knew about, cut 20 seconds off of startup time per instance and 10 minutes off of deployment. And enough memory to run another process per box.
This is not even the first time on that job and it won’t be the last. In fact it was a theme. I landed half of the real improvements to response time and cluster size in about a quarter of the time investment, mostly by unraveling recursive bullshit inserted by two architectural astronauts. About ten percent was done by people following up on stuff I started. The rest was long slogs trying to do major architecture shifts while leaving the shape of the existing code alone.
To be clear: the iteration is not a super optimization weapon. It just clears the water enough that people can see what’s going on. This is to me the heart of “make the change easy”. You start refactoring for one problem and you find two more while you’re in there that were lost in the noise.
I’d say roughly half of the rest of my time was spent reworking code we ostensibly wrote for other teams that they balked at owning, in order to add features and convince them to take ownership of it. And almost half of that work was ironing out similar Rube Goldberg machines. How do you expect people to own code they can’t figure out how to maintain?
You won’t know if you haven’t tried, and “I haven’t seen this” is not a strong signal that you have. Keep an eye on the next coworker who talks like me and see what they’re up to.
That's why React components are _weird and wrong_, SQL CTEs are _overly complicated_, and functional programming is _impossible to understand_. It's all boxes with other boxes inside them, instead of next to them, and many people can't understand you can nest boxes many times.
I just feel that a recursive calls makes state much more clear, but then again I am no fan of mutation in general. In my old python days I think a good 60% of my bugs were due to me being bad at managing state.
[1] https://rikspucko.koketteriet.se/bjoli/goof-loop
One of my favourite combinators is matrix multiplication. You define what 'addition' and 'multiplication' mean in your case, and all of a sudden you get an algorithm that computes shortest paths in a graph or does regex matching. See https://news.ycombinator.com/item?id=9751987
But for more bread and butter cases, there's 'map' over various data structures, and 'reduce' and traversals of trees etc.
I think maybe in languages like Ruby or Smalltalk a loop can be more readable, because of how they structure it as messages to objects, rather than special keywords in the language.
Well then you’re in luck because that was not an academic statement but a professional opinion - mine.
You can’t optimize the patterns you can’t see because they’re obscured by accidental complexity. And iteration is a frequent trick I use to surface deeper pattern s.
A big win I mentioned elsewhere involved splitting the search from the action, which resulted in a more Dynamic Programming solution that avoided the need for cache and dealing with complex reentrancy issues. I'm sure there's another name for this but I've always just called this Plan and Execute. Plans are an easy 'teach a man to fish' situation. Sit at one breakpoint and determine if the bug is in the execution, the scan, or the input data. You don't need to involve me unless you think you found a bug or a missing feature. And because you have the full task list at the beginning you can also make decisions about parallelism that are mostly independent from the data structures involved.
It's not so much enabling things that were impossible as enabling things that were improbable. Nobody was ever going to fix that code in its previous state. Now I had, and there were options for people to take it further if they chose.
This is true for some languages, but not all.
E.g. scala has @tailrec annotations, which make it a compile error for the annotated function to not be tail recursive. Clojure doesn't have tail call elimination, but has the `recur` special form for explicit recursion that is guaranteed to not consume any stack space.Rust has reserved the `become` keyword that will eventually guarantee tail call elimination (So pretty similar to Clojure's recur, but I think Rust's version will allow mutual recursion)
Zig goes the whole hog, and has `@call(modifier, fn, args)`, where `modifier` can be things like compile_time, always_tail, always_inline, never_tail, never_inline, and a bunch other desirable guarantees you might want.
Support got added to nightly quite recently, in fact - on 2025-07-31 [0].
[0]: https://github.com/rust-lang/rust/pull/144232
https://clojuredocs.org/clojure.core/trampoline
> This is true for some languages, but not all.
Useless anecdote that tail-recursive can be a foot gun even in Scala.
I did a (screening) job interview at Datadog, they asked for "give the spare change back on that amount of money" exercise (simple variant), "in whichever language you want". I did my implementation in tail-recursive Scala (with the annotation). I ended up trying to explain that tail-recursivity doesn't explode in memory for the rest of the call (and failed)
I count that as a successful interview – They sent an interviewer who doesn't understand how tail recursion enables tail call elimination, and is either unwilling or unable to listen when is is explained to them. Sounds like the company would be a bad fit for somebody whose go-to approach is to implement the solution that way.
What do you mean by easier to scan? I find (most) loops [0] hard to read, because they typically involve mutable state.
When properly implemented, tail calls are as fast as gotos and loops, and don't blow up any stack. (Not all languages are implemented with a stack in any case.)
However you have one point:
Most of the time, we don't use recursion directly in our programs even in a language like Haskell or Scheme. Instead we define a 'combinator' that encapsulates the specific, limited recursion scheme that we want, and then use that one. This is very similar to how people generally don't use goto directly in their programs.
You might be familiar with the combinators 'map', 'filter' and perhaps 'reduce'/'foldr'. You could re-interpret the various common loop types as such recursion combinators that weaker languages have to bake into their language, because they are not strong enough to express them as a user-defined library. And indeed, Haskell has Control.Monad.Loops (https://hackage.haskell.org/package/monad-loops-0.4.3/docs/Control-Monad-Loops.html) which gives you exactly the common loop types as a library.
Some examples of less common combinators are eg 'unfold' or various combinators to traverse trees or graphs.
[0] The foreach-loop over some sequence is less headache inducing than eg a while-loop.
It's well worth being familiar with both - if you learn how to shoehorn both approaches where they aren't ideal, your judgement on avoiding such practices will improve. :)
Pretty much true for any functional feature. Great in the classroom, less practical in the real world.
Pure functional is difficult, but breaking out the parts of the program which can be pure functional is generally a smart thing to do
If I had drive and ability the to make a programming language from scratch, it would be hybrid imperative/functional, and "pure" functions (no side effects, EXCEPT possibly debug logging) would be clearly marked as such.
Then you realize you are sort of forced to handle all exceptions from impure code at the top level, and after that all that remains is validated data running through pure functions that are easily testable.
At the top layer, your code will dip in and out of pure and impure functions. All possible exceptions coming from bad data and I/O issues are visible at that layer since those cannot reasonably occur in the pure code.
I admit it will be an odd sight at first, but the feeling of working with a domain safe from bad data makes it worth it.