Hacker News new | ask | show | jobs
by ajuc 1457 days ago
Optimizing calls using the same syntax as regular ones risk introducing stack overflow without noticing.

    function rec(x, y, z) {
       if (something(z)) {
          return rec(x, y, z-1);
       }
       return 1;
    }
Now you need to add 1 to the result.

    function rec(x, y, z) {
       if (something(z)) {
          return rec(x, y, z-1) + 1;
       }
       return 1;
    }
Ups, now it fails with stack overflow for big z values. Hope you have good unit tests to catch this.

To avoid this problem whenever you modify a possibly recursive function in a language with transparent TCO - you have to look through the code to see if it's using TCO. This is wasted time. And it can be non-trivial if you have recursive functions calling each other and other funny stuff.

If instead the language required special syntax for TCO:

    function rec(x, y, z) {
       if (something(z)) {
          return TAIL_CALL rec(x, y, z-1) + 1;
       }
       return 1;
    }
This would be a compilation error cause it's not a tail call contrary to the declaration.

What is the downside to requiring special syntax?

3 comments

Idk, rarely (never?) happened to me, that I make a tail call into a non-tail call, because I ad-hoc add something like in your example to it. Seems like a beginner mistake. When I design a function to be tail recursive, which is all the time in langs supporting that, I wont suddenly disregard all care and add code like that. And hopefully other people will at least spent a minute reading the function they are modifying, before slapping that change on and calling it a day. If not, then some syntax wont protect you from that either.
I don't quite get the argument. Isn't this similar to e.g requiring the `z` parameter to be annotated with something that ensures that it is a small number? ("What is the downside of having special syntax for that?")

My undrstanding is that a tail call is a tail call, and the variant with +1 is not that, thus being a candidate to be looked at.

Perhaps also IDEs can help here if it's difficult to spot?

> Isn't this similar to e.g requiring the `z` parameter to be annotated with something that ensures that it is a small number?

You mean something like "unsigned short int" :) ? Another example would be "const". What's the point - either the variable is const or it isn't. Programmer can just remove the mutation.

> My undrstanding is that a tail call is a tail call, and the variant with +1 is not that, thus being a candidate to be looked at.

Sure, but don't you agree it's an easy mistake to make? Especially when the recursion goes through several different functions. Additionally - you reading the code might not realize it's important for this function to continue to be tail-recursive.

> Perhaps also IDEs can help here if it's difficult to spot?

Or perhaps a compiler can do it?

I guess we can just agree to disagree here.

Regarding compiler vs IDE, I'd say that since it doesn't change the meaning of the code, just the depth of recursion, then IDE would fit better. Perhaps you could also ask for info on tail-call optimized functions from the compiler giving it a flag, but otherwise I don't think whether something was optimized or not doesn't matter much (depending on what you work on, of course).

The downside is that most web developers don't care about performance, so they wouldn't bother to use it.
TCO is kinda binary. If you use a recursive function on small data it doesn't matter. If you use it on big data it fails with stack overflow so they will fix it.
What if you're using it on small data, but inside a loop?
AFAICT, your question doesn't make sense. But I guess you are thinking, "what if we should be using the TAIL_CALL syntax for performance but most people don't, and it's exarbated inside a tight loop?" If so, I'll try to explain:

TCO is not about making code faster, it's about making it not eat up all the (stack) memory. It's a memory, not speed, optimization[1]. (OK, using less memory does make it somewhat faster (even when not swapping), too, due to fewer memory accesses, but it's usually not a big difference, and not what we worry about; what we worry about is eating up so much memory that the computer starts swapping because of it, yes, at that point it would become much slower, but that doesn't happen with "small data in a loop"...let me finish.)

Not using the TAIL_CALL syntax in a tail recursion would use up the stack memory iff the recursion is deep (in this context because the data is not small).

If "inside a loop" in your question means, a self-recursive function call (tail recursion),

    function foo(...) {
        if ... {
            foo(...)
        }
    }
then the answer would be, if it's on small data, it only uses a small amount of stack space. No problem. The problem only comes up if the data is large.

If "inside a loop" means that you're using a for or similar loop syntax:

    for ... {
        foo(...)
    }
then a call to a function inside that loop is not actually a tail call (so the compiler would report an error if you were to use the TAIL_CALL syntax), since at least the test in the loop has to run after returning from the function call.

Does that make sense?

[1] And the memory is only being used until the end condition in the recursion is met, i.e. temporarily; it doesn't contribute to bloat, just uses memory for a bit then releases it again; except when it uses so much memory that you run out of RAM (or stack space if the VM limits stack space separately).

> It's a memory, not speed, optimization

I assumed it was a speed optimization too, since a goto is faster than creating a whole stack frame and then destroying it again. Is that not the case?

By “inside a loop”, I meant a loop that is independent of the function, like this:

    function foo() {
      if (…) {
        foo();
      }
    }

    for (let i = 0; i < 1000000000; ++i) {
      foo();
    }
Here it won't cause a stack overflow either way (if foo is used on small data), but I'd assume that the loop would amplify the effect of even a small performance optimization.
> since a goto is faster than creating a whole stack frame

Yes, as I mentioned in parentheses--creating a stack frame costs because it is writing to memory, other than that it's just an increment of the stack pointer register in compiled/JITted code. But it's not a large cost, so you usually won't notice it or at least not much. Especially if your recursion depth is small, as then the memory writes might not leave the CPU cache. Sure, making some code 10% faster by way of TCO can still be useful, and it's probably why C compilers are doing it (they don't guarantee general TCO so you're limited in what you can do with it, so the only guaranteed advantage is a little speed gain), but if we're talking some JavaScript app there will be worse problems. Especially, the problem that with large enough data you're going to allocate lots of temporary memory for the stack--that's something a user will notice. Especially if the memory temporarily used is not given back to the OS, which is the case in C, and I rather expect JavaScript as well, which will mean that every tab running JavaScript will have some amount of RAM tied down to (even if rare) temporary stack use.

> and then destroying it again

This is then really just a decrement of the stack pointer.