Formas elegantes de apoyar la equivalencia ("igualdad") en las clases de Python


421

Al escribir clases personalizadas, a menudo es importante permitir la equivalencia mediante los operadores ==y !=. En Python, esto es posible implementando los métodos especiales __eq__y __ne__, respectivamente. La forma más fácil que he encontrado para hacer esto es el siguiente método:

class Foo:
    def __init__(self, item):
        self.item = item

    def __eq__(self, other):
        if isinstance(other, self.__class__):
            return self.__dict__ == other.__dict__
        else:
            return False

    def __ne__(self, other):
        return not self.__eq__(other)

¿Conoces formas más elegantes de hacer esto? ¿Conoces alguna desventaja particular al usar el método anterior de comparar __dict__s?

Nota : un poco de aclaración: cuando __eq__y __ne__no están definidos, encontrará este comportamiento:

>>> a = Foo(1)
>>> b = Foo(1)
>>> a is b
False
>>> a == b
False

Es decir, a == bevalúa Falseporque realmente se ejecuta a is b, una prueba de identidad (es decir, "¿Es ael mismo objeto que b?").

Cuando __eq__y __ne__están definidos, encontrará este comportamiento (que es el que buscamos):

>>> a = Foo(1)
>>> b = Foo(1)
>>> a is b
False
>>> a == b
True

66
+1, debido a que no sabía que dict usaba la igualdad de miembros para ==, supuse que solo los contaba igual para los dictados del mismo objeto. Supongo que esto es obvio ya que Python tiene el isoperador para distinguir la identidad del objeto de la comparación de valores.
SingleNegationElimination

55
Creo que la respuesta aceptada debe corregirse o reasignarse a la respuesta de Algorias, de modo que se implemente la verificación de tipo estricta.
max

1
También asegúrese de que el hash se anule stackoverflow.com/questions/1608842/…
Alex Punnen

Respuestas:


328

Considere este simple problema:

class Number:

    def __init__(self, number):
        self.number = number


n1 = Number(1)
n2 = Number(1)

n1 == n2 # False -- oops

Entonces, Python por defecto usa los identificadores de objeto para las operaciones de comparación:

id(n1) # 140400634555856
id(n2) # 140400634555920

Anular la __eq__función parece resolver el problema:

def __eq__(self, other):
    """Overrides the default implementation"""
    if isinstance(other, Number):
        return self.number == other.number
    return False


n1 == n2 # True
n1 != n2 # True in Python 2 -- oops, False in Python 3

En Python 2 , recuerde siempre anular la __ne__función también, como dice la documentación :

No hay relaciones implícitas entre los operadores de comparación. La verdad de x==yno implica que x!=ysea ​​falso. En consecuencia, al definir __eq__(), también se debe definir __ne__()para que los operadores se comporten como se espera.

def __ne__(self, other):
    """Overrides the default implementation (unnecessary in Python 3)"""
    return not self.__eq__(other)


n1 == n2 # True
n1 != n2 # False

En Python 3 , esto ya no es necesario, como dice la documentación :

Por defecto, __ne__()delega __eq__()e invierte el resultado a menos que lo sea NotImplemented. No hay otras relaciones implícitas entre los operadores de comparación, por ejemplo, la verdad (x<y or x==y)no implica x<=y.

Pero eso no resuelve todos nuestros problemas. Agreguemos una subclase:

class SubNumber(Number):
    pass


n3 = SubNumber(1)

n1 == n3 # False for classic-style classes -- oops, True for new-style classes
n3 == n1 # True
n1 != n3 # True for classic-style classes -- oops, False for new-style classes
n3 != n1 # False

Nota: Python 2 tiene dos tipos de clases:

  • clases de estilo clásico (o estilo antiguo ), que no heredan deobjecty que se declaran comoclass A:,class A():oclass A(B):dondeBes una clase de estilo clásico;

  • clases de estilo nuevo , que heredan deobjecty que se declaran comoclass A(object)oclass A(B):dondeBes una clase de estilo nuevo. Python 3 solo tiene clases de estilo nuevo que se declaran comoclass A:,class A(object):oclass A(B):.

Para las clases de estilo clásico, una operación de comparación siempre llama al método del primer operando, mientras que para las clases de estilo nuevo, siempre llama al método del operando de la subclase, independientemente del orden de los operandos .

Entonces aquí, si Numberes una clase de estilo clásico:

  • n1 == n3llamadas n1.__eq__;
  • n3 == n1llamadas n3.__eq__;
  • n1 != n3llamadas n1.__ne__;
  • n3 != n1llamadas n3.__ne__.

