Skip to content

No way to make instances of different classes equal without rewriting __eq__() entirely #1500

@finite-state-machine

Description

@finite-state-machine

Motivation and Problem

In my code, it's a common idiom to have a two classes called, e.g., SomethingMutable and SomethingFrozen; these are both attrs classes decorated with @attrs.define and @attrs.frozen, respectively. Their field definitions are "the same" with minor type differences to account for mutability (list vs. tuple, set vs. frozenset, etc.)

I'd like instances of SomethingMutable and SomethingFrozen to compare equal IFF every the values for the defined fields are all equal.

What doesn't work

The attrs-generated __eq__() always checks instance types are the same before checking that the field values match, meaning that instances of different attrs classes can never compare equal. Consequently, overriding __eq__() and calling super() isn't helpful.

There's no obvious way to leverage the attrs-generated, field-by-field comparisons currently in __eq__() unless both instances are exactly the same type.

Possible approaches

It would be very helpful if there were a way to tell attrs to ignore the classes when comparing, and consider only those fields defined by the left instance's class.

This could take a number of forms, such as:

  • __eq__() could take an optional keyword argument ignore_type: bool, intended for use via super()
  • attrs could break __eq__() into two steps: one to check instance types, and one to check field values
    • classmethod[__attrs_types_equal__(cls, other: type) -> bool]
      would replace today's __eq__()'s first step, i.e., roughly: if type(other) is not type(self): return False
    • __attrs_fields_equal__(self, other: Any) -> bool
      would check that every field in self is matched by an attribute of other which has an equal value (or whatever attrs.field(eq=...) says to do)
      • for example
        • if class Child(Parent): ... adds at least one field not found in Parent
        • and given: c: Child; p: Parent
        • then it would typically be true that p.__attrs_fields_equal__(c),
          • since p._afe_(c) considers only fields in p, all of which are also present in c (per the LSP/Liskov Substitution Principle),
        • ... but typically false that c.__attrs_fields_equal__(p)
          • since c._afe_(p) considers only fields in c, some of which are absent from p (by assumption)
    • __eq__() could be replaced by just a few, static lines:
      return (
              self.__attrs_types_equal__(type(other))  and
              self.__attrs_fields_equal__(other)
              )
      • breaking up __eq__() might carry a performance penalty
        • but I think it should usually be possible to detect cases where a bifurcated implementation is called for, add an override flag to attrs.define() (etc.) for edge cases, and use a unified implementation (as we do today) where we can
        • __eq__() could also check whether type(self).__attrs_types_equal__ is the unmodified attrs-provided version (identity comparison); and, if so, use an inlined copy of the same generated code to save the overhead of the method call
          • (...and similarly for __attrs_fields_equal__())
          • this should be safe even even where there's monkeypatching

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions