Элегантные способы поддержки эквивалентности («равенства») в классах 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, потому что я не знал, что dict использовал членское равенство для ==, я предполагал, что он считал их равными только для одинаковых объектных диктов. Я предполагаю, что это очевидно, поскольку в Python есть isоператор, позволяющий отличать идентичность объекта от сравнения значений.
SingleNegationElimination

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

1
Также убедитесь, что хеш переопределен stackoverflow.com/questions/1608842/…
Алекс Пуннен

Ответы:


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значение как:

Числовые методы и методы расширенного сравнения могут возвращать это значение, если они не реализуют операцию для предоставленных операндов. (Затем интерпретатор попытается выполнить отраженную операцию или какой-либо другой запасной вариант, в зависимости от оператора.) Его истинное значение равно true.

В этом случае делегаты оператора операция сравнения на отражение метод от другого операнда. В документации определяет отражение методы , как:

Не существует версий этих методов со свопированными аргументами (которые будут использоваться, когда левый аргумент не поддерживает операцию, а правый аргумент поддерживает); а, __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

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


Это хороший момент. Полагаю, стоит отметить, что встроенные в подклассы типы по-прежнему допускают равенство в любом направлении, и поэтому проверка того, что это тот же тип, может быть даже нежелательной.
получил

12
Я бы предложил вернуть NotImplemented, если типы разные, делегируя сравнение с rhs.
максимум

4
@max сравнение не обязательно выполняется с левой стороны (LHS) с правой стороны (RHS), затем с RHS на LHS; см. stackoverflow.com/a/12984987/38140 . Тем не менее, возвращение, NotImplementedкак вы предлагаете, всегда будет причиной superclass.__eq__(subclass), что является желаемым поведением.
получил

4
Если у вас куча участников, а не много копий объектов, то обычно лучше добавить начальный тест на идентичность if other is self. Это позволяет избежать более продолжительного сравнения словаря и может значительно сэкономить, когда объекты используются в качестве словарных ключей.
Дейн Уайт

2
И не забудьте реализовать__hash__()
Дейн Уайт

161

То, как вы описываете, это то, как я всегда это делал. Поскольку он полностью универсален, вы всегда можете разбить эту функциональность на класс mixin и наследовать ее в классах, где вы хотите эту функциональность.

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: шаблон стратегии, позволяющий легко заменять подклассы.
S.Lott

3
isinstance отстой. Зачем это проверять? Почему бы не самому себе .__ dict__ == прочее .__ dict__?
nosklo

3
@nosklo: я не понимаю .. что, если два объекта из совершенно не связанных классов имеют одинаковые атрибуты?
максимум

1
Я думал, что nokslo предложил пропустить isinstance. В этом случае вы больше не знаете, otherимеет ли подкласс self.__class__.
максимум

10
Другая проблема __dict__сравнения заключается в том, что если у вас есть атрибут, который вы не хотите учитывать в своем определении равенства (например, уникальный идентификатор объекта или метаданные, такие как метка, созданная во времени).
Адам Паркин

14

Не прямой ответ, но, казалось, достаточно уместным, чтобы его можно было использовать, так как иногда это спасает от многословной скуки. Вырезать прямо из документов ...


functools.total_ordering (ЦБС)

Учитывая класс, определяющий один или несколько методов упорядочения сравнительного сравнения, этот декоратор класса предоставляет остальное. Это упрощает усилия по определению всех возможных операций расширенного сравнения:

Класс должен определить один из __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тесты на предмет идентичности. Это означает, что a isb будет Trueв том случае, когда a и b содержат ссылку на один и тот же объект. В python вы всегда держите ссылку на объект в переменной, а не на фактический объект, поэтому, по существу, для того, чтобы a - b было верно, объекты в них должны быть расположены в одной и той же ячейке памяти. Как и самое главное, почему бы вам не изменить это поведение?

Изменить: я не знал, __cmp__был удален из Python 3, поэтому избегайте его.


Потому что иногда у вас есть другое определение равенства для ваших объектов.
Эд С.

оператор is дает вам ответ интерпретатора на идентичность объекта, но вы все еще можете высказать свое мнение о равенстве, переопределив cmp
Vasil

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

Тест 'is' проверит идентичность с помощью встроенной функции 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 не несут бремени всей этой дополнительной (и в большинстве случаев идентичной) логики.


Немного придирчиво, но 'is' тестирует с использованием id (), только если вы не определили свою собственную функцию-член is_ () (2.3+). [ docs.python.org/library/operator.html]
2010 года

Я предполагаю, что под «переопределением» вы на самом деле имеете в виду подстройку модуля оператора. В этом случае ваше утверждение не совсем точно. Модуль операторов предусмотрен для удобства, и переопределение этих методов не влияет на поведение оператора is. Сравнение с использованием «is» всегда использует id () объекта для сравнения, это поведение не может быть переопределено. Также функция is_ member не влияет на сравнение.
Макрут

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

Это очень хорошее решение, особенно когда оно __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.