You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A pragmatic new design for high-level abstractions
Monads (and, more generally, constructs known as “higher kinded types”) are a tool for high-level abstraction in programming languages1. Historically, there has been a lot of debate inside (and outside) the Rust community about whether monads would be a useful abstraction to have in the language. I’m not concerned with that here. You see, there’s a problem with talking about whether monads would be useful or not, and it’s this: there are a large number of design challenges to overcome to have any hope of implementing them at all — to the best of my knowledge, there currently exists no realistic (that is, practical) design for monads in Rust. In fact, there are so many obstacles that some people express doubt that it’s even possible.
In general, I don’t think it’s worth talking about the virtue of a language feature if we think there’s no way we could actually implement it. However, I think there are arguments to be made in favour of higher-level abstractions in Rust. Thus, to facilitate discussion, I want to demonstrate that monads are feasible in Rust.
Specifically, in this article, I’m going to describe a new approach to express monads in Rust. It is the most minimal design I have seen proposed and is, in my eyes, the first plausible design for such abstractions — those commonly known as “higher-kinded types”. This approach depends on a very minimal extension to Rust’s type system. In particular, this approach avoids the need for either higher-kinded types (e.g. as in this design) or full abstraction over traits (e.g. “traits for traits”). Most of the design challenges are tackled directly using existing features.
However, without explaining why these design choices have been made, the final design can seem opaque. In particular, the design is significantly influenced by several quirks found in the Rust type system that will be unobvious to those who have not already come across them. To make this design accessible and hopefully also to explain why monads are hard(er than you might think) in Rust, I’m going to build up the definition gradually, explaining the motivation behind the choices.
I am going to be assuming some familiarity with monads (and to a certain extent, do notation). There are enough introductions to monads out there as it is.
The design here was influenced by conversations with @Centril. Thanks also to @Nemo157 for providing illuminating insights into subtleties with the behaviour of yield.
Overview
There are several problems with a naïve design of monads (and similar abstractions) in Rust. The most prominent are the following.
Monads are naturally structures at the level of type constructors, not types. Extending Rust to support full higher-kinded types raises many significant design questions. There are arguments that such an extension is simply infeasible.
Monads abstract over function types, but Rust has many types of function (notably any type that implements Fn, FnMut or FnOnce)2.
As traits are the natural abstraction mechanism in Rust, many structures that appear monadic (such as iterators) are not, strictly speaking, monads at the type level. For instance, calling map on any iterator always produces an iter::Map, rather than preserving the original type constructor3.
Monadic structure appears at two levels in Rust: at the type level, such as with Option; and at the trait level, such as with Iterator. Abstracting over both elegantly is tricky.
A naïve implementation of do notation would be surprisingly limited in Rust, as control-flow is captured by closures and would thus be useless in bind desugarings.
As such, any design that puports to facilitate monads must provide solutions for each of these problems.
Let’s see how we can do so.
Idea
We’ll see how this works in detail below, but here’s a summary of the idea behind this design, to set the scene. Traditionally higher-kinded types cannot be defined naïvely, because we cannot abstract over type constructors in Rust. However, we can define their structure and properties pointwise on each type that is an instance (that is, instead of saying something like “Option is a Monad”, we instead say “Option<A> is a Monad<A> for all types A”). This is still not straightforward, however, because by defining a higher-kinded type in terms of each of its instantiations, we lose some higher level typing information. To remedy this, we employ generic associated types and generic associated traits (the one additional language feature) to restore the lost information. This way, Monad may be defined as a normal trait.
This is all a bit hand-wavy without seeing it in action, though, so we’ll leave the theory there and get into the details.
Functors
Famously, monads are functors. That means that before we can give a full definition of a monad, we have to give a definition of a functor.
For illustrative purposes, a definition of a functor in Haskell is as follows. The meaning should hopefully be clear even if you’re not familiar with Haskell’s syntax (just read class as trait and lowercase letters as uppercase ones).
classFunctorfwheremap::fa->(a->b)->fb
If we try to implement this in Rust, we immediately run into several problems.
(You may object to some, or all, of these definitions. That’s a perfectly reasonable reaction. There’s really no natural naïve definition.)
Note on syntax
I’m going to start by using impl Trait in both argument- and return-position, because I think it indicates intent more clearly. Later, I’ll demonstrate why these are insufficient, but for now I want to prioritise readability.
Attempt 1: Naïve definition
The Functor type classes in Haskell (what we’d expect to be the equivalent of a hypothetical Functor trait in Rust) is parameterised by a single type variable. So a first attempt at an analogous definition in Rust might look something like this.
There’s clearly a problem here. We can’t even define the return type of map. The problem is that we have no way to get a handle on the type constructor that we’re implementing Functor for. Self is the type we’re implementing Functor for, specifically when the type parameter is A. For example, if we’re implementing Functor for Option, then Self would be Option<A> and we have no way to declare the type Option<B>.
The key insight here is that what we’d really like is a higher-kinded Self. That is, we’d like to write something like the following.
This just doesn’t work with Self as-is, but the idea is promising. What we’re going to do is create a “higher-kinded” version of Self ourselves, using generic associated types.
With a generic associated type, we can actually define a signature for map that looks like something we might expect. (Think of Self as being the same as HigherSelf<A>.) We might almost be able to convince ourselves that this is a reasonable definition. Unfortunately, functions aren’t all that simple in Rust (as you’ll know if you’ve been using Rust for a little time).
Aside on identity
Here, we see a generic type HigherSelf<T> that we are pretending is the type we’re implementing a trait for. In the definition above, though, the type is completely generic; there’s no reason to believe that Self and HigherSelf<T> are related in any way. We can address this with type equality constraints, like in the following.
typeHigherSelf<T>whereSelf=HigherSelf<A>;
This ensures our notion of “higher-kinded Self” is somewhat well-behaved. This kind of condition gets more and more complex to declare as we progress through the design, so I’ve opted to leave them out here. In a real implementation, one may or may not want to enforce this property: having these equality relations is truer to the original concept, but in practice probably has little additional benefit.
A family of functions
Rust has several notions of “function”, which is another reason the definition of Functor isn’t quite so straightforward as in Haskell2. Namely, in Rust we have the following.
Unique types for each function or closure. For example the function fn foo() {} has the unique type fn() {foo}. (This type is unnameable in Rust, but may be printed in diagnostic messages.) Two functions that have the same signatures will have different types. There are historical reasons for this.
Function pointers, such as fn(bool) -> (u8, u8). Unique function types can be coerced to function pointers. So can unique closure types, as long as they do not capture variables from their environment.
Three traits for abstracting over functions: Fn, FnMut and FnOnce. These are automatically implemented by unique function types and unique closure types (the specific traits the closure type will implement depends on how it captures from the environment).
The Fn* traits are the correct way to abstract over functions in Rust: there’s no single function type constructor (->). Thus, we need to generalise our definition a little further.
Attempt 3. Generalised mapping function
This is where we need a new language feature. The only facility we require for this design that is not currently an accepted language feature in Rust is the concept of (generic) associated traits. If you’re familiar with associated types and associated consts, the idea will be obvious. The reason we need associated traits is to abstract over the three Fn* traits. Instead of hard-coding a specific kind of function in our functor definition, we’ll allow it to be specified in the implementation of Functor.
In practice, we would expect MapFn<A, B> to be instantiated to one of Fn(A) -> B, FnMut(A) -> B or FnOnce(A) -> B, and we could enforce this, but there’s no real need to do so. By leaving it generic, we create a more general abstraction4 (that’s no less useful).
So, now we’re done, right? This seems like a perfectly good definition of a functor.
Well… actually, not quite yet. We can see this isn’t quite general enough if we look at a trait that we expect to be functorial. The map method on Iterator<Item = A> has the following signature.
Notice how the return type has an extra parameter in it: the (unique) type of the mapping function F. (The reasons for this are to facilitate a design pattern called external iteration, but the motivation is really tangential here. The fact that we want Iterator to be functorial means we need to abstract over this design pattern.)
In general, a function’s return type could capture all of the input type parameters that are passed to the function. While it may not look like it in our signature, impl Trait in argument-position corresponds to a generic type argument. We need to take account of this detail: namely, we need to add this additional type parameter to the generic return type (that is, HigherSelf).
Attempt 4. Capturing generic arguments
Now that the return type also needs to capture the mapping function, it doesn’t make so much sense to call it HigherSelf: it still corresponds in some sense to Self, but at the same time, it’s been specialised to the map function signature. In general, if our traits have multiple methods, we’ll need different generic associated traits for each, so it makes sense to use a different name. I’ll call it Map for simplicity (not to be confused with the identically-named iter::Map… sorry).
With this last definition, we really are done. It’s taken several iterations, but this definition of Functor is general enough to capture the examples of functors that we’re interested in.
Implementing Functor
We’ve defined the trait; the next thing to do is to implement it. After deriving a correct definition, making use of it presents no problems.
I’m just going to give two definitions5: for a functorial type and a functorial trait, to demonstrate the flexibility of our definition. More examples can be found at the end of this post.
// Implementing `Functor` for a type.impl<A>Functor<A>forOption<A>{typeMap<T,F>=Option<T>;traitMapFn<T,U>=FnOnce(T)->U;fnmap<B,F:FnOnce(A)->B>(self,f:F)->Option<B>{self.map(f)}}// Implementing `Functor` for a trait.impl<A,I:Iterator<Item=A>>Functor<A>forI{typeMap<T,F>=iter::Map<T,F>;traitMapFn<T,U>=FnMut(T)->U;fnmap<B,F:FnMut(A)->B>(self,f:F)->iter::Map<B,F>{self.map(f)}}
Monads
Many of the difficulties in defining Monad are those we already encountered when figuring out how to implement Functor: the hardest parts are behind us. However, there is one subtlety in particular in how we define bind, which is a slightly more complex situation than map.
Let’s look at the definition in Haskell again first, which I think clearly presents the signature we expect. (What I call unit and bind here are usually called return and >>= in Haskell.)
classMonadmwhereunit::a->mabind::ma->(a->mb)->mb
This time, I’m going to start with the complete definition in Rust, as it should be mostly familiar, and explain the parts that are new.
The first thing that you’ll notice is that we have a new associated trait, SelfTrait. I’m going to come back to what role this plays shortly: you can ignore it for now.
The unit function is straightforwardly defined using the same reasoning as Functor::map. In the same way, we need to define a generic associated type that functions as its return type. (Here, there’s an additional SelfTrait bound on Unit, but that’s not important to understand yet6.)
In general, though, for each function we define that uses Self in a higher-kinded fashion, we need to use a generic associated type just like for map. Such as is the case with bind. Here also, we want to permit the binding function type to vary, so we use an associated trait, BindFn, representing the kind of binding function, just like before. We could have reused the MapFn trait: often map and bind will take the same kind of functions, but this isn’t always true, so for maximum generality we need another trait.
However, the definition of bind is a little different than map. Recall that the binding function has a signature of A -> M<B> (contrast to the mapping function’s signature A -> B). This means we have to take a function that returns a monad (specifically, the current one) parameterised over B. However, the specific type of this monad may not be fixed. For example, consider the monad Iterator. The bind operation for the Iterator trait corresponds to the flat_map method. We should be able to return any type that implements Iterator from the flat-mapping function we pass to flat_map. It’s probably easiest to demonstrate this with an example.
letxs=[1,2,3];// Here, the flat-mapping function returns `iter::Take`.letys:Vec<_>=xs.iter().flat_map(|x|iter::repeat(x).take(2)).collect();// `ys` contains `[1, 1, 2, 2, 3, 3]`.// Here, the flat-mapping function returns `option::IntoIter<_>`.letzs:Vec<_>=xs.iter().flat_map(|x|Some(x).into_iter()).collect();// `zs` contains `[1, 2, 3]`.
Clearly, the return type of the function we pass to flat_map (and, correspondingly, to bind) can vary from call to call. Therefore, we want to simply ensure it returns some type that implements our monad. This is the role SelfTrait plays. Just like Map and Bind act like “higher-kinded Self types”, SelfTrait acts like a “higher-kinded Selftrait”. (If this still doesn’t click just yet, wait till you see the examples, which should make things clearer.)
It’s worth noting that we only need to define one SelfTrait, even if we make use of it in multiple functions (unlike associated types like Map and Bind, which had to be defined on a per-function basis). This is essentially because generic arguments can vary, but the return type is fixed (up to their generic parameters). (This also effectively falls out of the difference between argument-position and return-position impl Trait, albeit in a slightly disguised form.
Note on return-position impl Trait in traits
If we had return-position impl Trait in trait definitions, we could eliminate the need for our specialised Map and Bind types entirely using our associated SelfTrait. This makes for a much cleaner definition. Here’s Functor.
This is technically just syntactic sugar, and it’s not necessary for any of the definitions here, as we’ve seen. It does simplify things though (and is arguably closer to the higher-kinded viewpoint, as we’re defining everything from a higher level of abstraction7).
Identities
If you were following the previous definition closely, you may have spotted an apparent oversight. In particular, the associated SelfTrait only makes sense for monadic traits. If we want to implement Monad for a type, then there’s no such trait! In the case of a monadic type, the binding function should return something of the (parameterised) type precisely, not just something that implements some trait.
As we saw, though, we do need this level of abstraction for monadic traits. Fortunately, there’s a tidy way we can make Monad work just as nicely for monadic types. The key is a generic “identity trait”. The identity trait on T will be implemented solely for T. No other type will be permitted to implement the identity trait (this is often known as a sealed trait). It’s straightforward to define.
traitId<T>{}impl<T>Id<T>forT{}
Now, for example, Option<bool> (and no other type) implements Id<Option<bool>>. For monadic types, we can now simply use Id for SelfTrait and everything works as expected.
Implementing Monad
// Implementing `Monad` for a type.impl<A>Monad<A>forOption<A>{traitSelfTrait<T>=Id<Option<T>>;// UnittypeUnit<T>=Option<T>;fnunit(a:A)->Option<A>{Some(a)}// BindtypeBind<T,F>=Option<T>;traitBindFn<T,U>=FnOnce(T)->U;fnbind<B,MB:Id<Option<B>>,F:FnOnce(A)->MB>(self,f:F)->Option<B>{self.and_then(f)}}// Implementing `Monad` for a trait.impl<A,I:Iterator<Item=A>>Monad<A>forI{traitSelfTrait<T>=Iterator<Item=T>;// UnittypeUnit<T>=iter::Once<T>;fnunit(a:A)->iter::Once<A>{iter::once(a)}// BindtypeBind<T,F>=iter::FlatMap<T,F>;traitBindFn<T,U>=FnMut(T)->U;fnbind<B,MB:Iterator<Item=B>,F:FnMut(A)->B>(self,f:F)->iter::FlatMap<B,F>{self.flat_map(f)}}
Easy!
Deriving functors and monads
In the examples above, though the implementations of Functor and Monad for types and iterators are straightforward, they do contain a lot of boilerplate. This is an unfortunate consequence of the lack of full higher-kinded types: we generally have to be much more explicit about the various types. This is likely to be true for any similar representation of higher-kinded types in Rust.
However, in practice, we could avoid this boilerplate in the majority of instances. This is made clear by observing how the entirety of the implementation definitions are determined by the map, unit and bind functions for Functor and Monad respectively. Often types and traits implementing these higher-kinded traits already define these functions (albeit with different names): we’ve already seen this with Option and Iterator, where we simply delegate to existing functions, and it’s certainly the case for many of the functors and monads in the Rust standard library.
In these cases, we could use a custom #[derive] attribute to generate the boilerplate automatically for us in the background. With this feature, implementing Functor and Monad could be as simple as the following.
The message to take away here is that, though the definitions might seem intimidating at first, in all likelihood they would rarely need to be implemented directly by the user.
Do notation
It would be remiss of me to talk about the feasibility of monads without even mentioning do notation. For those of you who are unfamiliar with do notation, it provides a convenient synactic sugar for working with monads without having to make do with deeply-nested binds and units in Haskell. Monads are useful abstractions even without do notation, but there is definitely something to be said for a design that also encompasses a similar synactic sugar for monads in Rust.
The main argument against the feasibility of do notation in Rust is the difficulty with composing control flow expressions (such as return or break) with closures. This is because closures capture control-flow: we can’t break out of a loop enclosing a closure within the closure itself, for instance.
loop{||break;// This isn't going to work.}
This is a problem, because do notation is desugared in terms of nested binds, which take closures as arguments. What we might expect to work intuitively in a naïve implementation of do would fail unexpectedly.
Let’s look at a concrete example to see exactly where this fails.
// This is a useless loop, but it's illustrative.loop{do{letx<-a;println!("x is {}.",x);break;// We've done what we came for: let's leave the loop.}}
Desugaring do from a naïve perspective, we might expect this to be equivalent to the following.
loop{a.bind(|x|{println!("x is {}.",x);break;// Uh oh...});}
Obviously, this doesn’t work, because we’re trying to break the outer loop from inside the bind closure. However, from the perspective of the do notation, it seems entirely reasonable.
There’s a happy conclusion to this story. I wrote a separate post on the topic a while ago, describing how we can resolve this dichotomy. In essence, control flow and do notation are not mutually exclusive: you just need to be a little cleverer in your desugaring. We might desugar something like the following8:
In practice, this exact desugaring has some rough edges. For example, any variables referenced inside a do {} block are moved inside the binding closures, and are then unusable after the scope of the do {} block.
This is a flaw that could be addressed with built-in support from the compiler for do notation (for example, it could move those variables live at the end of the do {} block’s scope to the enclosing scope). In practice, if we decided we wanted do notation in the language, we’d want to experiment with exactly how do should work, especially in its interaction with lifetimes. The main takeaway here is that do notation in Rust could make sense: even though by necessity we’re working with closures behind the scenes, we can still recover the expressivity of the notation with some sleight of hand. (Whether do notation would be useful in practice is another question, which would be a question for a full feature proposal, rather than the exploration here.)
Abstracting over monads
We’re almost done. The last topic I want to cover is the question of further abstraction. There are really two questions that are natural to ask at this point.
How can we write abstractions involving monads?
How can we abstract over monads?
The first question is easily answered. As Monad is simply a trait, we can use it like any other. As a user of Monad, we don’t need to worry about whether an implementer is a monadic type or a monadic trait: whatever form it takes, we know it conforms to the trait interface specified by Monad.
// A simple function making use of a monad.fndouble_inner<M:Monad<u64>>(m:M)->M{m.bind(|x|M::unit(x*2))}
Practically, monads are now no more imposing than any other trait. All the complexity is hidden away in the definition (and to some extent, the trait implementation).
The second question requires a little more thought to answer. Functors and monads are, in one respect, simply the first rung on the ladder of higher-kinded types. Just as monads (as type constructors) abstract over types, we can also abstract over monads themselves. To give an example, one such abstraction is a monad transformer.
Frankly, at this point, I’m not sure whether even higher-kinded types are possible to express in Rust. When I spent a little time playing around with potential definitions of monad transformers, I kept running into design problems: exactly the same tricks as for monads don’t quite carry across, because we end up wanting to quantify over all traits in an implementation, rather than one trait per implementation. That said, I didn’t try all that hard and it’s possible there’s a way to resolve the difficulties.
The main takeaway here is that, even though the designs here may not (obviously) generalise to all higher-kinded types, they generalise to a wide class of examples9 that could reasonably be considered sufficient for most of the abstraction users generally want to express in Rust. Even without full higher-kinded types, perhaps this is enough.
Summary
Let’s take a step back and appreciate what we’ve achieved.
We’ve defined an abstraction for functors and monads in Rust, two common higher-kinded types, using only one (conservative) hypothetical language feature: associated traits.
We’ve demonstrated how it elegantly abstracts over both monadic types and monadic traits.
We’ve shown how implementational boilerplate can be close to eliminated using #[derive] macros.
We’ve illustrated how control flow is compatible with a natural do notation in Rust.
Many have expressed reservations about the feasibility of providing abstractions over higher-kinded types like monads in Rust (perhaps the most notable being this thread, which I address directly in the appendix for completeness). The past proposals for monads in Rust that make use of advanced type system features such as full higher-kinded types do nothing to palliate these suspicions.
However, I think in this design, there is at last a plausible and reserved model for these abstractions. In particular, I think monads fall naturally out of a conservative extension of the language that is beneficial even without considering the potential for higher-kinded types.
At the very least, I hope that I have given some food for thought. We never needed higher-kinded types. My conclusion wherefore is this: monads are feasible in Rust.
Appendix
Accepting the challenge
IN CONCLUSION: a design that works in a pure FP which lazily evaluates and boxes everything by default doesn’t necessarily work in an eager imperative language with no runtime.
I haven’t explicitly addressed each of the points in the aforementioned “Monads and Rust” thread in the main post, because I think it is self-evident that the design here is sufficient to capture Monad in Rust. However, I shall do so briefly here, to satisfy any suspicions of avoiding difficulties by omission.
Borrowing across yield in do notation: there is a problem with pretending yield is simply the bind of the Future monad (despite both having similar effects), namely when borrowing is involved. (The only examples I’ve seen so far are rather involved, so I’m not going to reproduce them here.) That is: we can’t replace the yield keyword with <Future as Monad>::bind. However, we can use yield just fine within do notation (using the same propagation of control flow from closures as break or return). So yield per se isn’t a problem with do notation: we just can’t abstract it away entirely.
Control flow in do notation: as illustrated above, using a straightforward desugaring, we can recover full control flow within do notation (arguably solving an open research question).
External iterator patterns induces un-monadic signatures: by abstracting over traits, rather than types, we can describe monadic signatures entirely idiomatically.
There are three kinds of function type constructor: using associated traits, we can capture all three notions of function (and more).
Higher-kinded polymorphism is hard: in this design, we completely avoid higher-kinded polymorphism (and full higher-kinded types in general).
Abstracting over monads would be unergonomic in Rust: using #[derive], implementing higher-kinded types can be made very minimal and ergonomic.
To conclude, there’s no guarantee that a feature designed with a pure, lazy language in mind will work in an eager10, imperative language. But sometimes it does; it just requires a little more care.
Additional examples
In this post I’ve focused on functors and monads for Option and Iterator in particular. The techniques here extend naturally to other popular examples of higher-kinded types and other monadic types and traits. For completeness, I’m going to give some additional examples, to demonstrate that the framework here really is general enough to encompass the traditional and desirable use cases. This appendix can be viewed as a reference more than a section worth reading in its own right.
join
Monads have another operation, known as join, or simply “multiplication”, with the signature m (m a) -> ma. It’s interderivable with bind, so I haven’t included it in the definition above, but we could provide it as a default implementation11 on the Monad trait.
There are some higher-kinded types I suspect we need additional type system additions to support. For example, I think definining Traversable requires trait generics: i.e. traits as generic parameters. This is a similar, but distinct, feature from (generic) associated traits.
These are both plausible language extensions that also do not introduce the full complexity required by general higher-ranked types. However, I do not dwell on them here: I think associated traits provide the most immediate benefit of these type system extensions (it’s better to focus on one feature at a time).
via varkor’s blog https://ift.tt/2Urt7kb
April 5, 2019 at 12:28AM
The text was updated successfully, but these errors were encountered:
Idiomatic monads in Rust | varkor’s blog
https://ift.tt/2U5ZOEx
About 0 Minutes
A pragmatic new design for high-level abstractions
Monads (and, more generally, constructs known as “higher kinded types”) are a tool for high-level abstraction in programming languages1. Historically, there has been a lot of debate inside (and outside) the Rust community about whether monads would be a useful abstraction to have in the language. I’m not concerned with that here. You see, there’s a problem with talking about whether monads would be useful or not, and it’s this: there are a large number of design challenges to overcome to have any hope of implementing them at all — to the best of my knowledge, there currently exists no realistic (that is, practical) design for monads in Rust. In fact, there are so many obstacles that some people express doubt that it’s even possible.
In general, I don’t think it’s worth talking about the virtue of a language feature if we think there’s no way we could actually implement it. However, I think there are arguments to be made in favour of higher-level abstractions in Rust. Thus, to facilitate discussion, I want to demonstrate that monads are feasible in Rust.
Specifically, in this article, I’m going to describe a new approach to express monads in Rust. It is the most minimal design I have seen proposed and is, in my eyes, the first plausible design for such abstractions — those commonly known as “higher-kinded types”. This approach depends on a very minimal extension to Rust’s type system. In particular, this approach avoids the need for either higher-kinded types (e.g. as in this design) or full abstraction over traits (e.g. “traits for traits”). Most of the design challenges are tackled directly using existing features.
However, without explaining why these design choices have been made, the final design can seem opaque. In particular, the design is significantly influenced by several quirks found in the Rust type system that will be unobvious to those who have not already come across them. To make this design accessible and hopefully also to explain why monads are hard(er than you might think) in Rust, I’m going to build up the definition gradually, explaining the motivation behind the choices.
I am going to be assuming some familiarity with monads (and to a certain extent,
do
notation). There are enough introductions to monads out there as it is.The design here was influenced by conversations with @Centril. Thanks also to @Nemo157 for providing illuminating insights into subtleties with the behaviour of
yield
.Overview
There are several problems with a naïve design of monads (and similar abstractions) in Rust. The most prominent are the following.
Fn
,FnMut
orFnOnce
)2.map
on any iterator always produces aniter::Map
, rather than preserving the original type constructor3.Option
; and at the trait level, such as withIterator
. Abstracting over both elegantly is tricky.do
notation would be surprisingly limited in Rust, as control-flow is captured by closures and would thus be useless inbind
desugarings.As such, any design that puports to facilitate monads must provide solutions for each of these problems.
Let’s see how we can do so.
Idea
We’ll see how this works in detail below, but here’s a summary of the idea behind this design, to set the scene. Traditionally higher-kinded types cannot be defined naïvely, because we cannot abstract over type constructors in Rust. However, we can define their structure and properties pointwise on each type that is an instance (that is, instead of saying something like “
Option
is aMonad
”, we instead say “Option<A>
is aMonad<A>
for all typesA
”). This is still not straightforward, however, because by defining a higher-kinded type in terms of each of its instantiations, we lose some higher level typing information. To remedy this, we employ generic associated types and generic associated traits (the one additional language feature) to restore the lost information. This way,Monad
may be defined as a normal trait.This is all a bit hand-wavy without seeing it in action, though, so we’ll leave the theory there and get into the details.
Functors
Famously, monads are functors. That means that before we can give a full definition of a monad, we have to give a definition of a functor.
For illustrative purposes, a definition of a functor in Haskell is as follows. The meaning should hopefully be clear even if you’re not familiar with Haskell’s syntax (just read
class
astrait
and lowercase letters as uppercase ones).If we try to implement this in Rust, we immediately run into several problems.
(You may object to some, or all, of these definitions. That’s a perfectly reasonable reaction. There’s really no natural naïve definition.)
Note on syntax
I’m going to start by using
impl Trait
in both argument- and return-position, because I think it indicates intent more clearly. Later, I’ll demonstrate why these are insufficient, but for now I want to prioritise readability.Attempt 1: Naïve definition
The
Functor
type classes in Haskell (what we’d expect to be the equivalent of a hypotheticalFunctor
trait in Rust) is parameterised by a single type variable. So a first attempt at an analogous definition in Rust might look something like this.There’s clearly a problem here. We can’t even define the return type of
map
. The problem is that we have no way to get a handle on the type constructor that we’re implementingFunctor
for.Self
is the type we’re implementingFunctor
for, specifically when the type parameter isA
. For example, if we’re implementingFunctor
forOption
, thenSelf
would beOption<A>
and we have no way to declare the typeOption<B>
.The key insight here is that what we’d really like is a higher-kinded
Self
. That is, we’d like to write something like the following.This just doesn’t work with
Self
as-is, but the idea is promising. What we’re going to do is create a “higher-kinded” version ofSelf
ourselves, using generic associated types.Attempt 2: Generic associated
Self
With a generic associated type, we can actually define a signature for
map
that looks like something we might expect. (Think ofSelf
as being the same asHigherSelf<A>
.) We might almost be able to convince ourselves that this is a reasonable definition. Unfortunately, functions aren’t all that simple in Rust (as you’ll know if you’ve been using Rust for a little time).Aside on identity
Here, we see a generic type
HigherSelf<T>
that we are pretending is the type we’re implementing a trait for. In the definition above, though, the type is completely generic; there’s no reason to believe thatSelf
andHigherSelf<T>
are related in any way. We can address this with type equality constraints, like in the following.This ensures our notion of “higher-kinded
Self
” is somewhat well-behaved. This kind of condition gets more and more complex to declare as we progress through the design, so I’ve opted to leave them out here. In a real implementation, one may or may not want to enforce this property: having these equality relations is truer to the original concept, but in practice probably has little additional benefit.A family of functions
Rust has several notions of “function”, which is another reason the definition of
Functor
isn’t quite so straightforward as in Haskell2. Namely, in Rust we have the following.fn foo() {}
has the unique typefn() {foo}
. (This type is unnameable in Rust, but may be printed in diagnostic messages.) Two functions that have the same signatures will have different types. There are historical reasons for this.fn(bool) -> (u8, u8)
. Unique function types can be coerced to function pointers. So can unique closure types, as long as they do not capture variables from their environment.Fn
,FnMut
andFnOnce
. These are automatically implemented by unique function types and unique closure types (the specific traits the closure type will implement depends on how it captures from the environment).The
Fn*
traits are the correct way to abstract over functions in Rust: there’s no single function type constructor(->)
. Thus, we need to generalise our definition a little further.Attempt 3. Generalised mapping function
This is where we need a new language feature. The only facility we require for this design that is not currently an accepted language feature in Rust is the concept of (generic) associated traits. If you’re familiar with associated types and associated consts, the idea will be obvious. The reason we need associated traits is to abstract over the three
Fn*
traits. Instead of hard-coding a specific kind of function in our functor definition, we’ll allow it to be specified in the implementation ofFunctor
.In practice, we would expect
MapFn<A, B>
to be instantiated to one ofFn(A) -> B
,FnMut(A) -> B
orFnOnce(A) -> B
, and we could enforce this, but there’s no real need to do so. By leaving it generic, we create a more general abstraction4 (that’s no less useful).So, now we’re done, right? This seems like a perfectly good definition of a functor.
Well… actually, not quite yet. We can see this isn’t quite general enough if we look at a trait that we expect to be functorial. The
map
method onIterator<Item = A>
has the following signature.Notice how the return type has an extra parameter in it: the (unique) type of the mapping function
F
. (The reasons for this are to facilitate a design pattern called external iteration, but the motivation is really tangential here. The fact that we wantIterator
to be functorial means we need to abstract over this design pattern.)In general, a function’s return type could capture all of the input type parameters that are passed to the function. While it may not look like it in our signature,
impl Trait
in argument-position corresponds to a generic type argument. We need to take account of this detail: namely, we need to add this additional type parameter to the generic return type (that is,HigherSelf
).Attempt 4. Capturing generic arguments
Now that the return type also needs to capture the mapping function, it doesn’t make so much sense to call it
HigherSelf
: it still corresponds in some sense toSelf
, but at the same time, it’s been specialised to themap
function signature. In general, if our traits have multiple methods, we’ll need different generic associated traits for each, so it makes sense to use a different name. I’ll call itMap
for simplicity (not to be confused with the identically-namediter::Map
… sorry).With this last definition, we really are done. It’s taken several iterations, but this definition of
Functor
is general enough to capture the examples of functors that we’re interested in.Implementing
Functor
We’ve defined the trait; the next thing to do is to implement it. After deriving a correct definition, making use of it presents no problems.
I’m just going to give two definitions5: for a functorial type and a functorial trait, to demonstrate the flexibility of our definition. More examples can be found at the end of this post.
Monads
Many of the difficulties in defining
Monad
are those we already encountered when figuring out how to implementFunctor
: the hardest parts are behind us. However, there is one subtlety in particular in how we definebind
, which is a slightly more complex situation thanmap
.Let’s look at the definition in Haskell again first, which I think clearly presents the signature we expect. (What I call
unit
andbind
here are usually calledreturn
and>>=
in Haskell.)This time, I’m going to start with the complete definition in Rust, as it should be mostly familiar, and explain the parts that are new.
The first thing that you’ll notice is that we have a new associated trait,
SelfTrait
. I’m going to come back to what role this plays shortly: you can ignore it for now.The
unit
function is straightforwardly defined using the same reasoning asFunctor::map
. In the same way, we need to define a generic associated type that functions as its return type. (Here, there’s an additionalSelfTrait
bound onUnit
, but that’s not important to understand yet6.)In general, though, for each function we define that uses
Self
in a higher-kinded fashion, we need to use a generic associated type just like formap
. Such as is the case withbind
. Here also, we want to permit the binding function type to vary, so we use an associated trait,BindFn
, representing the kind of binding function, just like before. We could have reused theMapFn
trait: oftenmap
andbind
will take the same kind of functions, but this isn’t always true, so for maximum generality we need another trait.However, the definition of
bind
is a little different thanmap
. Recall that the binding function has a signature ofA -> M<B>
(contrast to the mapping function’s signatureA -> B
). This means we have to take a function that returns a monad (specifically, the current one) parameterised overB
. However, the specific type of this monad may not be fixed. For example, consider the monadIterator
. Thebind
operation for theIterator
trait corresponds to theflat_map
method. We should be able to return any type that implementsIterator
from the flat-mapping function we pass toflat_map
. It’s probably easiest to demonstrate this with an example.Clearly, the return type of the function we pass to
flat_map
(and, correspondingly, tobind
) can vary from call to call. Therefore, we want to simply ensure it returns some type that implements our monad. This is the roleSelfTrait
plays. Just likeMap
andBind
act like “higher-kindedSelf
types”,SelfTrait
acts like a “higher-kindedSelf
trait”. (If this still doesn’t click just yet, wait till you see the examples, which should make things clearer.)It’s worth noting that we only need to define one
SelfTrait
, even if we make use of it in multiple functions (unlike associated types likeMap
andBind
, which had to be defined on a per-function basis). This is essentially because generic arguments can vary, but the return type is fixed (up to their generic parameters). (This also effectively falls out of the difference between argument-position and return-positionimpl Trait
, albeit in a slightly disguised form.Note on return-position
impl Trait
in traitsIf we had return-position
impl Trait
in trait definitions, we could eliminate the need for our specialisedMap
andBind
types entirely using our associatedSelfTrait
. This makes for a much cleaner definition. Here’sFunctor
.This is technically just syntactic sugar, and it’s not necessary for any of the definitions here, as we’ve seen. It does simplify things though (and is arguably closer to the higher-kinded viewpoint, as we’re defining everything from a higher level of abstraction7).
Identities
If you were following the previous definition closely, you may have spotted an apparent oversight. In particular, the associated
SelfTrait
only makes sense for monadic traits. If we want to implementMonad
for a type, then there’s no such trait! In the case of a monadic type, the binding function should return something of the (parameterised) type precisely, not just something that implements some trait.As we saw, though, we do need this level of abstraction for monadic traits. Fortunately, there’s a tidy way we can make
Monad
work just as nicely for monadic types. The key is a generic “identity trait”. The identity trait onT
will be implemented solely forT
. No other type will be permitted to implement the identity trait (this is often known as a sealed trait). It’s straightforward to define.Now, for example,
Option<bool>
(and no other type) implementsId<Option<bool>>
. For monadic types, we can now simply useId
forSelfTrait
and everything works as expected.Implementing
Monad
Easy!
Deriving functors and monads
In the examples above, though the implementations of
Functor
andMonad
for types and iterators are straightforward, they do contain a lot of boilerplate. This is an unfortunate consequence of the lack of full higher-kinded types: we generally have to be much more explicit about the various types. This is likely to be true for any similar representation of higher-kinded types in Rust.However, in practice, we could avoid this boilerplate in the majority of instances. This is made clear by observing how the entirety of the implementation definitions are determined by the
map
,unit
andbind
functions forFunctor
andMonad
respectively. Often types and traits implementing these higher-kinded traits already define these functions (albeit with different names): we’ve already seen this withOption
andIterator
, where we simply delegate to existing functions, and it’s certainly the case for many of the functors and monads in the Rust standard library.In these cases, we could use a custom
#[derive]
attribute to generate the boilerplate automatically for us in the background. With this feature, implementingFunctor
andMonad
could be as simple as the following.The message to take away here is that, though the definitions might seem intimidating at first, in all likelihood they would rarely need to be implemented directly by the user.
Do notation
It would be remiss of me to talk about the feasibility of monads without even mentioning
do
notation. For those of you who are unfamiliar withdo
notation, it provides a convenient synactic sugar for working with monads without having to make do with deeply-nestedbind
s andunit
s in Haskell. Monads are useful abstractions even withoutdo
notation, but there is definitely something to be said for a design that also encompasses a similar synactic sugar for monads in Rust.The main argument against the feasibility of
do
notation in Rust is the difficulty with composing control flow expressions (such asreturn
orbreak
) with closures. This is because closures capture control-flow: we can’t break out of a loop enclosing a closure within the closure itself, for instance.This is a problem, because
do
notation is desugared in terms of nestedbind
s, which take closures as arguments. What we might expect to work intuitively in a naïve implementation ofdo
would fail unexpectedly.Let’s look at a concrete example to see exactly where this fails.
Desugaring
do
from a naïve perspective, we might expect this to be equivalent to the following.Obviously, this doesn’t work, because we’re trying to break the outer loop from inside the
bind
closure. However, from the perspective of thedo
notation, it seems entirely reasonable.There’s a happy conclusion to this story. I wrote a separate post on the topic a while ago, describing how we can resolve this dichotomy. In essence, control flow and
do
notation are not mutually exclusive: you just need to be a little cleverer in your desugaring. We might desugar something like the following8:into, intuitively, something of the form:
where, for the sake of illustration,
surface!
andbubble!
are macros that perform propogation of control flow.In practice, this exact desugaring has some rough edges. For example, any variables referenced inside a
do {}
block are moved inside thebind
ing closures, and are then unusable after the scope of thedo {}
block.This is a flaw that could be addressed with built-in support from the compiler for
do
notation (for example, it could move those variables live at the end of thedo {}
block’s scope to the enclosing scope). In practice, if we decided we wanteddo
notation in the language, we’d want to experiment with exactly howdo
should work, especially in its interaction with lifetimes. The main takeaway here is thatdo
notation in Rust could make sense: even though by necessity we’re working with closures behind the scenes, we can still recover the expressivity of the notation with some sleight of hand. (Whetherdo
notation would be useful in practice is another question, which would be a question for a full feature proposal, rather than the exploration here.)Abstracting over monads
We’re almost done. The last topic I want to cover is the question of further abstraction. There are really two questions that are natural to ask at this point.
The first question is easily answered. As
Monad
is simply a trait, we can use it like any other. As a user ofMonad
, we don’t need to worry about whether an implementer is a monadic type or a monadic trait: whatever form it takes, we know it conforms to the trait interface specified byMonad
.Practically, monads are now no more imposing than any other trait. All the complexity is hidden away in the definition (and to some extent, the trait implementation).
The second question requires a little more thought to answer. Functors and monads are, in one respect, simply the first rung on the ladder of higher-kinded types. Just as monads (as type constructors) abstract over types, we can also abstract over monads themselves. To give an example, one such abstraction is a monad transformer.
Frankly, at this point, I’m not sure whether even higher-kinded types are possible to express in Rust. When I spent a little time playing around with potential definitions of monad transformers, I kept running into design problems: exactly the same tricks as for monads don’t quite carry across, because we end up wanting to quantify over all traits in an implementation, rather than one trait per implementation. That said, I didn’t try all that hard and it’s possible there’s a way to resolve the difficulties.
The main takeaway here is that, even though the designs here may not (obviously) generalise to all higher-kinded types, they generalise to a wide class of examples9 that could reasonably be considered sufficient for most of the abstraction users generally want to express in Rust. Even without full higher-kinded types, perhaps this is enough.
Summary
Let’s take a step back and appreciate what we’ve achieved.
#[derive]
macros.do
notation in Rust.Many have expressed reservations about the feasibility of providing abstractions over higher-kinded types like monads in Rust (perhaps the most notable being this thread, which I address directly in the appendix for completeness). The past proposals for monads in Rust that make use of advanced type system features such as full higher-kinded types do nothing to palliate these suspicions.
However, I think in this design, there is at last a plausible and reserved model for these abstractions. In particular, I think monads fall naturally out of a conservative extension of the language that is beneficial even without considering the potential for higher-kinded types.
At the very least, I hope that I have given some food for thought. We never needed higher-kinded types. My conclusion wherefore is this: monads are feasible in Rust.
Appendix
Accepting the challenge
I haven’t explicitly addressed each of the points in the aforementioned “Monads and Rust” thread in the main post, because I think it is self-evident that the design here is sufficient to capture
Monad
in Rust. However, I shall do so briefly here, to satisfy any suspicions of avoiding difficulties by omission.yield
indo
notation: there is a problem with pretendingyield
is simply thebind
of theFuture
monad (despite both having similar effects), namely when borrowing is involved. (The only examples I’ve seen so far are rather involved, so I’m not going to reproduce them here.) That is: we can’t replace theyield
keyword with<Future as Monad>::bind
. However, we can useyield
just fine withindo
notation (using the same propagation of control flow from closures asbreak
orreturn
). Soyield
per se isn’t a problem withdo
notation: we just can’t abstract it away entirely.do
notation: as illustrated above, using a straightforward desugaring, we can recover full control flow withindo
notation (arguably solving an open research question).#[derive]
, implementing higher-kinded types can be made very minimal and ergonomic.To conclude, there’s no guarantee that a feature designed with a pure, lazy language in mind will work in an eager10, imperative language. But sometimes it does; it just requires a little more care.
Additional examples
In this post I’ve focused on functors and monads for
Option
andIterator
in particular. The techniques here extend naturally to other popular examples of higher-kinded types and other monadic types and traits. For completeness, I’m going to give some additional examples, to demonstrate that the framework here really is general enough to encompass the traditional and desirable use cases. This appendix can be viewed as a reference more than a section worth reading in its own right.join
Monads have another operation, known as
join
, or simply “multiplication”, with the signaturem (m a) -> ma
. It’s interderivable withbind
, so I haven’t included it in the definition above, but we could provide it as a default implementation11 on theMonad
trait.In the case of
Iterator
, for instance, an explicit definition would look like this.Further
Monad
implementationsMore higher-kinded types
(Some of the initial types are not higher-kinded, but are necessary for later definitions.)
Magma
Semigroup
Monoid
Other higher-kinded types
There are some higher-kinded types I suspect we need additional type system additions to support. For example, I think definining
Traversable
requires trait generics: i.e. traits as generic parameters. This is a similar, but distinct, feature from (generic) associated traits.As another example, the similarly-named
Traversal
probably requires higher-ranked types in trait bounds.These are both plausible language extensions that also do not introduce the full complexity required by general higher-ranked types. However, I do not dwell on them here: I think associated traits provide the most immediate benefit of these type system extensions (it’s better to focus on one feature at a time).
via varkor’s blog https://ift.tt/2Urt7kb
April 5, 2019 at 12:28AM
The text was updated successfully, but these errors were encountered: