Hacker News new | ask | show | jobs
by sfvisser 2124 days ago
I’m learning rust right now and there is a lot to like. Steady updates like this are also very motivating. The ecosystem feels very sane - especially compared to npm. Top notch Wasm support, cross compiling is a breeze.

That said, coming from a FP background (mostly Haskell/JS, now TS) Rust is... hard. I do understand the basic rules of the borrow checker, I do conceptually understand lifetimes, but actually using them is tricky.

Especially in a combinator world with lots of higher order functions/closures it’s often completely unclear who should own what. It often feels my library/dsl code needs to make ownerships decisions that actually depend on the usage.

Anyways, I guess this gets easier over time, right? Should I avoid using closures all over the place? Should my code look more like C and less like Haskell?

[edit] great answers all, providing useful context, thanks

9 comments

> Anyways, I guess this gets easier over time, right?

Yes.

> Should I avoid using closures all over the place?

Not necessarily.

> Should my code look more like C and less like Haskell?

Yes. Others sometimes don't like to hear this, but IMO, Rust is not at all functional. Passing functions around is not ergonomic (how many function types does Rust have again? Three?). Even making heavy use of Traits, especially generic ones, is difficult.

Rust is very much procedural. Java-style OOP doesn't work because of the borrowing/ownership. And FP style function composition doesn't work without Boxing everything. But then you'd need to be careful about reference cycles.

> how many function types does Rust have again? Three?).

Depending on what you meant, there are more than three:

  * There are 3 traits, used by closures depending on their needs:
    * Fn(Args) -> Output
    * FnMut(Args) -> Output
    * FnOnce(Args) -> Output
  * *Every* `fn` is its own type (`fn() {foo}`)
  * Function pointers (`fn()`), which is how you pass the above around in practice
> Rust is very much procedural.

I think this is like saying Python is very much procedural: true, but loses some nuance. Rust has some attributes of OOP, some attributes of FP. Some constructs from OOP and FP are made harder once you involve borrowing. Saying it is procedural conjures images of Pascal and K&R C in people's minds. To bolster your argument, though, I mostly use method chaining for iterators but every now and then I need to turn it into a `for` loop to keep the lifetimes understandable for the compiler, myself and others.

I realized after I wrote the comment that I was really referring to the closure traits when I said that. And I really should have said "kind" instead of "type" because, like you said, every different function has its own type.

But anyway, I don't really disagree with your point about categorizing languages as OOP, Procedural, or Functional.

But honestly, in this case, I think it's pretty damn clear than Rust is procedural WAY more than it's either OOP or FP. (Note: By OOP, I mean Java-style with tall ownership hierarchies and object-managed mutable state, not necessarily caring about inheritance. And definitely not referring to Alan-Kay-Style-OOP a la Lisp and Smalltalk).

Scala can be looked at as FP and/or OOP. C++ can be looked at as Proc and/or OOP. Python, IIRC, can kind of do all of them, but I don't remember it being easy to make copies/clones in Python, so FP is questionable.

Have you ever tried to write two versions of a complex async function in Rust? One with async and one with Futures combinators? Due to ownership, the Futures combinators approach very quickly devolves into a nightmare. The language doesn't "want" you to do that.

What about function composition? Very awkward to do with matching up the different Fn traits.

And deeply nested object hierarchies are a no-go, too, because of the inability to do "partial borrows" of just a single field of a struct.

I mean, yes, it's not C because it has a real type system and generics. But... it's pretty much C in that you just write functions and procedures that operate on structs.

EDIT: Perhaps my "hardline" approach on calling Rust procedural is in response to people who have come to Rust from non-FP languages, see `map`, `filter`, and `Option` and start calling Rust functional. That's not functional programming! Ask someone who does OCaml to try out Rust and see if they call Rust functional afterwards.

>And deeply nested object hierarchies are a no-go, too, because of the inability to do "partial borrows" of just a single field of a struct.

I'm not totally sure if this is what you mean, but FYI you can borrow multiple fields mutably using destructuring:

    struct Foo(u8, u8);
    
    fn main() {
        let mut bar = Foo(1, 2);
        let Foo(ref mut x, ref mut y) = &mut bar;
        *x += 1;
        *y -= 1;
        println!("{} {}", bar.x, bar.y);
    }