Y si Numberes una clase de nuevo estilo:

  • ambos n1 == n3y n3 == n1llamar n3.__eq__;
  • ambos n1 != n3y n3 != n1llama n3.__ne__.

Para solucionar el problema de no conmutatividad de los operadores ==y !=para las clases de estilo clásico de Python 2, los métodos __eq__y __ne__deberían devolver el NotImplementedvalor cuando no se admite un tipo de operando. La documentación define el NotImplementedvalor como:

Los métodos numéricos y los métodos de comparación enriquecidos pueden devolver este valor si no implementan la operación para los operandos proporcionados. (El intérprete intentará la operación reflejada, o alguna otra alternativa, dependiendo del operador). Su valor de verdad es verdadero.

En este caso, el operador delega la operación de comparación al método reflejado del otro operando. La documentación define los métodos reflejados como:

No hay versiones de argumentos intercambiados de estos métodos (para usarse cuando el argumento izquierdo no admite la operación pero el argumento derecho sí); más bien, __lt__()y __gt__()son el reflejo del otro, __le__()y __ge__()son el reflejo del otro, __eq__()y __ne__()son su propio reflejo.

El resultado se ve así:

def __eq__(self, other):
    """Overrides the default implementation"""
    if isinstance(other, Number):
        return self.number == other.number
    return NotImplemented

def __ne__(self, other):
    """Overrides the default implementation (unnecessary in Python 3)"""
    x = self.__eq__(other)
    if x is NotImplemented:
        return NotImplemented
    return not x

Devolver el NotImplementedvalor en lugar de Falsees lo correcto incluso para las clases de estilo nuevo si se desea la conmutatividad de los operadores ==y !=cuando los operandos son de tipos no relacionados (sin herencia).

¿Ya llegamos? No exactamente. ¿Cuántos números únicos tenemos?

len(set([n1, n2, n3])) # 3 -- oops

Los conjuntos usan los hash de los objetos y, de forma predeterminada, Python devuelve el hash del identificador del objeto. Intentemos anularlo:

def __hash__(self):
    """Overrides the default implementation"""
    return hash(tuple(sorted(self.__dict__.items())))

len(set([n1, n2, n3])) # 1

El resultado final se ve así (agregué algunas afirmaciones al final para la validación):

class Number:

    def __init__(self, number):
        self.number = number

    def __eq__(self, other):
        """Overrides the default implementation"""
        if isinstance(other, Number):
            return self.number == other.number
        return NotImplemented

    def __ne__(self, other):
        """Overrides the default implementation (unnecessary in Python 3)"""
        x = self.__eq__(other)
        if x is not NotImplemented:
            return not x
        return NotImplemented

    def __hash__(self):
        """Overrides the default implementation"""
        return hash(tuple(sorted(self.__dict__.items())))


class SubNumber(Number):
    pass


n1 = Number(1)
n2 = Number(1)
n3 = SubNumber(1)
n4 = SubNumber(4)

assert n1 == n2
assert n2 == n1
assert not n1 != n2
assert not n2 != n1

assert n1 == n3
assert n3 == n1
assert not n1 != n3
assert not n3 != n1

assert not n1 == n4
assert not n4 == n1
assert n1 != n4
assert n4 != n1

assert len(set([n1, n2, n3, ])) == 1
assert len(set([n1, n2, n3, n4])) == 2

3
hash(tuple(sorted(self.__dict__.items())))no funcionará si hay objetos no hashable entre los valores de self.__dict__(es decir, si alguno de los atributos del objeto se establece en, digamos, a list).
max

3
Es cierto, pero si tiene esos objetos mutables en sus vars (), los dos objetos no son realmente iguales ...
Tal Weiss


1
Tres comentarios: 1. En Python 3, ya no es necesario implementar __ne__: "Por defecto, __ne__()delega __eq__()e invierte el resultado a menos que sea NotImplemented". 2. Si todavía se desea implementar __ne__, una aplicación más genérica (la utilizada por Python 3 creo) es: x = self.__eq__(other); if x is NotImplemented: return x; else: return not x. 3. Lo dado __eq__y las __ne__implementaciones son subóptimas: if isinstance(other, type(self)):da 22 __eq__y 10 __ne__llamadas, mientras if isinstance(self, type(other)):que daría 16 __eq__y 6 __ne__llamadas.
Maggyero

44
Preguntó por la elegancia, pero se volvió robusto.
GregNash

201

Debes tener cuidado con la herencia:

>>> class Foo:
    def __eq__(self, other):
        if isinstance(other, self.__class__):
            return self.__dict__ == other.__dict__
        else:
            return False

