Difficulty of monad types in Rust

The whole "monad" meme-verse is so weird because the actual definition of a monad is dead simple: it's any type that provides the (amazingly badly named) return and bind operations, which you can express as function signatures in whatever language you want (and also obeys a few simple rules called "the monad laws"). What's weird is nobody ever just says that.

The actual complexity comes from "why does anyone care?" which has to do with how pure functional languages need to deal with the whole being pure and forbidding mutation thing, and it turns out almost all use cases for mutation can be simulated with some sort of monad. Now, why you would write code that is so hyper-generic it works on any monad... that is admittedly pretty hard to comprehend if you haven't worked in such a language. Just as deciphering C++ template errors is hard to comprehend if you've never used C++ in anger. Being generic over all monads is certainly not something I think you'd ever legitimately want to do in C++ or Rust or Typescript or whatever where regular mutation is already pervasive, so you don't need monad transformer stacks "just" to ergonomically mutate a complicated configuration object.

I currently believe "first-class" currying (as opposed to the "manual currying" you can obviously do with closures in every language) is another one of these features that doesn't have nearly as strong a cost/benefit ratio outside of the restrictions that the pure functional languages impose on themselves. Though I'd also say it's for the opposite reason: in pure functional languages, monads have far more benefits, while currying has a much lower cost.

Hopefully that way of presenting the opinion is enough to convince you I'm not suffering from the Blub paradox myself :slight_smile:


Incidentally, IMO the one aspect of monads which is relevant in other languages is that "the monad laws" are often a reasonable heuristic for "does this type make any basic conceptual sense?" when the type is vaguely wrapper-like (or burrito-like, if you prefer). For example, I believe if you applied them to Option the laws translate to Some(x).map(f) == f(x), opt.map(Some) == opt and opt.map(f).map(g) == opt.map(|x| { f(x).map(g) }). Admittedly, those are pretty funky expressions, but I think we'd all be shocked if one of them somehow failed to be true.

9 Likes

I think I'm suffering from terminal stupidity. That did not say anything to me I can get a handle on either.

Hmmm... Bear in mind that a "closure" is a new and exotic creature that I had never heard of until a few years back when I tackled Javascript for the first time. Given the event driven programming model of JS closures become an obviously useful thing.

No other language I had ever used had any notion of "closures". Although it seems that now all the world has got used to the idea through JS other guys, like C++, have felt the need to catch up and get closures of their own.

1 Like

Yeah, closures are one of the strongest examples of programming languages as a whole collectively converging on good ideas that were not obvious to anyone way back when C was new. I believe Java, C++, C#, Python, Ruby, Javascript, Rust, Lisp and Haskell (just to name the languages I know) all have some form of closure or lambda expression syntax, with at least two of them adding it post-1.0.

They're also a great example of something "the pure functional languages were right about", i.e. it turns out to be just as good in less pure languages, unlike monads and currying (lots and lots of IMOs here).

Oh, well I didn't actually say what the function signatures were, so that's probably on me. For some reason I thought the people in this thread were familiar with it already.

A generic type Wrapper<T> is a monad if it provides these two functions:

  • fn wrap<T>(t: T) -> Wrapper<T>
  • fn map<T, U>(w: Wrapper<T>, fun: impl Fn(T)->Wrapper<U>) -> Wrapper<U>

So for Option<T>, wrap() is just Some() and map() is, well, map(). Result and Promise are similar examples. Vec is one if you use its push() and pop() methods. And so on.

Haskell calls these functions return and >>=, or sometimes return and bind. Which is absolutely bonkers and IMO one of the reasons people end up concluding monads are scary. Every other language I know calls >>= something like map.

Obligatory: The Codeless Code: Case 143 Monolith

If you know map, filter, reduce from Javascript or Option and Result in Rust, then you've probably been "thinking with monads" and monad-adjacent concepts without knowing it. Just not quite in Haskell's jargon dialect.

5 Likes

map (monad bind) would translate to Option::and_then, which is similar to flat_map on Iterator.

1 Like

Perhaps the way I understand it will help: think of a monad as a container, it contains some kind of value (e.g. the value of type T in a value of type Option<T>).

But now you have a problem: you have to manipulate the inner value somehow, otherwise it's nothing more than an immutable type that you can't do anything with.