The problem comes when someone else needs to also borrow the Foo, even immutably. In Java-style OOP, you typically have "objects" that own other objects, all the way down. And you manage state internally.

So it often comes up that you might call several methods in a given scope. If even one of those mutably borrows one field of one sub-object, then you can't have any other borrows of that object anywhere else in that scope.

Newbies from other languages trip on that often enough that I used to see questions about it in r/rust fairly frequently.

Just let x = &mut bar.0 will work, but this "intelligence" is confined to the body of a single function. Rust possesses the somewhat curious property that there are functions that are intended to always be called like this

  foo(&bar.x, &bar.y, &bar.z)
which cannot be refactored to

  foo(&bar)
This is a complex topic, but what it boils down to is that the function signature is the API. If you borrow the whole thing, you borrow the whole thing, not disjoint parts of it.

This is also why it's okay in the body of a single function; that doesn't impact a boundary.

We'll see what happens in the future.

But honestly, in this case, I think it's pretty damn clear than Rust is procedural WAY more than it's either OOP or FP. (Note: By OOP, I mean Java-style with tall ownership hierarchies and object-managed mutable state, not necessarily caring about inheritance. And definitely not referring to Alan-Kay-Style-OOP a la Lisp and Smalltalk).

Scala can be looked at ...

Interesting perspectives, and I largely agree with all of them.

Related: I heard someone else say that while Clojure and Erlang embrace immutability for concurrency, Rust shows that you can "just mutate". It's still safe for concurrency (due to its type system).

Rust seems to be one of the only languages that embraces the combination of algebraic data types + stateful/procedural code.

But I've also found this in my Oil project [1], which is written with a bunch of custom DSLs!

I wrote it in statically-typed Python + ASDL [2], so it's very must procedural code + algebraic data types. Despite historically using an immutable style in Python, this combo has grown on me. Lexing and parsing are inherently stateful, and use a lot of mutation.

----

On top of that, my collection of DSLs even translates nicely to C++. Surprisingly, it has some advantages over Rust! The model of algebraic data types is richer ("first class variants"), described here:

https://news.ycombinator.com/item?id=24136949

https://lobste.rs/s/77nu3d/oil_s_parser_is_160x_200x_faster_...

[1] https://www.oilshell.org/

[2] http://www.oilshell.org/blog/tags.html?tag=ASDL#ASDL

> Related: I heard someone else say that while Clojure and Erlang embrace immutability for concurrency, Rust shows that you can "just mutate". It's still safe for concurrency (due to its type system).

Yes! I will repeat a sentiment I articulated on Reddit about that. Even after having used Rust on a handful of small-to-medium sized projects since 2016, I never realized that I could loosen/abandon my immutability fetish that I had been trained to love over the years of working with C++ and Java. C++ needs it for concurrency, and Java needs it for concurrency and because every method can mutate its inputs without telling you. Rust doesn't have either of those problems. Having immutable-by-definition objects in Rust isn't really that useful (unless, of course, the thing is naturally, semantically, immutable anyway, like a Date IMO). It was an eye-opening epiphany and I'm excited for my next Rust session to see how my "new worldview" pans out. :)

Yes I'll be interested to see how it turns out. Any blog posts / writing on the procedural viewpoint will be appreciated.

Does Rust have something like C++'s const methods? Where you can have a method that mutates a member, but doesn't logically mutate from the caller's perspective?

It seems like you could be prevented from having races on individual variables, but still have races at a higher level.

Like on database cells. I guess no language will help you with that, and that's why Hickey wrote Datomic -- to remove mutability from the database.

I think that Rust is often assumed to be functional because it has ADTs and nice pattern matching, both of which have historically been a feature specific to FP. Just goes to show how fuzzy our definition of FP really is...
Agreed. Same with "OOP". Who the hell knows what people really mean when they say that.

Those people who think that "FP" means "type system like Haskell" are wrong, though, IMO. It precludes languages that are much more function-based, such as Clojure, Schemes, Elixir.

