Functional Effects Vs. Rust

"Design choice" implies an intentional choice. However, Rust is as it is because of how it "evolved". Not every property of Rust has been intentionally designed. For example, I doubt that when Rust was created, the developers thought of the Pin/Unpin mechanism. It was something that came in later because it was needed because of other properties of the language.

What is a "serious issue"? To me, all the friction I exeperience when using Rust in everyday programming is "serious" because it eats up my time! And after thinking about it for a bit, a lot of it is indeed related to effects (but this isn't necessarily related to being "functional", which I will get back to later):

  • The async effect is modeled by returning Futures of a (usually anonymous) type.
    • This often results in lifetime issues because we can't specify the lifetime of a type that we can't even name.
    • We have difficulties doing async in
      • traits (existing trait methods or our own ones),
      • drop handlers,
      • Iterators (it's possible using Stream though, but still a lot of friction in practice),
      • existing synchronous callbacks where calling async/.await simply isn't possible (unless we use tricks like an executor or mechanisms like block_on).
    • We always have to decide in advance if we want to support async or not, or deal with having to refactor our code later. (Something that keyword generics aim to fix, though it's unclear if this would make things better or worse. On this forum, I heared a lot of scepticism.)
    • Due to async blocks, which may contain self-references, the mechanism of pinning is needed, which is really adding a lot of complexity to the language and also a lot of potential to mess things up.
  • The exception effect is modeled in at least four (partially orthogonal) ways.
    • In most cases, we don't unwind the stack for an exception effect. Instead, we deal with this using a return type that supports the question mark operator.
      • On stable Rust, we have three(!) possible types for that: Option, Result, ControlFlow. And if an interface supports one of them, it won't support the others. We would have to manually convert them into one another; otherwise our program won't compile.
      • Unstable Rust introduces a trait (#84277 and #91285) to abstract over these three return types. But Rust struggles when you try to make everything as generic as possible as you can (see in this example).
      • Some API's don't think of providing fallible interfaces at all, e.g. you can't raise an error in Regex::replace_all; and providing such interfaces may result in a lot of boilerplate code for the respective API providers.
    • Even though ordinary errors don't unwind the stack, an unwinding panic is still possible, which can be caught. This has a lot of implications for unsafe Rust, i.e. data might linger in an unexpected state which makes reasoning about soundness of unsafe code sometimes pretty hard. The fact that "everyday code" is discouraged from using this technique won't help us, because catch_unwind is safe, and unsafe Rust code must still expect unwinding panics being caught by any third-party code.
  • The effect of mutating a state is sometimes believed to be modeled by Rust's mut statement.
    • While it's true that mut also means "mutable" in 90% of the cases, it's not always true. Instead, the difference between & and &mut is to avoid aliasing. & is a shared reference and &mut is an exclusive one. Rust, however, uses mut for both aspects: exclusiveness and mutability. (At least its documentation does, which can lead to misunderstandings of the true nature behind mut.)
    • In practice, that means we sometimes need to use RefCell or pass extra arguments to be allowed to do certain mutations. (Playground)
    • And of course, we can't assume that an Fn closure is free of side-effects; or, more importantly, that passing an argument with & won't mutate its internal state (interior mutability).

I don't want to say these are "bad design choices". Most, or even all of them, have been necessary to enable certain important features of Rust. Nonetheless, as a result, we are stuck with a language that has a lot of friction and/or potentially confusing concepts (such as projections and structural pinning). A lot of that friction is related to effect handling being not "properly implemented"[1], but through years of evolution.

For writing better Rust code, I think it can help to be aware of these issues in Rust, as otherwise it will be more difficult to learn how to properly work around them in practice and cause even more friction than necessary.

Does this have to do with functional programming vs non-functional programming? I don't think so. Functional programming languages also struggle with effects and aren't necessarily more ergonomic. Languages like Haskell just happen to be effect-free by default, which makes them tackle the whole issue of effects from the other side, but not necessarily better. For a smoother treatment of effects, we'll likely have to wait for a new class/generation of programming languages, e.g. using rows of effects.

  1. I don't want to say it would even have been possible, because many concepts and consequences could not have been foreseen when certain core features of Rust have been developed and/or needed to be stabilized. ↩︎