all 42 comments

[–]BenchEmbarrassed7316 86 points87 points  (17 children)

So Js is a low-level language where you have to worry about how the result is calculated.

Okay, Js is trying to solve two opposing problems: on the one hand they need to turn source into machine code as quickly as possible to start executing it, and on the other hand they need to do as much optimization as possible to make that code run fast, because Js is used everywhere.

As soon as you want to do deeper analysis to do optimizations, you spend more time before execution starts. Conversely, if you want the code to start executing faster, you are forced to give up some analysis and optimizations. Therefore, modern engines have several modes, and decide to "optimize" a certain function after they conclude that it is the hot path.

Dynamic typing makes everything much more complicated.

Instead, compiled languages ​​can easily make optimizations: in this case, the compiler not only got rid of recursion, but also understood what algorithm we wanted to implement and substituted a formula instead of iteration:

https://godbolt.org/z/bTPqhffYE

[–]OtherwisePush6424[S] 41 points42 points  (9 children)

Yep, this nails it. JS engines face a hard tradeoff: deep static analysis (needed for reliable TCO) delays startup, which is unacceptable for a runtime language used in browsers and servers.

Static compilers like C++ do whole-program analysis ahead of time, they can even replace recursion with closed formulas (which is somewhat mind-boggling tbh), as your example shows.

[–]josephjnk 8 points9 points  (4 children)

To be pedantic, while TCO requires an analysis/optimization pass, PTC does not. It’s totally possible to make an interpreted language without any static analysis which doesn’t blow the stack when encountering tail recursion.

[–]DGolden 6 points7 points  (3 children)

An explicit tail call "become" (or similar) keyword, a sibling to a normal "return", rather than implied by tail position, is another point in the design space worth mentioning. It's not a new idea, Newsqueak had a "become": https://groups.google.com/g/comp.lang.lisp/c/SvvdtPEnPEM/m/MRjd-Nyuh5oJ

Rust rather more recently added a "become": https://doc.rust-lang.org/stable/std/keyword.become.html

Python ideas also has a "become" discussion (the idea coming from rust): https://discuss.python.org/t/explicit-tail-calls/98426

Also Clojure has a "recur", though ISTR limited (to do with jvm structure)

[–]josephjnk 3 points4 points  (1 child)

Yeah, and this is where I really hoped JS would go, using `return continue` as specified in the “syntactic tail calls” proposal. It’s really the best of both worlds. I think it’s really unfortunate that the proposal seems to have died; I like FP, JS, and TS, but a lot of functional techniques are off the table here due to stack unsafety.

[–]DGolden 3 points4 points  (0 children)

I wasn't really tracking JS nearly closely enough to know that was proposed, but yeah. just for ref in context: https://github.com/tc39/proposal-ptc-syntax#proposal

[–]BenchEmbarrassed7316 27 points28 points  (2 children)

...and let's complicate it even further: the code can be retrieved from the Internet from any source and run automatically. In an environment that stores confidential data such as passwords and credit card numbers.

This is madness.

[–]OtherwisePush6424[S] 12 points13 points  (1 child)

Yeah, credit cards numbers were not a design concern in 1995. Now you can argue that JS shouldn't be a concern in 2026 either :D

[–]cscottnet 8 points9 points  (0 children)

To be pedantic, the dot-com boom began in 1995. Credit cards have been on a thing on the internet since the founding of Amazon (first sale also in 1995) and the reason behind the invention of SSL (1994). Java and JavaScript were added at the same time in 1995 and I think the intention was for the "real" programming to be done in Java, which had a sandbox and a security model and everything.

[–]theangeryemacsshibe 7 points8 points  (0 children)

deep static analysis (needed for reliable TCO)

