`derive(PartialEq, Eq)` not working with inner types

Why does Rust force me to implement PartialEq/Eq for an inner type in order to perform the derive? PhantomData unconditionally implements those traits already (see example).

playground: Rust Playground

use core::marker::PhantomData;

#[derive(PartialEq, Eq)]
pub struct Id<T>(PhantomData<T>);

// manual implementation which would break the usage of const patterns
// impl<T> PartialEq for Id<T> { fn eq(&self, _: &Id<T>) -> bool { true } }
// impl<T> Eq for Id<T> {}

// This derive is undesired but cannot be removed without
// breaking the usages below
#[derive(PartialEq, Eq)]
struct SomeNode();

fn accept_eq(_: &impl PartialEq) { }

fn main() {
    let node = Id::<SomeNode>(PhantomData);

    // this will only work if
    // - `Partial/Eq` is implemented manually, or
    // - `SomeNode` also needlessly(?) implements `Partial/Eq`
    // otherwise: error[E0277]: can't compare `SomeNode` with `SomeNode`
    accept_eq(&node);
    
    const CONST_ID: Id::<SomeNode> = Id::<SomeNode>(PhantomData);
    // this will work only when `Partial/Eq` is being derived
    // otherwise: error: to use a constant of type `Id<SomeNode>` in a pattern,
    //   `Id<SomeNode>` must be annotated with `#[derive(PartialEq, Eq)]`
    match node {
        CONST_ID => {}
    }
}

I found this issue where I think the problem is at least related: #[derive(Clone, Copy)] doesn't work · Issue #108894 · rust-lang/rust · GitHub

Can somebody confirm that? Is there a workaround (other than avoiding pattern matching)?

Derive macros can't actually check what traits the inner types implement, nor how those implementations relate to their generics, so conservatively, the derive for PartialEq simply generates an implementation that is restricted by all generics.

Basically this:

impl<T: PartialEq> PartialEq for Id<T>

I dunno if you're omitting stuff, but as it is, you can't create a meaningful match statement. There's only one pattern that will ever match Id.

2 Likes

Thanks for your explanation.

It's a reduced example. In practice there will be several branches. I wanted to focus on the fact that pattern matching becomes unavailable as a whole.

My current workaround would be to do manual if … else …-chains.

This is the main issue and for PartialEq and how it relates to matching specifically, see this comment.

3 Likes

Thank you!

well … will live with a workaround then.

This is a misconception. Generating smarter (less restrictive) bounds doesn't require type-checking the traits on inner types. Any derive macro could emit bounds that simply copy the literal type syntax of fields and append the : PartialEq constraint. I have written several derive macros for custom traits that actually do this, it's perfectly possible.

Unfortunately, the current not-so-smart behavior can't be changed due to bakcward compatibility, but that's basically the only technical reason why it can't be done.

There are also voices of fear regarding such smart bounds potentially exposing implementation details (because the type may come from a non-public field). I find that argument unconvincing, because generics and trait bounds already leak implementation details: the very point of bounds is to rely on them in the implementation. That is absolutely not an issue in practice, though. I have literally never seen anyone wonder, "why can I only put hashable types into a hash map?".

2 Likes

The difference though is that you have the Hash constraint explicitly stated in the source code. There is no risk that changing the implementation of hash maps will accidentally make the constraints stricter. For instance, if some hash map function was changed to depend on Ord, this change would simply not compile unless you explicitly add Ord to the function signature.

A better example is Sync and Send. Those traits are implemented automatically depending on private fields and in this case there is a risk that by adding private fields you may accidentally change the interface in a backwards incompatible way.

Neither of your analogies are relevant:

  • Send and Sync aren't derived
  • derive macros would also explicitly state the bound, because that's the very trait being derived.

What do you mean by "explicitly", do you mean in the generated documentation of the crate? I did not say that the crate docs wouldn't state the bound. My point was that the source code wouldn't explicitly state the bound in the signature of the struct, and so it would be easier for the library implementer to inadvertently change the bounds by changing some private fields. With the current definition of #[derive(PartialEq)] it's not possible to change bounds on a struct by changing private fields.

No. It's the macro invocation itself:

#[derive(PartialEq)]
struct MyUdt<T> {
    ...
}

It is the expectation by widespread convention that deriving PartialEq will impose additional PartialEq bounds.

By this strict definition, this is already not the case. Deriving a trait only adds the bounds by convention, as I pointed out above. The status quo doesn't spell out the exact bound itself, either. What specific types have or don't have the bound doesn't matter at that point, it's equally clear (or unclear, depending on your PoV).

There are no additional bounds in this example. Currently, when you see this in a library, you know that MyUdt implements PartialEq, you don't have to read the ....

Obviously, I meant MyUdt<T>. The same derive
on a generic type doesn't result in a visible T: PartialEq bound in the source, either. You have to have the background knowledge that derive macros place bounds on type parameters. If you don't have that knowledge, you may expect that MyUdt<T>: PartialEq for all T, and be surprised as to why it doesn't work with some T: !PartialEq.

My point still holds with the modified example. You still don't have to read the .... Yes you have to know what derive(PartialEq) does, but the additional bounds are implied just by the signature struct MyUdt<T>, not by private fields. So if the implementer changes private fields, it will not change the implied bounds.

Yeah, I think I get that. I still think you are overstating its importance. Both bounds are invisible. In both cases, you have to have additional experience to even know about their existence. In both cases, you have to resort to the documentation in order to be 100% sure about the exact bounds.

In any case, the behavior regarding private fields could be opt-in, in which case there would be an explicit warning sign in the code that the generated bounds potentially depend on private fields.

Also, I think this is not a real issue precisely because we are talking about trait bounds and purely type-level definitions, not executable code. The bounds could change, but that is absolutely a change in the interface. It would be reflected in the documentation, and it could be tested, just like any other trait should be tested.

Assuming derive macros do what they are supposed to do is no good, anyway. It is my personal experience that until I actually started to use a derive macro, there could and were edge cases regarding complex trait bounds that I needed to codify in tests in order not to cause surprises. The "private field added a bound and broke backwards" problem would be caught by such mandatory tests, too.

I can see your arguments, but my whole point was that your hash map example did not support your case.

In hash maps it is also currently true that all the bounds are implied by the signatures and you don't have to look at private implementation details of struct HashMap or inside any functions to deduce the bounds from the source code.

That was not my point, though. My point was that for a hash map, you obviously have to make your keys hashable, but technically this is an implementation detail, and this is basically true for all generic implementations, because the whole point of generics is to be able to use the bounds in the implementation.

This is analogous to bounds on private fields, because it is equally obvious that a field-wise implementation of a trait needs the bounds on the fields, even though it's technically an implementation detail.

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.