Same trait bounds for multiple type parameters

(reposting Same trait bounds for multiple type parameters - #3 by mankinskin - help - The Rust Programming Language Forum)

Hi there, I just came across this situation:

fn my_fn<
    'a,
    I,
    A: Really<Item=I> + ALot<'a, I> + ToType,
    B: Really<Item=I> + ALot<'a, I> + ToType,
>(a: A, b: B)
{ ... }

And was wondering if I there is a syntax (or an RFC for it) to define trait bounds on multiple type parameters, something like:

fn my_fn<
    'a,
    I,
    A & B: Really<Item=I> + ALot<'a, I> + ToType,
>(a: A, b: B)
{ ... }

The only way I would know to mitigate this is by implementing a utility trait:

trait ReallyALotToType<'a, I>: Really<Item=I> + ALot<'a, I> + ToType {}
impl<'a, I, T: Really<Item=I> + ALot<'a, I> + ToType> ReallyALotToType<'a, I> for T {}
fn my_fn<
    'a,
    I,
    A: ReallyALotToType<'a, I>,
    B: ReallyALotToType<'a, I>,
>(a: A, b: B)
{ ... }

But that doesn't scale as well as the first solution, especially when there are many type parameters involved in the trait. Also it introduces a lot of cognitive clutter, you have to think of a name for the trait, you have to implement it, all only really to tell the compiler something very simple: "These type parameters have the same trait bounds!"

The syntax could be debated, of course , would be the nicest to separate type parameter names before a trait bound, but that is already taken in this context. Other versions I could think of:

  • A : B: Trait,
  • A | B: Trait,
  • A B: Trait,
  • (A, B): Trait,

Maybe this could be taken even further and the type parameter context could allow for incremental definitions of trait bounds, i.e. type parameter names can be repeated to add new bounds, so that this would be possible: <(A, B): Trait, A: Debug>

2 Likes
  • A & B is surprising, because it looks like an intersection type. It reads like "the intersection of type A and B satisfies the bounds on the RHS", but that's not what it means.
  • A : B is surprising, because : is used in two completely different meanings in two immediately neighboring contexts.
  • A | B is also surprising, because it means alternation ("or") in every other situation (even in the regex crate), not conjunction.
  • (A, B) doesn't work because it already means a tuple.
  • Simple juxtaposition A B could technically work, but it's ugly, as in "unusual for the Rust programmer's eye". I don't know of any other place in Rust-the-core-language where items aren't separated by anything. (People do that in quick one-off private macros out of laziness, but the trait system is so central, it deserves much more than that.)

For conjunction of trait bounds, + is used, but that would also mean overloading the + symbol even more, so I don't really like that, either.

2 Likes

I've come across situations where this would be really helpful as well. What about just being able to write A: B?

fn my_fn<
    'a,
    I,
    A: Really<Item=I> + ALot<'a, I> + ToType,
    B: A, // <- B has all the same bounds as A
>(a: A, b: B)
{ ... }
1 Like

A : B: Trait does look weird, although I would not say the meaning is all that different, if you think of it as "is bounded by". This would even make sense with respect to impl Trait, which are traits in type argument position.

This is an even better idea! This would also easily allow for A: B + SomethingElse.

1 Like

But that's exactly the problem: it does not mean "is bounded by" here. It means "and". However, "bounded by" approximately means subtyping, so A: B in a type context would basically amount to saying that "type A should inherit from type B", if only Rust supported inheritance. However, there's no such subtyping relationship between types proper; types can only be bounded by traits, not other types.

Anyway, wouldn't trait aliases solve this problem without the need for even more additional syntax? If you were allowed to write trait Long = Really<Item=I> + ALot<'a, I> + ToType, then you could just say A: Long, B: Long instead.

(And at that point, I don't think this proposal would stand because it wouldn't really simplify enough for it to be worth a language change.)

Would it be easier to carve a syntax for this in where clause?

fn my_fn<'a, I, A, B>(a: A, b: B)
  where A, B: Really<Item=I> + ALot<'a, I> + ToType
{ 
  ...

Of course this isn't that nice to parse either... Maybe something else after where?...

This does help, but for traits with many type parameters it would still not be ideal... trait Long<T, C, E, U>, especially when there are more than two type parameters to be bounded. It might seem like an edge case, but I think this needs to be considered to enable more of rust's generic programming capabilities. I think a big factor that is holding us back here is the syntax.

It does mean A inherits from B, but only the trait bounds. B is a generic type parameter and all it basically is is a name and a trait bound and a lifetime.

However this does raise the question, should A: B also inherit the lifetime of B? In accordance to the usual : Trait + 'a syntax it would mean lifetimes are part of the trait bound and would be inherited.

That would be another use case for B: Trait, A: B + 'a, B: 'b, i.e. multiple trait bound definitions for the same type parameter. That would help define the structural relationships between type parameters. Of course cyclic inheritance needs to be detected.

This seems like a very narrow path to follow, the semantic of the , is conflicting with that in the <> generic parameter context and I think that would be confusing (does this bound apply to A or not? In what context am I?). But those are just my first thoughts.

How about nesting the generic parameters?

fn my_fn<
    'a,
    I,
    <A, B>: ReallyALotToType<'a, I>,
>(a: A, b: B)
{ ... }
2 Likes

The RFC as well as my example above demonstrates that trait aliases do intend to allow type parameters.

Citation needed.

It does seem that if a suitable syntax is to be found it will be after where. Another imperfect attempt:

fn my_fn<'a, I, A : Long, B : Long>(a: A, b: B)
  where Long = Really<Item=I> + ALot<'a, I> + ToType
{ 
  ...
14 Likes

That looks much better indeed.

1 Like

Now that, is a really great idea. Local trait aliases.

1 Like

Once (non-local) trait aliases hit stable, are there any great reasons not to just use them? I mean, we don't have local type aliases in generic signatures either. (Should we?)

You'd end up with

which is good but still arguably less nice than a local trait alias

...so if added to generic fn-s it should also work on generic struct-s, etc? Guess so

1 Like

Yes, I like that idea a lot. Also important that you can reuse the generic context for the local alias' definition, which is what you would not get with module-level trait aliases.

Also I realized that B: Trait, A: B + 'a, B: 'b does not really make sense, because type parameters are generally not ordered, so definition order should not matter. That is also where the other solution where Alias = Trait<T> makes more sense.

although there is still this option, too. I can think of cases where where Alias = NotThatMuch<'a> is actually not worth introducing a new name.

fn my_fn<'a, A: X, B: X, C: X, D: X>(a: A, b: B, c: C, d: D)
    where X = NotThatMuch<'a>
{
    ...

Here a shorter syntax would be nice to have.

fn my_fn<'a, A: B: C: D: NotThatMuch<'a>>(a: A, b: B, c: C, d: D) {
    ...
}

I think this can work fine. In general, : is very loosely defined, so I don't think this would be surprising or ambiguous to anyone.

Such cases are pretty rare though, most of the time you can use a single type parameter for all the variables you need, but sometimes you need to allow different types within the same trait bounds, and I don't see any reason not to support those cases.

That's of course correct, but makes me wonder whether it could cover a bunch of the scenarios, without adding anything new.

As a trivial example, (A, B, C): Clone + Debug works great.

2 Likes

Oh wow, I did not know this.

hm.. but I guess this only works for "derivable" traits. it doesn't seem to work for something like:

(A, B): Fn(u32) -> u32

Just as an example, this is what I am dealing with right now:

fn intersections<
    L: Clone,
    R: Clone,
    W: Fn(&Token<T>, &Token<T>) -> usize,
    LE: Fn(Vec<LoadedEdge>, Vec<LoadedEdge>) -> (Vec<LoadedEdge>, Vec<LoadedEdge>),
    RE: Fn(Vec<LoadedEdge>, Vec<LoadedEdge>) -> (Vec<LoadedEdge>, Vec<LoadedEdge>),
    LMI: Fn(EdgeMappingMatrix) -> Vec<L>,
    RMI: Fn(EdgeMappingMatrix) -> Vec<R>,
    P: Fn(NodeIndex, NodeIndex, NodeIndex, NodeIndex, usize, usize, usize, usize) -> bool,
    LC: Fn(Vec<L>) -> EdgeMappingMatrix,
    RC: Fn(Vec<R>) -> EdgeMappingMatrix,
    LMC: Fn(Vec<LoadedEdge>, EdgeMappingMatrix, Vec<LoadedEdge>) -> LoadedEdgeMapping,
    RMC: Fn(Vec<LoadedEdge>, EdgeMappingMatrix, Vec<LoadedEdge>) -> LoadedEdgeMapping,
    LZ: Fn(&mut LoadedEdgeMapping),
    RZ: Fn(&mut LoadedEdgeMapping),
>(
    #[allow(unused)] name: &str,
    #[allow(unused)] sec_name: &str,
    w_sel: W,
    ledge_sel: LE,
    redge_sel: RE,
    lmat_iter: LMI,
    rmat_iter: RMI,
    pred: P,
    lconstr: LC,
    rconstr: RC,
    lmap_ctr: LMC,
    rmap_ctr: RMC,
    ldezero: LZ,
    rdezero: RZ,
    lhs: Self,
    rhs: Self,
    dist: usize,
) -> Option<(Self, Self)>
{
    ...

So in here are some exact duplicates, but also some parameterized patterns, which I could implement a trait for, but this would honestly clutter the namespace too much and is not worth the one line saved. So actually it might even be useful to allow local trait aliases to be parameterized too:

fn intersections<M, L, R, LMI: MI<L>, RMI: MI<R>, ...>(...)
    where MI<S> = Fn(M) -> Vec<S>,
          ...
{
    ...

i.e. every local alias captures all independent type parameters in the surrounding scope (MI uses M), and can also introduce its own parameterized scope (MI<S>, S only visible in the local alias).