>>> class Bar(Foo):pass

>>> b = Bar()
>>> f = Foo()
>>> f == b
True
>>> b == f
False

Verifique los tipos más estrictamente, así:

def __eq__(self, other):
    if type(other) is type(self):
        return self.__dict__ == other.__dict__
    return False

Además de eso, su enfoque funcionará bien, para eso están los métodos especiales.


Este es un buen punto. Supongo que vale la pena señalar que la subclasificación de tipos incorporados aún permite la igualdad en cualquier dirección, por lo que verificar que sea del mismo tipo puede incluso ser indeseable.
gotgenes 05 de

12
Sugeriría devolver NotImplemented si los tipos son diferentes, delegando la comparación a los rhs.
máximo

44
La comparación de @max no se hace necesariamente del lado izquierdo (LHS) al lado derecho (RHS), luego de RHS a LHS; ver stackoverflow.com/a/12984987/38140 . Aún así, regresar NotImplementedcomo sugiere siempre causará superclass.__eq__(subclass), que es el comportamiento deseado.
gotgenes

44
Si tiene un montón de miembros, y no hay muchas copias de objetos, entonces generalmente es bueno agregar una prueba de identidad inicial if other is self. Esto evita la comparación de diccionario más larga y puede ser un gran ahorro cuando los objetos se utilizan como claves de diccionario.
Dane White

2
Y no olvide implementarlo__hash__()
Dane White

161

La forma en que describe es la forma en que siempre lo he hecho. Como es totalmente genérico, siempre puede dividir esa funcionalidad en una clase mixin y heredarla en las clases donde desee esa funcionalidad.

class CommonEqualityMixin(object):

    def __eq__(self, other):
        return (isinstance(other, self.__class__)
            and self.__dict__ == other.__dict__)

    def __ne__(self, other):
        return not self.__eq__(other)

class Foo(CommonEqualityMixin):

    def __init__(self, item):
        self.item = item

66
+1: Patrón de estrategia para permitir una fácil sustitución en subclases.
S.Lott

3
Esta instancia apesta. ¿Por qué verificarlo? ¿Por qué no solo self .__ dict__ == other .__ dict__?
nosklo

3
@nosklo: No entiendo ... ¿qué pasa si dos objetos de clases completamente no relacionadas tienen los mismos atributos?
max

1
Pensé que nokslo sugirió saltarse es la instancia. En ese caso ya no sabes si otheres de una subclase de self.__class__.
max

10
Otro problema con la __dict__comparación es qué sucede si tiene un atributo que no desea tener en cuenta en su definición de igualdad (por ejemplo, una identificación de objeto única o metadatos como un sello de tiempo creado).
Adam Parkin

14

No es una respuesta directa, pero parecía lo suficientemente relevante como para ser agregada, ya que en ocasiones ahorra un poco de tedio detallado. Corte directamente de los documentos ...


functools.total_ordering (cls)

Dada una clase que define uno o más métodos de ordenación de comparación, este decorador de clase proporciona el resto. Esto simplifica el esfuerzo involucrado en la especificación de todas las posibles operaciones de comparación enriquecida:

La clase debe definir uno de __lt__(), __le__(), __gt__(), o __ge__(). Además, la clase debe proporcionar un __eq__()método.

Nuevo en la versión 2.7

@total_ordering
class Student:
    def __eq__(self, other):
        return ((self.lastname.lower(), self.firstname.lower()) ==
                (other.lastname.lower(), other.firstname.lower()))
    def __lt__(self, other):
        return ((self.lastname.lower(), self.firstname.lower()) <
                (other.lastname.lower(), other.firstname.lower()))

1
Sin embargo, el total_ordering tiene escollos sutiles: regebro.wordpress.com/2010/12/13/… . ¡Ten cuidado!
Mr_and_Mrs_D

8

No tiene que anular ambos __eq__y __ne__solo puede anular, __cmp__pero esto tendrá una implicación en el resultado de ==,! ==, <,> y así sucesivamente.

ispruebas de identidad de objeto. Esto significa que a isb estará Trueen el caso en que a y b tengan la referencia al mismo objeto. En python siempre tiene una referencia a un objeto en una variable, no el objeto real, por lo que esencialmente para que a b sea verdadero, los objetos en ellos deben ubicarse en la misma ubicación de memoria. ¿Cómo y, lo que es más importante, por qué anularías este comportamiento?

Editar: no sabía que __cmp__se eliminó de Python 3, así que evítalo.


Porque a veces tienes una definición diferente de igualdad para tus objetos.
Ed S.

