Production use cases pushed me towards Haskell. What elegance there is serves towards reasoning about the correctness of complex code, however the tradeoff is difficulty in reasoning about performance. You can still get great performance! And I use it for prototyping in the Type-Driven Development style. Strongly disagree with "if it compiles it is correct" but we have great tools like Doctest and QuickCheck for that. I usually write types with DocTest examples first, then implement. undefined with lazy evaluation is really cool for figuring out function signatures first.
> reasoning about the correctness of complex code, however the tradeoff is difficulty in reasoning about performance
That's what drove me to languages like OCaml and Rust, which try to solve this tradeoff in a different way: Same thing about strictness and correctnes (despite slight differences in the type system), but lazy evaluation is only provided when explicitly asked for.
The cool thing about OCaml is that you can actually reason about performance, as long as you ignore memory issues (memory usage, garbage collector runs, etc.). Rust is heavily inspired by OCaml and allows you to also reason about correctness and performance of memory usage.
> The cool thing about OCaml is that you can actually reason about performance, as long as you ignore memory issues (memory usage, garbage collector runs, etc.).
If you ignore mem issues you can also reason about the performance of Haskell :)
If we want to reason about perf, I think Rust is currently leading in terms of a modern language that allows perf reasoning.
> If you ignore mem issues you can also reason about the performance of Haskell :)
Really? My guess (based on close to complete ignorance) would be that laziness would make reasoning about performance hard. Can you ELI5 why my guess is wrong?
All performance issues caused by laziness are memory issues. Something isn't evaluated promptly, so large amounts of memory are consumed storing unevaluated expressions. This causes drag on the garbage collector and excess cache thrashing when it finally is evaluated.
But all of that is just because data stays in memory longer than expected.
If you ignore that, laziness has a small performance impact, but it's less than the performance impact of interpreting code, which is something many people consider completely acceptable.
Both you and cies said that "All performance issues caused by laziness are memory issues." My intuitive sense was more like: You've got a lazy calculation that has several stages. At some point in the pipeline, you're doing something slow, but it's not obvious. When you get to the end and start using the data, the whole calculation is slow. But it's not clear where it's slow, because of the laziness.
That may not be "a performance issue caused by laziness", in your terms, because the laziness isn't causing performance issues. But it's laziness making a performance issue hard to find.
Does my scenario happen? Is it common? Is it easy to analyze/find/fix when it does happen?
Most generally, does laziness make it harder to reason about performance?
That happens sometimes, but the solution is the same as in a strict language. Break out the profiler and see what's taking all the time. There's a learning curve while you figure out what the usual suspects are, but usually you can find things pretty quickly afterwards.
Profiling is a bit of a black art in any case. I've seen horrible performance problems in python when we added enough code to cause cache thrashing to a loop. No new code was slow, but the size of it crossed a threshold and things got suddenly bad.
Some performance problems in Haskell are hard to track down, but most are pretty easy to find and fix. It's basically the same as every other language in that way.
Laziness can actually make things faster in some scenarios. Take Python2's range() function for instance, which eagerly builds the entire list and returns it. range() is commonly used for for loops, so Python builds this list (an N operation) and returns it, and then the for loop iterates over it (another N operation). But if you use xrange(), you get a generator instead, which can lazily construct values when asked for one. It turns the for loop case into a single N operation. Similarly if you have nested function call chains, if everything is returning an eagerly evaluated list, you have to construct the whole list in memory at each call and pass it to the next stage which will eagerly iterate over the whole thing to generate its list in memory and so forth, eventually the old list can be freed / GC'd when the new list has been made.. but with lazy sequences, you only need memory for the whole list at the very end, if you want it and not just individual values. Python3's range() replaced Python2's with xrange(). (The other option is a numpy-like approach where you eagerly allocate the list and then every stage just mutates that single thing.)
The 'memory stays around indefinitely' problem is caused by not cleaning up references to generators. You can make your own xrange() clone in Python easily, and start consuming values, but if you stop before consuming all of them (assuming it's not an infinite generator), that xrange() generator object still lives and has to keep around whatever references it needs to generate the next value if you ask it for one. When your whole language is based around laziness, you might have a lot of these and it may not be clear what references are still around that keep the GC from cleaning them up.
I wouldn't say reasoning about performance is necessarily more difficult, and profiling will light up a slow part of a lazy pipeline just as well as an eager one. My own struggles with laziness in Clojure were all around my own confusions about "I thought this sequence was going to be evaluated, but it wasn't!" which may have led to unnecessary calls to (doall), which forcibly walks a sequence and puts the whole thing in memory.