Usually when the term OOP is used, it actually means ClassOrientedProgramming (C++/Java/C# etc)
Which I also don't understand. Is that a style of overusing classes where functions would suffice (FooHelper)? Or is it something about the language? Because almost all popular languages have classes. Rust and Go call them "struct", but it's the same thing. Swift has "class" and "struct", but they're both the same thing as a C++ class.
Rust structs are not classes. Rust puts structs inline (on the stack), classes are virtual. Since Rust is a systems programming language, it's an actual distinction that makes a difference
Functional in my mind more or less means means working with immutable values.
> how many function types does Rust have again? Three?

It has to, right? ATS has many function types as well, plus stack-allocated closures (I think Rust has that too??)

Rust's closures do not heap allocate unless you box them, like any other struct, because closures are sugar for a struct + a function, that's correct.

(and yes, there are three types of closures, because they need to know if they take said struct by reference, by mutable reference, or by owner.)

It does have to, because of the way mutation and ownership work. Which is great! But it makes functional programming awkward. The language does not "steward" you toward function composition.
>the borrow checker, I do conceptually understand lifetimes, but actually using them is tricky.

I've been using Rust for a little over year, almost daily at work, and for several projects. I have a pretty good intuition about how the borrow checker works and what needs to be done to appease it. That said, I don't think I'm any closer to understanding lifetimes. I know conceptually how they are supposed to work (I need the reference to X to last as long as Y), but anytime I think I have a situation that could be made better with lifetimes, I can't seem to get the compiler to understand what I'm trying to do. On top of that very little of my code, and the code I read actually uses lifetimes.

I've been writing Rust code since before the 1.0 days, and I still can't understand lifetimes in practice.

When the compiler starts complaining about lifetimes issues, I tend to make everything clone()able (either using Rc, or Arc, or Arc+Mutex, or full clones).

Because if you start introducing explicit lifetimes somewhere, these changes are going to cascade, and tons of annotations will need to be added to everything using these types, and their dependent types.

I think you're really intended to do the latter rather than the former. I mean, Rust lets you do either–it gives you the choice if performance isn't your concern–but usually it's better to not clone everything.
Nah, either is fine. Rust gives you the tools to do both for good reason. Which is right for you completely depends.
I find that lifetimes are ok, albeit annoying sometimes, especially the cascade part, as you mention. The one thing I can't get to stick in my brain is variance. Every time, I need to go back to https://doc.rust-lang.org/nomicon/subtyping.html#variance
It's worth noting that every reference in Rust has a lifetime, but the compiler is usually smart enough to infer it. What you are talking about is explicit lifetimes.
As someone in a similar description as you - i find my lifetime understanding... moderate. Complex lifetime usage still can tweak my brain - notably how i can design it. But simple lifetime usage is intuitive.

A simple example i often run into is wanting to do something with a string, without taking owned parts of the string. Very intuitive how the str matches the lifetime of the owned value.

On the otherhand, the other day i was trying to write a piece of software where:

1. I wanted to deserialize a large tree of JSON nodes. I had the potential to deserialize these nodes without owning the data - since Serde supports lifetimes, i could deserialize strings as strs and hypothetically not allocate a lot of strings.

2. In doing that, because a tree could be infinitely large i couldn't keep all of the nodes together. Nodes could be kept as references, but eventually would need to be GC'd to prevent infinite memory.

3. To do this, i _think_ lifetimes would have to be separate between GC'd instances. Within a GC'd instance, you could keep all the read bytes, and deserialize with refs to those bytes. When a GC took place, you'd convert the remainder partial nodes to owned values (some allocation) to consume the lifetime and restart the process with the owned node as the start of the next GC lifetime. ... or so my plan was.

I have, i think, just enough understanding of lifetimes to _almost_ make that work. I _think_ some allocations would be required due to the GC behavior, but it would still reduce ~90% of allocations in the algorithm.

Unfortunately, i got tired of designing this complex API and just wrote a simple allocation version.

Conceptualizing allocations and the lifetimes to make it work are.. interesting. Especially when there is some data within the lifetime that you want to "break out of" the lifetime, as in my example (where i had a partial node, and i made it owned).

I still think i understand enough to do it - it'll just take a fair bit of thinking and working through the problem.

These kind of optimization are incredibly painful in Rust one common suggestion is to sidestep the issue and store an offset + length in the nodes and then you take that to look up the value from the original string when you need the value.
I've tried and tried but I've never found a situation where explicit lifetimes was the answer. It's almost always more complex than that. I mean, everywhere that is complex enough that implicit lifetimes don't work is also too complex for explicit lifetimes and almost always required Rc or Arc to solve it. Maybe I'm missing something, but it seems like there are so many other missing topics the Rust Book could be spending time on that would be more effective than teaching about explicit lifetimes.
There are a lot of situations where explicit lifetimes are the answer, TBH. However, you have to have a very good working model of lifetimes in order to get the annotations right.
I wrote a parser that needed it. But yeah for the most part whenever explicit lifetimes came into the picture it means that I have made some sort of mistake and need to rethink my approach.
I don't have time for an exhaustive answer, so I'll give you some rules of thumb when using Functional-style combinators:

* If you need to keep unchanged the input, you must either use a reference-to (.iter()) or copy-of (.iter().cloned()) of each item

* If you don't need the input ever again, you should move the items (.into_iter())

These rules follow for each step of the chain.

I very very often write very Functional code in Rust and I find it natural and easier to reason about than imperative-style code. The example I could find the fastest: https://github.com/thenewwazoo/aoc2019/blob/master/src/day10...

Edit: another example (this one uses types that are Copy so the copies are implicit) https://github.com/thenewwazoo/cryptopals/blob/master/src/tr...

Another edit: I am not a Functional programmer, and have never known Haskell or any Lisp. Erlang is as close as I've ever gotten. I've found Rust to be a fantastic language for writing Functionally.

I've been using .to_owned() liberally, that often does the trick in the first instance, albeit at the potential cost of a copy.
This is a perfectly reasonable solution. You might be leaving performance on the table but

1) if perfomance isn't a measurable problem for you, then there's on point on eking the last bit of performance from these allocations

