Hacker News new | past | comments | ask | show | jobs | submit login
PureScript and Haskell (drewolson.org)
167 points by allenleein on Feb 26, 2021 | hide | past | favorite | 60 comments



> While not directly related to strictness, a pain point on the PureScript side that I didn’t experience in Haskell was stack safety.

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.


Memory and Haskell's function calls being allocated on the heap doesn't seem at first glance to be tied to laziness. You could have a strict language with a growable stack that effectively puts function calls on the heap (I mean it's usually not actually the heap per se, but the shrinking and growing of the stack makes it similar from a performance perspective). That's usually not done for ABI compatibility reasons rather than any intrinsic reason.

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.


> I can't think of any large program that has ever been forced to a drop a feature

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.


I can totally believe that and have fought similar bugs (basically unexpected references that keep memory around way longer than necessary), but memory leaks of that form affect both strict and lazy languages, i.e. it's an independent problem.

Lazy languages can have that same problem in addition to space leaks.


> You could have a strict language with a growable stack

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.


This is a solved problem, see the Racket programming language. I think this may apply to most Scheme implementations actually.


Can you explain how racket solves this?



The answers there seem to suggest racket just allows large amounts of space to be allocated to the stack. This doesn't "solve" the problem at all. It's just a tweak to kick the can down the road a bit.

Non-strict languages fundamentally change the way one can think about function context; unlimited guarded corecursion via thunks is a canonical example.


Sure, but you can fix that with any number of streaming libraries in strict languages those times when you do actually need that.

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.).


> you can fix that with any number of streaming libraries in strict languages

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.


> This is moving the goalposts a bit.

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).


This is making me reconsider a bit my stance on laziness. I'm usually in favor of eager, call by value, cause it's simpler to reason about in terms of complexity; but in OCaml I often have to be careful about deep recursions and it's annoying.


I've come around to preferring laziness by default. The gotchas are few and far between compared to strictness, and quite easy to avoid if you just use -XStrictData to flip the default and explicitly annotate laziness on your data types with ~

The status quo of strictness and lack of general TCO has a real cost. Every abstraction taxes your application!


But general TCO is precisely less important in lazy languages. In fact stack overflows in Haskell often arise when you move a function call to the tail position when it's not supposed to be there. EDIT: perhaps I misunderstood your original comment. You maybe mean that if a language is strict it should have TCO?

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.


> stack overflows in Haskell often arise when

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].

[1]: https://gitlab.haskell.org/ghc/ghc/-/issues/8189


Oh you're right!

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!


Is tail call elimination particularly problematic?

With Erlang, recursion is effectively the only looping construct, so it becomes second nature to make everything end with a tail call.


Tail call elimination doesn't let you do guarded (co-)recursion. For example, in a strict language you can't have a corecursively defined list like

   loop a b = a : loop b (a + b)
   fibs = loop 0 1
Because tail call elimination can't do anything about the first case.


This example actually does not have a tail-recursive call, so if you mean that a non-tail recursive call is not stack safe in a strict language, that is correct, yes.


Right - my point is that tail call elimination doesn’t help you with guarded corecursion, which lazy languages handle just fine. Guarded corecursion represents a huge set of programs it’s useful to be able to evaluate.


I think wyager's point is that you can extract individual Fibonacci numbers from fibs rather than realizing the infinite list immediately.


Both function calls are in tail position. This is what actually matters (along with the ABI return type matching for runtimes where that matters).


'loop' is not in a tail position. The last operation is the list cons. It can be optimized with TCO modulo cons but that would just make it hang/exhaust heap rather than stack overflow in a strict language.


Not all recursion is tail recursion. If you traverse trees you either deal with your bounded stack somehow (or ignore the problem) ; derecursify by hand, which is a nightmare ; or refactor to continuation passing style. There are options but none is simple and elegant.


Tail call elimination works fine in OCaml, same as in Erlang.


Racket is not lazy (at least by default) and will not stack overflow, it will just run out of memory. The only thing that tail recursion changes is how much memory an operation will take. Folds are also trivial to reason about in Racket whereas Haskell is much more tricky due to laziness.


