Façons élégantes de prendre en charge l'équivalence («égalité») dans les classes Python

421

Lors de l'écriture de classes personnalisées, il est souvent important de permettre l'équivalence au moyen des opérateurs ==et !=. En Python, cela est rendu possible en implémentant respectivement les méthodes spéciales __eq__et __ne__. La méthode la plus simple que j'ai trouvée pour ce faire est la méthode suivante:

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)

Connaissez-vous des moyens plus élégants de le faire? Connaissez-vous des inconvénients particuliers à utiliser la méthode ci-dessus pour comparer __dict__s?

Remarque : Un peu de clarification - lorsque __eq__et ne __ne__sont pas définis, vous trouverez ce comportement:

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

C'est-à-dire, a == bévalue Falseparce qu'il fonctionne vraiment a is b, un test d'identité (c.-à-d. "Est-ce ale même objet que b?").

Lorsque __eq__et __ne__sont définis, vous trouverez ce comportement (qui est celui que nous recherchons):

>>> a = Foo(1)
>>> b = Foo(1)
>>> a is b
False
>>> a == b
True
gotgenes
la source
6
+1, car je ne savais pas que dict utilisait l'égalité des membres pour ==, j'avais supposé qu'il ne les comptait que pour les mêmes objets. Je suppose que cela est évident car Python a l' isopérateur pour distinguer l'identité de l'objet de la comparaison de valeurs.
SingleNegationElimination
5
Je pense que la réponse acceptée doit être corrigée ou réaffectée à la réponse d'Algorias, afin que la vérification de type stricte soit mise en œuvre.
max
1
Assurez-vous également que le hachage est remplacé stackoverflow.com/questions/1608842/…
Alex Punnen

Réponses:

328

Considérez ce problème simple:

class Number:

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


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

n1 == n2 # False -- oops

Ainsi, Python utilise par défaut les identificateurs d'objet pour les opérations de comparaison:

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

Remplacer la __eq__fonction semble résoudre le problème:

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 , n'oubliez pas de remplacer également la __ne__fonction, comme l' indique la documentation :

Il n'y a aucune relation implicite entre les opérateurs de comparaison. La vérité de x==yn'implique pas que ce x!=ysoit faux. Par conséquent, lors de la définition __eq__(), il convient également de définir de __ne__()sorte que les opérateurs se comportent comme prévu.

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 , cela n'est plus nécessaire, comme l' indique la documentation :

Par défaut, __ne__()délègue __eq__()et inverse le résultat à moins qu'il ne le soit NotImplemented. Il n'y a pas d'autres relations implicites entre les opérateurs de comparaison, par exemple, la vérité de (x<y or x==y)n'implique pas x<=y.

Mais cela ne résout pas tous nos problèmes. Ajoutons une sous-classe:

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