2) it simplifies the code itself

3) sometimes clones are actually efficient, people forget to make their small ADTs Copy

4) if you're learning the language this lets you delay the moment when you have to fully understand the way lifetimes actually behave in complex cases, which means that when you do do that you will have a better grasp of the rest of the language and will be able to form a better mental model of how it fits with other features

> I do understand the basic rules of the borrow checker

It ends up being doable. I dabbled in ATS, developed Stockholm syndrome, and now Rust ain't too bad.

Higher-order functions are difficult in Rust or with linear/affine types in general. Haven't looked at what Rust does recently.

> Should I avoid using closures all over the place? Should my code look more like C and less like Haskell?

When in Rome do as the Romans :)

Anyway, some fun imperative programming stuff you can do in Rust that is fickle in Haskell (or OCaml/Standard ML).

Ah, if you’re making dsl code or functional combinators, you usually want to ‘move’ your values instead of ‘borrowing’ them.

example:

fn add(mut self) -> Self { self }

fn add(self) -> Self { self }

instead of:

fn add(&mut self) {}

fn add(&self) {}

With this, you will be able to ‘store’ closures easily and apply them later. No more fighting with the borrow checker over where to borrow as mut or not. You will also avoid a few copies.

This echoes my experience with learning Rust over the past few weeks (coming from Elixir).

There is a lot to like, understand lifetimes conceptually, but it's hard.

If you're coming from elixir, and not doing this for work, I highly suggest zig; zig feels like elixir since both have the comptime concept.
Yep. I made my first Rust script last week and the amount of care required is similar to C++.

It is definitely not easier compared to C++, contrasting with D, which is easier than C++.

However, the program worked correctly at the first try, which I guess it is also a consequence of the Rust model.

> The ecosystem feels very sane [] compared to npm

Now that's damning with faint praise.

Parts of Rust's ecosystem are just npm but with saner people using it at the moment.
And it's probably going to be a pretty big issue in a few years, IMO.
Sometimes you will be annoyed by changes I guarantee it BUT since 1.0 that's decreased a lot and compared to npm it's night and day. You'll think you're dealing with C in relative terms of stability if npm is your baseline :D