Associated types and what they are good for

Continuing the discussion from Associated types: What are they good for?:

Ergonomics means less words? I personally can't appreciate this design. Associated types bring me a lot mental burden.
First of all as you also mentioned it's actually generic.
I don't think there is a strong reason to departure from normal generic and introduce special case here.
And regarding to syntax: <T>, T in angle brackets is broadly used in langue to indicate type parameter(in definition) or type argument(in application). While associate type also put T=XX in <>, what this syntax mean? It's type argument or not? Can someone give me a name regarding to this syntax?

The big difference is whether you have a single trait Iterator, or whether you have many different traits Iterator<Item = A>, Iterator<Item = B>, Iterator<Item = C>. Does it make sense to implement the trait multiple times with different choices for the same type? If not, that's what associated types are for.

5 Likes

Hi and welcome, I've moved your post to a new topic, because we try to avoid reviving very old topics.

Regarding the questions you pose, I'll try to answer some

The syntax you are referring to only appears in trait bounds, so when there's a trait

trait Trait {
    type T;
}

then a generic parameter can be introduced A: Trait<T = XX>, or a where clause can contain something like Foo<Bar>: Trait<T = Baz>.

I don't know of a good concrete name for this kind of syntax off the top of my head either. In order to understand it better, it might be helpful to interpret it as an "abbreviation" for a combination of constraints where

A: Trait<T = XX>

is short for

A: Trait,
A::T == XX,

assuming we could use a ==-notation for requiring two types to be equal. (Such a notation does however not exist in Rust [yet].)

Despite the fact that <T = XX> appears inside of angled brackets, it doesn't mean that it should be considered a "generic type parameter" necessarily.


I looked up the RFC that introduced the syntax (as well as introducing associated types in the first place), and it describes it here:

Constraining associated types

Associated types are not treated as parameters to a trait, but in some cases a function will want to constrain associated types in some way. For example, as explained in the Motivation section, the Iterator trait should treat the element type as an output:

trait Iterator {
    type A;
    fn next(&mut self) -> Option<A>;
    ...
}

For code that works with iterators generically, there is no need to constrain this type:

fn collect_into_vec<I: Iterator>(iter: I) -> Vec<I::A> { ... }

But other code may have requirements for the element type:

  • That it implements some traits (bounds).
  • That it unifies with a particular type.

These requirements can be imposed via where clauses:

fn print_iter<I>(iter: I) where I: Iterator, I::A: Show { ... }
fn sum_uints<I>(iter: I) where I: Iterator, I::A = uint { ... }

In addition, there is a shorthand for equality constraints:

fn sum_uints<I: Iterator<A = uint>>(iter: I) { ... }

In general, a trait like:

trait Foo<Input1, Input2> {
    type Output1;
    type Output2;
    lifetime 'a;
    const C: bool;
    ...
}

can be written in a bound like:

T: Foo<I1, I2>
T: Foo<I1, I2, Output1 = O1>
T: Foo<I1, I2, Output2 = O2>
T: Foo<I1, I2, Output1 = O1, Output2 = O2>
T: Foo<I1, I2, Output1 = O1, 'a = 'b, Output2 = O2>
T: Foo<I1, I2, Output1 = O1, 'a = 'b, C = true, Output2 = O2>

The output constraints must come after all input arguments, but can appear in any order.

