Functional Effects Vs. Rust

Hello again.

Several months ago I started a thread to discuss Rust vis-a-vis pure functional programming (in Scala in particular).

I think it was a great discussion. I'd like to explore a closely related topic now: Can we approximate, simulate, or exceed the 'power' of functional effects in Rust?

What is great about functional effects for me in particular is how the ability to compose them (because they are pure values) makes complex error-handling and retrying very clean.

A simple pseudo-cody example:

def getData: Task[Data] = {
  val getDataViaHttp: Task[Data] = ???
  val retrySchedule = Retry
    .start(1.second)
    .max(10)
    .exponential
  getDataViaHttp.withRetry(retrySchedule)
}

Thinking about how I would do something similar in Rust, what I came up with is a higher-order function that takes a "getData" function and performs internal retries.

The retry schedule could be defined by an ADT (as above). The types could be parameterized so the function was of general use.

My Rust is so Rusty I'm not even going to attempt some pseudocode, though.

Can I get some opinions on that? Is it viable? Are there better ways to do it?

In general, can we get close to (or better than) this power of separating specification from execution that the functional effect model gives us?

P.S. Doing functional effects directly in Rust is, I believe, a non-starter because the lack of a for-comprehension mechanism makes monadic programming syntactically very - difficult.

2 Likes

Well, this didn't go places. :slight_smile:

It might be a bit much for me to expect others here to be familiar with functional effects systems, since that's not a Rust thing at all.

What I'll do is update this after I do some experiments of my own. (I'm re-re-learning Rust.)

I experimented with describing functors and monads in Rust, which resulted in the fmap crate. It's more of an experiment rather than a real plan to bring functional programming to Rust.

I did experience some difficulties and recently also made an interesting discovery:

First of all, It is sometimes tough for the compiler to figure out that two types are equal (or to allow the compiler to even have enough information to infer that two types will be equal). Consider this example that I basically had to go through in the other thread:

pub trait Functor<'a, B> {
    type Inner: 'a;
    type Mapped;
    fn fmap<F>(self, f: F) -> Self::Mapped
    where
        F: 'a + Send + FnMut(Self::Inner) -> B;
}

impl<'a, A, B> Functor<'a, B> for Vec<A>
where
    A: 'a,
    B: 'a,
{
    /* … */
}

fn double_inner_i32<'a, T>(functor: T) -> T
where
    T: Functor<'a, i32, Inner = i32>,
    // One possible fix:
    // T: Functor<'a, i32, Inner = i32, Mapped = T>,
{
    functor.fmap(|x| x * 2)
}

fn main() {
    let mut vec = vec![1, 2, 3];
    vec = double_inner_i32(vec);
    println!("doubled vec = {:?}", vec);
}

(Playground)

While the extra Mapped = T constraint may be bearable in this case, it can become much more complex and difficult, which I will get back to.