El operador is le da a los intérpretes la respuesta a la identidad del objeto, pero aún puede expresar su punto de vista sobre la igualdad anulando cmp
Vasil

77
En Python 3, "La función cmp () desapareció y el método especial __cmp __ () ya no es compatible". is.gd/aeGv
gotgenes


2

Creo que los dos términos que está buscando son igualdad (==) e identidad (es). Por ejemplo:

>>> a = [1,2,3]
>>> b = [1,2,3]
>>> a == b
True       <-- a and b have values which are equal
>>> a is b
False      <-- a and b are not the same list object

1
Tal vez, excepto que uno puede crear una clase que solo compare los dos primeros elementos en dos listas, y si esos elementos son iguales, se evalúa como Verdadero. Esto es equivalencia, creo, no igualdad. Perfectamente válido en eq , aún.
gotgenes

Estoy de acuerdo, sin embargo, que "es" es una prueba de identidad.
gotgenes

1

La prueba 'is' probará la identidad utilizando la función incorporada 'id ()' que esencialmente devuelve la dirección de memoria del objeto y, por lo tanto, no se puede cargar.

Sin embargo, en el caso de probar la igualdad de una clase, es probable que desee ser un poco más estricto sobre sus pruebas y solo compare los atributos de datos en su clase:

import types

class ComparesNicely(object):

    def __eq__(self, other):
        for key, value in self.__dict__.iteritems():
            if (isinstance(value, types.FunctionType) or 
                    key.startswith("__")):
                continue

            if key not in other.__dict__:
                return False

            if other.__dict__[key] != value:
                return False

         return True

Este código solo comparará miembros de datos no funcionales de su clase, así como omitirá todo lo privado que generalmente es lo que desea. En el caso de Plain Old Python Objects, tengo una clase base que implementa __init__, __str__, __repr__ y __eq__ para que mis objetos POPO no lleven la carga de toda esa lógica extra (y en la mayoría de los casos idéntica).


Un poco quisquilloso, pero 'is' prueba usando id () solo si no ha definido su propia función miembro is_ () (2.3+). [ docs.python.org/library/operator.html]
gastado el

Supongo que por "anulación" realmente quiere decir parchear el módulo del operador. En este caso, su declaración no es del todo precisa. El módulo de operadores se proporciona por conveniencia y anular esos métodos no afecta el comportamiento del operador "es". Una comparación que usa "es" siempre usa el id () de un objeto para la comparación, este comportamiento no se puede anular. Además, una función is_ member no tiene efecto en la comparación.
mcrute

mcrute: hablé demasiado pronto (e incorrectamente), tienes toda la razón.
gastado el

Esta es una solución muy buena, especialmente cuando __eq__se declarará en CommonEqualityMixin(ver la otra respuesta). Encontré esto particularmente útil al comparar instancias de clases derivadas de Base en SQLAlchemy. Para no comparar _sa_instance_stateme cambié key.startswith("__")):a key.startswith("_")):. También tenía algunas referencias inversas y la respuesta de Algorias generó una recursión interminable. Así que nombré todas las referencias anteriores comenzando '_'para que también se omitan durante la comparación. NOTA: en Python 3.x cambie iteritems()a items().
Wookie88

@mcrute Por lo general, __dict__una instancia no tiene nada que comience a __menos que sea definida por el usuario. Cosas como __class__, __init__etc. no están en la instancia __dict__, sino en su clase ' __dict__. OTOH, los atributos privados pueden comenzar fácilmente __y probablemente deberían usarse para __eq__. ¿Puede aclarar qué estaba tratando de evitar exactamente al omitir los __atributos prefijados?
max

1

En lugar de usar subclases / mixins, me gusta usar un decorador de clase genérico

def comparable(cls):
    """ Class decorator providing generic comparison functionality """

    def __eq__(self, other):
        return isinstance(other, self.__class__) and self.__dict__ == other.__dict__

    def __ne__(self, other):
        return not self.__eq__(other)

    cls.__eq__ = __eq__
    cls.__ne__ = __ne__
    return cls

Uso:

@comparable
class Number(object):
    def __init__(self, x):
        self.x = x

a = Number(1)
b = Number(1)
assert a == b

0

Esto incorpora los comentarios sobre la respuesta de Algorias y compara los objetos por un solo atributo porque no me importa todo el dict. hasattr(other, "id")debe ser cierto, pero sé que es porque lo configuré en el constructor.

def __eq__(self, other):
    if other is self:
        return True

    if type(other) is not type(self):
        # delegate to superclass
        return NotImplemented

    return other.id == self.id
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.