Элегантные способы поддержки эквивалентности («равенства») в классах 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.