Another thing I just recently discovered is that while in a functional programming language, we seem to have a hierarchy "Monad is an Applicative Functor is a Functor", this might not make that much sense in a non-purely-functional language such as Rust. That is because if we assume this hierarchy, we would need to be able to express the applicative functor's "apply" method in terms of the monad's "bind" method. How would this look like? The following is more or less pseudo-code (I'm still experimenting with it):

pub fn apply_using_bind<…>(
    wrapped_closures: …,
    wrapped_values: …,
) …
{
    wrapped_closures.bind(move |inner_closure| {
        wrapped_values.clone().fmap(inner_closure)
    )
}

We need the clone() call because the move closure (that gets a closure as argument) may be called several times. Thus we will require an additional Clone bound on the wrapped_values.

We can observe this effect in the higher crate in the implementation of one of Applicative's supertraits here:

impl<'a, A> Apply<'a, A> for Vec<A>
where
    A: 'a,
    Vec<A>: Clone,
{
    /* … */
}

Note the extra Clone bound on Vec<A>.

On the other hand, a Vec<A> where A: !Clone could totally be a monad, as shown in the implementation of Monad for Vec in my fmap crate, where we don't require a Vec<A>: Clone or A: Clone bound:

impl<'a, A, B> Monad<'a, B> for Vec<A>
where
    A: 'a,
    B: 'a,
{
    fn bind<F>(self, mut f: F) -> Self::Mapped
    where
        F: 'a + Send + FnMut(/* … */) -> /* … */,
    {
        let mut vec = Vec::new();
        for item in self.into_iter() {
            for item in f(item).into_iter() {
                vec.push(item);
            }
        }
        vec
    }
}

This anomaly doesn't exist in a functional language like Haskell, because it's possible to copy/clone etc. every value.

Side note: The higher crate introduces a Bind trait (as alternative to Monad, which requires Applicative).

My conclusion is that when we deal with mutation, the hierarchy of typeclasses/traits will look differently (unless I made a mistake in my reasoning). In particular: not every monad seems to be an applicative functor (but a non-applicative functor) if we do allow that all values can be cloned/copied. Not sure if there are more implications following from this.

But the bigger issue seem to be that Rust's type system currently makes reasoning about types hard and can force you to add more and more trait bounds that are difficult to comprehend (see fmap::NestedMonad for an example):

pub trait NestedMonad<'a>
where
    Self: Monad<'a, <Self::InnerMonad as FunctorSelf<'a>>::Inner>,
    Self: FunctorSelf<'a>,
    Self: Functor<
        'a,
        <Self::InnerMonad as FunctorSelf<'a>>::Inner,
        FmapIn = Self::Inner,
        Mapped = Self::Inner,
    >,
{
    /// Helper type always equal to [`Self::Inner`]
    ///
    /// This type is needed to circumvent
    /// [Rust issue #20671](https://github.com/rust-lang/rust/issues/20671).
    ///
    /// [`Self::Inner`]: FunctorSelf::Inner
    type InnerMonad: FunctorSelf<'a>;
    /// Generic join
    ///
    /// `.mjoin()` is equivalent to `.bind(|x| x)`.
    fn mjoin(self) -> Self::Inner {
        self.bind(|x| x)
    }
}

impl<'a, T> NestedMonad<'a> for T
where
    T: Monad<'a, <Self::Inner as FunctorSelf<'a>>::Inner>,
    T: FunctorSelf<'a>,
    T::Inner: FunctorSelf<'a>,
    T: Functor<
        'a,
        <Self::Inner as FunctorSelf<'a>>::Inner,
        FmapIn = Self::Inner,
        Mapped = Self::Inner,
    >,
{
    type InnerMonad = Self::Inner;
    fn mjoin(self) -> Self::Inner {
        self.bind(|x| x)
    }
}

In Haskell, this seems so much easier.

Maybe it's feasible to use higher's monads and macros or fmap::Monad, but I still have some doubts on usability when things get more complex. I think the main reason is that Rust (for a good reason) imposes a lot of work on the programmer to manually decide when values are cloned, when they are referenced (exclusively or in a shared manner), etc. This could make functional-style programming difficult, I guess.

P.S.: Another practical problem is that closures can't be described as types but only as traits (unless we wrap them in a Box).

5 Likes

Very interesting and important topic.
An alternative to an "effect systems" is "capabilities":

And an effect crate for Rust:

3 Likes

"...if you want to have multiple types of effect, things get tricky because all your effect types want to hold the pure thing inside themselves, but you only have one pure thing going on. This means you have to use monad transformers. I don't really understand monad transformers, therefore they are bad."

Great stuff!!

But what I really want to know is why you did this - because Rust really needs an effects mechanism, or just for fun?

I did see your "why" answer in the readme. I understood it, but not knowing Rust really well at this point, I'm not sure how serious a practical issue that is.

It isn't. The language really doesn't have any serious issues that arise out of inferior design choices. The only kind of issues I know of that would count as serious are instances of unsoundness in the compiler, which allow some memory-unsafe code to be written in safe Rust (albeit usually in such convoluted ways that people are unlikely to hit it – yet, they're technically possible so they do count as bugs). There are also features that people would like to see, but said missing (to some) features don't make the language impractical or excessively inconvenient.

When people haven't got bigger problems, they start to magnify the smaller ones in their head, or even invent new ones. Poor people who have nothing to eat will think about getting food and earning some money all the time. On the other hand, celebrities, businessmen, and politicians, who have way more money than they need for basic survival, will create artificial problems for themselves (such as sending private rockets to Mars or buying into crypto scams).

Similarly, Rust programmers who can be relieved that their programs are memory-safe and type-checked will soon start complaining about much smaller problems. They love to complain about error handling, for example. Maybe because they (think they) are familiar with it due to "exception handling" in other languages. In Rust, we've got the elephant in the room (UB) out of the way, therefore we can afford to focus on how it is "tedious" or "annoying" to write Ok(value) at the end of the function, instead of the value being magically wrapped in Ok. Good for us.

Similarly, the perceived problems with effect composition are not really significant in everyday Rust code. Fallibility (Result/Option) is the most prominently used effect and it's already well-supported; I/O itself isn't effectful (except for fallibility); and multiple effects are pretty rare – to the point that I don't know when was the last time I needed to write any sort of effect-composing code other than Option<Result<_>> or Result<Option<_>>.

So while it is nice to play around with the type system like that (because it can be the origin of some other, related fruitful techniques applicable elsewhere), I wouldn't say that effect composition is a practical problem in Rust today.

11 Likes

I'm really glad you said that. Because having read through this thread twice I still have absolutely no idea what the problem posed is or what anyone is talking about.

I guess I could take a few months out to get familiar with "functional programming" and find out what Functional Effects are and why I might want them. Frankly I'm happy not to do that and from what you say I won't be missing anything. Which is good because life is short.

Thanks.

Effects are essentially a way of putting all values/expressions of a computation in a wrapper context. Such values can only be manipulated through that context, which is why they are infectious.

Effects commonly used in Rust include

  • fallibility/results (you can get a value out of a Result without panicking if you handle errors, which usually means bubbling them up via ?, rendering the outer function Result-returning too)
  • async (if you want to await a future inside a function, you must make that function async)

In pure languages such as Haskell, operations that induce side effects such as I/O also can only be manipulated in functions that return specially-tagged IO types.

The problem being posed here is the composition of multiple such effects (wrapping an effectful type inside another), e.g. nicely handling Result-returning async functions or fallible I/O. Generally, Rust already has built-in language features for the most common effects, and composition rarely ever goes beyond 2 levels of wrappers, which both contribute to this not being a real problem in this language.

With that said, studying the basics of functional programming and type theory are an extremely valuable tool for reasoning about high-level problems and patterns for solving them. These disciplines give you a more holistic and mature view of what "computation" is, rather than viewing it as tossing around bits and bytes.

10 Likes

I got distracted with other things and never circled back to state the actual intent of my thread here, which went off on a tangent.

Here is the question: In Scala with an effects system I have the power to handle errors and retries via composition that is very powerful. What facilitates this mechanism is the separation of declaration from execution of code that the effects system offers, along with the for-comprehension mechanism in the language that proves the syntactic sugar necessary to make monadic programming palatable.

I am going to give one example here that hopefully illustrates what I'm talking about. This is a pseudo-coding thing using ZIO (which I find much more pragmatic than the Cats-style effects that rely on higher-kinded types and the tagless-final pattern):

  val retrySchedule = {
    val limit        = Schedule.recurs(10)
    val spacing  = Schedule.spaced(50 milliseconds)
    limit && spacing
  }

  def open(file: String): IO[IOError, File] =
    ZIO
      .attempt(url.openConnection.getFile(file))
      .retry(retrySchedule)
      .mapError(e => IOError(s"Cannot open $file: ${e.getMessage()}"))

This illustrates the power of effect composition: We can take any (effectful) operation and make it retry, in about any way we please.

(Of course that's only the tip of the iceberg of what the separation of specification & execution gives you, but it illustrates the nature and the power.)


For a guy like me, the question of, "Can we do this in Rust?" has to be asked - this is a very powerful way to program.

(That's not to say there are no downsides, of course. Monadic programming is much less straightforward - for imperative functionality like looping & branching - than using native language features. And the layer of abstraction certainly has a substantial performance cost.)

Here's what I've been able to come up with so far on how I might do this in Rust - general thoughts:

  • While we don't need monads in Rust to give contexts to immutable data for "protection," because Rust's ownership model solves the same problem, we do need monads - a context with a bind/flatMap operation - for doing this kind of thing

  • We do not need higher-kinded types for an effects system (ZIO doesn't use them at all) but we do, realistically, need a for-comprehension mechanism - which Rust lacks

So the best I can come up with is a general Effect object (Trait impl) with combinators on it to specify retries, etc. It would wrap a closure that would do the work, and have a "run" method. Methods like retry() would produce a new Effect that wraps the closure with the retry functionality.

You'd have to run such Effects yourself, and chaining them would be ugly.

Perhaps a totally general solution - like ZIO - is not the answer here, and one should instead target specific functionality (like retrying).

I'm curious to hear how you would go (or have gone) about this.

(BTW, I'm aware of this: GitHub - jonathanrlouie/env-io: A highly experimental, work-in-progress, functional effect system for Rust that is inspired by the ZIO library for Scala

and other efforts at functional effects in Rust, based on the Cats style. All seem dead/impracticable.)

I suspect that the whole idea of functional effects + Rust is basically a hard impedance mismatch.

I don't get why that would be needed. Comprehensions are merely syntactic sugar over a few methods on iterators (mapping, filtering, and perhaps flattening).

If I needed retry functionality, I'd write it over Result-returning functions. Or one level higher, generically over types that implement Try, so that eg. Option and ControlFlow would work, too.

That's a strictly practical approach, though – to be honest, I acknowledge that I don't really appreciate what needs to be further generalized, or what problem would such a generalization solve.

2 Likes

For-comprehensions are needed, but the lack of them makes any kind of real monadic programming very messy.

Look at some Scala ZIO code and desugar it into the flatMap-map-filter calls and you'll see what I mean.

That said, no, not strictly needed.

I suspect your thoughts on the practical needs of retry functionality are sound. I will give that a try soon. Thank you.

"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. ↩︎

9 Likes

I mean, that's just not true? It's not like there was a long random process that happened to spit out a compiler at the end. The language is a result of very intentional design efforts.

Pinning was needed for making a new feature, async. Of course it wasn't needed when Rust didn't yet have async. But that can hardly count as "unintentional". It's an additive feature, ie., one that doesn't change the use and semantics of the language, only extends it.

You may want to re-adjust your expectations towards Real LifeTM programming languages. You are never going to have a language without friction. Getting a couple compiler errors and having to google them is not a serious issue.

Hard disagree. Rust is the language with the least friction I've ever used. It's because it's actively trying to make the correct way of doing things easy, but that means it has to make the incorrect ways harder or impossible. If you are experiencing so much friction that it annoys you, then Rust is likely not at fault – you are probably trying to do things in the old, sloppy way, which won't work in this language.

8 Likes

While proper effect handling would solve some of these problems, properly implementing it will likely have to face the same challenges, if not more, of solving them individually. It is not a silver bullet, especially in a context like Rust where many abstractions are missing on purpose to avoid their cost.

2 Likes

I don't have enough hard Rust coding time to have a firm opinion regarding these annoyances, but, I will point out that there must be a reason why Rust is consistently ranked THE most loved programming language by its users. :slight_smile:

A couple things:

  • The more general problem is not just retries or error-handling, but the ability to compose code by treating it as pure values.

That is really the biggest advantage of functional effects systems such as ZIO.

  • It has dawned on me, however, that Rust's ? op is actually related to monadic programming in a sense - the automatic unwrapping of error values and short-circuiting is exactly the main nicety of the for-comprehension.

I think that bullet point #1 above is a Big Deal but it will always come with costs in performance. It's just not Rust's style. And what Rust offers instead is just as compelling and powerful in another sense.

3 Likes

That's pretty abstract. Rust's type system already revolves around values; basically anything value-level can be treated uniformly (have inherent and trait methods, passed to functions, put into a generic container). Many abstractions involving eg. control flow are composable. Not just Result and Option, but things like an Entry of a map, Futures and Streams, and RAII lock guards, they have useful utility methods/functions/macros for extracting additional functionality from them, and otherwise they can be treated just like any other value.

So I don't really get what concretely needs to be done here; it seems to me that "nothing" already gets us to a perfectly usable status quo.

2 Likes

This needed to be said. I'm all for improving on the status quo, but the discussion so far has departed from that goal, IMO.

To revisit an example posed in an earlier reply:

fn double_inner_i32<'a, T>(functor: T) -> T
where
    T: Functor<'a, i32, Inner = i32>,
    // One possible fix:
    // T: Functor<'a, i32, Inner = i32, Mapped = T>,
{
    functor.fmap(|x| x * 2)
}

fn main() {
    let mut vec = vec![1, 2, 3];
    vec = double_inner_i32(vec);
    println!("doubled vec = {:?}", vec);
}

We can already do this with the standard library:

fn main() {
    let vec: Vec<_> = [1, 2, 3].into_iter().map(|x| x * 2).collect();
    println!("doubled vec = {:?}", vec);
}

Is the purpose of reimplementing this basic fundamental trait (Iterator) a desire to hide details (fn double_inner_i32)? That can be done like this:

fn double_inner_i32(x: i32) -> i32 {
    x * 2
}

fn main() {
    let vec: Vec<_> = [1, 2, 3].into_iter().map(double_inner_i32).collect();
    println!("doubled vec = {:?}", vec);
}

Or maybe it's a desire to hide the map operation?

fn double_inner_i32<I>(iter: I) -> impl Iterator<Item = i32>
where
    I: Iterator<Item = i32>,
{
    iter.map(|x| x * 2)
}

fn main() {
    let vec: Vec<_> = double_inner_i32([1, 2, 3].into_iter()).collect();
    println!("doubled vec = {:?}", vec);
}

I'm running out of things to say about this example. I know it's a contrived example, and I've probably given it far too much attention. But also maybe the problem is that it's contrived.

We can see a similar problem in an even more convoluted example:

impl<'a, A, B> Monad<'a, B> for Vec<A>
where
    A: 'a,
    B: 'a,
{
    fn bind<F>(self, mut f: F) -> Self::Mapped
    where
        F: 'a + Send + FnMut(/* … */) -> /* … */,
    {
        let mut vec = Vec::new();
        for item in self.into_iter() {
            for item in f(item).into_iter() {
                vec.push(item);
            }
        }
        vec
    }
}

This is just another way to write impl Iterator for Vec<T>. I'm not sure why we need this at all. What's wrong with the Iterator trait that Monad supposedly solves?

The passing mention of the keyword generics initiative gives some valuable hints: You're trying to merge Iterator and Stream without posing it in the problem statement, right? Something like keyword generics will be useful for writing libraries that need to be generic over these kinds of effects, that's a given.

But what's more important is the practicality of it. Writing a single interface that is generic over effects only makes sense for library authors. When put in practice (an application that uses the interface) it's going to fall on one side or the other: sync iteration or async stream. Just like calling a generic function, the call site decides a concrete type for the generic parameters, the caller will decide a concrete keyword at the call site.

So what you are talking about is making things nicer for writing libraries. Unless I've completely missed the mark. I have before; it's difficult for me to comprehend why this topic keeps coming up on the forums. Considering that I have less than zero ambition to replace the Iterator and Stream traits. But if there is something more interesting than "merge the bifurcation" that we won't get from language someday (maybe), then I would love to be corrected.

For now, this just looks like doing things the hard way for the sake of it being hard.

2 Likes

Yup, absolutely: https://rust-lang.github.io/rfcs/3058-try-trait-v2.html#prior-art

Result and ? and such is very much like Railway Oriented Programming | F# for fun and profit as well.

2 Likes

I assumed you were familiar with Scala functional effect systems, but I think not?

No problem. My statement is actually entirely concrete in the context it came from. A functional effect monad - a Cats IO or a ZIO - is just a value. It has code inside, but that code doesn't run when the monad is created. That is the sense in which these things can be treated as pure values, which can be modified by combinators and composed in all kinds of ways - the retry example above is one of them.

Because these things are values, there needs to be a runtime to run them. That's part of a functional effects framework. And because that runtime is also just library code, it can do anything, such as run effects concurrently in various ways, implement lightweight threads (fibers), interrupt those, etc.

I certainly have no argument that ""nothing" already gets us to a perfectly usable status quo," in terms of Rust. Rust is very much its own thing. The ability to slice & dice effects is very cool, but comes at a cost in performance that is just, well, antithetical to the Rust spirit, I think.

Rust already does error handling really well with Result and the monad-like short-circuiting ? operator. Retries... yes, we want to be able to control retrying IO-like operations in a generic way. In order to do so, we need an effectful-like thing - a trait object with a run()-type method and a corresponding runtime to call it.

I like your idea of implementing this at the Try level. But is such an abstraction really at home in Rust? It can't be zero-cost.

I intend to take a stab at writing something like this, and see where it goes, and if it makes sense.

1 Like