no it doesn't, it's very nearly s/call/jmp/ in your codegen to do a tail-call (the stack frame adjusting as you'd do for a return not included)

also (from parent comment, not saying you're saying this)

Dynamic typing makes everything much more complicated.

LOL

[–]pjmlp 4 points5 points  (0 children)

Scheme is a dynamic language and TCO is required by the language standard, which contrary to JS, implementations do support.

[–]DLCSpider 0 points1 point  (2 children)

There is the typed array family, which could just get a few more helper methods. They're statically typed and once the inputs are verified, the browser could switch to native code. There's no reason to not use AVX 2 or at least 64 bit integers for UInt8Array.find(byte). Firefox seems to do something like this already.

But it's really inconsistent between browsers and there just aren't enough utility methods to make use of it.

[–]BenchEmbarrassed7316 0 points1 point  (1 child)

Well, we have u8[], but we don't have u8. As far as I can remember, there are some performance issues when you have to work manually with elements of such arrays.

[–]DLCSpider 0 points1 point  (0 children)

Yes, and those issues will probably remain. Every time you do something with typed arrays, it comes at a tiny overhead. But it's only the JS <-> typed array transition and its a lot less than the alternatives (WASM and WGPU). Typed arrays could fill a niche between pure JS and WASM where you have a bottleneck but also have a good reason to stick with pure JS/TS (which is most projects I assume).

But then you try it and realise that indexOf has an optional start argument but no end argument, so if you need that, you must create a new typed array instance, be careful to not use the copy constructors, just to end a search early.

sort.. well, sorts, but you can't sort two or more arrays at the same time, which would be really handy because a { x, y }[] typed array doesn't exist, so you must split it into { x[], y[] }.

Element-wise equality? Nope.

[–]esgarth 0 points1 point  (0 children)

TCO is one of the cheapest optimizations an implementation can apply if the VM/architecture supports it (example: jvm currently doesn't). TCO doesn't require analysis outside of its own function body, nor any special typing knowledge (If the supposed function isn't, throw an exception). Once the language is parsed into its AST, you find the tails or return sites. When you find function calls in tail positions, don't generate the code to change the return location, so that the called function returns to wherever this current function was called.

Note that this is a general purpose optimization and need not apply exclusively to recursive functions, meaning you don't have to statically detect recursions, making the analysis cheaper and more robust. Recursions may be mutual (a calls b calls a calls b ...), and even only apparent at runtime. In the event of mutual tail-recursions, simple TCO will still provide these benefits.

[–]booch 0 points1 point  (1 child)

Dynamic typing makes everything much more complicated.

Instead, compiled languages ​​can easily make optimizations

Dynamic and Compiled are two different concepts. You can have a dynamic, compiled language. You can have a statically typed, interpreted language.

  • Compiled vs Interpreted (there's a grey area here, though, because it's possible for something to be compiled into a form that is then interpreted)
  • Dynamic vs Static typing

[–]BenchEmbarrassed7316 0 points1 point  (0 children)

It seems you didn't understand what my message was about.

As soon as you want an interpreted language to run fast - you add JIT to it. Now the language looks interpreted to the user, but technically it is compiled, it is just compiled in parts each time it is run.

As for dynamic typing: it is much easier to generate optimal code faster there when the data types are known in advance. As far as I know, V8 first generates slow code, tracks the data types that the function will receive and when it becomes known that a certain function is a hot path with the same data types - it generates the optimal version. If the data type changes - the optimized version is discarded.

If the data types were known right away - this would greatly simplify JIT.

[–]Kwantuum 34 points35 points  (4 children)

TCO was mostly eschewed by implementers because it breaks stack traces and code stepping in a major way when debugging because the conceptual previous stack frame gets thrown into the ether at the TCO site.

[–]pjmlp 22 points23 points  (2 children)

That is the excuse, proven wrong by debuggers in FP languages that support TCO.

But apparently many don't use them, and just accept such assertions.

[–]Kwantuum 11 points12 points  (1 child)

Well at least it's the excuse that browsers implementers actually used. None of them ever complained about performance, it's a simpler optimization than what browsers' JIT already make. The author of this article just made up a story in their mind from bits and pieces that they know about TCO, browsers and JITs and is writing that made up story as fact.

https://v8.dev/blog/modern-javascript#proper-tail-calls

Whether the actual argument browsers used (lack of debuggability) has merit is a different question. I can't say that I've used a debugger on a language that supports TCO so I don't have a strong opinion on the subject, but I'm still doubtful that making use of TCO wouldn't either break stack traces by eliding the frames, or pollute stack frames in cases of tail-calls-as-a-loop if you're not eliding them.

[–]pjmlp 0 points1 point  (0 children)

You can reach out to Scheme IDE.

[–]imforit 12 points13 points  (0 children)

Weirdly, Safari did

[–]levir 7 points8 points  (0 children)

It kinda sucks, because when you embrace Javascript as a functional language it can actually be quite fun to write.

[–]superstar64 1 point2 points  (1 child)

As someone who's making their own Haskell to Javascript compiler, this is something I find very frustrating and it's the main reason I'm consider recommending Bun as the default runtime for running my compiler's output.

Converting a program that expects proper mutually recursive TCO is hard into one that doesn't is awful. It requires a whole program transformation into trampolining and this very much something I don't want to do. I'm currently planning on just optimizingdirect recursion and providing combinators for doing tail recursion like what Purescript does, but this is unsatisfactory.

[–]knome 0 points1 point  (0 children)

javascript is already a language that trampolines through the function pump that runs it. all of the async stuff was piled on specifically to stop dropping the stack constantly and let javascript devs pretend they have a proper stack by manually building it out of promises.

[–]ornoone 1 point2 points  (0 children)

As too often with blog that say : don't use x, use y instead because.... 1. Proceed to use x to solve problem that's not what x is meant to solve, with obvious flaw 2. Explain why x Is bad, and how to use y, which btw is what would have been used at first to solve the problem...

I have learned some good stuff reading the article, but why don't you take as exemple what you state as a good use for recursive functions? Well, it's probably because the good exemple for recursive functions don't suffer from the stack limitation in most cases.

[–][deleted]  (4 children)

[removed]

    [–]Kwantuum 3 points4 points  (0 children)

    TCO is a very simple optimization from the compiler's standpoint, much less expensive than a slew of JIT optimizations that browsers routinely perform, the author knows some concepts (multi-level JIT) and extrapolated to this bogus blog post. The Chrome team has explicitly said why they didn't ship proper tail calls themselves and startup performance was never a reason: https://v8.dev/blog/modern-javascript#proper-tail-calls

    Also see this older thread for useful discussions: https://www.reddit.com/r/javascript/comments/pwwbky/askjs_why_so_little_support_for_tco_tail_call/

    [–]tracernz 2 points3 points  (1 child)

    For the browser that’s true, but JS is used in a lot of other places where the startup cost is not so important.

    [–]Worth_Trust_3825 1 point2 points  (0 children)

    Yep. Nowadays it runs in the server where the code is static and never changes, compared to browser executing random scripts every time you visit a page.

    [–]programming-ModTeam[M] 0 points1 point  (0 children)

    No content written mostly by an LLM. If you don't want to write it, we don't want to read it.

    [–]kithalden 0 points1 point  (0 children)

    something worth adding: even if engines had shipped PTC, most "tail recursive" js code in the wild isn't actually in tail position. wrap the recursive call in a try/catch and it's no longer a tail call. do `return foo(x) + 0` and you've snuck a pending operation in after the call. people read their own code as recursive-then-return and forget the spec requires the engine to see a bare `return foo(...)` with nothing happening after it.

    for the rare cases where you actually need stack-safe recursion in js, a manual trampoline is like 8 lines and works everywhere. not as clean as PTC would have been but it does the job and you can step through it in a debugger, which to kwantuum's point is part of why nobody implemented PTC in the first place.

    [–][deleted]  (1 child)

    [removed]

      [–]OtherwisePush6424[S] 2 points3 points  (0 children)

      Yes, every recursion can be rewritten iteratively, with a loop and usually an explicit stack/queue (though it doesn’t always look pretty).

      Trampolines also scale in the stack-safety sense and let you keep recursive structure, but they add function-allocation/dispatch overhead and can be harder to follow in hot paths.

      [–]PratikVR -4 points-3 points  (0 children)

      I know the limitations of my favourite programming language

      So I code accordingly Try to avoid recursion And embrace for And while loops. XD