Remarque: Python 2 a deux types de classes:

  • les classes de style classique (ou à l' ancienne ), quin'héritent pas deobjectet qui sont déclarées commeclass A:,class A():ouclass A(B):où seBtrouve une classe de style classique;

  • classes de nouveau style , qui héritent deobjectet qui sont déclarées commeclass A(object)ouclass A(B):Best une classe de nouveau style. Python 3 n'a que des classes de nouveau style qui sont déclarées commeclass A:,class A(object):ouclass A(B):.

Pour les classes de style classique, une opération de comparaison appelle toujours la méthode du premier opérande, tandis que pour les classes de nouveau style, elle appelle toujours la méthode de l'opérande de sous-classe, quel que soit l'ordre des opérandes .

Voici donc, si Numberc'est une classe de style classique:

  • n1 == n3appels n1.__eq__;
  • n3 == n1appels n3.__eq__;
  • n1 != n3appels n1.__ne__;
  • n3 != n1les appels n3.__ne__.

Et si Numberc'est une classe de nouveau style:

  • les deux n1 == n3et n3 == n1appelez n3.__eq__;
  • les deux n1 != n3et n3 != n1appelez n3.__ne__.

Pour résoudre le problème de non-commutativité des opérateurs ==et !=pour les classes de style classique Python 2, les méthodes __eq__et __ne__doivent renvoyer la NotImplementedvaleur lorsqu'un type d'opérande n'est pas pris en charge. La documentation définit la NotImplementedvaleur comme:

Les méthodes numériques et les méthodes de comparaison riches peuvent renvoyer cette valeur si elles n'implémentent pas l'opération pour les opérandes fournis. (L'interpréteur tentera alors l'opération réfléchie, ou une autre solution de rechange, selon l'opérateur.) Sa valeur de vérité est vraie.

Dans ce cas, l'opérateur délègue l'opération de comparaison à la méthode reflétée de l' autre opérande. La documentation définit les méthodes reflétées comme:

Il n'y a pas de versions à arguments échangés de ces méthodes (à utiliser lorsque l'argument gauche ne prend pas en charge l'opération mais que l'argument droit le fait); plutôt, __lt__()et __gt__()sont le reflet de l'autre, __le__()et __ge__()sont le reflet de l'autre, et __eq__()et __ne__()sont leur propre reflet.

Le résultat ressemble à ceci:

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

Renvoyer la NotImplementedvaleur à la place de Falseest la bonne chose à faire même pour les classes de nouveau style si la commutativité des opérateurs ==et !=est souhaitée lorsque les opérandes sont de types non liés (pas d'héritage).

Sommes-nous déjà là? Pas assez. Combien de numéros uniques avons-nous?

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

Les ensembles utilisent les hachages des objets et, par défaut, Python renvoie le hachage de l'identifiant de l'objet. Essayons de le remplacer:

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

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

Le résultat final ressemble à ceci (j'ai ajouté quelques assertions à la fin pour la validation):

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
Tal Weiss
la source
3
hash(tuple(sorted(self.__dict__.items())))ne fonctionnera pas s'il y a des objets non hachables parmi les valeurs de la self.__dict__(c'est-à-dire si l'un des attributs de l'objet est défini, disons, sur a list).
max
3
C'est vrai, mais si vous avez de tels objets mutables dans vos vars (), les deux objets ne sont pas vraiment égaux ...
Tal Weiss
12
Excellent résumé, mais vous devez implémenter __ne__using ==au lieu de__eq__ .
Florian Brucker
1
Trois remarques: 1. En Python 3, plus besoin d'implémenter __ne__: "Par défaut, __ne__()délègue __eq__()et inverse le résultat à moins qu'il ne le soit NotImplemented". 2. Si l' on veut encore mettre en œuvre __ne__, une application plus générique (celui utilisé par Python 3 je pense) est la suivante : x = self.__eq__(other); if x is NotImplemented: return x; else: return not x. 3. Les données __eq__et les __ne__implémentations sont sous-optimales: if isinstance(other, type(self)):donne 22 __eq__et 10 __ne__appels, tandis if isinstance(self, type(other)):que donnerait 16 __eq__et 6 __ne__appels.
Maggyero
4
Il a posé des questions sur l'élégance, mais il est devenu robuste.
GregNash
201

Vous devez être prudent avec l'héritage:

>>> 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

Vérifiez les types plus strictement, comme ceci:

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

En plus de cela, votre approche fonctionnera bien, c'est à cela que servent les méthodes spéciales.

Algorias
la source
C'est un bon point. Je suppose qu'il convient de noter que la sous-classification des types intégrés permet toujours l'égalité dans les deux sens, et donc vérifier qu'il s'agit du même type peut même être indésirable.
gotgenes
12
Je suggère de renvoyer NotImplemented si les types sont différents, en déléguant la comparaison aux rhs.
max
4
La comparaison @max ne se fait pas nécessairement du côté gauche (LHS) au côté droit (RHS), puis du RHS au LHS; voir stackoverflow.com/a/12984987/38140 . Pourtant, revenir NotImplementedcomme vous le suggérez provoquera toujours superclass.__eq__(subclass), ce qui est le comportement souhaité.
gotgenes
4
Si vous avez une tonne de membres et pas beaucoup de copies d'objets, alors il est généralement bon d'ajouter un test d'identité initial if other is self. Cela évite la comparaison plus longue du dictionnaire et peut être une énorme économie lorsque les objets sont utilisés comme clés de dictionnaire.
Dane White
2
Et n'oubliez pas de mettre en œuvre__hash__()
Dane White
161

La façon dont vous décrivez est la façon dont je l'ai toujours fait. Puisqu'il est totalement générique, vous pouvez toujours diviser cette fonctionnalité en une classe mixin et l'hériter dans les classes où vous voulez cette fonctionnalité.

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
cdleary
la source
6
+1: modèle de stratégie pour permettre un remplacement facile dans les sous-classes.
S.Lott
3
isinstance suce. Pourquoi le vérifier? Pourquoi pas seulement soi .__ dict__ == autre .__ dict__?
nosklo
3
@nosklo: Je ne comprends pas .. que se passe-t-il si deux objets de classes complètement indépendantes ont les mêmes attributs?
max
1
Je pensais que nokslo avait suggéré de sauter l'occurrence. Dans ce cas, vous ne savez plus si otherappartient à une sous-classe de self.__class__.
max
10
Un autre problème avec la __dict__comparaison est de savoir si vous avez un attribut que vous ne souhaitez pas prendre en compte dans votre définition de l'égalité (par exemple, un identifiant d'objet unique ou des métadonnées comme un horodatage créé).
Adam Parkin
14

Pas une réponse directe, mais semblait suffisamment pertinente pour être abordée car elle économise un peu d'ennui verbeux à l'occasion. Couper directement à partir des documents ...


functools.total_ordering (cls)

Étant donné une classe définissant une ou plusieurs méthodes de commande de comparaison riches, ce décorateur de classe fournit le reste. Cela simplifie l'effort impliqué dans la spécification de toutes les opérations de comparaison riches possibles:

La classe doit définir l' un des __lt__(), __le__(), __gt__()ou __ge__(). De plus, la classe doit fournir une __eq__()méthode.

Nouveau dans la version 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()))
John Mee
la source
1
Cependant, total_ordering a des pièges subtils: regebro.wordpress.com/2010/12/13/… . Être conscient !
Mr_and_Mrs_D
8

