Generic trait usage syntax confusion

After over a year since discovering Rust I was looking at some code I wrote a year ago, which has been in in production successfully all that time, with a view to extending it. It occurred to me that maybe use of traits and/or generics would help or at least make it look more Rustic. Shamefully I had never looked very hard at that end of the book. So now was the time to do so...

Following along with the examples I find there are three different syntaxes for passing traits as parameters. Referred to as: "trait bound syntax", "impl trait syntax" and "where syntax". Yikes. After fiddling around I sort of get the hang of it and come up with these simple examples which use the classic Circle and Rectangle structs and the Shape trait which hopefully allows functions to accept either of them:

    // Using "trait bound" syntax.
    pub fn area_of_two_shapes<T: Shape, U: Shape> (a: &T, b: &U) -> f32 {
        a.area() + b.area()
    }
    // Using the "impl trait" syntax.
    pub fn area_of_two_shapes_impl(a: &impl Shape, b: &impl Shape) -> f32 {
        a.area() + b.area()
    } 
    // Using the "where" syntax.
    pub fn area_of_two_shapes_where<T, U>(a: &T, b: &U) -> f32
    where
        T: Shape,
        U: Shape,
    {
        a.area() + b.area()
    } 

So far so good.

Is it really so that those there syntaxes are the same? Or are there things some can do others cannot? Which of them are the currently preferred ways to proceed?

The problem comes when I start fiddling with those three syntaxes on a simpler function that accepts traits with an associated type (I believe it is called):

    // Using "trait bound" syntax.
    pub fn add<T: Add<Output = T>>(a: T, b: T) -> T {
        a + b
    }

    // Using the "where" syntax.
    pub fn add_where<T>(a: T, b: T) -> T
    where
        T: Add<Output = T>,
    {
        a + b
    }

Try as I might I cannot find a way to do that with the impl trait syntax. One of my best guesses being the following:

    // Using the "impl trait" syntax.
    pub fn add_impl<T>(a: impl Add<Output = T>, b: impl Add<Output = T>) -> T
    {
        a + b
    }

Which fails with the rather odd error message:

error[E0308]: mismatched types
...
43 |         a + b
   |             ^ expected type parameter `impl Add<Output = T>`, found a different type parameter `impl Add<Output = T>`

Odd because the complaint is about mismatched types but the expected and found types are the same!

So, how would it be done with the impl trait syntax? Is it even possible to do?

Slightly at a tangent. The language used to describe these language features has been confusing. For example a trait "bound" seem to do the opposite, it adds to what a function can do rather than bounding it. For example:

    pub fn do_nothing<T>(a: T) -> T {
        a
    }

Can do nothing with a T, it does not know what a T is, it can only return it. One has to add "bounds" to allow it to do more. Seems a bit backward.

1 Like

No, they aren't. Your impl-based signature is not equivalent with the other two. It is equivalent with:

fn add_impl<A, B>(a: A, b: B) -> T
    where
        A: Add<A, Output=T>,
        B: Add<B, Output=T>,
{
    a + b
}

which of course doesn't compile, because in order for a + b to compile, it should be T: Add<U, Output=T> (and no bounds are required on U whatsoever). However, using impl Trait syntax, there is no way to name the second, distinct type (here spelled U), nor is there a way to specify that the two type variables should in fact be one, AFAICT.

2 Likes

Sorry, just noticed my code formatting was a bit messed up in that post which likely confused things.

Notice that in my add_impl example both parameters have the same type, T, and the error message makes no sense on the face of it:

   = note: expected type parameter `impl Add<Output = T>` (type parameter `impl Add<Output = T>`)
              found type parameter `impl Add<Output = T>` (type parameter `impl Add<Output = T>`)

Are you saying that because I have a two separate impls on the two parameters they are some how different even though they look exactly the same?

I don't get it.

But T being the same is not the part what matters at all. impl Trait<AnyParamsWhatsoever> generates a new, fresh type variable every time it is invoked. So you have to read that error message as: "arbitrary type 1 implementing Add<Output=T> is not the same as another arbitrary type also implementing Add<Output=T>".

For example, you would be able to invoke an fn(impl AsRef<str>, impl AsRef<str>) with an argument list of type (String, &str) just as well as with (String, String), because both String and &str implement AsRef<str>, yet they are not the same type.

2 Likes

Yes. In your example, it would be possible to call add_impl with a being an u32 but b being an i32. There is no impl in the standard library that lets you add an u32 to an i32. (you would need to convert one of them first to do that)

1 Like

Hmm... don't understand. Is't that what I have when I do this:

pub fn area_of_two_impl(a: &impl Shape, b: &impl Shape) -> f32 {
        a.area() + b.area()
    }

Which works fine?

Sure, and in that example I would indeed be able to call it with a being a triangle and b being a circle, but that doesn't stop you from adding their area together.

4 Likes

