Hacker News new | ask | show | jobs
by amelius 1222 days ago
I think painting Rust and Haskell as similar languages is very misleading.

Haskell has a garbage collector for a reason.

You can't program naturally with closures unless you have a garbage collector, because closures introduce cyclic references.

Also, Haskell has a type inference system that allows e.g. the IO monad to work seamlessly with the rest of the language.

EDIT: and laziness of course.

4 comments

Rust has closures and does not use a GC. The borrow checker just makes sure that a closure never outlives its captures or any of its references. The most prominent difference between Rust and Haskell is that Haskell uses lazy evaluation throughout; it's the language's unique selling point. Rust doesn't even have generators in the stable variety of the language yet, although they are internally used to implement async/await.
There is nothing "natural" about working with Rust closures. If you want to take a losing fight against the borrow checker, they are the best way to do it.

(Rust has your C-like run of the mill function references too. People should emphasize those more, because differently from closures, those work very well.)

Closures work very well in Rust and are a core aspect of the standard library. The 'Iterator' trait adapters, for example, make very heavy use of closures, as do the combinators on 'Option' and 'Result'. Closures are also how you spawn threads. They are absolutely ubiquitous and quite natural.

In contrast, function pointers are very rarely used.

Are they equivalently nice in every way to closures in Haskell? Of course not. But I think your comment is swinging way too far in the opposite direction.

I think the word ‘natural’ in this context is too vague. If you’re coming to Rust from C++, sure, Rust’s closures feel quite natural. If, on the other hand, you’re coming to Rust from Haskell then they’ll feel wholly unnatural and confusing.
Sure it's vague. We're expressing opinions here. :-) But it's important to push back here. Closures are absolutely natural enough in Rust that a huge swath of very fundamental APIs in Rust rely on them. If they weren't natural in at least some sense, it would be absolutely bonkers to do that.

I came to Rust from Haskell (among other languages). I was a little confused by closures, but I attributed that to the fact that I started writing Rust before 1.0 when closures were far far far less convenient than they are today. (This is back when there were 'proc' closures.) Now I find them extremely natural.

Wait, they got rid of proc closures? Hahaha. Shows how long it’s been since I last looked at Rust. Okay, now they do look quite natural!
I understand your point and perhaps I'm in fierce agreement.

OTOH, things like parser combinators are much more ergonomic in Haskell than Rust.

Well yes... For many reasons. Including automatic currying and the fact that eta reductions work seamlessly in Haskell and less so in Rust.
Closures in Rust are fundamentally broken. See [1] for the discussion.

[1] https://hirrolot.github.io/posts/rust-is-hard-or-the-misery-...

Lol. "fundamentally broken" and yet I've been using them with little to no friction for pretty close to a decade now. If that's what you think "broken" means, then, well, you've completely devalued the word. This right here is what we call sensationalism folks.

Now if you said, "Rust has some rough points at the intersection of generics, closures and async programming," I'd say that's absolutely true!

> "fundamentally broken" and yet I've been using them with little to no friction for pretty close to a decade now.

This might rather confirm my point, since engineers using a specific programming language quite often have a contorted picture of how code in other languages is written, as they become more and more acquainted with their main tool. If we compare Rust closures with those of ML languages, it becomes pretty clear how natural closures are in ML and tricky in Rust.

Not quite a sensation either to anybody who happened to use closures in a typical Rust code, which, apparently, happens to have quite a lot of generics and async!

That's not just closures, but a confluence of closures, async, and more.
The example provided in the post -- yes. But I can basically demonstrate other language features that are not working with closures, such as generics -- you just can't have generic closures in the same way as you have generic functions, even in a synchronous code. With closures, you have a pretty limited scope of what you can do, considering the rest of the language, so I think it'd be pretty useless to consider closures as a separate mechanism that shouldn't interact with the rest of the language.
this comment made my day.
> The borrow checker just makes sure that a closure never outlives its captures or any of its references.

The point is that the borrow checker forces you to program in a fundamentally different style than idiomatic Haskell.

