Элегантные способы поддержки эквивалентности ("равенства") в классах Python

При написании пользовательских классов часто важно разрешить эквивалентность посредством == а также != операторы. В 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

12 ответов

Решение

Рассмотрим эту простую проблему:

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 not NotImplemented:
        return not x
    return NotImplemented

Возвращение 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

Вы должны быть осторожны с наследованием:

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

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

То, как вы описываете, это то, как я всегда это делал. Поскольку он полностью универсален, вы всегда можете разбить эту функциональность на класс 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

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


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

Вам не нужно переопределять оба __eq__ а также __ne__ вы можете переопределить только __cmp__ но это повлияет на результат ==,!==, <,> и так далее.

is тесты на предмет идентичности. Это означает is б будет True в случае, когда a и b содержат ссылку на один и тот же объект. В python вы всегда держите ссылку на объект в переменной, а не на фактический объект, поэтому, по существу, для того, чтобы a b было истинным, объекты в них должны быть расположены в одной и той же ячейке памяти. Как и самое главное, почему бы вам не изменить это поведение?

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

Из этого ответа: /questions/30635972/python-ya-dolzhen-realizovat-operator-ne-na-osnove-eq/30635990#30635990 Я продемонстрировал это, хотя это правильно определить __ne__ с точки зрения __eq__ - вместо

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

вы должны использовать:

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

Я думаю, что вы ищете два термина: равенство (==) и идентичность (есть). Например:

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

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

Другой элегантный способ поддержки эквивалентности — использовать@dataclass. ТвойFooпример тогда станет:

      from dataclasses import dataclass

@dataclass
class Foo:
    item: int

Вот и все! Теперь поведение следующее:

      a = Foo(1)
b = Foo(1)
print(a == b)  # True
c = Foo(2)
print(a == c)  # False

Если вашему классу необходимо предоставить другие атрибуты экземпляра, которые не должны играть роли в эквивалентности, определите их в__post_init__, вот так:

      from dataclasses import dataclass
from random import randint

@dataclass
class Foo:
    age: int
    name: str
    
    def __post_init__(self):
        self.rnd = randint(1, 100000)

a = Foo(38, "Helen")
b = Foo(38, "Helen")
print(a == b)  # True
print(a.rnd == b.rnd)  # False, probably ;-)

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

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

Это включает комментарии к ответу Алгориаса и сравнивает объекты по одному атрибуту, потому что меня не волнует весь dict. 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

Я написал собственную базу с реализацией по умолчанию __ne__ это просто отрицает:

      class HasEq(object):
  """
  Mixin that provides a default implementation of ``object.__neq__`` using the subclass's implementation of ``object.__eq__``.

  This overcomes Python's deficiency of ``==`` and ``!=`` not being symmetric when overloading comparison operators
  (i.e. ``not x == y`` *does not* imply that ``x != y``), so whenever you implement
  `object.__eq__ <https://docs.python.org/2/reference/datamodel.html#object.__eq__>`_, it is expected that you
  also implement `object.__ne__ <https://docs.python.org/2/reference/datamodel.html#object.__ne__>`_

  NOTE: in Python 3+ this is no longer necessary (see https://docs.python.org/3/reference/datamodel.html#object.__ne__)
  """

  def __ne__(self, other):
    """
    Default implementation of ``object.__ne__(self, other)``, delegating to ``self.__eq__(self, other)``.

    When overriding ``object.__eq__`` in Python, one should also override ``object.__ne__`` to ensure that
    ``not x == y`` is the same as ``x != y``
    (see `object.__eq__ <https://docs.python.org/2/reference/datamodel.html#object.__eq__>`_ spec)

    :return: ``NotImplemented`` if ``self.__eq__(other)`` returns ``NotImplemented``, otherwise ``not self.__eq__(other)``
    """
    equal = self.__eq__(other)
    # the above result could be either True, False, or NotImplemented
    if equal is NotImplemented:
      return NotImplemented
    return not equal

Если вы унаследованы от этого базового класса, вам нужно только реализовать __eq__ и база.

Оглядываясь назад, можно было бы подумать, что лучшим подходом было бы вместо этого реализовать его как декоратор. Что-то вроде@functools.total_ordering

Другие вопросы по тегам