In that example, a.area() and b.area() are both concrete types, f32. The point is that any two, potentially different, types produce an .area() of type f32 if they implement Shape. You are not adding the two shapes together; you are adding two values of a known, concrete type together, which you happened to obtain from two, potentially distinct types. In other words, there's nothing generic in that addition.

1 Like

@H2CO3 the thing you said it was equivalent to is not quite right. The actual thing is as follows:

fn add_impl<T, A, B>(a: A, b: B) -> T
where
    A: Add<A, Output=T>,
    B: Add<B, Output=T>,
{
    a + b
}

So the problem is that your where bounds allow the operations A + A and B + B, but you are trying to perform an A + B, which is not allowed by any of the where bounds.

2 Likes

Indeed, you are right. I just tend to use T and U for type variables, and they happened to collide with OP's notation for the output type. :man_facepalming: Fixed now.

2 Likes

"trait bound" syntax only works with the generics with "where" you can use any type e.g. where u32: Into<Something> or an associated type like

pub fn add_where_output_into<T: Add>(a: T, b: T) -> T
where
    T::Output: Into<T>
{
    (a + b).into()
}
1 Like

I don't begin to understand what is going on there.

Why would I write your suggested:

pub fn add_where_output_into<T: Add>(a: T, b: T) -> T
where
    T::Output: Into<T>
{
    (a + b).into()
}

Surely what I had already is easier on the eyes and brain:

    pub fn add_where<T>(a: T, b: T) -> T
    where
        T: Add<Output = T>,
    {
        a + b
    }

Which, to my simple mind, says that add_where takes two T's and produces a T. And then goes on to specify what a T is.

I used it more as an example what you can write with the "where" syntax but not with "trait bounds" it says that the output of the Add can be anything that implements Into<T> but it don't has to be a T.

A more useful example off this would be with iterators were you want to say that the item implements some trait.

pub fn sum_of_areas<I>(iter: I) -> f32
where
    I: Iterator,
    I::Item: Shape,
{
    iter.fold(0.0, |sum, shape| sum + shape.area())
}

here the item of the itertor can be anything as long it implements Shape

2 Likes

Douglas Hofstadter described why he quit pursuing his Phd in mathematics by saying he had hit his "abstraction ceiling".

I'm feeling the same way, none of what people are posting is making any sense, my head is bashing against its, apparently very low, abstraction ceiling.

Or is it syntax blindness?

The abstraction I want to express is simple:

  1. There is a function.
  2. That function takes two parameters.
  3. Those two parameters are of the same type.
  4. The type of those parameters is such that they support addition, the "+" operator.
  5. The result of that addition is of the same type as the parameters.
  6. The function returns the same type as it's parameters.

How hard can it be?

All of which is kind of, sort of, clear with the "trait bound" and "where" syntaxes I opened with.

Can I take away from all this that it is not possible with the "impl trait" syntax. Or is there an as yet undiscovered means to do it?

Should I just give up, and just accept that my abstraction ceiling is so low I should forego all thoughts of generics/traits/composition/meta programming and go back to fiddling with bits and bytes?

There is indeed no way to do that with impl Trait syntax alone.

1 Like

That nearly puts my mind at rest. I can give up thinking about the "impl Trait" syntax.

Except for the "alone" you have tacked on the end there.

That "alone" hints that if I have:

pub fn add_impl [[somthing here]] (a: impl T, b: impl T) -> T 
    where
    [[something here]]
{...}

then there is something I can fill in the [[something here]] parts that would make it work.

?

I had some ideas for something very convoluted when I wrote that "alone", but after thinking more about it, they don't work. As far as I can tell, there is nothing you could fill into your template to make it work.

1 Like

In general, back when the impl Trait syntax was being added to the language, there was a big argument about whether it should be usable as the type of an argument at all. The reasoning behind this was:

  1. If you put impl Trait as the type of a function argument, it does not do the same thing as when you put it as the return value. This is confusing.
  2. It is just a less powerful version of generics.

Point two is what you have run into with this thread.

(Note: The thing it does if you put it as the return value cannot be replicated with generics.)

3 Likes

In general, I also find it at least inconsistent that impl Trait is universal in argument position but existential in return position. The "less powerful" argument also includes that one can't explicitly substitute types for type variables generated by impl Trait. I think in the name of KISS and single responsibility, impl Trait should have been reserved for use in return position. Anything else seems to make no sense and is only a continuous source of confusion, for the very marginal benefit of not having to type a <T>. I honestly don't understand how that was deemed nearly enough of an "advantage" to be worth all the other costs.

[/rant]

8 Likes

Step 3 is the one that trips you up. With impl Trait in argument position, every appearance of impl X introduces a new, unnamed, and unnameable type, which is ultimately only known to the compiler. All that you know is that it implements trait X. You can't express a guarantee that any two appearances are of the same underlying type.

3 Likes