I’m happy to announce the release of quiver, a new commutative diagram editor for the web. I’ve been working on quiver for the past two years and, while I only now feel ready to share it more widely, it has already become an essential tool in my own workflow. If you want to try it out immediately, you can do so at q.uiver.app; if you’re interested in how quiver came to be, or want a quick overview of its features, continue reading.
Commutative diagrams are a diagrammatic tool in mathematics used to express complex relationships between mathematical structures: in particular, commutative diagrams are used prolifically, and to great effect, in the field of category theory. If you’re not familiar with commutative diagrams, I hope you’ll nevertheless be able to appreciate the diagrams purely from an aesthetic viewpoint (I imagine quiver works well even as an editor for other sorts of diagrams too). I use commutative diagrams frequently in my own work. However, unfortunately, typesetting complex diagrams can be a time-intensive and tedious affair. Though there exist tools for dealing with very simple commutative diagrams – for instance, Yichuan Shen’s tikzcd-editor (to my knowledge, the earliest visual editor for commutative diagrams) – any diagram involving higher-dimensional structure (intuitively, diagrams having arrows between other arrows, rather than just between vertices), such as natural transformations, adjunctions, equivalences, and so on, requires manually writing elaborate LaTeX TikZ code. Large pasting diagrams can take hours to typeset perfectly. My desire was to be able to produce such diagrams on a computer just as quickly as I could draw them by hand, so that my willingness to spend hours struggling with LaTeX would no longer be a limiting factor1.
quiver is designed for exactly this purpose, making it essentially painless to create large, complex commutative and pasting diagrams for exporting to LaTeX, or to simply take a screenshot for sharing elsewhere. I’ve spent a long time working on a slew of features I think are necessary for an editor like this and so, while it would take too long to list all of the features here, I’m going to share some of my favourites to give you an idea of quiver’s capabilities.
Most diagrams can be drawn by clicking and dragging: dragging creates a new arrow, with the endpoints being created automatically. (If you want a disconnected object, you can double-click anywhere on the canvas.) Once a diagram has been drawn, you can rearrange the objects by clicking and dragging in the empty space around them; similarly, you can modify the source and target of an arrow by dragging its endpoints.
From its inception, a motivating goal for quiver has been to support the design of higher-dimensional pasting diagrams with the same ease as that of ordinary commutative diagrams. Concretely, this means that drawing higher cells (arrows between arrows) is as easy as clicking and dragging from one arrow to another.
For instance, here’s a modification between natural transformations. This took me less than 30 seconds to draw in quiver.
(You can click on any of the screenshots in this post to open the diagram in q.uiver.app.)
The grid cells in quiver aren’t a fixed size: they will grow to fit their content (just like in TikZ).
Along with the ability to zoom and pan the canvas, this means that even large, complex diagrams2 are easily manipulated and navigated.
quiver has a wide range of arrowhead, body, and tail styles, all of which can be composed seamlessly. Here are just a few, to give you a taste of the flexibility. Some of the combinations are so obscure that I can’t imagine anyone seriously using them3, but I think it’s freeing to have the option.
Once you feel comfortable using the mouse to create diagrams, it probably won’t be long before you want to pick up some speed. You’ll be in luck, because everything can be controlled from the keyboard, which can be significantly faster than the mouse with a little practice. If you enable hints from the toolbar, the keyboard shortcuts for various actions will be highlighted on-screen, helping you pick up the controls whilst you’re learning.
Even if you feel more comfortable with the mouse, the cell queue can be a handy time-saver. Simply draw out your diagram first (ignorning the labels and styles initially), then then press Tab (⇥) to select each cell in turn, filling out the details of each cell. This two-stage process: draw, then edit, feels very natural and should take little time to pick up, even if you don’t feel so at ease with the keyboard.
You can select multiple cells (both objects and arrows) to edit them simultaneously – for example if a collection of arrows should be styled identically – by holding Shift (⇧) when selecting.
If you’re using quiver to design diagrams for LaTeX, you’ll likely have a
host of macro declarations defining various names and symbols. By default,
these will be rendered as invalid commands, but you can paste the URL of a text
file containing your \newcommand
declarations to have them rendered correctly
in the editor.
Without macros:
With macros (example macro file):
While quiver is intended to render beautiful diagrams in-editor for viewing
and taking screenshots, it is important to be able to use the diagrams
seamlessly in LaTeX. This is facilitated by the accompanying LaTeX package
quiver.sty
4, which includes all the
dependencies for the exported diagrams. The generated LaTeX code is prefixed by
a q.uiver.app link to the diagram, which allows you to
easily tweak it in the future when you want to make changes.
quiver has had a long gestation period in part because I’m developing it in my free time (along with other side-projects), but also because rendering aesthetic commutative diagrams is hard. I’m not aware of another editor that has the same degree of composability and flexibility as quiver: in fact, it can even be difficult to render some of the more exotic combinations in TikZ directly. I think the implementation techniques for aesthetic arrow rendering are interesting, and I may make a blog post in the future going into more detail.
quiver is open source: you can find the repository on GitHub. This is also the place to report any bugs or feature requests you might have. I’d like to make quiver the ideal tool for designing commutative diagrams, so if you have a suggestion for how to make it better, I’d love to hear about it. So far, quiver has had a relatively small userbase, and I wouldn’t be surprised to find I’ve overlooked a few rough edges.
As I previously mentioned, quiver can be a little too flexible when it comes to generating appropriate TikZ to replicate the diagrams in LaTeX. I am not a TikZ expert, and so there are a few places where the LaTeX export feature struggles, because I haven’t been able to figure out how to produce satisfactory results in TikZ: drawing curved triple arrows and shortened curved arrows are a couple of examples (here is a complete list with a little more detail). If you’re experienced with TikZ, and would like to help make this tool even better, I’d love to hear from you: you can contact me by opening an issue on GitHub, or by sending me a message on Twitter.
I want quiver to be helpful for as many people as possible, so I’d be very grateful if you shared it with anyone else who might find it useful!
Try quiver out: q.uiver.app
Follow quiver on Twitter for updates: @q_uiver_app
Anyone who has used it will know that writing LaTeX, and spending hours struggling with it, are the same thing. My hope is that this will no longer be necessary specifically when creating commutative diagrams for LaTeX. ↩
This diagram, and the one at the start of the post, are taken from Gurski’s Biequivalences in tricategories, which is a rich source of aesthetic pasting diagrams. ↩
Or perhaps, now that the possibility presents itself, the floodgates will open: maybe 2021 will be the year of the hook-tailed squiggly harpoon. ↩
For now, the package is available directly from the editor, though in the future, when the package has stabilised, I will probably make it available through CTAN. ↩
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
.
There are several problems with a naïve design of monads (and similar abstractions) in Rust. The most prominent are the following.
Fn
, FnMut
or FnOnce
)2.map
on any iterator always produces an iter::Map
, rather than preserving the original type constructor3.Option
; and at the trait level, such as with Iterator
. 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 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.
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.
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).
class Functor f where
map :: f a -> (a -> b) -> f b
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.)
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.
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.
trait Functor<A> {
fn map<B>(Self, fn(A) -> B) -> /* ??? */;
}
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.
trait Functor<A> {
fn map<B>(Self<A>, fn(A) -> B) -> Self<B>;
}
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.
Self
trait Functor<A> {
type HigherSelf<T>: Functor<T>;
fn map<B>(Self, fn(A) -> B) -> Self::HigherSelf<B>;
}
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).
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.
type HigherSelf<T> where Self = 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.
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 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.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
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.
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
.
trait Functor<A> {
type HigherSelf<T>: Functor<T>;
trait MapFn<T, U>;
fn map<B>(Self, impl Self::MapFn<A, B>) -> Self::HigherSelf<B>;
}
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.
fn map<B, F: FnMut(A) -> B>(Self, F) -> iter::Map<Self, F>;
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
).
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).
trait Functor<A> {
type Map<T, F>: Functor<T>;
trait MapFn<T, U>;
fn map<B, F: Self::MapFn<A, B>>(Self, F) -> Self::Map<B, F>;
}
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.
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> for Option<A> {
type Map<T, F> = Option<T>;
trait MapFn<T, U> = FnOnce(T) -> U;
fn map<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> for I {
type Map<T, F> = iter::Map<T, F>;
trait MapFn<T, U> = FnMut(T) -> U;
fn map<B, F: FnMut(A) -> B>(self, f: F) -> iter::Map<B, F> {
self.map(f)
}
}
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.)
class Monad m where
unit :: a -> m a
bind :: m a -> (a -> m b) -> m b
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.
trait Monad<A>: Functor<A> {
trait SelfTrait<T>;
// Unit
type Unit<T>: Monad<T> + SelfTrait<T>;
fn unit(A) -> Unit<A>;
// Bind
type Bind<T, F>: Monad<T> + SelfTrait<T>;
trait BindFn<T, U>;
fn bind<B, MB: Self::SelfTrait<B>, F: Self::BindFn<A, MB>>(Self, F) -> Self::Bind<B, F>;
}
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.
let xs = [1, 2, 3];
// Here, the flat-mapping function returns `iter::Take`.
let ys: 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<_>`.
let zs: 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 Self
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 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.
impl Trait
in traitsIf 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
.
trait Functor<A> {
trait SelfTrait<T>;
trait MapFn<T, U>;
fn map<B, F: Self::MapFn<A, B>>(Self, F) -> impl Self::SelfTrait<B>;
}
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).
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.
trait Id<T> {}
impl<T> Id<T> for T {}
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.
Monad
// Implementing `Monad` for a type.
impl<A> Monad<A> for Option<A> {
trait SelfTrait<T> = Id<Option<T>>;
// Unit
type Unit<T> = Option<T>;
fn unit(a: A) -> Option<A> {
Some(a)
}
// Bind
type Bind<T, F> = Option<T>;
trait BindFn<T, U> = FnOnce(T) -> U;
fn bind<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> for I {
trait SelfTrait<T> = Iterator<Item = T>;
// Unit
type Unit<T> = iter::Once<T>;
fn unit(a: A) -> iter::Once<A> {
iter::once(a)
}
// Bind
type Bind<T, F> = iter::FlatMap<T, F>;
trait BindFn<T, U> = FnMut(T) -> U;
fn bind<B, MB: Iterator<Item = B>, F: FnMut(A) -> B>(self, f: F) -> iter::FlatMap<B, F> {
self.flat_map(f)
}
}
Easy!
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.
#[derive(Functor(map), Monad(Some, and_then))]
enum Option { ... }
#[derive(Functor(map), Monad(once, flat_map))]
trait Iterator { ... }
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.
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 bind
s and unit
s 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 bind
s, 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 {
let x <- 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:
let expr0 = do {
expr1;
let a <- expr2;
expr3;
let b <- expr4;
expr5;
expr6(a, b);
expr7
};
into, intuitively, something of the form:
let expr0 = surface! {
expr1;
bubble! expr2.bind(move |a| {
expr3;
bubble! expr4.bind(move |b| {
expr5;
expr6(a, b);
Monad::unit(expr7)
})
})
}
where, for the sake of illustration, surface!
and bubble!
are macros that perform propogation of control flow.
use ControlFlow::*;
// `bubble!(expr)` desugars to...
match expr {
Return(_) => return expr,
Break(Some(_)) => break expr,
Break(None) => break,
Continue => continue,
Yield(Some(_)) => yield expr,
Yield(None) => yield,
Value(_) => expr,
}
// `surface!(expr)` desugars to...
match (|| expr)() {
Return(t) => return t,
Break(Some(t)) => break t,
Break(None) => break,
Continue => continue,
Yield(Some(t)) => yield t,
Yield(None) => yield,
Value(t) => t,
}
In practice, this exact desugaring has some rough edges. For example, any variables referenced inside a do {}
block are moved inside the bind
ing 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.)
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 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.
fn double_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.
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.
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.
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.do
notation: as illustrated above, using a straightforward desugaring, we can recover full control flow within do
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.
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.
trait Monad<A>: Functor<A> {
// ...
// Join
type Join = Self::Bind<A, impl Self::BindFn<impl Self::SelfTrait<Self>, impl Self::SelfTrait<Self>>>;
fn join<MA: Self::SelfTrait<Self>>(ma: MA) -> Self::Join {
ma.bind(|a| a)
}
}
In the case of Iterator
, for instance, an explicit definition would look like this.
impl<A, I: Iterator<Item = A>> Monad<A> for I {
// ...
// Join
type Join = iter::Flatten<Self>;
fn join<MA: Iterator<Item = I>>(ma: MA) -> Self::Join {
ma.flatten()
}
}
Monad
implementationsFuture
impl<A, E, R: Future<Item = A, E>> Functor<A> for R {
type Map<_, F> = future::Map<Self, F>;
trait MapFn<T, U> = FnOnce(T) -> U;
fn map<B, F: FnOnce(A) -> B>(self, f: F) -> future::Map<Self, F> {
self.map(f)
}
}
impl<A, E, R: Future<Item = A, E>> Monad<A> for R {
trait SelfTrait<T> = Future<Item = T, E>;
// Unit
type Unit<T> = Ready<T>;
fn unit(a: A) -> Ready<A> {
future::ready(a)
}
// Bind
type Bind<T, F> = AndThen<Self, T, F>;
trait BindFn<T, U> = FnOnce(T) -> U;
fn bind<B, MB: Future<Item = B, E>, F: FnOnce(A) -> B>(self, f: F) -> AndThen<B, F> {
self.and_then(f)
}
}
(Some of the initial types are not higher-kinded, but are necessary for later definitions.)
trait Magma {
fn mul(Self, Self) -> Self;
}
trait Semigroup: Magma {}
trait Monoid: Semigroup {
fn unit() -> Self;
}
Foldable
trait Foldable<A> {
trait FoldMapFn<T, U>;
fn fold_map<M: Monad, F: FoldMapFn<A, M>>(Self, F) -> M;
}
Applicative
trait Applicative<A>: Functor<A> {
trait SelfTrait<T>;
// Unit
fn unit(A) -> Self;
// Apply
type Apply<T, F>: Applicative<T>;
trait BindFn<T, U>;
fn apply<B, F: BindFn<A, B>, T: SelfTrait<F>>(T, Self) -> Apply<B, T>;
}
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).
Strictly speaking, they’re a lot more than that, but we’re only interested in the programming angle here. ↩
A similar problem arises in Haskell when linear function types are introduced. ↩ ↩2
This is known as the external iterator pattern and is done for performance reasons. ↩
From the perspective of category theory, this corresponds to defining a functor from an enriched category. ↩
Technically, these two implementations are going to conflict, because the Iterator
implementation will also apply to Option
. For the sake of the examples, consider these implementations as living in isolation. In practice, we’d need to carefully decide which traits or types to implement these for. ↩
We could have added a SelfTrait
to Functor
too, and imposed a similar bound on Functor::Map
, which would have made the definition stronger. However, I thought it clearer to leave it out until it was strictly necessary. That is, the weaker definition of Functor
defined above is sufficient to implement the types we expect to be functors, albeit while allowing us to implement Functor
for some types we probably don’t expect to be functors. ↩
Technically, using impl Trait
is actually a little more restrictive than we intend, because impl Trait
is opaque: the underlying type is not observable. What we’d really like here is some kind of transparent analogue. ↩
To those of you who recognise the <-
sigil as being experimental syntax for “placement new”, just note that I’m using <-
here for monadic bindings, reminiscent of the do
notation from Haskell, not placement new. ↩
See the appendix for a large range of examples of useful higher-kinded types that are expressible with the techniques advocated here. ↩
As we’ve seen, eagerness and laziness don’t actually factor into the design problem at all. ↩
Okay, I’m taking some liberties here. We should be able to define it automatically, but impl Trait
in associated types is too limited at the moment. There’s an RFC (in the final stages at the time of writing) that should address this, though. You’re still able to define join
manually for each implementer of Monad
, though: it’s just a little less convenient. ↩
const
and const fn
. While, initially, const
may seem like a reasonaby straightforward feature, it turns out to raise a wealth of interesting and complex design questions. In this post, we’re going to look at a particular design question that has been under discussion for some time and propose a design that is natural and expressive. This is motivated both from a syntactic perspective and a theoretic perspective.
At present, const fn
is a very restricted form of function. In particular, generic type parameters with trait bounds in any form are not permitted. This is mainly due to the many cases that need consideration when const
code interacts with run-time code. As we’ll see below, this is more complex than one might first think.
This is obviously a desirable feature, but it’s hard to be sure that a design meets all the desiderata, while being as minimal as possible. We’re going to look at a solution to this problem that should tick all the boxes.
?const
trait bounds and default trait implementations.
This proposed design and post have been coauthored with @cartesiancat. Thanks to @ubsan and @rpjohnst for feedback on an early draft.
The most important concept to get right when dealing with const
types, traits and implementations is the question of how const functions are treated as, or converted to, runtime functions. We should always able to call const functions at runtime, with the most permissive set of rules on their arguments. The rules determining this behaviour should feel natural (users shouldn’t usually have to explicitly think about them), but the explicit rules should also be straightforward.
The syntax proposed here is, we think, the simplest and most permissive syntax, while being consistent with the existing syntax.
First, let’s take a look at the syntax and see some examples of the conversions from const fn
to fn
.
Take the following const fn
declaration:
const fn foo<A: T>(A) -> A;
This is interpreted in the following manner:
foo
at compile-time, we must have a const
value of some type A
that implements T
. Importantly, the implementation of T for A
must itself be const
. (We shall see exactly what a “const
implementation” is soon.)foo
at run-time, we must have a run-time value of some type A
that implements T
. The implementation of T for A
may be const
or not.Analogously, take the following run-time fn
declaration:
fn bar<A: T>(A) -> A;
This is interpreted in the following manner:
bar
cannot be called at compile-time.bar
at run-time, we must have a run-time value of some type A
that implements T
. The implementation of T for A
may be const
or not.Here are some examples of const
functions and their run-time analogues.
const fn a(u8) -> bool;
// ...when called at runtime is equivalent to...
fn a(u8) -> bool;
const fn b<A>(A) -> bool;
// ...when called at runtime is equivalent to...
fn b<A>(A) -> bool;
const fn c<A: T>(A) -> bool;
// ...when called at runtime is equivalent to...
fn c<A>(A: T) -> bool;
const
implementationsA const
implementation of a trait T
for a type A
is an implementation of T
for A
such that every function is a const fn
.
struct C; struct D; struct E; struct F;
trait T {
fn foo(C) -> D;
fn bar(E) -> F;
}
struct Q;
// This is a "non-`const`" implementation of `T` for `Q`.
impl T for Q {
fn foo(c: C) -> D { ... }
fn bar(e: E) -> F { ... }
}
struct R;
// This is a "non-`const`" implementation of `T` for `R`.
impl T for R {
fn foo(c: C) -> D { ... }
const fn bar(e: E) -> F { ... }
}
struct S;
// This is a `const` implementation of T for S.
// An implementation is a `const` implementation
// iff all functions within are `const`.
impl T for S {
const fn foo(c: C) -> D { ... }
const fn bar(e: E) -> F { ... }
}
Any implementation containing any non-const
functions is not a const
implementation, e.g. those for Q
and R
in the examples above.
If there are default method definitions in the trait, these must either be overridden with const
method definitions, or the method must be declared const
in the trait definition (see below).
const
functions with generic trait bound typesConsider again this previous example:
const fn baz<A: T>(A) -> A;
foo
may only accept const
implementations of the trait T
. Otherwise, it would be possible to write invalid code inside the function body:
const fn baz<A: T>(A) -> A {
// `A::foo` might be run-time function here,
// which we cannot call at compile-time!
let x: D = A::foo(...); // ERROR!
...
}
Therefore, in general, any const
function definition of the form
const fn bop<A1: T1, ..., An: Tn>(...) {
...
}
may take only const
implementations for each of the traits T1, ..., Tn
.
As mentioned above, any const
function can also called as a run-time function. Intuitively, by removing the const
prefix from any function, we get the corresponding run-time definition of the function (as the body is entirely unmodified).
const
trait boundsTake the following run-time function signature:
fn baz<A: const T>(A) -> A;
This syntax means A
is explicitly required to const
implement T
. When is it useful for users to be able to explicitly declare trait bounds const
?
Specifically, explicit const
trait bounds are necessary when run-time functions contain const
code. Here’s a simple example:
fn baz<A: const T>(A) -> A {
// We can only call a `T` method of `A`
// in a `const` variable declaration
// if we know `A` `const`-implements `T`,
// so the trait bound must explicitly
// be `const`.
const X: bool = <A as T>::choice();
...
}
In the proposed design, it necessary to explicitly declare const
bounds on const
functions when those traits are made use of inside const
definitions, so that they remain valid when converted to run-time functions. For example, const fn baz<A: const T>(A) -> A
will always take only const
impls for A
, whether called at compile-time or run-time.
const
in traitsIn a trait declaration we can place const
in front of any function declaration to require that all implementations must define that function as const
. Consider again the previous example:
trait T {
const fn choice() -> bool;
...
}
fn baz<A: T>(A) -> A {
// Now, `<A: const T>` is not needed, since
// `choice` is always const in any implementation
// of `T`.
const X: bool = <A as T>::choice();
...
}
const
trait bounds with ?const
There’s one more ability we would like to be completely flexible with the strictness of our trait bounds (and to avoid requiring any duplication of trait definitions in some situations).
Trait bounds in const
functions require const
implementations by default, which matches the intuition for run-time functions: “if you have a parameter with a trait bound T
, you know that all the requirements of the bound can be used inside the function”. However, sometimes you don’t need such a strong restriction. Recall how, with const
declarations in trait definitions, we could avoid having const
trait bounds in run-time functions, as long as every method we used in a const
context was declared const
in the trait. Equally, we would like this ability in const fn
.
For example, take the following:
trait T {
const fn choice() -> bool;
fn validate(u8) -> bool;
}
struct S;
impl T for S {
const fn choice() -> bool {
...
}
fn validate(u8) -> bool {
...
}
}
const fn bar<A: T>(A) -> A {
let x: bool = <A as T>::choice();
...
}
// We can't call `bar` with a value of `S`, because
// `S` doesn't `const`-implement `T`, even though it
// only makes use of `const` functions!
We would like some way to relax this requirement when necessary. This is achieved by means of the explicit const
trait bound opt-out: ?const
.
// ...continuing the previous example...
const fn bar_opt_ct<A: ?const T>(A) -> A {
let x: bool = <A as T>::choice();
...
}
// We can call `bar_opt_ct` with a value of `S`, because
// the only method it makes use of is declared `const`
// in the trait `T`.
The ?const
syntax mirrors that for ?Sized
, as an opt-out of the default (most common) behaviour. With this keyword, one now has full expressivity over trait bounds.
const fn
will require const
trait bounds, so that you can freely use the trait within the function. At run-time, such functions have no restrictions on the trait bounds.const
act like normal at compile-time, but also require const
trait bounds at run-time.?const
do not require const
trait bounds, at compile-time or at run-time.const
context (such as at compile-time, or in an inner const
at run-time) if either they originate from a const
trait bound, or if they are explicitly declared const
in the trait.const
keywordSince any const
function can be called at run-time, it must also be a valid non-const
function (after a suitable translation): this is what gives the intuition and motivation for our definition. The translation simply modifies the function signature without changing the body. This translation is extremely simple and involves simply removing the const
prefix from a function and removing any ?const
bounds.
trait T {
const fn choice() -> bool;
...
}
// This function at compile-time...
const fn baz_ct<A: ?const T>(A) -> A {
let x: bool = <A as T>::choice();
...
}
// ...is equivalent to this function at run-time.
fn baz_rt<A: T>(A) -> A {
let x: bool = <A as T>::choice();
...
}
Recall that if a method in the trait is not declared const
then it cannot be used inside a const
definition in the body of a function (regardless of whether that function is const
or non-const
).
trait T {
fn choice() -> bool;
...
}
// The following function is not permitted, as its
// run-time translation is not a valid run-time
// function.
const fn bop_ct<A: T>(A) -> A {
const X: bool = <A as T>::choice();
...
}
// `bot_ct` is equivalent to this function.
fn bop_rt<A: T>(A) -> A {
// This is not OK, because we have no assurance
// that `choice` is a `const fn`.
const X: bool = <A as T>::choice(); // ERROR!
...
}
const
on trait
s and impl
sFor the common practice of declaring const
every method in an impl
, or in a trait, we have the following syntactic sugar. Prefixing impl
or trait
with const
amounts to prefixing every function declaration and definition with const
.
const trait V {
fn foo(C) -> D;
fn bar(E) -> F;
}
// ...desugars to...
trait V {
const fn foo(C) -> D;
const fn bar(E) -> F;
}
struct P;
const impl V for P {
fn foo(C) -> D;
fn bar(E) -> F;
}
// ...desugars to...
impl V for P {
const fn foo(C) -> D;
const fn bar(E) -> F;
}
Note that the syntactic sugar for traits, const trait
, is consistent with the explicit const
trait bounds on generic type parameters. In both cases, a const
-prefix implies that all trait methods must be const
.
When const
-prefixing is simply syntactic sugar, it may be easy to accidentally change the const
-ness of an implementation by changing a single function. It thus may be desirable to only consider an implementation const
if it is prefixed with const
. That way, the only way implementations may be converted between const
and non-const
is by explicitly adding or removing the const
prefix. For now, whether this a sensible design choice is left as an open question.
const impl
versus impl const
We have a choice of syntaxes for the const
implementation syntax sugar, both of which are consistent (in different ways) with other similar syntaxes.
const impl
is consistent with the practice of prefixing impl
with modifiers (e.g. default
, unsafe
) and prefixing const items with const
(e.g. const fn
and, in this proposal, const trait
).
impl const
is consistent with the syntax in this proposal used for const
trait bounds, where trait names are prefixed with const
.
The former choice seems slightly more justified by existing syntax, but either is a viable option from a consistency perspective.
Note that while inherent implementations receive the same const
-prefixing syntax as trait implementations, the notion of “const
inherent implementation” does not apply. Inherent functions may be called in const
code when they themselves are const
. const
inherent functions are converted to run-time functions in the same way as any other const
function.
const
and subtypingIn the above discussion, we’ve talked about const fn
from the perspective of being “equivalent to” or “converted to” a run-time fn
, when called at run-time. This is one way to consider const
functions’ relation to run-time functions, but not the only one.
Alternatively, we may view const
function types as being subtypes of run-time function types. In this light, a const fn
type is a subtype of the run-time function type that we’ve described it as being “converted” to. Values of subtypes are simply particular cases of their parent types, which makes it evident that const
functions should be callable at run-time.
For example1:
const fn foo(A) -> B
is a subtype of fn foo(A) -> B
.const fn bar<A: T>(A) -> B
is a subtype of fn bar<A: T>(A) -> B
.const fn bop<A: const T>(A) -> B
is a subtype of fn bar<A: const T>(A) -> B
.We’ll briefly touch on why these are equivalent ways to view “const
at run-time” in the theoretic model below.
As far as we’re aware, this encompasses all use cases for generic const
functions with trait bounds in a syntactically minimal and natural manner and hopefully this is reflected in the design. For the most part, users should not have to worry about where to place const
, but the rules of behaviour are straightforward even in more complex scenarios.
This ends the design of the feature, but before finishing, we’re going to briefly see that this design reflects a sound theoretic model of const
types, which provides more confidence in the correctness of the design.
const
From a practical standpoint, the syntax proposed we’ve proposed seems most natural and expressive. But it can be easy to overlook aspects of a new feature in a programming language, especially when it interacts with the type system. To be fully justified in a new design, it is extremely valuable to have a (type or category) theoretic model, which is a precise mathematical description of the types and their interactions. Indeed, it was from the theoretic model that led us to ultimately settle on this choice of syntax for the proposal.
Here, we’re going to briefly outline what this design corresponds to in a naïve category theoretic model of Rust. This should give a basic intuition for why this is a natural design from a theoretic viewpoint as well as a practical one. A full treatment of Rust’s type system in general, and with respect to const
, is left for a future occasion.
This is intended as a sketch for those with some familiarity with category theory: understanding it isn’t critical to the understanding of the proposed design. (Equivalently, one could consider this design from the perspective of type theory.)
The collection of all types in Rust, together with the collection of all functions2 and the obvious notions of composition (namely, function composition) and identities (any notion of identity function, such as |x| x
), forms a category.
This universe contains the usual types, such as ()
, bool
, u8
and user-defined types. However, it also contains another version of each of the types, corresponding to const
. For example, when we write:
const X: bool = false;
X
implicitly has type const bool
. All (non-function) user-defined and primitive types have const
analogues.
Within the scope of a const fn
, all values have const
types.
struct A;
struct B;
const fn foo(a: A) -> (A, B) {
// a: const A
let b = B; // b: const B
(a, b) // (a, b): const (A, B)
}
Note that this gives us the reason why run-time functions may not be called at compile-time: they simply cannot provide the correct input types.
struct A;
fn foo(a: A) { ... }
const fn bar(a: A) {
foo(a) // ERROR! `foo` expects a (run-time) `A`,
// but we've given it a `const A`.
}
Of course, we’re able to use const
types within run-time functions. Implicitly, this makes use of a coercion from const
types to non-const
types at run-time. The coercion cannot be applied at compile-time (or, equivalently, in const
contexts), which is why the const A
in the example above cannot simply be coerced to a run-time A
to call foo
. The coercion of most types is trivial, but the coercion of const
function types in particular is given by the rules described in the previous section.
There is a canonical operation that transforms a const
type into a run-time type. Inuitively, this corresponds to removing the const
prefix from any type (and potentially adding explicit const
modifiers to each trait bound). Let’s call this operation U
(for unconst). U
acts on both types (the objects of the universe Type
) and functions (conserving their definitions while converting const fn
to fn
). Run-time types are fix-points forU
: applying it on a non-const
type has no effect. U
is a functorial operation (following directly from the definitions) and hence forms an endofunctor on the category Type
.
What’s more, given any const
type, we have a trivial function taking any value thereof to that of the run-time value3. Applying U
twice is the same as applying it once (due to the fix-point observation above), so we have a trivial isomorphism for any type A
, from U(U(A))
to U(A)
. Together, these functions give U
the structure of an idempotent monad on Type
45.
In Rust, practically speaking, this unconst monad U
is applied as an implicit coercion whenever a value of a const
type is used as a non-const
type, or a const fn
is called at run-time.
When viewed as an implicit coercion, U
reflects the perspective of “const fn
is converted at run-time to fn
”. We can also view it from the perspective of “const fn
s are subtypes of fn
s”.
Here, we may define a partial order on types such that a type A < B
if A = B
or U(A) = B
. It is easy to see this has the required properties. This partial order provides a subtyping relation on types: A: B
if A < B
. The transitive closure of this partial order relation with the existing subtyping relation between types of the same const
ness gives us the full model of subtyping arising from the proposed design.
This theoretic model is simple, but provides some justification for the definitions of the coercions described above. The rich structure on the coercion indicates that, at least from a theoretic perspective, this is quite a natural choice. Some of the previous drafts of const
implementations, bounds, etc. have not been so naturally expressible theoretically; so while a simple one, the existence of such a model can be an effective litmus test.
This design will certainly require more discussion, but we hope that this, or a similar proposal, will make its appearance as a new RFC in the not too distant future.
Technically, I’m abusing notation here, as function signatures aren’t actually types. The type of const fn foo(A) -> B
should properly be written const fn(A) -> B {foo}
. In the name of readability, I’m going to pretend signatures are types (signatures uniquely determine types, so this is unambiguous). ↩
Here, the function type A -> B
is taken to be any type that implements a corresponding Fn*
trait, for example Fn(A) -> B
, FnMut(A) -> B
and FnOnce(A) -> B
. ↩
If our model of the types in Rust includes const generic functions, this function can be explicitly described as a Rust function; otherwise it simply lives in our metatheory. ↩
We’ve presented a monad here from the perspective of a monad as a monoid. Reformulating it in terms of Kleisli maps may be more familiar to a functionally-oriented programmer and is left as an exercise to the reader. ↩
For those of you wondering, we don’t need U
to be a monad to construct this model: it would work similarly well if U
was simply a functor (or even a type-level function). But monads are a lot more fun. ↩
Iterator
and Future
, I thought it would be interesting to tackle another one of the arguments against monads in Rust.
I view this one more as an aside than a central point in the argument, but it’s worth addressing anyway (it’s also easier to solve than the general case of higher-kinded types (HKTs), which makes it attractive to cherry-pick).
What’s the point in question?
Rust’s imperative control flow statements like
return
andbreak
inside of do notation also doesn’t make sense, because we do not have TCP preserving closures.
Let me clarify the objection that’s being made here.
“do
notation” refers to Haskell’s do
notation, which is a special syntax for manipulating monads without necessitating long sequences of nested functions (we’ll see an example of this shortly).
“TCP” is an obscure initialism1, but people seem to use it in this sense: a language follows “TCP” if any expression expr
is equivalent to (|| expr)()
2. In general in Rust this is true, but it breaks down when expr
contains control flow. Take the following program:
// This function returns `5`.
fn early_return() -> u8 {
return 5;
0
}
If we enclose return 5;
in a closure, we get a different result:
// This function returns `0`.
fn abstracted_early_return() -> u8 {
(|| return 5)();
0
}
Why is this a problem? We’ll need to take a look at an example of do
notation to make this clearer. I’m going to use a hypothetical syntax that, while not being too pretty, should at least be functional for our examples.
(Rather than write out the fully explicit syntax as in the last post, I’m going to assume the function traits are implicit and use the shorthand impl (impl Trait)
for a type that implements a trait that implements a trait-on-traits. Obviously, if higher-order traits were to exist in Rust, we’d need a nicer syntax for them.)
Take the following snippet:
// `monadA` has type `impl (impl Monad<A>)`.
// `monadB` has type `impl (impl Monad<B>)`.
let monadAB = do! {
// The type annotations aren't necessary,
// but hopefully make things clearer.
//
// `let!` takes a monad and binds (using
// `Monad::bind`) its "inner value" to a
// new variable.
let! a: A = monadA;
let! b: B = monadB;
// `return!` takes a value and wraps it in
// a monad (using `Monad::unit`).
return! (a, b);
};
// `monadAB` has type `impl (impl Monad<(A, B)>)`.
This is intuitively equivalent to:
monadA.bind(|a| monadB.bind(|b| Monad::unit((a, b))))
(If you’re familiar with Haskell, let a! = monadA;
corresponds to a <- monadA
and return! expr;
corresponds to return expr
.)
You can see that the do
notation version is a lot more readable. With more complex sequences of bind
s and unit
s, the difference becomes even more pronounced.
However, there’s a problem with desugaring this in the obvious way shown above. We’d like to be able enclose any normal Rust block inside a do!
. However, let’s take a look at what happens when we introduce control flow.
do! {
// This loop is a little pointless, but
// it gives us something to break from.
loop {
let! a: A = monadA;
break;
let! b: B = monadB;
println!("{} {}", a, b);
}
}
This naïvely desugars to:
loop {
monadA.bind(|a| {
break; // Uh oh...
monadB.bind(|b| {
println!("{} {}", a, b);
})
})
}
There’s an obvious problem: we expect break
to break out of the loop
, but since it’s now inside a closure, it’s not going to work (in fact, it won’t even compile). This is the problem withoutboats is referring to when they say that return
and break
don’t make sense in do
notation.
We could simply forbid control flow expressions in do!
, but this is very much an artificial (and to the user, a seemingly-arbitrary) solution and limits the general applicability and usefulness of do
notation in Rust. Fortunately, however, there’s a solution.
do!
We’ll use a less specific example for the proposed desugaring so that it’s slightly clearer.
Take the do
notation below:
do! {
expr1;
let! a = expr2;
expr3;
let! b = expr4;
expr5;
expr6(a, b);
return! expr7;
}
As a reminder, this is the naïve desugaring:
expr1;
expr2.bind(|a| {
expr3;
expr4.bind(|b| {
expr5;
expr6(a, b);
Monad::unit(expr7)
})
})
As we saw, this isn’t good enough, so instead, we’re going to desugar it like this3:
surface! {
expr1;
bubble! expr2.bind(|a| {
expr3;
bubble! expr4.bind(|b| {
expr5;
expr6(a, b);
Monad::unit(expr7)
})
})
}
where bubble! expr
desugars to:
match expr {
ControlFlow::Return(_) => return expr,
ControlFlow::Break(Some(_)) => break expr,
ControlFlow::Break(None) => break,
ControlFlow::Continue => continue,
ControlFlow::Value(_) => expr,
// We'd also want `Yield` here eventually,
// but that comes with its own problems,
// which is a story for another time.
}
We’ve got a couple of options for surface!
, depending on whether or not we want control flow to be able to bubble up out of do!
(for instance, whether a return
inside do!
will return out of the function enclosing the do!
or not).
If we do, surface! block
desugars to:
match (|| block)() {
ControlFlow::Return(t) => return t,
ControlFlow::Break(Some(t)) => break t,
ControlFlow::Break(None) => break,
ControlFlow::Continue => continue,
ControlFlow::Value(t) => t,
}
If we want to forbid control flow at the top level, we need some custom error handling, but it’s straightforward, technically, to implement.
The enum
ControlFlow
is defined:
enum ControlFlow<T> {
Return(T),
Break(Option<T>),
Continue,
// `Value` simply means we're forwarding
// the value without effecting any control
// flow.
Value(T),
}
Essentially, ControlFlow
reifies the control flow. By capturing the control flow inside a closure and forwarding it on immediately outside the closure, we can pretend that the control flow escapes the closure directly, which is precisely what we want for do
notation.
As a serious proposal for a do
notation desugaring in Rust, this has its flaws. Simulating the control flow in this way increases branching and it is thus likely not to have ideal performance. If we actually did want some form of do
notation, we’d probably prefer to handle control flow more directly in the compiler, rather than simulating it using ControlFlow
. But hopefully this demonstrates that it’s not necessary to have special handling to support monadic do
in Rust and at least it’s not an issue with control flow that makes do
notation implausible.
The issue with control flow in do
notation is not the only one that was raised (see this tweet and this one for the others), so we don’t have a full solution yet, but by tackling the difficulties one at a time we can get closer to a point at which we understand where the difficulty with these abstractions in Rust really lie.
It stands for “Tennent’s Correspondence Principle”, if you’re wondering, but it’s not a standard term in programming language design. As far as I can tell was first mentioned in this old Rust internals post. This Stack Exchange post gives a bit more context. ↩
This is simply \(\eta\)-equivalence for functions whose domain is the unit type ()
. (In general, \(\eta\)-equivalence for functions doesn’t hold for call-by-value languages with effects, but it’s plausible in this special case it could hold, as ()
has no effect.) ↩
surface!
and bubble!
here aren’t actually macros: in actuality, we would probably define the desugaring in a single step, which would render them unnecessary, but they help illustrate what’s going on. ↩
Personally, I think the design decisions behind these features are sound and that a monadic abstraction for Rust is infeasible with the design constraints. However, I think the thread is interesting, academically, as a set of design challenges. These are a set of problems, without existing solutions, that could potentially have useful consequences if tackled.
In this post, I’m going to take a look at one of the assertions and see if there’s any way we might address it. In doing so, I hope to show that a functorial abstraction for existing interfaces in Rust is not as impossible as some think.
[Note: it turned out that I wasn’t the only one to be thinking about this problem recently. Here’s a very similar solution to the one in this post.]
Okay, so Monad can’t abstract over Future, but still let’s have Monad.
(Source. Emphasis mine.)
I want to challenge this statement, purely as an exercise, because abstraction is fun. I’m not going to present a complete solution, but a hint that these problems are not insurmountable.
Let’s see what the arguments against Future
’s monadicity were.
Getting beyond that, this is assuming that “future” implements “monad,” so if we could just add HKT to have a Monad trait, everything would be hunkydory. That’s not true!
the signature of »= is
m a -> (a -> m b) -> m b
the signature of Future::and_then is roughlym a -> (a -> m b) -> AndThen (m a) b
That is, in order to reify the state machine of their control flow for optimization, both Future and Iterator return a new type from their »= op, not “Self<U>”
Also, our functions are not a
->
type constructor; they come in 3 different flavors, and many of our monads use different ones (FnOnce vs FnMut vs Fn).
So what’s the problem? Let’s get our definitions straight first.
A functor \(F: \mathscr{C} \to \mathscr{D}\) (for categories \(\mathscr{C}\) and \(\mathscr{D}\)) is formed from two components:
An example of a functor is the List
type constructor along with map
on lists, which:
A
to the type List(A)
of lists containing elements of type A
.f: A -> B
and returns a function List(f): List(A) -> List(B)
, which “maps” the function f
over every element in its input.Functors are often used for “mappable containers”: some data structure that we can map a function over.
Intuitively, this seems to fit iterators quite well: in Rust, any type implementing Iterator
has a map
function, which looks just like the map
in List
.
However, if we take a look at the signature of Iterator::map
, we can see something’s wrong.
fn map<B, F>(self, f: F) -> Map<Self, F> where F: FnMut(T) -> B;
Let’s write this out using type notation, because I think it’s clearer. I’m using \(\mathcal{U}\) for the category (universe) of types (or Type
in the code examples), \(\text{Iterator}(A)\) for some type implementing Iterator<Item = A>
and \(\text{Map}\) for the type constructor iter::Map
. The function Iterator::map
then has a signature that looks something like this:
Whereas for a functor, we want something more like:
\[\text{map}: \prod_{A, B: \mathcal{U}} \prod_{F: A \to B} \text{Iterator}(A) \times F \to \text{Iterator}(B)\]Notice how the return type is different: for a functor, we want the return type to be over the same family of types as the input, but for \(B\) rather than \(A\). However, regardless of what type Iterator::map
is called on, it always returns an iter::Map
: it’s not as parameteric as the functorial map.
This is the fundamental problem: we want to express the type of a functor map generically, whereas with Rust it’s always specialised to some specific data type (such as an external iterator like iter::Map
). We just don’t have a functor. Let’s see how we can address this.
Why is this a problem in Rust but not other, functional languages like Haskell? It’s primarily due to optimisation concerns. Rust makes performance a high priority: you should be able to safely use its high-level abstractions with little to (ideally) no cost. Naïve iterators (when chained together) are very inefficient, continually constructing and then deconstructing data structures. Rust’s strategy ensures that even long chains of iterator methods are performant (this is known as internal versus external iteration).
Because of this design pattern (among others), Rust doesn’t put so much of an emphasis on abstraction through types, instead preferring to use traits as the primary form of abstraction.
Thus, when considering this problem, the mistake is to get caught up in the types. There are several strategies for approaching this problem: for example, one could try to consider how we can convert from an iter::Map
to another, arbitrary iterator, so that we can get the type we started with back (and hence restore the functorial nature of Iterator::map
by composing it with the conversion function). This is ultimately an unilluminating rabbit-hole to go down, as it doesn’t consider take the semantics of Iterator
at all. Fundamentally, when you have an iteration, the underlying types (such as iter::Map
, iter::Once
or iter::Chain
) are unimportant: it’s the iteration itself that’s key (it may seem somewhat obvious in retrospect, but it can be easy to get caught up with the specific implementation details).
The key insight is that, instead of quantifying over types like many functional programming languages, we want to quantify over traits. Therefore, instead of having an endofunctor on the category of types \(\mathcal{U}\) (as is the norm when it comes to functors in programming languages), we have a functor from the category of types \(\mathcal{U}\) to the category of traits \(\mathcal{T}\). (It doesn’t particularly matter what the “category of traits” is, as long as you can believe that traits could plausibly form a category. We can define traits mathematically another time.) The domain of the functor will parameterise over the underlying item type (e.g. bool
in Iterator<Item = bool>
), whereas the codomain will parameterise over traits (e.g. the Iterator<Item = _>
).
To define a functor trait, therefore, we need to be able to parameterise over traits. In Rust currently, it’s only possible to parameterise over values (i.e. function parameters) and types (i.e. type parameters) (well, and lifetimes, but that’s not relevant here). In the pseudocode below, I’m going to add “trait generic parameters”, prefixed with trait
. We also have a marker trait on traits, Func
, which is implemented for Fn
, FnMut
and FnOnce
.
Our functors, then, are going to be functors on traits, not types.
// Forget boring old traits for types: traits for traits are the hot thing now!
trait Functor<trait G: Func> {
// This is pseudo-syntax for an associated trait.
trait MapOb<A>;
// I'm uncurrying `map_mor` here to avoid requiring that we add currying
// to Rust to support this pattern.
// Notice how the particular function variant we're using is abstracted
// into a trait parameter on `Functor`.
fn map_mor<A, B>(
xa: impl MapOb<A>,
f: impl G(A) -> B,
) -> impl MapOb<B>;
}
// Now we can define an implementation of a trait *for* a trait.
// This might be a slightly confusing concept at first, but the
// definitions themselves are very straightforward: we're essentially
// forwarding everything to `Iterator`, which already has all the
// information we need.
// `Iterator`'s map takes a `FnMut`, so we pass it in explicitly here.
impl Functor<trait FnMut> for trait Iterator {
trait MapOb<A> = Iterator<Item = A>;
fn map_mor<A, B>(
xa: impl MapOb<A>,
f: impl FnMut(A) -> B,
) -> impl MapOb<B> {
<MapOb<A> as Iterator<Item = A>>::map(xa, f)
}
}
Defining a trait for a trait is very similar to a trait for a type: the only difference is in the handling of Self
. When defining a trait for a trait, Self
is known to be a trait, so we can use it in bounds, or in impl Trait
, and so on. Actually, in this first example, it’s not even necessary, as none of the functions are defined for self
, but it’ll be useful in the next example.
We might more conveniently express this with the following psuedocode, assuming the API convention that if we’re implementing Functor
for Trait
, then we can define the map on objects to take a type A
to Trait<A>
. Of course, in Rust, we often use associated type parameters instead (e.g. Iterator<Item = A>
rather than Iterator<A>
), but let’s just pretend the latter is sugar for the former for now, because it makes the code even simpler.
// Here we're implicitly assuming that `Self` is a type constructor,
// by the presence of `Self<A>`. This is all pseudo-syntax anyway,
// so take it with a pinch of salt, but it works out quite nicely.
trait Functor<A, trait G: Func> {
// `Self` here refers to the *trait* implementing `Functor<A>`,
// as demonstrated below.
fn map_mor<B>(
xa: impl Self<A>,
f: impl G(A) -> B,
) -> impl Self<B>;
}
impl<A> Functor<A, trait FnMut> for trait Iterator<Item = A> {
fn map_mor<B>(
xa: impl Iterator<Item = A>,
f: impl FnMut(A) -> B,
) -> impl Iterator<Item = B> {
xa.map(f)
}
}
Future
(defined in rust-lang-nursery
) is similarly simple to define in this way:
impl<A> Functor<A, trait FnOnce> for trait Future<Item = A> {
fn map_mor<B>(
xa: impl Future<Item = A>,
f: impl FnOnce(A) -> B,
) -> impl Future<Item = B> {
xa.map(f)
}
}
Now if we want to define a function that’s generic over functors, we can do it.
// We would never actually need to use such a function as this in reality,
// because we could just use method call syntax, but it demonstrates the
// full flexibility of this approach.
fn functorial_map<
A, B, G: Func,
FA: Functor<A, G>, FB: Functor<B, G>,
>(
xa: impl FA,
f: impl G(A) -> B,
) -> impl FB {
<FA as Functor<A>>::map_mor(f, xa)
}
Using this technique, I’m assuming that if you implement a trait S
for a trait T
, then any types implementing T
will also have access to all the functions (and associated types) for S
. This would making calling the functorial map as simple as calling a method on an instance of the type.
The technique is similarly extended to monads. Once we have functors, the rest follows quite simply. Let’s see, just to make it clear. (I’ll be using the more elegant syntax with Self
-as-a-type-constructor here, but it’s easily rewritten in the more explicit syntax.)
// We're parameterising over two function traits here, so that we can use
// distinct ones for the functorial map and the monadic bind.
trait Monad<A, trait G: Func, trait H: Func>: Functor<A, G> {
fn unit(a: A) -> impl Self<A>;
fn bind<B>(
xa: impl Self<A>,
f: impl (H(A) -> impl Self<B>),
) -> impl Self<B>;
}
impl<A> Monad<A, trait FnMut, trait FnMut> for trait Iterator<Item = A> {
fn unit(a: A) -> impl Iterator<Item = A> {
iter::once(a)
}
fn bind<B>(
xa: impl Iterator<Item = A>,
f: impl (H(A) -> impl Iterator<Item = B>),
) -> impl Iterator<Item = B> {
xa.flat_map(f)
}
}
impl<A> Monad<A, trait FnOnce, trait FnOnce> for trait Future<Item = A> {
fn unit(a: A) -> impl Future<Item = A> {
future::ready(a)
}
fn bind<B>(
xa: impl Future<Item = A>,
f: impl (H(A) -> impl Future<Item = B>),
) -> impl Future<Item = B> {
xa.and_then(f)
}
}
And to round things off, let’s define a couple of functions generic over monads.
fn monadic_unit<A, G: Func, H: Func, MA: Monad<A, G, H>>(
a: A,
) -> impl MA {
<MA as Monad<A, G, H>>::unit(a)
}
fn monadic_bind<
A, B,
G: Func, H: Func,
MA: Monad<A, G, H>, MB: Monad<B, G, H>,
>(
xa: impl MA,
f: impl (H(A) -> impl MB),
) -> impl MB {
<MA as Monad<A, G, H>>::bind(xa, f)
}
And so we’re done. We can now define functions that parameterise over functors and use the functorial mapping and similarly extend that to monads. It requires (naturally) a little more effort than usual abstractions in Rust, because it’s an abstraction over an abstraction (unlike the typical first-order functor and monad abstractions in other programming languages).
What would we require in Rust to do this (or something similar)?
Self
was. However, we can always be more explicit and avoid requiring this feature at all.And that’s it. Note that we don’t need full higher-kinded types here: trait parameterising is sufficient.
Here I’ve shown how we can provide an abstraction for a Functor
and Monad
“higher-order trait” that works for Iterator
and Future
.
This technique doesn’t compromise on efficiency, and it feels like the right abstraction to me, but I’m not sure how useful it is. That is, though I think we can solve this particular problem from the original thread, it’s not an argument against the premise. That said, maybe there’s some potential; it’d be interesting to hear if anyone working with very generic code has some concrete use cases. But then, we still have quite a few more problems to work through…
]]>3.0<millimeter> + 5.0<foot> // error
I have an alternative type theoretic interpretation of units of measure, which I believe is more cohesive and representative of the use of units of measure (specifically in dimensional analysis).
In dimensional analysis, one is concerned with performing some analysis (often through computation) of physical quantities (like time, or length, or mass, or acceleration, etc.). The important thing is that one doesn’t lose track of which physical quantity one is concerned with. You don’t want to accidentally end up with an area instead of a speed, or attempt to add a mass to a force.
The physical quantities with which the measurements are concerned are called the dimensions. The values these quantities can take are called denominate numbers, which are simply numbers with units of measure attached to them. The thing to note here is that the choice of unit for each dimension is not unique. For example, the values 5 metres, 3 centimetres and 8 yards all measure length. These units — metre, centimetre and yard — are all related: you can freely convert between them (usually in terms of ratios), and you can think of these units as ways of representing some “canonical length”.
This establishes a core facet of the type theory of units of measure. Different units may be compatible (that is, have the same type): the important property for enforcing safety is the dimension.
Taking the collection of all dimensions as a set, the dimensions form an abelian group under multiplication. Given two dimensions, such as length and time, we can form a new dimension, length × time (measured in a unit such as metre-seconds). Given any dimension, we can also form its inverse. A unit for the speed, metres per second, measures the product of the dimension length and the inverse of the dimension time. The order of multiplication is unimportant: length × time is the same dimension as time × length and the dimension (length × length) × length is the same as (length × length) × length. We also have an internal operations on dimensions: two values of the same dimension can be added or subtracted, for instance.
What does this look like from a type theoretic perspective? Informally speaking, if we consider a universe of types, \(\mathcal{U}\), as a category (the objects of which are types and whose morphisms are functions between those types), then we can embed the dimensions as types within the universe: Length
, Time
, Charge
, Area
, and so on. (Note that there might be multiple ways to refer to a dimension, such as Area
or Length × Length
, but these are all just names for the same dimension. We usually pick a set of base dimensions from which all other compound dimensions can be generated by multiplication, taking inverses, and exponentiation by rationals: and then the names we give compound dimensions are simply aliases.)
These “dimension types” are related. Together, they form a subcategory of our universe, known as a strict symmetric 2-group. The multiplication operation2 on dimensions appears as the tensor product in the category, with a special “unitless dimension”, referred to simply as 1
(some numeric type, whose exact form will depend on your type system).
So where do our units of measure appear? They form the type constructors for our dimension types. To do this, we have to pick a canonical unit for each dimension, which is the “internal representation” of the values of the type. (The choice is entirely arbitrary.) Then, each unit of measure constructor takes a number and constructs a value of the canonical unit using the respective conversion method. Since units of measure only appear as constructors, we don’t have any restrictions regarding different units measuring the same dimension interacting.
Units of measure, and dimensional analysis, in general is useful. It’s useful in programming languages too, but we don’t see them all too often (F# is a notable example of a general programming language that treats units of measure as a first-class component of the type system). How feasible is it to provide a type system for a programming language that is expressive enough to allow units of measure as a library feature, rather than something built into the compiler itself (like F#)?
I want to take a quick look at this from the perspective of Rust, which is a reasonably-typed language with a fairly expressive type system (including polymorphism, but without type operators).
We can represent the base dimensions straightforwardly:
// We're using unsigned 128-bit integers for our numeric values
// here, but we could feasibly use any "numeric" type, or even
// make it generic. See "Practical considerations" below.
type Canonical = u128;
// This is a trait that is simply used for identifying
// which types are dimension types.
trait Dimension {
fn new(Canonical) -> Self;
fn value(&self) -> Canonical;
}
struct Unitless(Canonical);
struct Length(Canonical);
impl Dimension for Length {
fn new(c: Canonical) -> Self { Length(c) }
fn value(&self) -> Canonical { self.0 }
}
struct Time(Canonical);
impl Dimension for Time {
fn new(c: Canonical) -> Self { Time(c) }
fn value(&self) -> Canonical { self.0 }
}
We can now also construct units of measure for our dimensions, in the form of constructor functions.
// We're choosing "metre" and "second" as our canonical base
// units here. SI units are not compulsory, but are good
// standard choices.
fn metres(m: Canonical) -> Length {
Length(m)
}
fn decametres(dm: Canonical) -> Length {
Length(dm * 10)
}
fn seconds(s: Canonical) -> Time {
Time(s)
}
Now, we’re going to want to construct new dimensions using multiplication and inverses. We can do that using a polymorphic type.
use std::marker::PhantomData;
struct DimensionMultiply<S: Dimension, T: Dimension>(
// We only want to store a single value, but we need
// to keep some reference to the types it came from,
// which is why we need the `PhantomData`s. This is
// a Rust-specific nuance.
Canonical,
PhantomData<S>,
PhantomData<T>,
);
impl<S: Dimension, T: Dimension> Dimension for DimensionMultiply<S, T> {
fn new(c: Canonical) -> Self {
DimensionMultiply(c, PhantomData, PhantomData)
}
fn value(&self) -> Canonical { self.0 }
}
struct DimensionInverse<T: Dimension>(
Canonical,
PhantomData<T>,
);
impl<T: Dimension> Dimension for DimensionInverse<T> {
fn new(c: Canonical) -> Self {
DimensionInverse(c, PhantomData)
}
fn value(&self) -> Canonical { self.0 }
}
This all works so far, but we can already see a problem: we don’t have uniqueness of dimensions. To pick one example, the dimension for speed, Length / Time
, can be represented in multiple different ways.
// We can define derived units using type aliases.
type Speed1 = DimensionMultiply<Length, DimensionInverse<Time>>; // Speed
type Speed2 = DimensionMultiply<DimensionInverse<Time>, Length>>; // ...also Speed...
type Speed3 = DimensionInverse<DimensionMultiply<Time, DimensionInverse<Length>>>; // ...and again...
Note that having explicit conversions between these representations isn’t enough: these dimension types are not just isomorphic, they’re actually identical. From an implementation point-of-view, we need to somehow normalise dimensions. Techniques for this exist, but to apply these at a library level at the very least you need type-level functions (or some way to assert equality of [ostensibly different] types, providing conversion methods that are unlikely to be automatically checked for correctness).
Let’s pretend we can get around this issue somehow. We then can provide operations on values of dimension types.
use std::ops::{Add, Sub, Mul, Div};
// These examples don't actually work in Rust, where you
// cannot implement a trait for every (bounded) type like
// this, but this is what it might look like if you could.
impl<T: Dimension> Add for T {
type Output = T;
fn div(self, rhs: T) -> Self::Output {
Self::new(self.value() + rhs.value())
}
}
impl<T: Dimension> Sub for T {
type Output = T;
fn div(self, rhs: T) -> Self::Output {
Self::new(self.value() - rhs.value())
}
}
impl<S: Dimension, T: Dimension> Mul<T> for S {
type Output = DimensionMultiply<S, T>;
fn div(self, rhs: T) -> Self::Output {
// The `new` method on `Dimension` is
// defined to take a canonical value.
// This locks us into ensuring consistency
// of canonical representation: we can't
// measure `Distance` in metres, `Time`
// in seconds, but then measure `Speed` in
// feet per nanosecond, but it means that we
// can derive the various operations
// automatically.
DimensionMultiply::new(self.value() * rhs.value())
}
}
impl<S: Dimension, T: Dimension> Div<T> for S {
type Output = DimensionMultiply<S, DimensionInverse<T>>;
fn div(self, rhs: T) -> Self::Output {
DimensionMultiply::new(self.value() / rhs.value())
}
}
Here, I’m making use of associated types, a form of which you’re going to need to define such operations. We’re a way beyond Rust’s capabilities now, but in a type system based on something about as expressive as System-Fω, it’s plausible we could get this far. Once we’re here, we’re effectively done (save perhaps some nice synactic sugar for the units). The representation here does support only dimensions with integer powers, but with type-level rationals, you could extend this representation to rational powers fairly naturally.
My goal here was to describe a type theoretic model for units of measure. In an abstract setting, this model seems to fulfil our expectations. When working with units of measure practically, in a programming language, we need to take a little care regarding the data type we use to represent the dimensional types. From a theoretic standpoint, picking an arbitrary canonical unit (such as metre for Length
) is entirely reasonable, as we assume that we’re working with unlimited precision. This is not a luxury we can afford in an implementation, of course, so the representation of the value of dimension types requires a little more care. Ultimately, this comes down to the same questions as working with numeric types generally: how important do we expect precision to be (for example, should users have to accept that adding 5 feet
to 3 metres
will be prone to rounding, or do we want a more precise representation).
This consideration is separate from the type theoretic interpretation, so I’m not going to dwell on it, but it’s certainly a point of which to be aware. In an extreme case, to avoid these implicit conversions, one could separate the incompatible units of measure (that is, those units of measure that cannot be precisely converted into one another) into separate types: for example, treating foot
as a unit of a synthetic dimension Feet
, effectively reducing the unit of measure system to one mirroring F#’s.
To sum it all up, I’ve proposed that:
I think this demonstrates a satisfying justification for why dimensional analysis looks so intuitively like type-checking. What’s more, the complexity is not as great as might be imagined. I’d very much like to see an increase in the prevalence of type-checked systems for units of measure in programming languages, which I feel is an area of computation that (regrettably) often gets ignored by type systems.
I don’t think this is the end of interesting questions in the type theory of units of measure. I’m curious to see whether there are useful extensions of dimension types to non-numeric values, for one (straying decidedly away from physical meaning into the abstract3). Perhaps with an intuitive model for such types, others will be encouraged to investigate further…
This causes issues from a language point-of-view, because constructions on types don’t automatically carry over to units, as you’d expect. You’re forced either to implement the same features twice, for both types and units, or forbid your users from using them in the same ways as each other. ↩
Although I’m using the symbol “×” here, the operation is not the same as the product of types (which is also often denoted with the same symbol). If you consider the dimensional multiplication of any two types, versus their type theoretic product, this is easy to see: 5 metre-seconds is a value of the dimensional multiplication of length and time; whereas the pair (3 metres, 1 second) is a value of the type theoretic product of length and time. The dimensional multiplication combines the numeric components of the values (by the usual notion of multiplication), whereas the type theoretic product preserves the denominate numbers as components of a pair. ↩
Naturally, the way things ought to be. ↩
impl Trait
as a form of existential type. This explanation hopefully cleared up some of confusion that I had seen around viewing impl Trait
as an existential type.
However, a big part of the problem stemmed from the type theoretic terminology. Specifically, the term “existential type” was often thrown around, without anyone really explaining what it meant. (And while it may have been clear to some, it was definitely, understandably, murky for many.)
Really, even with the model in the post, there are some unsatisfactory consequences. Specifically:
impl Trait
is different depending on APIT (argument-position impl Trait
) versus RPIT (return-position impl Trait
).type Foo = impl Bar
is highly inadvisable, due to ambiguity.It would be great if we could find a way to describe impl Trait
in a way that avoids these problems (without introducing worse ones). As you’ve probably surmised by the existence of this post at all, there might be a way we can achieve this.
(Most of the credit for kicking off this discussion goes to @rpjohnst.)
impl Trait
from the perspective of type inferenceThough the term “existential type” has been thrown around a lot, including in the impl Trait
RFCs and official messages (e.g. blog posts), the semantics of impl Trait
as they’re currently implemented (in function signatures) are not uniquely satisfied by existential types. There’s another consistent interpretation, which I think was obscured by the prevalence of theoretic terminology.
To do this, we need to consider the _
symbol. When used in place of type, _
stands for an inferred type variable. For example, in:
let x: _ = vec![1u8, 2u8, 3u8].into_iter().map(|n| [n]);
The _
here stands for std::iter::Map<std::vec::IntoIter<u8>, [closure@...]>
. (Of course, in this case, you can simply omit the type ascription, but that’s arguably simply syntactic sugar.) In the following code:
let y = vec![1u8, 2u8, 3u8].into_iter().collect::<Vec<_>>();
The _
here stands for u8
.
Wherever you use _
, you have to provide enough information for it to be inferred uniquely (that is: there must be exactly one choice for which type to infer). In theory, you could always replace _
with the name of the type that’s being inferred — because there’s only one type it could be (though in practice, some types are not directly nameable, like closure types).
Let’s consider what we would be possible if we could add a trait bound to _
. I’m going to use the made-up syntax _: Trait
(expressly not one I’m proposing) for now. Consider the following function signatures:
fn foo() -> (_: Trait);
fn bar((_: Trait)) -> (); // (*)
*The last syntax is slightly confusing because :
is used both for type ascription and trait bounds.
What would the semantics of these functions be? Well, foo
has a return type that is inferred, and bar
has an argument whose type is inferred. foo
can’t return multiple types (for the same generic arguments, anyway), because inference must result in a unique type. Likewise, bar
’s type is inferred from the argument passed in. (If you’re not quite happy with this statement yet, go with it for now: we’re going to get back to the precise semantics soon.)
You’ve probably noticed something about this (the heading might give it away). Our definitions of foo
and bar
are very similar to these definitions:
fn foo() -> impl Trait;
fn bar(impl Trait) -> ();
That is, impl Trait
acts very similarly to type inference. In fact, under this interpretation, APIT and RPIT have the same semantics. This is something that the previous approaches (involving existential types) just haven’t been able to do. And suddenly, once we have this interpretation, we open up new possibilities.
dyn Trait
, but that’s another story).type Foo = impl Bar
!, Now impl Bar
has a single meaning, so we don’t have to worry about which variant we’re talking about.There’s a subtle but important point to make here, which involves transparency. Types inferred using _
are always transparent (this is true for all uses of _
as type inference in Rust today). This means that our hypothetical _: Trait
syntax is transparent — you can observe exactly which type has been inferred. However, impl Trait
is known to be opaque.
This isn’t actually a problem. In this model, we define impl Trait
thus:
impl Trait
is an inferred type with a trait bound, Trait
,Note that the last two points are already part of the definition of impl Trait
— it’s just the first point that’s different, switching from (varying) existential types to inferred types.
To clarify exactly what’s going on with the above examples, let me spell out exactly what I mean. We can view a fn
definition as declaring a type implementing Fn
, which is parameterised by its argument types and that has an associated Output
type corresponding to the return type.
fn foo(A, B, C) -> D;
// desugars to...
impl Fn<(A, B, C)> for {foo} {
type Output = D;
/* ... */
}
We can now consider this desugaring from the perspective of an inferred type:
fn foo(impl Bar) -> impl Baz;
// desugars to...
impl Fn<(impl Bar,)> for {foo} {
type Output = impl Baz;
/* ... */
}
This has precisely the semantics we expect for impl Trait
: in particular, the types of the arguments will always be inferred, whereas the return type, being an associated type, is fixed.
(Technically, because we’re implicitly introducing generic parameters here, we’re actually performing a sort of “ML-style let-polymorphism”, which permits the inference of universal quantifiers.)
impl Trait
is an established (stabilised) part of the language. This means whatever model we pick to interpret impl Trait
must be consistent with the existing usage in the language — specifically, function signatures.
Both the existential type model described in the previous post, and the type inference model described in this one, are consistent with the existing usage. However, adding an additional feature is likely to force a specific model. We should be very intentional about precisely which model offers more advantages (in terms of usage and explainability), rather than making an instinctive reaction (either way). I think it’s more important at this stage to establish the precise model, before adding more features (such as impl Trait
type aliases).
There’s one obvious drawback to this approach, but I think it’s significantly better than those surrounding the impl Trait
-as-an-existential viewpoint.
In a pure language, you can always substitute the value of an expression with the expression itself. This means that if you have a variable let x = y;
, you can always use x
or y
interchangeably. This obviously isn’t true in a language with side-effects like Rust. However, right now, it is true for type aliases: if you have an alias type Foo = Bar
, then you can always replace Foo
with Bar
and Bar
with Foo
anywhere in your code.
Consider type Foo = impl Trait
. If you see impl Trait
anywhere else in your code, you cannot replace it with Foo
, because Foo
is potentially more restricted (if used in more than one location). Likewise, you cannot replace any occurrence of Foo
with impl Trait
, because it’ll be potentially less restricted. Though these changes aren’t visible in the type system (i.e. your program will still type-check, assuming it did before), it could affect behaviour if you have specialising impl
s, as different types will be inferred.
impl Trait
is opaque, but following the proposal in the last post, there’s also a sensible syntax suggestion for transparent type aliases, namely: type Foo: Bar = _
. This flexibility: consistently disambiguating between opaqueness and transparency is a strength, though I think the transparency type alias would be better suited for an RFC after that for type Foo = impl Bar
. Exercising caution is never a bad thing in language design.
In conclusion, I think that by viewing impl Trait
in this alternative way, completely distinct from existential types, we’re able to formulate a much more satisfactory semantics, which leads into more intuitive syntax for features like type aliases.
impl Trait
. This addition was declared a long-awaited syntax for existential types, but its inclusion was not without some controversy.
There seems to be a lot of confusion as to just what impl Trait
really means from a type theoretic (viz. precise) perspective, even two months after its stabilisation, and I don’t think anyone has really captured exactly what impl Trait
is and isn’t. This is the result of several facets of the design, each of which compounds the bewilderment.
But there’s another feature that rarely gets mentioned in the same setting, which also contributes some form of existentially-quantified type to Rust, known as dyn Trait
, and it’s not possible to really understand the situation without taking it into account.
In addition to all of this, there have been several RFCs proposing extensions to the state of affairs. I think that, without properly understanding exactly what each syntax means at the moment, it’s going to be far too easy to make mistakes with new syntax — so we need to make sure we’re very careful about what we’re proposing.
This post is intended to clear up hopefully most of the confusion around impl Trait
and existential types in general in Rust, and provide some guidance in particular for one ongoing discussion around existential type aliases.
I’ll be trying to explain the concepts here without recourse to too much type theory to hopefully make it accessible.
An “existential type”, or “existentially-quantified type”, is a type that intuitively represents “any type satisfying a given property”. In the context of Rust, this usually means “any type implementing a given trait”. I’ll use a notation from logic to describe an existential type here (which I think is slightly clearer than the type theoretic notation).
∃ T. T: Trait
is the logical notation for some type T
such that T
implements the trait Trait
.
For example, ∃ T. T: IntoIterator<Item = S>
could be satisfied with the type Vec<S>
or HashSet<S>
, or so on.
We’ll also talk about universally-quantified types in passing too, so let’s clarify the notation for that too. The type ∀ T. (T, T)
is a universally-quantified type that takes a type argument T
and produces a type of pairs of that type, (T, T)
. You can think of ∀ T. S
as a function taking a type T
and returning a type S
.
impl Trait
mean?Naïvely, any time you see the type impl Trait
, you can substitute it with an existential type ∃ T. T: Trait
— that is, there exists some type T
such that T
implements the trait Trait
. This existential quantification binds tightly, so fn foo(a: A, b: B, c: C) -> impl Bar
is the syntax for a type fn(A, B, C) -> (∃ T. T: Bar)
.
However, this isn’t quite true, though it’s not for the reason many people think. To explain it, let’s quickly talk about why people find impl Trait
so troublesome.
Rust has had universally-quantified types for a long time, in the form of generic parameters. However, there’s a couple of differences between the syntax for existentially-quantified types and universally-quantified types that are easy to overlook at first.
fn foo<S, T>(s: S, t: T) -> T
is syntax for a type ∀ S, T. (fn(S, T) -> T)
— that is, a function taking two type arguments (S
and T
) and two values of those respective types and returning a value of type T
. Notice the difference here with the existential type: the universal quantifier ∀
is scoped over the entire type — everything after the ∀
— whereas the existential quantifier ∃
is scoped over just the immediate trait bound (looking at where the quantifier is placed, in fn(A, B, C) -> (∃ T. T: Bar)
the ∃
is after the function arrow ->
, whereas in ∀ S, T. (fn(S, T) -> T)
the ∀
is at the outermost level). ∃
is, in some sense, much more local. The syntax is indicative of this, with type parameters being placed at the beginning of the definition, in contrast to impl Trait
, which is placed inline.
It’s useful to bear in mind that one key difference between universal quantification (generic parameters) and existential quantification is this scoping difference. (Be reassured that this difference is entirely intentional. If you try to think what it would mean if their scopes were different, you’ll probably realise that it makes things a lot harder to make sense of.)
impl Trait
vs argument-position impl Trait
The supposed distinction, or lack thereof, between impl Trait
in different positions, is a very contentious issue. This is unsurprising, as even official sources have made conflicting assertions about what impl Trait
really means in argument position.
One quick side note before we begin: I’m using RPIT (return-position impl Trait
) and APIT (argument-position impl Trait
) as initialisms, because typing out the whole phrases each time gets old quickly. Okay. Let’s take a look at the issue. Say we have two functions signatures:
fn foo() -> impl Foo; // Return-position `impl Trait` (RPIT)
fn bar(impl Bar) -> (); // Argument-position `impl Trait` (APIT)
The question is thus: if the return-position impl Foo
is an existential type, as proposed earlier, what does that make the argument-position impl Bar
?
To answer that, let’s apply our syntax-to-type transformation: fn bar(impl Bar) -> ()
is a type (∃ T. T: Bar) -> ()
. This is a function that takes a value of some type, given that the type implements the trait Bar
. The type T
itself is not a parameter — only the value is.
We’re going to have to take a slight diversion into type theory here, because it motivates a result that is perhaps intuitive. The following proposition holds in intuitionistic logic: ((∃ x. P(x)) → Q) ⇔ (∀ x. (P(x) → Q))
, which means that, according to the Curry–Howard Correspondence, it also holds when considering the proposition as a type.
What does this mean for us? It means that if we have a function type fn(∃ S. S: Foo) → T
, then we can construct a new type ∀ S. (fn(S: Foo) -> T)
(and vice versa). But hey, look at that! If we convert the type to our Rust syntax, the first is a function fn(impl Foo) -> T
and the second is a function fn<S: Foo>(S) -> T
. So while the two function types are not the same (in terms of being identical), they are isomorphic — and we can freely convert between them.
(We can tell they’re not exactly the same, because we can distinguish between fn(impl Foo)
and fn<S: Foo>(S)
by trying to pass a type argument to both. We can pass a type argument to the latter, universally-quantified function, because S
is actually a parameter (it’s just usually inferred by Rust) — using the “turbofish” notation ::<S>
. But we cannot pass a type argument to the former, because it doesn’t have a type parameter — it simply takes a value whose type implements the trait Foo
.)
In summary: impl Trait
is always existential. It’s not universal in argument position — it’s just conveniently isomorphic to a very similar universally-quantified type.
There’s still something fishy about impl Trait
though, which is why the story isn’t quite finished here. Consider the following:
trait Trait {}
struct A;
struct B;
impl Trait for A {}
impl Trait for B {}
fn foo(pick_a: bool) -> impl Trait {
if pick_a { A } else { B } // ERROR: if and else have incompatible types
}
If impl Trait
just represented an existential type, this should work. A
is certainly a value of a type that implements Trait
. So is B
. So what’s the problem?
The problem is that the Rust compiler needs to know which (unquantified) type will be returned from the function. The existential type doesn’t “exist” at run-time — it needs to pick a specific unquantified type. (This makes returning an impl Trait
just as efficient as any other type.)
But this is a pretty big restriction. In fact, it changes the semantics of our existentials. For each occurrence of impl Trait
, we only allow a single unquantified type to represent it. Specifically, for return-position impl Trait
, the existential quantifier is over the entire function (though within the universal quantifiers) — it can only ever have one instantiation. However, argument-position impl Trait
really is existential at the correct, tightly-binding scope.
…Which means we now have a distinction between the two. Though argument-position and return-position impl Trait
are both forms of existential quantification, they’re over different scopes: APIT is tightly-bound, whereas RPIT is bound at the level of the whole function.
I hinted at something in the introduction, though, which if left unmentioned would leave our story incomplete. There’s another type that provides some form of existential quantification: dyn Trait
. In present-day Rust, dyn Trait
has to be boxed — that is, placed behind a pointer of some kind, like &
or Box<>
. But if we hypothetically extend the concept to an unboxed dyn Trait
, we notice that it has precisely the semantics we’d expect of an existential type. In fact, the type for dyn Trait
is the same as that of argument-position impl Trait
: (∃ T. T: Trait)
. The difference is in the representation (in the compiler): because dyn Trait
is a true existential, it has to hold information about the specific type T
at run-time, which is a disadvantage that impl Trait
doesn’t have. In theory, though, impl Trait
is just a more restricted (albeit unboxed) version of dyn Trait
, which you can intuitively think of as having some guaranteed compiler optimisations (like not having to store type information).
Even if you didn’t follow all of the details above, you can take away a few key points, which should clear up some of the confusion surrounding impl Trait
:
impl Trait
is not universally-quantified, but it acts very similarly (due to neat type theory equivalences!).If you aren’t already very familiar with how much confusion existential types has created, the above has probably given you some idea. I think this demonstrates that we need to be really careful with any additional syntax we propose for existential types (or similar), as the concepts are quite subtle, but can have huge consequences for the design of the language. To show this isn’t just an idle discussion, I’m going to take a look at a proposed feature for defining existential types, outside of inline use in function signatures.
There are currently two proposed and widely-supported syntaxes for declaring an existential type. However, I propose that these are both inconsistent with Rust’s syntax and semantics, and we need to take a different tack to solve the presented problem.
Before we look at the proposals, note what problem we’re trying to fix. We want to declare a type that is the same wherever it is placed, but doesn’t require explicit mention of what the type is, as long as it satisfies some bound. We want something like RPIT as a standalone type (which is not currently possible).
type Foo = impl Bar
This proposal is put forward as the “intuitive” syntax for defining an existential type. It’s not as rosy as it first seems, however.
The syntax type A = B
is type alias syntax. It gives a new name for an existing type. So if we expected type Foo = impl Bar
to be valid, we’d expect it to be a type alias. Let’s see how this could work.
type Foo = impl Bar
indicates that impl Bar
is a type. But what type is it? It could be a new (hidden) type ∃ T. T Bar
(like argument-position impl Trait
). But then you wouldn’t be able to use it as the return type of a function. Or it could be a new existential type quantified over the entire program (like return-position impl Trait
). But then you wouldn’t be able to use it as an argument type of a function.
The point is that impl Trait
is contextual. It doesn’t represent a single type in isolation. So type Foo = impl Bar
is ambiguous syntax; it’s no longer an alias.
You could suggest overloading type Foo = impl Bar
to be a particular existential type declaration (such as return-position impl Trait
), but now type Foo = /* ... */;
isn’t consistent: it might be a type alias or a type definition (this breaks what people refer to as “referential transparency” — you can’t just use Foo
anywhere you would use impl Bar
).
Alternatively, you could suggest that a type alias of a contextual type is also contextual. This completely changes the semantics of type aliases and I can’t imagine anyone suggesting this would be a good idea, as it breaks a lot of assumptions people have about aliases (as opposed to impl Trait
, which is arguably syntactic sugar for something that doesn’t actually exist in Rust yet).
type Foo: Bar
This proposal is put forward as an alternative to the above syntax, as syntax in which we declare a type, but don’t specify it. This is also problematic.
The syntax type A: B
is associated type syntax. (In fact, associated types are often brought up with respect to this syntax proposal.) But associated types are not existential types: they’re (a restricted form* of) type parameters. By introducing a syntax type Foo: Bar
for existential types, you now no longer have consistency with regards to what the syntax means. Just like impl Trait
, it’s suddenly, confusingly, contextual. Inconsistencies in Rust’s syntax (though thankfully these are relatively few) have caused enough problems that I hope we’re at least starting to learn the lessons of the past. Using the same syntax for distinct concepts is sure to cause consternation.
*Specifically uniquely determined by the generic parameters of their type.
type Foo: Bar = _
Though it’s helpful to point out flaws with existing proposals, without a good alternative, it doesn’t help the discussion progress. Fortunately, I have a new syntax that I think fits the intended use cases better than impl Trait
, while also being consistent with existing syntax.
This proposal promotes type Foo: Bar = _
as a type alias for some type T: Bar
, such that the compiler must infer the type T
. The type T
is unique — that is, there must be only one possible type T
that satisfies the restriction (just like return-position impl Trait
). This syntax is unambiguous and fits in with existing usage of _
in turbofish (e.g. .collect::<Vec<_>>()
) and type annotations (e.g. let x: _ = 0u8;
). To reiterate: the _
always represents a single type, and this is true in existing Rust syntax. So it’s entirely consistent with our goal.
How is it different to existing to the previous proposals? Unlike the others, it’s not actually a syntax for existential types — it’s a syntax for type inference. But semantically, it can be used anywhere return-position impl Trait
type aliases could be, so it’s functionally equivalent for these purposes. (I argue that we’re not actually looking for a syntax for existential types — they’re just what were leapt to as a solution — but they aren’t the only one.)
On top of this, the underlying (inferred type) is transparent. This means that we can make assumptions about which type Foo
is in type Foo: Bar = _
. This is actually a good thing: you don’t lose any information, which makes doing things like implementing traits for the type straightforward and calling inherent methods on the type. If you happen to what an opaque definition, you can wrap it up in a newtype — i.e. a single-field struct
.
As the compiler is inferring the type _
in the proposal, it’s often going to be possible to infer the type even without a trait bound. Therefore, we could even consider the unbounded syntax type Foo = _
(which leads in naturally to declaring trivial bounds on trait aliases).
In theory, you could allow _
anywhere in a type alias (the semantics of which was one point of contention with the previous proposals). type Foo: Bar = (_, _)
would require its two components to be inferred separately.
However, I think these features are best left for a later proposal, to avoid getting caught up in even more tangential discussion now. Let’s leave it as a promising note for the future.
In conclusion, I think this is an intuitive and consistent solution to the problem with existential type declarations that avoids a lot of the problems with the previously proposed syntaxes. This involved quite a lot of setting up — but it’s an intricate issue — so I absolutely think it’s best to be as unambiguous as we can when we’re thinking about these concepts. Let’s see where we can get with this fresh perspective.
Though this formulation is much cleaner from a syntactic and theoretical perspective, there are still some loose ends. Using this type alias, we have a disconnect between the impl Trait
syntax and the type alias syntax. Is there any way we can reconcile them? In fact, there might very well be a solution that has the benefits of both worlds… but I think we’ll leave that for next time. I think we have enough food for thought for now.