You are not forced into any design choice. You can use explicit reference counting to enable closure captures to add to object lifetime, or even a lightweight tracing GC implementation (such as the recently developed Samsara https://redvice.org/2023/samsara-garbage-collector/ ) to collect cycles as Haskell does. Of course, Rust makes the performance tradeoffs involved quite clear, even though the implementations it can use are quite possibly leaner than Haskell's.
Infix operators and currying give Haskell completely different ergonomics to Rust, even if you could largely do the same thing in both languages.
You should look at what you can do with the Rust type inference too, it's not too far away from Haskell, at least not superficially. For example using the return type to deduce type parameters for a method like let samples: Vec<_> = iterator.collect();
But this is the most trivial example of inference. Rust has neither generic implementation of higher kinded data nor global inference for the existing specific cases of it like GAT.
Is it the most trivial? C++ doesn't support it, and it has type inference using `auto`.

Note that in my example the type information flows from the variable to picking which generic method that is called, which is reversed information flow of what I would call the most trivial case - when the variable gets its type from the method that was called. (This is trivial: let x = 1i32;)

> For example using the return type to deduce type parameters for a method like let samples: Vec<_> = iterator.collect();

> C++ doesn't support it

How exactly is it different?

    #include <iostream>
    #include <vector>
    
    int main() {
        std::vector<int> v1 = {1,2,3};
        auto             it = v1.begin();
        std::vector      v2 (it + 1, it + 2);
    
        for (auto i: v2) { std::cout << i << ' '; }
    }
C++ auto can only do forward type-inference, i.e. the RHS must have a definitive type and this type is what `it` will end up having in your example. In the Rust example from above:

  let samples: Vec<_> = iterator.collect();
The RHS by itself has the type "B for any B that implements FromIterator<I> where I is the Item type of the given iterator". This cannot be assigned directly to a variable without any type hint (like with C++ auto) because it's an entire class of types and not a specific type. However, in the provided example, the Rust type checker can use the provided clue to narrow this down to an exact type, Vec<I>, without requiring an explicit statement of the type I (instead the underscore serves as a placeholder).
Sorry, I still don't get how it's different from C++, and my point wasn't about `auto` but rather about `std::vector v2`:

    std::vector v2 (v1.begin() + 1, v1.begin() + 2);

> However, in the provided example, the Rust type checker can use the provided clue to narrow this down to an exact type

The Rust example is incomplete and its RHS by the time it gets to `.collect()` within the context of `iterator` has to be bound to a particular type of the context via `impl Iterator for <MyContext>`. This is pretty much the same thing as template argument deduction for class templates in C++17 [1][2], and in C++20 it got extended to generic concepts and constraints [3]:

    #include <numeric>
    #include <vector>
    #include <iostream>
    #include <concepts>

    template <typename T> 
    requires std::integral<T> || std::floating_point<T>
    constexpr auto avg(std::vector<T> const &v) {
        auto sum = std::accumulate(v.begin(), v.end(), 0.0);        
        return sum / v.size();
    }

    int main() {
        std::vector v { 1, 2, 3 };
        std::cout << avg(v) << std::endl;                                      
    }
Note that nowhere in the snippet do I specify the type explicitly except for the container of type vector.

[1] https://en.cppreference.com/w/cpp/language/template_argument...

[2] https://devblogs.microsoft.com/cppblog/how-to-use-class-temp...

[3] https://www.cppstories.com/2021/concepts-intro/

I think they meant trivial in comparison to Haskell's type inference, which you had started off comparing against, not C++.
Also Haskell is getting linear types, which gets us the best of both worlds, GC productivity and low level performance when needed.
Every talk I've seen on Haskell linear types is that at best they can do some specific compuatations more efficiently (especially with arrays) but are not a good fit in Haskell to significantly elide GC.
> but are not a good fit in Haskell to significantly elide GC

That's because the entire haskell ecosystem would have to be rewritten which seems insurmountable.

They are on the early days of integrating them, so how could we expect them to refactor GHC all the way already?
What would be an example of significant elision in this context?