Vous n'avez pas besoin de remplacer les deux __eq__et __ne__vous ne pouvez les remplacer que __cmp__mais cela aura une implication sur le résultat de ==,! ==, <,> et ainsi de suite.

istests d'identité de l'objet. Cela signifie que isb sera Truedans le cas où a et b détiennent tous deux la référence au même objet. En python, vous détenez toujours une référence à un objet dans une variable et non l'objet réel, donc pour que a soit b vrai, les objets qu'ils contiennent doivent être situés dans le même emplacement de mémoire. Comment et, surtout, pourquoi voudriez-vous remplacer ce comportement?

Edit: je ne savais pas qu'il __cmp__avait été supprimé de python 3, alors évitez-le.

Vasil
la source
Parce que parfois, vous avez une définition différente de l'égalité pour vos objets.
Ed S.
L'opérateur is vous donne la réponse des interprètes à l'identité de l'objet, mais vous êtes toujours libre d'exprimer votre point de vue sur l'égalité en remplaçant cmp
Vasil
7
En Python 3, "La fonction cmp () a disparu et la méthode spéciale __cmp __ () n'est plus prise en charge." is.gd/aeGv
gotgenes
4

De cette réponse: https://stackoverflow.com/a/30676267/541136 J'ai démontré que, bien qu'il soit correct de définir __ne__en termes __eq__- au lieu de

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

Tu devrais utiliser:

def __ne__(self, other):
    return not self == other
Aaron Hall
la source
2

Je pense que les deux termes que vous recherchez sont égalité (==) et identité (is). Par exemple:

>>> 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
trop de php
la source
1
Peut-être, sauf que l'on peut créer une classe qui ne compare que les deux premiers éléments de deux listes, et si ces éléments sont égaux, elle est évaluée à True. C'est l'équivalence, je pense, pas l'égalité. Parfaitement valide en eq , quand même.
gotgenes
Je suis toutefois d'accord pour dire que "est" est un test d'identité.
gotgenes
1

Le test «is» testera l'identité en utilisant la fonction intégrée «id ()» qui renvoie essentiellement l'adresse mémoire de l'objet et n'est donc pas surchargeable.

Cependant, dans le cas d'un test d'égalité d'une classe, vous voudrez probablement être un peu plus strict sur vos tests et ne comparer que les attributs de données de votre classe:

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

Ce code ne comparera que les données non fonctionnelles des membres de votre classe et ignorera tout ce qui est privé, ce qui est généralement ce que vous voulez. Dans le cas des objets Plain Old Python, j'ai une classe de base qui implémente __init__, __str__, __repr__ et __eq__, donc mes objets POPO ne supportent pas le poids de toute cette logique supplémentaire (et dans la plupart des cas identique).

mcrute
la source
Bit nitpicky, mais 'is' teste en utilisant id () uniquement si vous n'avez pas défini votre propre fonction membre is_ () (2.3+). [ docs.python.org/library/operator.html]
passé
Je suppose que par «remplacer», vous entendez en fait patcher le module opérateur. Dans ce cas, votre déclaration n'est pas entièrement exacte. Le module des opérateurs est fourni pour plus de commodité et le remplacement de ces méthodes n'affecte pas le comportement de l'opérateur "is". Une comparaison utilisant "est" utilise toujours l'id () d'un objet pour la comparaison, ce comportement ne peut pas être remplacé. De plus, une fonction is_ member n'a aucun effet sur la comparaison.
mcrute
mcrute - J'ai parlé trop tôt (et incorrectement), vous avez absolument raison.
2010
C'est une très bonne solution, surtout lorsque le __eq__sera déclaré en CommonEqualityMixin(voir l'autre réponse). J'ai trouvé cela particulièrement utile lors de la comparaison d'instances de classes dérivées de Base dans SQLAlchemy. Pour ne pas comparer, _sa_instance_stateje suis passé key.startswith("__")):à key.startswith("_")):. J'avais aussi quelques références en eux et la réponse d'Algorias a généré une récursion sans fin. J'ai donc nommé toutes les références arrières en commençant par '_'afin qu'elles soient également ignorées lors de la comparaison. REMARQUE: dans Python 3.x, changez iteritems()en items().
Wookie88
@mcrute Habituellement, __dict__une instance n'a rien qui commence par, __sauf si elle a été définie par l'utilisateur. Des choses comme __class__, __init__etc. ne sont pas dans l'instance __dict__, mais plutôt dans sa classe __dict__. OTOH, les attributs privés peuvent facilement commencer __et devraient probablement être utilisés pour __eq__. Pouvez-vous clarifier ce que vous tentiez exactement d'éviter en sautant les __attributs préfixés?
max
1

Au lieu d'utiliser des sous-classes / mixins, j'aime utiliser un décorateur de classe générique

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

Usage:

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

a = Number(1)
b = Number(1)
assert a == b
bluenote10
la source
0

Cela incorpore les commentaires sur la réponse d'Algorias et compare les objets par un seul attribut parce que je ne me soucie pas du dict entier. hasattr(other, "id")doit être vrai, mais je sais que c'est parce que je l'ai mis dans le constructeur.

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
Noumenon
la source