> The only thing that tail recursion changes is how much memory an operation will take.

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.


You can do this in the browser as well to a degree. Just use CPS plus a trampoline function (which is also a way to implement general continuations). Using promises also works since it is a restricted form of CPS.


This is a great writeup, and I just want to offer a counterpoint regarding the eager vs. lazy trade-offs.

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.


Your example relates to the properties of lazy sequences, which are generally acknowledge as a good idea and implemented as part of the standard libraries of most major languages (e.g. Java, C#, Python 3).

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.


> the error will not necessarily surface at the call site

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.


> 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.

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.


> [laziness] blurs the distinction between macros and functions by allowing arguments to be lazily evaluated.

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.


Doesn't "vau" in the kernel language do this? Or does that not qualify as commonly known?


Indeed, FEXPRs are Kernel’s entire thing. However, vau is a language primitive, not a notion of evaluation, such as eager or lazy.


I got into PureScript last year, with a little apprehension about spending my free time with such a non-mainstream language.

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.


I really want to use PureScript, but I wonder why I should use PureScript if I already have TypeScript?

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.


You want Typescript because you want to write JS with type ascribtions that will catch some errors (but give you a false sense of security in more complex code). You also prefer to use JS idioms and using hundreds of npm packages, so you want the most frictionless integration for that.

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.


Random interesting factoid: the guy behind Scala.js (and lots of other awesome Scala libraries) is actually the son of the prime minister of Singapore. I discovered this when talking to some Singaporeans who were quite surprised to hear the name in the context of programming language libraries. This means that if like his father and grandfather he eventually becomes prime minister, it'll be the first to-Javascript compiler maintained by a head of state.


I do find it interesting. I can't speak for Singapore's rules of succession, but I will nitpick however that Li Haoyi does not maintain the Scala.js compiler, that would be Sebastien and Tobias. He is nevertheless an exceptional, foundation-building creator in the Scala.js ecosystem, and has been at it from the start.

https://github.com/com-lihaoyi

https://github.com/lihaoyi?tab=repositories

https://www.lihaoyi.com/


Thanks for clarifying! I assumed he was the maintainer because he's put a lot of effort into publicising it.


I remember when the PM's wife very leisurely but thoroughly calculated the duration for a missile from NK to reach sg. https://www.facebook.com/permalink.php?story_fbid=8576844644...


So that puts Scala in the same corner as ReasonML and ReScript: FP with OO. Maybe Kotlin(JS) may also join, while that is OO with some FP.

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.


I dunno what's the problem with Scala's syntax, it's pretty standard and uniform.

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.


PureScript isn't just for business logic at all - it has its own UI libraries, React bindings, DOM API bindings, and so on. It is more often used for front-ends than anything else that I've seen.

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.


I haven't actually checked more on PureScript and about the UI libraries and so, I'll try to dig more.



Huh, I would go the other way around:

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.)


> Half-measures seem wasteful.

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.


This is a good counter-argument to my simplistic view. Thanks for bringing it up!


If you considering Purescript then also look at Elm.

Few plusses

- It handles dom manipulation. - Simpler syntax - Elm-UI framework - No extra dependencies


As far as I've heard, it's crippleware as of v0.19


I tried Elm but it is too DSL for me. Its approach also doesn't scale. Too much writing, like Redux.


> I tried Elm but it is too DSL for me.

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.


> I expected the Haskell ecosystem to be far more mature in the backend HTTP service space.

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 expected the Haskell ecosystem to be far more mature in the backend HTTP service space.

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.


Anyone know how I can write an AWS Lambda in PureScript? Do I just need a JS wrapper and then call my compiled PureScript from that?


That would certainly work, I've got a rewrite of a WebExtension in progress that does the same thing.

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.


If only Haskell had a prelude more similar to that of PureScript. Insisting on keeping backwards compatibility for didactic reasons is getting very silly after 30 years. The haskell Num class is just ugly. And that is just one example.




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: