Referencing types from subtraits in generic traits?

I wanted to implement the Try trait (Playground link), but I ran into a strange design decision.

The issue

The Try trait inherits from the FromResidual trait, but the FromResidual trait uses type from the Try trait? This line in particual:

pub trait FromResidual<R = <Self as Try>::Residual> {

seems off. One of the best features of Rust (in my opinion) is the lack of magic going on. For example, you have to re-define all the generic bounds when implementing a struct, which is tedious, but more verbose, and thus you can see which the generic bounds must the types comply with right at the implementation, with no need to go and check the struct definition.

Why I think it's wrong

This, on the other hand, is going in the opposite direction. Here, from the playground example:

impl<S, F, I> FromResidual for Outcome<S, F, I> {

can you tell me what is the R type in FromResidual? You would have to look at the docs to see that it is inferred from the Try subtrait, and then try to look for the Try implementation to find out.

Comparison to DerefMut

A similar thing is going on with DerefMut, but there the type is inherited from a supertrait, which makes much more sence, whereas here it is almost as if it was inhered from a subtrait.

Combining generic and type-associated trait

I think part of the problem is that Try is an associated-types traits, and FromResidual is a generic trait, but that should be no excuse for this type of "hidden magic" going on. The Try trait already defines that the FromResidual generic type must be it's Residual associated type:

pub trait Try: FromResidual<Self::Residual> {

so there is no need (IMO) to specify the the generic type in FromResidual should be taken from the Try subtrait at FromResidual's definition.

Proposed solution

Generic or associated types should never be inferred from subtraits, only inhereted from supertraits. FromResidual's definition should simply be

pub trait FromResidual<R> {

and all implmentations should have to explicitly specify the type - example from the playground link:

impl<S, F, I> FromResidual<Failure<F, I>> for Outcome<S, F, I> {

The RFC explains this decision by arguing that it's pretty common to have a single FromResidual implementation with that specific generic parameter, so this removes a bit of boilerplate.

2 Likes

This is entirely convenience. The way defaults work for type parameters is that if you specify the value directly in a use, then the default isn't used or checked or emitted in any way -- you can impl FromResidual<Foo> for SomethingNotEvenTry { … } if you want.

But there's definitely on-going discussion about exactly how the traits should be structured here. You might be interested in this comment (and related ones) in the tracking issue: https://github.com/rust-lang/rust/issues/84277#issuecomment-1078700107.

I think trying to consider this trait impl on its own is a bit of a fool's errand. The two current traits (maybe three later!) are so highly interconnected that one should always think of them together, and they're essentially always defined next to each other in the code anyway.

And even if not, if you're curious what the residual type is, you can just look one line lower:

impl<S, F, I> FromResidual for Outcome<S, F, I> {
    fn from_residual(residual: Failure<F, I>) -> Self {
                               ^^^^^^^^^^^^^ look, the residual type!

This is the same as how one can write impl Add for MyNumber { instead of being forced to say impl Add<MyNumber> for MyNumber {, which has always seemed fine to me. I suppose you can say that it's a problem of degree, since the default on FromResidual is more complicated than the one on Add. But I actually think it's more helpful to be able to read it as "oh, it's from the normal one" than to see it repeated -- when it's written out again I need to double-check that all the type parameters are matching up exactly to know that, since one can write a FromResidual that doesn't just take the Self::Residual. (For example, Result's is more flexible.)

2 Likes

I understand it's more convenient. But couldn't almost every argument that you have just made be also made for not requiring to re-define generic bounds in implementation? Example: Rust Playground

Points that you made that also apply here:

  • It's more convenient.
  • You can still specify more (different) generic bounds if you want.
  • Having impl without a struct which is being implemented is a fools errand.
  • You can just look at the implementation to see which methods is it using.
  • It's annoying to re-define the generic trait over and over again, just like it is annoying to re-specify Add<MyNumber> over and over again.

So, why did Rust's team chose verbosity in one case, but convenience in another? Are there some guiding principles, or s it just arbitrary?

This has been talked about a bunch, but it has one major semver implication: today, weakening the bound on the type is a non-breaking change. That's because any pre-existing use will have copied the original bound onto the function/impl that needs it. And thus if I change, say

 struct Foo<T>(T)
-    where T: Copy;
+    where T: Clone;

the function

fn bar<T: Copy>(x: Foo<T>) -> [Foo<T>; 10] {
    [x; 10]
}

will still work, whereas if it relied on the bound on the type and thus was just

fn bar<T>(x: Foo<T>) -> [Foo<T>; 10] {
    [x; 10]
}

that function would break when I weaken the requirements on the type.

So I'd say the general principle is that making things more general shouldn't break users, like how changing

-fn foo<T: Copy>(x: T);
+fn foo<T: Clone>(x: T);

should never break callers because all it's done is weaken the preconditions.

You can also see these defaults as being part of the "make it more general" rule. I don't know if this actually happened in rust history, but for example you could imagine that it was originally trait PartialOrd { … } and only later changed to trait PartialOrd<Other = Self> { … }, with the default meaning it got more general without breaking the existing uses.

Ord doesn't currently support anything other than Self, so there's periodic conversation about whether it should grow one, and that's only possible with a default.

(Removing a default is of course a breaking change, but that's not a weakening, so I consider it more like removing a field or a method or …, and thus also consistent with general rules.)

Note that I very specifically mentioned the impls being right next to each other for these specific traits, not that the impl is right next to the struct. In general, impls and their corresponding structs/enums can be quite far apart, even in different crates.

Which is also another major difference here: the bar for defaulting (or convenience features in general) is much higher at the language level.

To add a default like this to one specific trait in a library (even if it's the standard library) is a specific question that's mostly about that individual trait. Certainly there's consistency arguments and other convention questions, but the impact of potentially being wrong is much less.

Especially in crates, the author can choose whether having a default on the trait is a good fit for their particular library and coding style. And can relatively-easily make a major version break if they need to. Whereas a language change to default stuff affects everyone, especially if rustc starts marking things as unnecessary after they're made optional.

1 Like

You are right about the backward compatibility with the generic bounds, didn't realize that one.

Ord doesn't currently support anything other than Self , so there's periodic conversation about whether it should grow one, and that's only possible with a default.

No it's not. A new trait could be included in such a case instead:

use core::cmp::*;

trait PartialOrdWith<Other> {
    fn partial_cmp_with(&self, other: &Other) -> Option<Ordering>;
}

impl<T> PartialOrdWith<T> for T
where T: PartialOrd {
    fn partial_cmp_with(&self, other: &T) -> Option<Ordering> {
        self.partial_cmp(other)
    }
}

But that is a different problem, since the default is referencing Self, and not a type from a subtrait.

Certainly there's consistency arguments and other convention questions, but the impact of potentially being wrong is much less.

Yes. My question is a consistency argument. Using Self or something from a supertrait as a default is quite fine, but using stuff from subtraits as defaults just feels wrong. It is not as bad as not re-including generic trait bounds in implementation, as you pointed out, but it still looks like an anti-pattern.

For example, removing a sub-trait implementation should never (IMO) break the super-trait implementation, but with default types from subtraits, it can. This thankfully cannot happen across creates (at least I hope so!), so it's not that big of a deal, but still.

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