Елегантні способи підтримки еквівалентності («рівності») в класах Python


421

При написанні спеціальних класів часто важливо дозволити еквівалентність за допомогою операторів ==та !=операторів. У Python це стає можливим завдяки впровадженню __eq__та __ne__спеціальних методів відповідно. Найпростіший спосіб я знайшов це зробити наступним методом:

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)

Чи знаєте ви більш елегантні засоби для цього? Чи знаєте ви якісь особливі недоліки у використанні вищевказаного методу порівняння __dict__s?

Примітка . Трохи уточнення - коли __eq__і __ne__не визначено, ви знайдете таку поведінку:

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

Тобто, a == bоцінює, Falseоскільки це дійсно працює a is b, тест на ідентичність (тобто "Чи aтакий же об'єкт, що і b?").

Коли __eq__і __ne__визначено, ви дізнаєтесь про таку поведінку (яку ми ведемо після):

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

6
+1, оскільки я не знав, що диктант використовує членську рівність для ==, я вважав, що він зараховує їх лише до однакових об'єктів. Я думаю, що це очевидно, оскільки Python має isоператора, щоб відрізняти ідентичність об'єкта від порівняння значень.
SingleNegationElimination

5
Я думаю, що прийняту відповідь буде виправлено або переназначено на відповідь Алгорія, щоб було здійснено сувору перевірку типу.
макс

1
Також переконайтеся , що хеш переопределяется stackoverflow.com/questions/1608842 / ...
Alex Punnen

Відповіді:


328

Розглянемо цю просту проблему:

class Number:

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


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

n1 == n2 # False -- oops

Отже, Python за замовчуванням використовує ідентифікатори об'єктів для операцій порівняння:

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

Перевіщення __eq__функції, здається, вирішує проблему:

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

У Python 2 завжди пам’ятайте про перекриття __ne__функції, як зазначено в документації :

Між операторами порівняння не маються на увазі зв’язки. Істина x==yне означає, що x!=yце помилково. Відповідно, визначаючи __eq__(), слід також визначитись __ne__()так, що оператори будуть вести себе так, як очікувалося.

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


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

У Python 3 це більше не потрібно, оскільки в документації зазначено:

За замовчуванням __ne__()делегує результат __eq__()та інвертує результат, якщо він не є NotImplemented. Інших мається на увазі зв’язків між операторами порівняння, наприклад, правда (x<y or x==y)не означає x<=y.

Але це не вирішує всіх наших проблем. Додамо підклас:

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

Примітка: Python 2 має два типи класів:

  • в класичному стилі (або в старому стилі ) класів, які НЕ успадковуютьobjectі які оголошені якclass A:,class A():абоclass A(B):деBкласкласичному стилі;

  • класи нового стилю , які успадковуютьobjectі декларуються якclass A(object)абоclass A(B):деBє клас нового стилю. У Python 3 є лише класи нового стилю, які оголошені якclass A:,class A(object):абоclass A(B):.

Для класів класичного стилю операція порівняння завжди називає метод першого операнда, тоді як для класів нового стилю він завжди називає метод операнда підкласу незалежно від порядку операндів .

Тож ось, якщо Numberце клас у класичному стилі:

  • n1 == n3дзвінки n1.__eq__;
  • n3 == n1дзвінки n3.__eq__;
  • n1 != n3дзвінки n1.__ne__;
  • n3 != n1дзвінки n3.__ne__.

А якщо Numberклас нового стилю:

  • обидва n1 == n3і n3 == n1дзвоніть n3.__eq__;
  • обидва n1 != n3і n3 != n1дзвоніть n3.__ne__.

Щоб виправити питання некомутативності ==та !=операторів класів класичного стилю Python 2, __eq__і __ne__методам слід повернути NotImplementedзначення, коли тип операнду не підтримується. Документація визначає NotImplementedзначення як:

Числові методи та методи багатого порівняння можуть повернути це значення, якщо вони не реалізують операцію для наданих операндів. (Тоді інтерпретатор спробує відобразити операцію чи інший резервний запас, залежно від оператора.) Її значення правдивості є істинним.

В цьому випадку делегати оператора операція порівняння на відображення метод від іншого операнда. У документації визначає відображення методи , як:

Немає версій цих методів з заміненим аргументом (використовуватися, коли лівий аргумент не підтримує операцію, а правий аргумент); швидше, __lt__()і __gt__()є відображенням один одного, __le__()і __ge__()є відображенням один одного, __eq__()і __ne__()є їх власним відображенням.

Результат виглядає приблизно так:

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

Повернення NotImplementedзначення замість Falseє правильним , що потрібно зробити , навіть для нових класів , якщо коммутативности з ==і !=операторів бажано , коли операнди неспоріднених типів (без успадкування).

