Generic structs as trait bounds

(aka how to stop rampant spread of generic parameter bounds)

TL;DR: Why can't you specify a generic struct as a bound in a where clause on a function/struct? Or is this something that can already be achieved with nightly features?

Say we have a library that defines

pub struct VeryUsefulStruct<A, B, C, ...>
  where
    A: Copy + Eq,
    B: SomeExternalTrait,
    C: ...
{ ... }

And also a function which nicely fills in all those generic parameters and returns that new struct:

pub fn make_useful_struct()
  -> VeryUsefulStruct<Aimpl, Bimpl, Cimpl, ...> {
    ...
}

Initially using this struct is easy:

let new_struct = useful_rs::make_useful_struct();
new_struct.do_something_useful();

But as soon as you try to pass it to a function/save it in another struct you will run into an issue:

fn accepts_useful_struct(
    s: &useful_rs::VeryUsefulStruct
      missing generics for struct ^^^

This error makes sense - changing the generics on the struct can change its storage, which will change the code used to access its various fields. So Rust can't just compile a one function and have it work with every possible parameter. We need to make the function generic too:

fn accepts_useful_struct<V>(s: &V)
  where V: useful_rs::VeryUsefulStruct
      expected trait, found struct ^^^

Now, for an average struct without generics that makes sense. But when a struct does have generic parameters, how's it different from a normal trait bound?

At this point, in order to continue, you either need to find every concrete trait implementation that make_useful_struct returned and manually specify it:

fn accepts_useful_struct(
  s: &useful_rs::VeryUsefulStruct<
    useful_rs::Aimpl,
    useful_rs::Bimpl,
    useful_rs::Cimpl,
    ...
    >
) {
  ...
}

That is definitely not very flexible. Also some implementations might even be inaccessible from outside the library.

Another solution is to copy struct's trait bounds manually:

fn accepts_useful_struct<A, B, C, ...>(
  s: &useful_rs::VeryUsefulStruct<A, B, C, ...>
) where
    A: Copy + Eq,
    B: other_lib::SomeExternalTrait,
    C: ...
{
  ...
}

This is slightly better. However, it requires copying code from a library (which might change), trait bounds can get massive (and they need to be duplicated for every function). It may even require extra dependencies in Cargo.toml (if a trait in a bound comes from a private import inside the library).

A better solution could be, for example, the compiler replacing a generic struct in trait bounds with a copy of that struct's own trait bounds and generic parameters.

So my questions are:

  • How difficult would the above proposal be to implement?
  • Could it somehow cause inconsistencies in the type system?
  • Is it something that has already been considered/is being worked on?
  • What other solutions exist to this problem?

P.S. There is of course a simpler solution to this problem - the library could provide traits for it's generic structs. However, if there's only ever a single implementation of that trait, it means function definitions are needlessly duplicated

Thank you for reading to the very end!

Yes. Note that a big downside is that it makes changing the bounds on the declaration a breaking change. (Another downside is that it makes the required bounds invisible at the function site.)

Due to the "have to repeat everything" aspect of the current system, it's idiomatic to not put bounds on the struct declaration unless necessary (e.g. because you need to name an associated type). Instead, you put no bounds on the struct declaration, and only declare bounds when and where they are needed.

4 Likes

It would mess up the readability of the code pretty badly. I, for one, am absolutely horrified by the idea of Foo secretly meaning Foo<T: Tr1, U: Tr2, V: Tr3, W: Tr4 + Tr5 + Tr6>.

"Should be easier to write" stuff is always suspicious. You write code once and read it 10 or 100 or 1000 times. Don't optimize for "easier to write", at least definitely not at the expense of read-time information content.

If the library changes the bounds, that's going to be a breaking change anyway (at least when it adds bounds – when it removes bounds, that's not going to break your code, obviously).

Needing to declare trait bounds that a generic function relies on is a feature, not a bug. We have languages where you don't have to do that (C++), and it's a maintenance nightmare. You never know you messed up your assumptions in the implementation, until someone tries to call your function in a way you didn't completely anticipate, and then it doesn't compile for them, even though it should.

1 Like

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.