One thing you don’t mention which rather complicates the use of traits is restrictions on “orphan instances”: in order to ensure that there’s only one implementation of a certain trait for each type, Rust will only allow you to implement a trait for a type if you’re the one declaring the type, or you’re the one declaring the trait[1].
This leads to some less than ideal code. For example you get:
Crates which don’t depend on each other adding optional “features” so that where one crate defines a trait and the other defines types that should implement the trait, one of the crates can implement the traits for the types (when by rights it shouldn’t know that the other crate exists). For example, the tinytga crate mostly just deals with loading images in tga format. However, if you want to use it with the embedded-graphics crate, it has an optional graphics feature which needs to be enabled, so that it can implement the relevant traits from the embedded-graphics crate.
Multiple crates reimplementing the same data types over and over. I was experimenting with some image manipulation recently, and it seemed like every single crate had its own types for RGB values and Images (width, height, plus data buffer). The problem here is that you can’t choose a good RGB type and use it with all the other crates by implementing the necessary traits (even assuming that the crates’ interfaces are sufficiently generic): The only way you’re allowed to do that is if you ‘own’ the RGB type. So you have to define a new RGB type. Now when somebody tries to use your crate with a couple of other crates, they have exactly the same issue, except with one more RGB type thrown into the mix.
Lots of wrappers, where the wrapper doesn’t change the data layout of the original type, it is simply a workaround to allow the user to use the original type with a trait from another crate.
For comparison, while there are issues with orphan instances in Haskell, I think it takes a more reasonable approach. I can’t remember all the details, but I think Haskell will warn you about orphan instances, but does allow them, so you will see things like a pipes-servant module, which implements the necessary instances for you to use pipes and servant together (Importantly, you don’t need to persuade the pipes author to add servant support, nor the servant author to add pipes support: anyone can write the extra pipes-servant module which makes them work together, and it doesn’t add any complexity for people who are just using one or the other). Sometimes you do see newtype wrappers used to implement typeclass instances, but this is usually when there are multiple valid implementations of a typeclass which need to be differentiated.
I’m not sure whether these complications would genuinely affect a decision to use traits vs. enums, but it does mean that traits aren’t quite as “open” as you might expect.
[1] As I understand it. One unfortunate downside of language development in the open is that the outdated discussions of how something could be done are sometimes “louder” than the final decision that was implemented.
It’s definitely worth talking about the orphan impl problem more! I think it’d be a separate post. There are trade-offs for the different options, and I think Rust made the right choice given the broader design of the language.
As you can probably guess from my post, I don’t think they’ve made a good choice in this case. Unfortunately, my impression is that the Rust language designers keep doing this sort of thing over and over again. They take a well-studied and well-tested idea, often from one of the functional languages, then they half-arse the implementation, such that nobody really reaps the full benefits.
They take error handling with sum types, but they don’t allow the monadic code that makes handling errors this way pleasant, instead bolting on some ad-hoc ? operator with slightly weird semantics. And in this case, they chose typeclasses/traits over an inheritance model, which should allow for much more composable and extensible code, and better separation of generic concepts from the specifics of each type. And yet thanks to a few awkward choices in the implementation, we’re back to having many modules with their own ‘internal’ reimplementation of basic structures like it’s old-school C++ (Or alternatively we have groups of crates forming “families” or frameworks that work together, but not with other crates).
Don’t get me wrong, I still think rust is a big improvement on C and C++, and it’s ok to work with, I personally just feel like it could have been much better.
I am still learning Rust, so I’m happy to be persuaded that I’m wrong. I’d love to see a post discussing why you think the orphan instance rules are a good thing, and ideally, how I can work with them to solve (or avoid) some of the problems I described in my first comment.
I don’t have a ton of time to make the case here, but in general I think programming languages should be evaluated holistically and in the context of their goals. Language design is a selection of trade-offs, and it’s not generally feasible to say “X is what language A does and so language B should do it too” because while A may work in the context of language A, it may not work in the context of language B because of other design choices in B.
Now, you’ve identified a few places where you think Rust made the wrong call.
Orphan impl rules
Lack of monadic do notation
The semantics of the ? operator
Let me take a crack at the first one, at least.
The issue of orphan impls is how do you handle the possibility that multiple implementations may exist for a particular pair of types and traits. Your options include:
Permit writing of implementations without restriction, but error out if conflicts are present (this is what Haskell does)
Permit writing of implementations without restriction, and apply some selection rule to pick an impl from several options when conflicts arise
Permit writing of implementations without restriction, and require users to select a desired impl when conflicts arise
Place restrictions on writing implementations to disallow creation of impl conflicts (what Rust does).
To help explain Rust’s choice, here’s a review of the challenges, written about two years ago. The “hashtable problem” is a real issue, and one that doesn’t appear to have a good available solution. Under the orphan impl rules, there are still problems (catalogued in the document), but which generally have available solutions. Those solutions may be tedious (like newtypes), but they’re possible, and it’s also possible (and Rust has been working on this) to make them less tedious.
Maybe you disagree with this selection of trade-offs, but if you look at the history (also linked near the bottom of the document), you’ll see that this issue has been deeply considered and discussed.
I’d love to see a post discussing why you think the orphan instance rules are a good thing
AIUI, the principal reason why the current design was chosen was to avoid unintentional breakage when bringing in new crates. Evaluating the orphan rules without considering this rings a bit hollow to me, personally. This kind of breakage could have become extremely frustrating and difficult to predict. It could create unanticipated incompatibilities when two instances ambiguously overlap. And this could happen deep in a dependency tree.
Now maybe there is a better middle ground, and I think people are thinking about that. But from your comments, all I see are the downsides of the current approach and the upsides of allowing orphan instances. What I’d like to see you include are the upsides of the current approach and the downsides of allowing orphan instances. :-)
Now maybe there is a better middle ground, and I think people are thinking about that. But from your comments, all I see are the downsides of the current approach and the upsides of allowing orphan instances.
I didn’t really set out to write a nuanced discussion of orphan instances, I just wanted to point out that there are some caveats to the “Enum == closed set, Trait == open set” idea, once you step outside your crate. If it sounds like I don’t like rust, bear in mind that I wouldn’t be taking the time to think or talk about rust if I didn’t like and use it. I do think they made a lot of very good choices. I am half hoping that someone will say, “you’re doing it wrong, look at this project which completely avoids the issue by doing X”.
AIUI, the principal reason why the current design was chosen was to avoid unintentional breakage when bringing in new crates.
Anecdotally, I have never actually encountered conflicting instances while using Haskell, which makes me think that while it is theoretically possible, it just doesn’t happen very much (Most orphan instances are in modules like I mentioned before, where they provide compatibility between two different modules; there’s generally very little motivation for anyone to implement a duplicate instance anywhere else). By contrast, I have recently experienced the frustration of trying to use rust crates together where the types from one can reasonably implement the traits from the other (and need to in order to use the two together), but don’t, necessitating wrappers or other workarounds (and at least for a novice, it’s not always trivial to prove that the wrapper isn’t accidentally incurring some sort of conversion overhead somewhere). So you can hopefully see why, based on my experiences, I lean towards less restrictive rules on orphan instances.
It looks like we have two potential levels of “don’t work together” when including multiple crates in a project.
With the current rules, you have a large number of crates that don’t work together without wrappers. Paper cuts, but lots of them.
With a rule along the lines of “only one instance allowed. Compile error if it’s not the case”, you could occasionally run into a catastrophic incompatibility which can’t be worked around, but I’m not convinced that it should happen all that often.
My personal preference would be potential catastrophe with no paper cuts, but I’m a novice when it comes to rust, so take that with the requisite dose of salt.
but I’m not convinced that it should happen all that often.
I am. The orphan rules are really hard to internalize. I don’t think I’ve managed to do it yet, with all of its intricacy, and I’ve been writing Rust daily for more than six years. It’s really easy to write overly general or blanket impls that would conflict with other code. Rust makes use of these sorts of traits perhaps more pervasively than Haskell does, largely to try and smooth over the various kinds of pointers that Rust supports. (I’m thinking about things like AsRef, AsMut, Borrow and BorrowMut.) I think it’d be really easy to write an overly general impl somewhere, and without any feedback that it might clash with some other crate in the entire universe, that would make for a particularly bad experience.
The wrapper type paper cuts you’re talking about are indeed annoying, but I guess I’d personally classify that as “mild” annoyance, to be honest. There are some domains where the need for wrapper types is circumvented through additional annotations, for example, with Serde. But the general problem certainly still exists.
I’ve published a lot of crates and spent a lot of time working in the ecosystem, and the most annoying problems by far are the problems that don’t surface easily or aren’t automatically checked by tooling via the normal means. (Semver violations, accidental increase in MSRV and so on.) I think allowing overlapping instances would just make it worse. I’d really rather a few small paper cuts here and there as opposed to a universe of unknown incompatibility. I want things checked for me as much as possible. Hell, that’s a big reason why I gravitate away from unityped languages.
I don’t know what domain you’re working in, but maybe you’re hitting a particularly bad case? Or it might just be sticking out to you more because Haskell’s modus operandi is fresh in your mind’s eye.
(and at least for a novice, it’s not always trivial to prove that the wrapper isn’t accidentally incurring some sort of conversion overhead somewhere)
Interesting. Usually a “wrapper type” is just something that encases another type. e.g.,
pub struct FooWrapper(Foo);
The annoying part, or the meat of the papercut if you will, is that now you have to choose between just exposing the inner Foo or re-creating Foo’s public API on top of FooWrapper. But putting Foo inside of FooWrapper is indeed zero cost.
One thing you don’t mention which rather complicates the use of traits is restrictions on “orphan instances”: in order to ensure that there’s only one implementation of a certain trait for each type, Rust will only allow you to implement a trait for a type if you’re the one declaring the type, or you’re the one declaring the trait[1].
This leads to some less than ideal code. For example you get:
graphics
feature which needs to be enabled, so that it can implement the relevant traits from the embedded-graphics crate.For comparison, while there are issues with orphan instances in Haskell, I think it takes a more reasonable approach. I can’t remember all the details, but I think Haskell will warn you about orphan instances, but does allow them, so you will see things like a pipes-servant module, which implements the necessary instances for you to use pipes and servant together (Importantly, you don’t need to persuade the pipes author to add servant support, nor the servant author to add pipes support: anyone can write the extra pipes-servant module which makes them work together, and it doesn’t add any complexity for people who are just using one or the other). Sometimes you do see newtype wrappers used to implement typeclass instances, but this is usually when there are multiple valid implementations of a typeclass which need to be differentiated.
I’m not sure whether these complications would genuinely affect a decision to use traits vs. enums, but it does mean that traits aren’t quite as “open” as you might expect.
[1] As I understand it. One unfortunate downside of language development in the open is that the outdated discussions of how something could be done are sometimes “louder” than the final decision that was implemented.
It’s definitely worth talking about the orphan impl problem more! I think it’d be a separate post. There are trade-offs for the different options, and I think Rust made the right choice given the broader design of the language.
As you can probably guess from my post, I don’t think they’ve made a good choice in this case. Unfortunately, my impression is that the Rust language designers keep doing this sort of thing over and over again. They take a well-studied and well-tested idea, often from one of the functional languages, then they half-arse the implementation, such that nobody really reaps the full benefits.
They take error handling with sum types, but they don’t allow the monadic code that makes handling errors this way pleasant, instead bolting on some ad-hoc
?
operator with slightly weird semantics. And in this case, they chose typeclasses/traits over an inheritance model, which should allow for much more composable and extensible code, and better separation of generic concepts from the specifics of each type. And yet thanks to a few awkward choices in the implementation, we’re back to having many modules with their own ‘internal’ reimplementation of basic structures like it’s old-school C++ (Or alternatively we have groups of crates forming “families” or frameworks that work together, but not with other crates).Don’t get me wrong, I still think rust is a big improvement on C and C++, and it’s ok to work with, I personally just feel like it could have been much better.
I am still learning Rust, so I’m happy to be persuaded that I’m wrong. I’d love to see a post discussing why you think the orphan instance rules are a good thing, and ideally, how I can work with them to solve (or avoid) some of the problems I described in my first comment.
I don’t have a ton of time to make the case here, but in general I think programming languages should be evaluated holistically and in the context of their goals. Language design is a selection of trade-offs, and it’s not generally feasible to say “X is what language A does and so language B should do it too” because while A may work in the context of language A, it may not work in the context of language B because of other design choices in B.
Now, you’ve identified a few places where you think Rust made the wrong call.
do
notation?
operatorLet me take a crack at the first one, at least.
The issue of orphan impls is how do you handle the possibility that multiple implementations may exist for a particular pair of types and traits. Your options include:
To help explain Rust’s choice, here’s a review of the challenges, written about two years ago. The “hashtable problem” is a real issue, and one that doesn’t appear to have a good available solution. Under the orphan impl rules, there are still problems (catalogued in the document), but which generally have available solutions. Those solutions may be tedious (like newtypes), but they’re possible, and it’s also possible (and Rust has been working on this) to make them less tedious.
Maybe you disagree with this selection of trade-offs, but if you look at the history (also linked near the bottom of the document), you’ll see that this issue has been deeply considered and discussed.
The same is true of the other issues too.
AIUI, the principal reason why the current design was chosen was to avoid unintentional breakage when bringing in new crates. Evaluating the orphan rules without considering this rings a bit hollow to me, personally. This kind of breakage could have become extremely frustrating and difficult to predict. It could create unanticipated incompatibilities when two instances ambiguously overlap. And this could happen deep in a dependency tree.
Now maybe there is a better middle ground, and I think people are thinking about that. But from your comments, all I see are the downsides of the current approach and the upsides of allowing orphan instances. What I’d like to see you include are the upsides of the current approach and the downsides of allowing orphan instances. :-)
I didn’t really set out to write a nuanced discussion of orphan instances, I just wanted to point out that there are some caveats to the “Enum == closed set, Trait == open set” idea, once you step outside your crate. If it sounds like I don’t like rust, bear in mind that I wouldn’t be taking the time to think or talk about rust if I didn’t like and use it. I do think they made a lot of very good choices. I am half hoping that someone will say, “you’re doing it wrong, look at this project which completely avoids the issue by doing X”.
Anecdotally, I have never actually encountered conflicting instances while using Haskell, which makes me think that while it is theoretically possible, it just doesn’t happen very much (Most orphan instances are in modules like I mentioned before, where they provide compatibility between two different modules; there’s generally very little motivation for anyone to implement a duplicate instance anywhere else). By contrast, I have recently experienced the frustration of trying to use rust crates together where the types from one can reasonably implement the traits from the other (and need to in order to use the two together), but don’t, necessitating wrappers or other workarounds (and at least for a novice, it’s not always trivial to prove that the wrapper isn’t accidentally incurring some sort of conversion overhead somewhere). So you can hopefully see why, based on my experiences, I lean towards less restrictive rules on orphan instances.
It looks like we have two potential levels of “don’t work together” when including multiple crates in a project.
My personal preference would be potential catastrophe with no paper cuts, but I’m a novice when it comes to rust, so take that with the requisite dose of salt.
I am. The orphan rules are really hard to internalize. I don’t think I’ve managed to do it yet, with all of its intricacy, and I’ve been writing Rust daily for more than six years. It’s really easy to write overly general or blanket impls that would conflict with other code. Rust makes use of these sorts of traits perhaps more pervasively than Haskell does, largely to try and smooth over the various kinds of pointers that Rust supports. (I’m thinking about things like AsRef, AsMut, Borrow and BorrowMut.) I think it’d be really easy to write an overly general impl somewhere, and without any feedback that it might clash with some other crate in the entire universe, that would make for a particularly bad experience.
The wrapper type paper cuts you’re talking about are indeed annoying, but I guess I’d personally classify that as “mild” annoyance, to be honest. There are some domains where the need for wrapper types is circumvented through additional annotations, for example, with Serde. But the general problem certainly still exists.
I’ve published a lot of crates and spent a lot of time working in the ecosystem, and the most annoying problems by far are the problems that don’t surface easily or aren’t automatically checked by tooling via the normal means. (Semver violations, accidental increase in MSRV and so on.) I think allowing overlapping instances would just make it worse. I’d really rather a few small paper cuts here and there as opposed to a universe of unknown incompatibility. I want things checked for me as much as possible. Hell, that’s a big reason why I gravitate away from unityped languages.
I don’t know what domain you’re working in, but maybe you’re hitting a particularly bad case? Or it might just be sticking out to you more because Haskell’s modus operandi is fresh in your mind’s eye.
Interesting. Usually a “wrapper type” is just something that encases another type. e.g.,
The annoying part, or the meat of the papercut if you will, is that now you have to choose between just exposing the inner
Foo
or re-creatingFoo
’s public API on top ofFooWrapper
. But puttingFoo
inside ofFooWrapper
is indeed zero cost.