PureScript and Haskell
blog.drewolson.org
blog.drewolson.org
Stack safety is directly related to laziness—Haskell being lazy[1] by default is what lets functions that would cause stack overflows in other languages work in Haskell. If you're incrementally producing or consuming a lazy data structure, memory can be allocated on the heap and garbage collected as you're traversing the data structure without needing to use the stack[2]. Haskell makes recursing over or producing lazy data structures the path of least resistance, so you end up almost never worrying about stack overflows—they only pop up in code written with strict types like numbers.
Of course you end up with other issues that are unique to lazy languages—like space leaks—but you really do get some pervasive advantages from having lazy code by default. Some people make it seem like laziness has lots of downsides with only niche upsides, but that hasn't matched my experience at all: I've found laziness makes day-to-day programming better in a few ways (like not thinking about tail calls and stack overflows) that are the opposite of niche—so pervasive it's easy to take them for granted.
[1]: Technically Haskell is "non-strict" rather than "lazy", but I've found that distinction obscures the core issues in discussions like this. Just throwing in this footnote as a fig leaf to accurate terminology :).
[2]: I feel this explanation is oversimplifying or not quite getting details right, but can't think of a better way to explain it at the moment.
With Erlang, recursion is effectively the only looping construct, so it becomes second nature to make everything end with a tail call.
loop a b = a : loop b (a + b)
fibs = loop 0 1
Because tail call elimination can't do anything about the first case.The status quo of strictness and lack of general TCO has a real cost. Every abstraction taxes your application!
Laziness has its own performance tax. It's only performant in Haskell because GHC goes to great lengths to perform various strictness analyses and libraries try to provide fusion. In fact when those optimizations fail to fire (which is the bane of any large Haskell program), Haskell will far under-perform compared to other strict languages in the ML family.
Do you have some examples for this happening?
I have seen extremely few stack overflows in Haskell since I convinced GHC to default to infinite stack size 7 years ago [1].
I was curious and just ran the standard bad `foldl` example and I got a stack overflow in GHCi (8.8.4) but compiling without optimizations in GHC (to avoid strictness analysis) just led to the program taking over all of my system memory.
I do use `runhaskell` quite a bit for my personal utility programs, so I wonder if that's what gave me the impression of those stack overflows (`runhaskell` naturally also gave me a stack overflow).
Of course the wider point is that tail calls in Haskell can often end up not taking constant space and in fact moving the function out of the tail position can go back to constant space, but thanks nonetheless for the correction!
And moreover I think the majority of programmers can immediately spot when something could lead to a stack overflow. I can't recall the last time I had an issue with a stack overflow that wasn't an infinite looping bug. It's not a difficult problem to begin with in strict languages. This is even more true when I think back to how many times I've written a recursive function by hand rather than use a higher order combinator in functional code. I think less than 1% of the code I write is even recursive to begin with.
Space leaks are a pretty annoying problem in Haskell programs though. There have been quite a few large, widely-used Haskell programs that have battled with them. Heck Shellcheck's author dropped multithreading because he couldn't figure out the issue with space leaks. I can't think of any large program that has ever been forced to a drop a feature because they couldn't solve a stack overflow from overly deep recursion (it is a purely mechanical transformation to take any recursive function's call stack and pass it in instead as an explicit stack on the heap).
And being able to locally reason about performance rather (i.e. only needing to take into account the implementation of a function) than having to take into account nonlocal reasoning (how your function will be called) is a huge win on par with the analogous gain of local vs nonlocal reasoning for correctness in functional programs.
I agree it's a matter of experience and changing your way of thinking, but personally I'm way more confident about what the performance profile of a program I've written in Purescript will look like before extensive testing than what the performance profile of an equivalent Haskell program will look like before extensive testing.
This doesn't fix the more general problem of something that is generated by a (co-)recursive function and consumed gradually.
Non-strictness lets you "pause" the recursion until you need it. You can have a data structure that's bigger than memory (even infinite) and it's not a problem as long as you don't need all of it at once.
And importantly, in line with local reasoning, in a strict language, it will be very apparent when you need it. It won't take you by surprise.
On the other hand, Haskell's default list and its associated functions are just enough to let you get by but have enough pitfalls that can you take you off-guard that in the end, for more complex operations, you just end up using a streaming library anyways (e.g. conduit, pipes, streamly, etc.).
This is moving the goalposts a bit. Certainly you can do streaming in a strict language if you're willing to use libraries, but we're talking about language primitives.
> you just end up using a streaming library anyways
You generally only do this if you're interleaving effects with your streaming. I use guarded corecursion all the time, e.g. when I write an expression like
index = zip [1..]
Any strict language that supports something like `[1..]` has a lot more crap to deal with, and usually has to implement something like overloading list expressions to create iterators, which have their own problems.I don't think so. My original comment was focusing on stack overflow errors, but my wider thesis is this:
By and large, strict languages trade off some level of syntactic convenience for easier, local reasoning about performance. And I am willing to accept that hit. So for example, yeah sure maybe you have to do a mechanical transformation of a recursive function to either use a trampoline or an explicit stack or rewrite with an iterative loop, but that's an acceptable trade-off for me.
Stated another way:
The cases where a lazy language can cause weird performance issues are harder to debug than the cases where a strict language can cause weird performance issues.
Indeed the fact that a library is all that is needed to patch up a lack of language primitives makes me more willing to accept the trade off of syntactic convenience.
index = zip [1..]
vs index = Stream.zip (Stream.from 1)
or even conversions to and from lists if you really don't want streams elsewhere such as index xs = Stream.toList . Stream.zip startIndex $ streamedXs
where
startIndex = Stream.from 1
streamedXs = Stream.fromList xs
is a sufficiently small price for me to pay to gain back local performance reasoning (I mean in this case I'd just write index with a fold to begin with in a strict language).> interleaving effects with your streaming
But the issue that effects are supposed to be values in and of themselves (i.e. if I really take effects as a value seriously, why must my machinery change when I have a list of IO actions vs a list of integers?). This is really a symptom of the deeper issue is that there are a lot of things that should be strict for other reasons that can interfere with purely lazy lists or other structures because they basically represent this sort of weird interleaving of strict and lazy needs (this is why e.g. foldl' fails without strict data fields).
Non-strict languages fundamentally change the way one can think about function context; unlimited guarded corecursion via thunks is a canonical example.
I have to anecdotes of having to drop features in a large python program due to memory leaks.
The first one was when someone introduced an endpoint that consumed data from a gRPC service. After weeks of effort trying to find where the source of the leak was, we decided to drop the change as we could not find the cause in a reasonable time.
The second case was when introducing jaeger for distributed tracing. Similarly to the previous story, it was cheaper for us to drop the feature and use something else than to figure out where the memory leak was.
I believe that it is a matter of balancing effort vs, benefit. I would not be surprised if someone working on their free time on an open source project decides to drop a feature because they cannot find the time to make it work properly.
Lazy languages can have that same problem in addition to space leaks.
Tail recursion specifically changes whether a compiler can optimize away the stack, which means your out of memory will come from lack of memory for a stack. Whether the stack is allowed to use all memory is a decision beyond your control in a language that runs in a browser.
I found this article really helpful for explaining why laziness is important: http://augustss.blogspot.com/2011/05/more-points-for-lazy-ev...
In particular, scroll down to the "Reuse" section. Lazy evaluation enables you to compose functions to build new functions with the right performance characteristics (constant factors aside), as in:
any :: (a -> Bool) -> [a] -> Bool
any p = or . map p
In an eager language, predicate `p` would have to be evaluated on every element in the list, even if the predicate is already satisfied after the first element. So instead you'd have to write this function in an ad hoc way with explicit recursion/looping, without being able to reuse the more primitive functions.Of course, in an impure language, laziness can't be the default because you wouldn't be able to reason about when your side effects occur. But in my mind that's an argument for purity rather than an argument for eagerness.
BTW, comparing "strict" vs. "lazy" is a category error. The proper comparison would be either "strict" vs. "non-strict" or "eager" vs. "lazy". The former is in reference to the denotational semantics, whereas the latter characterizes the operational semantics.
For example, the following Python code has the same evaluation semantics.
def any_predicate(predicate, iterable):
return any(map(predicate, iterable))
What's less clear is whether using lazy evaluation everywhere is a good idea. Certainly it has some really nice properties - for example it blurs the distinction between macros and functions by allowing arguments to be lazily evaluated.However, It can lead to hard to debug errors (as the error will not necessarily surface at the call site), and make it hard to reason about the performance and memory usage of code.
One of the positive side effects of this fact is that Haskellers have been forced to figure out proper error handling strategies, for the exact same reason Haskellers were forced to figure out proper effect management strategies (e.g. monadic IO). In fact, errors and effects are part of the same problem.
In imperative languages, the temptation to use evaluation sequencing as a hack for handling errors and effects has proven too tempting for every pre-Haskell language I'm aware of.
Good point. Interestingly, there doesn’t seem to be a commonly known notion of evaluation that would blur the distinction between FEXPRs and functions — that is, one that would allow “functions” access to the unevaluated syntactic form of the arguments.
Agreed. Clojure uses lazy sequence by default. After ClojureScript is compiled to js, sometimes it's crazily hard to figure out why the error happened due to the obscure call site in the code.
The community’s passion turned out to be really encouraging. I’m still writing things in it when I have the opportunity, and I’m watching it to see where all the natural energy will end up carrying it.
Besides, as far as I know, the PureScript part is only for the business logic, not for the rendering, etc like if you want to use React or Angular or pure DOM manipulation. The backend story goes the same (i.e, PureScript only for business logic).
With the extra tooling required (i.e, spago, bower) it seems pretty pointless to use PureScript these days, what am I missing? Someone please convince me to use PureScript.
Few plusses
- It handles dom manipulation. - Simpler syntax - Elm-UI framework - No extra dependencies
Fair. And that's what it wants/advertises to be. "Not a general prog lang".
> Its approach also doesn't scale. Too much writing, like Redux.
Not sure what you mean by this. Software that needs to scale do not run in browsers. Elm is just for the FE bit, the UI, the interactive browser stuff. Hence the DSL thing makes sense (to me); though I totally understand one does not like that.
And when it comes to writing: it has very terse syntax. Look at all TodoMVC examples and you find that Elm will use about the least number of characters of any implementation that gives strong typing guarantees.
You want PureScript because you want the safety of Haskell style functional programming on the frontend, with strict types, effects tracking, category theory, and all.
You want Scala.js because neither of these extremes are appealing to you. You've been burned by Typescript, you're interested in functional programming but don't want to completely give up the good parts of OOP just yet (or ever).
I'm here to sell Scala.js (of course). You can use it with React or with one of Scala.js UI libraries. It's very nice.
https://github.com/com-lihaoyi
Of all these languages I find Scala the "biggest" language: most syntax to learn, most quirks, least notion of what is idiomatic.
I'm not here to sell Scala, more warning people to stay away from it.
The language, like any other, does have some quirks, but Scala 3 takes care of many long standing problems (in RC1 already).
It's true that there is no single idiom for Scala code. That's because unlike other languages, it's a true multiparadigm language, and so different people actually do use it differently. It's not a monoculture, but there is a well defined idiom for each style, be it hardcore FP, or Actor based programming, or Java++, or something in between.
Anyway. Ocaml is one of the better alternatives, but it's not without its problems either, both in terms of language and ecosystem.
You only need to use one of spago or bower (spago being the recommended choice now), and that's because npm style dependencies are not suitable for PS.
As for why you'd choose it over TypeScript... well, you'd do so if you want to use a functional language that doesn't allow arbitrary effects and has a sound type system.
If you're adding all the complexity that comes with using a language that must first be compiled to ECMAScript anyway, then why would you pick a language that's essentially ECMAScript with fancier clothes?
The build chain complexity tax is already paid. Now you can go for the best language the ecosystem offers. Half-measures seem wasteful.
(This was at least how I reasoned when I went for F#-to-ES.)
I would be very very cautious about this line of reasoning. It can work for certain situations, but there are enough tradeoffs and exceptions that I am hesitant to recommend this as a general rule of thumb.
The ability to incrementalize risk (JS code snippets on the web just work, the compilation target is trivial to deduce, runtime characteristics do not change, no need to write wrapper code to consume npm packages, etc) instead of taking a lump sum hit to your novelty budget is valuable. Not always overwhelmingly so, but enough to give me pause.
I'm curious what the author means by this; Wai and Warp are collectively by far the best (and in a sense "most mature") HTTP(S) backends I have dealt with.
It sounds like the author had a weird constraint ("with the ability work within my custom monad stack") that I have never run into before which caused him to use a relatively less popular backend; I would be curious to hear more about this.
I also could not place this remark. Haskell has about the most mature BE ecosystem I can think of. Servant, IHP, Yesod, Wai/Warp, and there is lots more.
Depending upon what the likes of `Aff` compile down to, you may not even need a JS wrapper. If the compiled type signature is compatible you can just point the Node.js runtime directly to the PureScript output.