Help With Circular Trait Bounds

Hello!

I have the following simplified example:

pub trait Data
where
    File<Self>: IntoResponse,
{
    fn as_bytes(&self) -> Result<Vec<u8>>;
    fn filepath_to_files(path_prefix: &str, file_name: &str) -> Result<Vec<File<Self>>>;
}

pub struct File<T: Data + ?Sized>
where
    File<T>: IntoResponse, //Compiler requires this line
{
    pub metadata: HashMap<String, String>,
    pub data: Box<T>,
}

pub fn some_fn<T: Data>()
where
    File<T>: IntoResponse, //Compiler requires this line
{
}

My goal is that I want to require that for every impl Data for MyType, there is also an impl IntoResponse for File<MyType>.

I don't fully understand why the where clause is required for File, but it's not too big of a deal. My issue is that I have a handful of such bounds, and dozens of fns like some_fn() which are smallish functions that parametrize over T: Data. It feels like all of these functions get "infected" with where File<T>: IntoResponse and this doesn't feel correct.

I am aware that if I did impl<T: Data> IntoResponse for File<T> then the "infection" stops, but this wouldn't work for my case because I don't want a generic File<T> implementation for IntoResponse, I want specific ones. It is also not sufficient to have Data: IntoResponse and use self.data.into_response() in a generic File<T> implementation of IntoResponse because the impl IntoResponse for Data could require information from file.metadata, for example.

As an example:

impl IntoResponse for File<Image> {
    fn into_response(self) -> axum::response::Response {
        let name = &self.metadata.name().unwrap_or_default();
        let headers = [(
            header::CONTENT_DISPOSITION,
            format!("attachment; filename=\"{name}\""),
        )];
        (headers, Bytes::from(self.data.0)).into_response()
    }
}

pub trait IntoData<T> { fn as_data(&self) -> Result<T>; }

impl IntoData<Texture> for Bytes {
    fn as_data(&self) -> Result<Texture> {
        Ok(Texture(self.to_vec()))
    }
}

Any ideas, better approaches, or general feedback?

This is actually closely related to why it's required for the functions. There is a long-standing quirk of how trait bounds work that causes all of this.

Suppose that you had trait Bar: Foo {}. Then, whenever you write the trait bound T: Bar, the compiler considers it to be equivalent to T: Foo + Bar — it implicitly requires both traits. The same applies if you write trait Bar where Self: Foo {} — this is another syntax for the same thing.

However, if the bound on the trait is not of the form Self: Something — as in your case of File<Self>: Something — then it is not implied at all use sites in this way. You cannot actually use T: Bar without meeting its bounds, so you have to repeat the bound everywhere.

The workaround for this is, in general: rewrite your code so that the bound you need is of the Self: SomeOtherTrait form. In this case, you can do it with a fake-trait-alias:

trait FileIntoResponse {}
impl<T> FileIntoResponse for T
where
    T: Data,
    File<T>: IntoResponse,
{}

pub trait Data: FileIntoResponse {
    ...

This does not allow some_fn to use items from IntoResponse or use File<T> as an IntoResponse implementor, but as long as it’s only using Data, it doesn’t need an IntoResponse bound.

2 Likes

Thanks! :slight_smile:

This is super interesting and I never would've guessed it...

Not sure if there's a compendium of sharp corners in Rust, but the requirement of the form Self: Something for implicitly adding inherited bounds doesn't match my intuition

Another way of putting it, which I think makes slightly more intuitive sense, is that “supertrait” syntax trait Foo: Bar {} is implied and everything else isn't. It’s just that the rule as implemented is structural, not literally syntactic, so trait Foo where Self: Bar {} is also implied.

1 Like

I wouldn't really call it a quirk exactly. Implied bounds have Semver related downsides, so expanding them locks in maintainers (makes more things breaking changes). Ideally you could declare which bounds are implied (guaranteed to not go away this major version) and which are not.

There are indeed reasons to want one behavior or the other and you don't get to pick, except in a roundabout and limited way. I stand by using the word “quirk” to describe this. Maybe even a harsher word.

(I think that, in general, Rust makes far too many things relevant to API evolution determined implicitly. We live with it, and in some cases it may be the best option for usability, but it's not good.)

2 Likes