Note that output constraints are allowed when referencing a trait in a type or a bound, but not in an IMPL_SEGMENT path:

  • As a type: fn foo(obj: Box<Iterator<A = uint>> is allowed.
  • In a bound: fn foo<I: Iterator<A = uint>>(iter: I) is allowed.
  • In an IMPL_SEGMENT: <I as Iterator<A = uint>>::next is not allowed.

The reason not to allow output constraints in IMPL_SEGMENT is that such paths are references to a trait implementation that has already been determined -- it does not make sense to apply additional constraints to the implementation when referencing it.

Output constraints are a handy shorthand when using trait bounds, but they are a necessity for trait objects, which we discuss next.

Notably, associated lifetimes don't seem to have made it into the language after all; but the I: Iterator<A = uint> syntax is described as a shorthand for I: Iterator, I::A = uint.

Ah, and there is a name after all, they call it “Output constraints”. I don’t know if that name stuck around and anyone uses that name in particular, but at least it’s some name for the syntax.

And to understand the example of using them “in a type”,

As a type: fn foo(obj: Box<Iterator<A = uint>> is allowed.

note that this is just old syntax for fn foo(obj: Box<dyn Iterator<Item = usize>>.

Also feel free to scroll up to the motivation section in that RFC to learn more about why they were introduced in the first place.

4 Likes

I would go further and say that they are definitely not type parameters. They are outputs of a type-level computation (i.e. the trait impl itself decides the associated type), while parameters are inputs (i.e. that who uses the generic can decide what the type parameter should be).

7 Likes

I would certainly call it a constraint ala RFC 0447. It's not a parameter of the trait. It's a constraint on an output type of the trait implementation.

It is arguably a parameter of the type dyn Iterator<Item = usize> though, or more formally, some type constructor dyn Iterator<Item = T>.


As for functionality, the fact that associated types are not inputs to a trait can be significant when it comes to things like higher-ranked trait bounds. For example, you can write bounds like

fn f<U>(_: U)
where
    for<'any> U: Trait<&'any str>,
    for<'any> <U as Trait<&'any str>>::Out: Clone,
{}

And this has a different meaning than

fn g<U, V>(_: U)
where
    for<'any> U: Trait<&'any str, Out = V>,
    V: Clone,
{}

As in the latter, Out must be the same type V for every lifetime (it cannot be dependent on the lifetime of the type parameter of Trait), whereas with the former you could have <U as Trait<&'x str>>::Out = &'x str for example.

If Out was a parameter of the trait, you would similarly have to name it in every bound.

This comes up when people want HRTBs involving the output of the Fn traits, whose syntactic sugar/salt requires always naming the output type (unless you use the unstable unboxed_closures feature); on stable one has to use a GAT-like helper trait to work around it.

2 Likes

I know the purpose for this syntax, to avoid multiple implementation. I am only concerning about syntax itself here.
A programming language itself first of all is a language. It need to have a consistent syntax & semantic definition.

I know this is should not regards as parameter, as per designer's purpose. I can not appreciate here to put in in <>, which in my opinion that <> is for parameter. As an output should not mix into the place for input.

well the syntax is not about input vs. output, really. Many languages use <> for generics, and associated types are related to generics.

1 Like

I know in the essential, it means this. I also tried this syntax before I post this answer. Personally I prefer this unsupported syntax. Which separate output from input syntactically.

Sorry, for the inaccurate express. I know it's not function input/output. To me generic type more related to parametric type. Word Input is more to do with this.

For example...

I know. My point still stands.

As m: impl Add<Meters, Output=Millimeters> in below code.
Meters is type parameter while Output=Millimeters is not parameter but a constraint.
But to me they are syntactically equal. This is what confuse me.

use std::ops::Add;

#[derive(Debug)]
struct Millimeters(u32);
struct Meters(u32);

impl Add<Meters> for Millimeters {
		type Output = Millimeters;
		fn add(self, other: Meters) -> Millimeters {
				Millimeters(self.0 + (other.0 * 1000))
		}
}

fn addOneMeter(m: impl Add<Meters, Output=Millimeters>) -> Millimeters {
		m + Meters(1)
}

fn main() {
		println!("{:?}", addOneMeter(Millimeters(40)));
}

I would argue that they are all constraints there. It expresses that m is of some type that:

  • implements Add
  • for the RHS type Meters
  • with an Output type of Millimeters.

That's a constraint on the type of m with three parts to it. Any value for m would be rejected if its type doesn't have precisely that trait implementation. The fact that Output is an associated type constrains the possibilities as to what trait implementations the type can have, and that's useful to bear in mind, but it's parameterising the trait all the same.

1 Like

https://doc.rust-lang.org/book/ch19-03-advanced-traits.html#default-generic-type-parameters-and-operator-overloading
regarding to this already explaned in the book

I'm not sure how you would want to write dyn Trait types then. E.g. how would you want to write this?

fn iter() -> Box<dyn Iterator<Item=i32>> {
    Box::new(0..10)
}

Notice how, for dyn traits, the associated types share identical semantics to type parameters.

2 Likes

I am OK with dyn Trait. I cannot appreciate Item=i32 in <> while it's not type parameter.

Associated types on traits are like return values from functions.

So are they strictly necessary? Well, not exactly. You could replace fn foo(a: i32) -> u32 with fn foo(a: i32, r: &mut u32) and make it work (with enough effort). But returning is a different enough thing that's it's worth having the separate syntax and behaviour for it.

Rust actually didn't originally have associated types at all, way back in the pre-1.0 days. You can see the RFC that added them, as well as things like how they changed the Add trait, in https://rust-lang.github.io/rfcs/0195-associated-items.html#clearer-trait-matching.

6 Likes

I am not even sure about that. How would you express type-level outputs, then? For example, bringing up my now-classic REST API client example for the umpteenth time:

trait Request: Serialize {
    type Resp: Response;
}

trait Response: Deserialize {}

fn send<R: Request>(req: R) -> Result<R::Resp, HttpError> {
    ...
}

First of all, if there is no associated type, then this has to be rewritten as

trait Request<Resp: Response>: Serialize {}
trait Response: Deserialize {}

fn send<Q, A>(req: Q) -> Result<A, HttpError>
where
    Q: Request<A>,
    A: Response,
{
    ...
}

which breaks type inference (i.e., while it is logically sound in that it won't allow deserializing into the wrong type, it won't be able to deduce the correct type either) while also losing the uniqueness property of the response type.

Furthermore, there are cases where this kind of transformation is simply not feasible, because you will end up with unconstrained type parameters, so it simply won't compile (I have personally run into this kind of blocker while trying to apply this transformation in order to work around some projection bugs).

This explain and rfc doc enlighten me on the understanding of this.
Now quit reasonable.
Thanks a lot.