That's what those operations are for: they allow you, in a way that's conceptually consistent across monads, to 1. create a monad value from a value of the inner type, and 2. manipulate the contained value. The way that happens is by transforming that inner value into something else. The easiest example of this I can give here is my_option.map(f) for some function f that has the correct type signature (I'm on a phone so typing it out isn't trivial).

In addition, to guarantee that certain desirable properties hold, the monad laws need to be observed, but that's fairly easy to do in most cases.

2 Likes

I agree that currying is heavily overrated (it's a nice trick, but it has plenty of its own problems, such as the fact that argument order is now incredibly important, and manual currying isn't a big deal).

However, I disagree that monads aren't useful for non-functional languages: Future, Stream, Option, Result... Rust already has a lot of monads. But each one has different syntax: manual method calls, async.await, try/?, yield (for generators).

Right now each new monad which is added to Rust needs to have a new syntax created for it. Having a generalized do syntax would be fantastic, it would unify the existing syntaxes and would enable people to create their own custom monads.

You might think that requires higher order types, but it does not. F# created computation expressions which allow it to have generalized monad syntax without higher order types. It's not quite as nice as Haskell's do, but it is more explicit, so it's probably a better fit for Rust.

Thankfully, Rust has a very powerful macro system, so it's actually possible to create computation expressions with macros. Various experiments have been done with recreating Haskell's do notation, but I think we should instead try to recreate computation expressions (with a Rust twist).

4 Likes

No. Not the least bit.