Ми там ще? Не зовсім. Скільки унікальних чисел у нас є?

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

Набори використовують хеші об’єктів, і за замовчуванням Python повертає хеш ідентифікатора об'єкта. Спробуємо її перекрити:

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

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

Кінцевий результат виглядає приблизно так (я додав кілька тверджень в кінці для перевірки):

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())))не працюватиме, якщо серед значень self.__dict__(наприклад, якщо будь-який з атрибутів об'єкта встановлено значення, скажімо, a list), є якісь непередавані об'єкти .
макс

3
Щоправда, але тоді, якщо у вас є такі змінні об’єкти у вашій варі (), два об'єкти насправді не рівні ...
Тал Вайс


1
Три зауваження: 1. У Python 3 більше не потрібно реалізовувати __ne__: "За замовчуванням __ne__()делегує результат __eq__()та інвертує результат, якщо він не є NotImplemented". 2. Якщо один ще хоче реалізувати __ne__, більш загальна реалізація (один використовується Python 3 , я думаю) це: x = self.__eq__(other); if x is NotImplemented: return x; else: return not x. 3. Дані __eq__та __ne__реалізації є неоптимальними: if isinstance(other, type(self)):дає 22 __eq__та 10 __ne__дзвінків, тоді як if isinstance(self, type(other)):дасть 16 __eq__та 6 __ne__викликів.
Maggyero

4
Він запитав про витонченість, але він став надійним.
GregNash

201

Вам потрібно бути обережним у спадок:

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

Перевірте типи більш суворо:

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

Крім того, ваш підхід буде добре працювати, саме для цього існують спеціальні методи.


Це хороший момент. Я припускаю, що варто відзначити, що підкласифікація, побудована за типами, все ще дозволяє забезпечити рівність будь-якого напрямку, і тому перевірити, чи не є той самий тип, може бути навіть небажано.
gotgenes

12
Я б запропонував повернути NotImplemented, якщо типи різні, делегуючи порівняння до rhs.
макс

4
@max порівняння не обов'язково проводиться з лівої сторони (LHS) в праву частину (RHS), потім RHS в LHS; див. stackoverflow.com/a/12984987/38140 . І все-таки повернення, NotImplementedяк ви пропонуєте, завжди спричинить superclass.__eq__(subclass), а це - бажана поведінка.
gotgenes

4
Якщо у вас є безліч членів, і не багато копій об'єктів сидять навколо, то зазвичай добре додати початковий тест на посвідчення особи if other is self. Це дозволяє уникнути більш тривалого порівняння словника і може бути величезною економією, коли об’єкти використовуються як клавіші словника.
Дейн Вайт

2
І не забудьте реалізувати__hash__()
Дейн Вайт

161

Те, як ви описуєте, це те, як я завжди робив це. Оскільки це абсолютно загальний характер, ви завжди можете розбити цю функціональність на клас змішання та успадкувати її у класах, де вам потрібна ця функціональність.

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

6
+1: Шаблон стратегії для легкої заміни в підкласах.
С.Лотт

3
речовина смокче. Навіщо це перевіряти? Чому б не просто себе .__ dict__ == інший .__ dict__?
nosklo

3
@nosklo: Я не розумію .. що робити, якщо два об'єкти з абсолютно непов'язаних класів мають однакові атрибути?
макс

1
Я думав, що nokslo запропонував пропуститиречовину. У такому випадку ви більше не знаєте, чи otherє підкласом self.__class__.
макс

10
Інша проблема __dict__порівняння полягає в тому, що якщо у вас є атрибут, який ви не хочете враховувати у своєму визначенні рівності (скажімо, наприклад, унікальний ідентифікатор об'єкта або метадані, як штамп, створений часом).
Адам Паркін

14

Не пряма відповідь, але здавалася достатньо актуальною, щоб її можна було застосувати, оскільки це економить трохи багатослівної татуювання при нагоді. Виріжте прямо з документів ...


functools.total_ordering (cls)

Враховуючи клас, що визначає один або кілька методів упорядкування порівняння, цей декоратор постачає решту. Це спрощує зусилля, що вкладаються у визначенні всіх можливих операцій порівняльного розширення:

Клас повинен визначити один з __lt__(), __le__(), __gt__()або __ge__(). Крім того, клас повинен подати __eq__()метод.

Нове у версії 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
Однак у total_ordering є тонкі підводні камені: regebro.wordpress.com/2010/12/13/… . Бережись !
Mr_and_Mrs_D

8

Вам не потрібно переохочувати обидва, __eq__і __ne__ви можете лише переосмислити, __cmp__але це вплине на результат ==,! ==, <,> тощо.

isтести на предмет ідентичності об'єкта. Це означає, що isb буде Trueв тому випадку, коли a і b мають посилання на один і той же об'єкт. У python ви завжди маєте посилання на об'єкт у змінній, а не на фактичний об'єкт, тому для a є b, щоб бути правдою, об'єкти в них повинні розташовуватися в одному місці пам'яті. Як і найголовніше, чому ви б вирішили переосмислити таку поведінку?

Редагувати: я не знав, що __cmp__його видалено з python 3, тому уникайте цього.


Тому що іноді у вас є різне визначення рівності для ваших об'єктів.
Ред С.

Оператор is дає вам перекладачів відповідь на ідентифікацію об'єкта, але ви все одно можете висловити свою точку зору на рівність, відмінивши cmp
Василь

7
У Python 3 "функція cmp () відсутня, і спеціальний метод __cmp __ () більше не підтримується." is.gd/aeGv
gotgenes

4

З цієї відповіді: https://stackoverflow.com/a/30676267/541136 я продемонстрував це, хоча це правильно визначити __ne__в термінах __eq__- замість

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

ви повинні використовувати:

def __ne__(self, other):
    return not self == other

2

Я думаю, що два терміни, які ви шукаєте, - це рівність (==) та тотожність (є). Наприклад:

>>> 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
Можливо, за винятком того, що можна створити клас, який порівнює лише перші два пункти у двох списках, і якщо ці елементи рівні, він оцінює значення True. Я думаю, це еквівалентність, а не рівність. Ідеально діє в еквіваленті , досі.
gotgenes

Я згоден, однак, що "є" - це перевірка ідентичності.
gotgenes

1

Тест 'є' перевірить ідентичність за допомогою вбудованої функції 'id ()', яка по суті повертає адресу пам'яті об'єкта і тому не завантажується.

Однак у випадку тестування рівності класу ви, мабуть, хочете бути трохи більш суворими щодо своїх тестів і лише порівнювати атрибути даних у своєму класі:

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

Цей код буде лише порівнювати дані, які не функціонують, членів вашого класу, а також пропускати все приватне, що є загальним. У випадку звичайних об’єктів Python у мене є базовий клас, який реалізує __init__, __str__, __repr__ та __eq__, тому мої об'єкти POPO не несуть тягар усієї додаткової (і в більшості випадків однакової) логіки.


Трохи вибагливий, але "є" тести, використовуючи id (), лише якщо ви не визначили власну функцію-члена is_ () (2.3+). [ docs.python.org/library/operator.html]
вересень

Я припускаю, що під "переосмисленням" ви насправді маєте на увазі мавпа-патчінг операторського модуля. У цьому випадку ваше твердження не зовсім точне. Модуль операторів передбачений для зручності, а переосмислення цих методів не впливає на поведінку оператора "є". Порівняння, що використовує "є", завжди використовує для порівняння id () об'єкта, таку поведінку неможливо перекрити. Також функція-член-член не впливає на порівняння.
mcrute

mcrute - я говорив занадто рано (і неправильно), ви абсолютно праві.
понеділок

Це дуже приємне рішення, особливо коли __eq__заповіт буде оголошено CommonEqualityMixin(див. Іншу відповідь). Я вважаю це особливо корисним при порівнянні екземплярів класів, похідних від Base в SQLAlchemy. Для порівняння _sa_instance_stateя змінився key.startswith("__")):на key.startswith("_")):. У мене були також деякі зворотні посилання, і відповідь Алгорія викликав нескінченну рекурсію. Тому я назвав усі зворотні параметри, починаючи з '_'того, що вони також пропускаються під час порівняння. ПРИМІТКА: у Python 3.x змініть iteritems()на items().
Wookie88

@mcrute Зазвичай, __dict__екземпляр не має нічого, що починається, __якщо тільки це не було визначено користувачем. Такі речі, як __class__і __init__т. Д., Не належать до примірника __dict__, а до його класу ' __dict__. OTOH, приватні атрибути можна легко почати __і, мабуть, їх слід використовувати __eq__. Чи можете ви уточнити, чого саме ви намагалися уникати, пропускаючи __попередньо встановлені атрибути?
макс

1

Замість того, щоб використовувати підкласинг / міксин, я люблю використовувати декоратор загального класу

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

Використання:

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

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

0

Це включає коментарі до відповіді Алгорія і порівнює об’єкти за одним атрибутом, бо мені не байдуже цілий дикт. hasattr(other, "id")повинно бути правдою, але я знаю, що це так, тому що я його встановив у конструкторі.

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
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.