Result (and by extension, Option) was perfectly fine witht the try! macro, but it turned out that a postfix operator is somewhat beneficial for readability (e.g. in the case of chaining). (I personally don't really find it that beneficial, as I rarely encountered the need for nested try!s, if ever, but many others seem to like it.)

Async/await would just as well be possible without special syntax; in fact, for a long time, await!() was a macro-like syntax extension. And asynchronous control flow is achievable even without literal async-await, by means of a purely library-based approach, built on future combinators, much like Result's combinators.

For some reason, however, people are just so keen on having special syntax, and the language designers seem to agree with them, so they put in the special syntax. It's not a necessity by itself.

Even if you decided to use macro syntax instead of keywords, that doesn't actually nullify my point at all.

My point is that each monad needs to create its own unique syntax. So you have try! for Option/Result, await! for Future, yield! for generators, etc.

Just because it's macro syntax doesn't mean it's not syntax. Macros are syntax.

That is incorrect. There are a lot of issues with combinators: they don't compose with loops, they don't compose with other language features like return/try!/?, and they do not work well with the borrow checker.

The point of syntax is to make things easier, more readable, and more maintainable, which is a necessity if you want good ergonomics. Syntax does matter.

If you think that ergonomics aren't important, that's fine, but most people (including the Rust lang team) disagree with you, so you might be happier with a different language which doesn't care about ergonomics.

1 Like

When it comes to discussion of functional programming and unintelligible words like "monad" one might as well be speaking in Klingon to me. So I have a question:

To my mind trying something, waiting for something, yielding something are all very different activities. As such those words used in Rust make perfect sense.

I read what you have written as saying you would like them to all use the same word or whatever symbol.

What would that word/symbol be? How would it not be confusing?

I recall the Monty Python Philosophers Sketch: "Do you mind if we call you Bruce to avoid confusion?"

Or have I totally missed the point?

2 Likes

You keep repeating this but you don't explain why you think they need to. It is an assertion without proof. Many languages did just fine with monadic construct without their own syntax in the past, and as I mentioned, so did Rust. Things change, but just the fact that some of them have special syntax now doesn't mean that this is a general necessity, or else implementing them without the syntax would have been impossible in the past.

Yes, I have heard that argument before. And those are largely stylistic issues. Both can be solved trivially by providing combinators that allow one to mirror loops and try and everything else. The standard Iterator trait with its combinators such as try_fold is the prime example that this is possible today.

I specifically wanted to avoid the dreaded "ergonomics" word, because nowadays that's being used as a trump card for shoving absolutely every last niche feature into the language without proper consideration. And the language team is sadly pushing this. I do care about ergonomics (that's why I switched from C++), but destroying core principles in the name of something that maybe 1% of the users need (I'm not only talking about async/await, but things like: one, two, three, four, five, six) is just generally misguided.

And you got it completely backwards with your last sentence. I've been using Rust for several years – I started using it because I loved they way it was. I have invested who knows how many hours in learning it, in writing opensource libraries in it, and even teaching it to friends that seemed interested. Why should I be forced to go away, hunting for another language and ecosystem, just because some newcomers are bending it in ways that completely hijack its original direction?

Furthermore, Rust is already pretty darn comfortable to use – it's one of the most "ergonomic" languages out there in fact. Not enough syntax isn't anywhere near its worst or most important problems. I'd rather have shorter compile times, or more precise rules around FFI / unsafe in general, or tier 1 AVR support, or variadic generics, or fully-fledged const evaluation. I'd take any of these over Yet Another Keyword To Learn™, and I'm still pretty comfortable without these features. They'd be nice, but not essential, and I can encapsulate my way out of the eventual and minor ugliness the lack thereof might eventually cause.

People complain about the borrow checker all the time: the proof is last week's 6 posts I linked to above, which all go something like "the borrow checker/the ownership model is hard and therefore we must change it". But once the authors of those posts make some effort understanding the underlying principles, and once they learn how to write idiomatic code, they almost invariably stop complaining because the issues simply go away if the code is structured properly.

Code using futures is not special: I have seen the exact same line of reasoning (or rather, set of complaints) in the context of async without a single concrete example as to what doesn't work and what people would like to work. At least I could then – as I often do with other kinds of non-idiomatic code – take a stab at rewriting it in a more structured, more principled manner so that the borrow checker can be happy.

2 Likes

I can see your point. Monads capture the idea of chaining operations together in some way, but the nature of the chaining is different between the different types: Result/Option chain together until an Err/None is encountered, Futures chain together when a value is eventually produced, Iterators can flatten operations over their elements that produce other iterators. It is hard to come up with a term that captures all those (and other monads, some of which are fairly strange), other than something vague like "chaining". I can see why functional programming reached for a term from category theory to describe them.

The one thing generalising over monads would give us is the ability to create general functions that work for any monad, to save reinventing the wheel when a new monadic type is created. That does, of course, extend to special syntax such as ? and await, but it would seem strange using the same operator for both futures and error handling.

1 Like

They are indeed very different activities. A monad is a generalization, so many different things can be a monad.

A monad is simply something which has a concept of "do this, then do this". In other words it's about sequential computation. So the thing that Option, Result, Future, etc. all have in common is that they are doing sequential computation.

Okay, but so what? Why do we need this "monad" concept for that? Because it allows us to generalize our code. Rather than writing a new function for each thing (?/await/yield/etc.) we can write generic code which works with anything. And it also makes the syntax extensible, so people can create their own monads in a library without needing to create new syntax for it.

Monads aren't essential, most people can do just fine without monads (just like how most people can do just fine without generic code or traits), but they're nice to have in some situations (just like generics/traits).

Because if you have syntax like this...

await!(foo());

That has a different type and behavior compared to syntax like this...

try!(foo());

I had assumed that this was obvious, which is why I didn't mention it.

async/await requires syntax, it cannot be done with pure combinators. The same is true for generators. This has been covered many times, over the past few years.

Future combinators cannot contain captured references, all references must be 'static. This cannot be fixed by adding new combinators, it can only be fixed with the async/await syntax.

We have given examples, such as this: Borrowing in async code · Aaron Turon

But even if it was possible to use combinators, it is very tedious. Being able to use normal constructs like loop, for, while, match, let, return, ?, etc. is incredibly useful. It makes it easy to convert sync code to async code. It makes the code far shorter, more readable, and more maintainable. You can disagree all you like, but that won't change the facts.

I don't know why you're bringing up random forum posts. They do not represent the views of the majority of Rust users (none of those proposals even got a single heart), and they do not represent the views of the Rust lang team. Random people making forum posts doesn't mean anything.

You seem to have this bizarre idea that some small minority of forum posters will somehow influence the language. That is false, as many people have explained to you in the past.

The Rust lang team generally doesn't look at the forum posts, and language changes are only done through RFCs, and they are decided by the Rust lang team. Rust is not a democracy. It is impossible for these "new users" to "bend Rust", because all decisions are made by the Rust lang team, based on RFCs.

And if you think that "these forum posters will influence the Rust lang team into making bad decisions", that is not the case. There have been multiple large proposals where the Rust lang team went against what the forum posts (and Reddit) wanted.

We have explained this to you many times in the past, but you continue to push this idea that "it's bad that people are discussing things in the forum, they're ruining Rust!" which is just simply incorrect.

You seem to have extremely little trust in the Rust RFC process, and even less trust in the Rust lang team. Of course the system isn't perfect, but it's the best system I've ever seen in any language or community, and it has worked extremely well so far.

And your constant complaints will never fix or improve anything. If you have some actual concrete suggestions on how the system can improve, please do share them, but you don't do that, you just keep talking about "random people are making forum posts, make them stop!". That will not fix anything, it will only generate animosity toward you.

For the sake of your mental stress, I suggest you ignore the proposals on the forum, and only focus on the RFCs, because the RFCs are what actually cause Rust to change. Forum posts don't do anything.

Also, I wasn't advocating for more syntax anyways, I specifically said that computation expressions should be created in a library using macros, I never said that they should be added to the language.

And the reason why I like computation expressions is because it means we need less syntax, because we can unify these different things into a single syntax, so you should be happy about that, since you dislike extraneous syntax. So I really don't know what you're arguing for.

This will be my last message to you.

5 Likes

I thought the rest of my post had already spelled this out, but to be clear: Monadic types are absolutely useful in other languages, and are quickly becoming as rightfully ubiquitous as closures. What I meant here is that "monad" as an abstraction over all possible monadic types, i.e. "code that is so hyper-generic it works on any monad" is nowhere near as feasible or useful outside of pure functional languages.

1 Like

Thanks for that. I guess I'm a bit to slow understand it.

When I look at those two examples I see the same syntax, not two different syntaxes. Namely a macro invocation, with a single parameter, which happens to be a function. Of course they deal with different types and have different behavior. That is why different names are used.

What am I missing here? How would they look with the mysterious monad thing?

1 Like

As I understand it, Monads or something similar could allow a single macro which works with both types - we wouldn't need 2 functions, it could just be a single one. You say it's a single one, yet 2 names are used - monad would allow it to really be a single macro/function.

That's the main thing the Monad is: an abstraction, like generics, which enables the same code to work with different abstractions.

Note: this is independent of my opinion on the topic, which is largely unformed. I can see good arguments for both sides, and I don't think I have anything useful to add at the moment.

That is not the case. You are thinking about it from the perspective of the Rust parser, but that's not how macros work.

In fact, macros aren't even parsed, they accept arbitrary tokens and can output arbitrary tokens. That's why they can accept tokens which aren't even valid Rust:

macro_rules! foo {
    (test ^ yes => [ $name:ident ]) => {
        
    };
}

foo!(test ^ yes => [ bar ])

As another example, macros can completely rewrite or change any of their input:

macro_rules! foo {
    ($name:ident()) => {
        launch_the_nukes();
    };
}

In this case if you use foo!(bar()) it will not call bar(), it will instead call launch_the_nukes().

The macro can do whatever it likes, because a macro is a system for creating new syntax. All macros fundamentally create new syntax. Or to put it more simply: macros are syntax.

As soon as you see a macro call, you know that you're dealing with new syntax, and that the macro can do arbitrary things.

Since we're talking about syntax, there's many different ways to do it (Haskell uses do notation, F# uses computation expressions, etc.)

So I'll just pick an arbitrary syntax that I like and use that as an example:

// Current Rust
async {
    foo().await
}

try {
    foo()?
}

|| {
    yield foo()
}
// With computation expressions
seq!(Async {
    foo()?
})

seq!(Try {
    foo()?
})

seq!(Gen {
    foo()?
})

With functions it would be a lot nicer:

#[seq(Async)]
fn foo() {
    bar()?
}

#[seq(Try)]
fn foo() {
    bar()?
}

#[seq(Gen)]
fn foo() {
    bar()?
}

The Async, Try, and Gen would be structs which impl a Monad trait. I'm reusing ? for the "bind" operator, since it already exists in Rust, it's short, and it allows for chaining. But you could use a different marker instead, like bind!() or whatever.

This is starting to get pretty far off topic though, so I think we should continue in a new thread.

That's the same syntax, with different semantics. It's not "different syntax" just because the identifier is different.

That's somewhat true, yes.

I have done that in great length and detail, more than a year ago.

Cheers.

1 Like

Okay, I've split this monad tangent out into its own thread. Link back to the old currying one.

3 Likes

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.