Maneiras elegantes de oferecer suporte à equivalência ("igualdade") nas classes Python


421

Ao escrever classes personalizadas, geralmente é importante permitir equivalência por meio dos operadores ==e !=. No Python, isso é possível implementando os métodos especiais __eq__e __ne__, respectivamente. A maneira mais fácil de encontrar isso é o seguinte método:

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)

Você conhece meios mais elegantes de fazer isso? Você conhece alguma desvantagem específica em usar o método acima para comparar __dict__s?

Nota : Um pouco de esclarecimento - quando __eq__e __ne__não definido, você encontrará este comportamento:

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

Ou seja, a == bavalia Falsecomo realmente executa a is b, um teste de identidade (ou seja, "É ao mesmo objeto que b?").

Quando __eq__e __ne__estiver definido, você encontrará esse comportamento (que é o que procuramos):

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

6
+1, porque eu não sabia que o dict usava a igualdade de membro para ==, presumi que ele os contasse apenas como iguais para o mesmo dict de objeto. Eu acho que isso é óbvio, já que o Python tem o isoperador para distinguir a identidade do objeto da comparação de valores.
SingleNegationElimination

5
Penso que a resposta aceita seja corrigida ou reatribuída à resposta de Algorias, para que a verificação estrita de tipo seja implementada.
max

1
Também certifique-se de hash é substituído stackoverflow.com/questions/1608842/...
Alex Punnen

Respostas:


328

Considere este problema simples:

class Number:

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


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

n1 == n2 # False -- oops

Portanto, o Python, por padrão, usa os identificadores de objeto para operações de comparação:

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

A substituição da __eq__função parece resolver o problema:

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

No Python 2 , lembre-se sempre de substituir a __ne__função também, conforme a documentação declara:

Não há relacionamentos implícitos entre os operadores de comparação. A verdade de x==ynão implica que isso x!=yseja falso. Assim, ao definir __eq__(), deve-se definir também __ne__()para que os operadores se comportem conforme o esperado.

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


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

No Python 3 , isso não é mais necessário, pois a documentação declara:

Por padrão, __ne__()delega __eq__()e inverte o resultado, a menos que seja NotImplemented. Não há outras relações implícitas entre os operadores de comparação, por exemplo, a verdade de (x<y or x==y)não implica x<=y.

Mas isso não resolve todos os nossos problemas. Vamos adicionar uma subclasse:

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

Nota: Python 2 possui dois tipos de classes:

  • de estilo clássico (ou de estilo antigo ) classes, que não herdamobjecte que são declarados comoclass A:,class A():ouclass A(B):ondeBé uma classe de estilo clássico;

  • classes de novo estilo , que herdamobjecte são declaradas comoclass A(object)ouclass A(B):ondeBé uma classe de novo estilo. O Python 3 possui apenas classes de novo estilo que são declaradas comoclass A:,class A(object):ouclass A(B):.

Para classes de estilo clássico, uma operação de comparação sempre chama o método do primeiro operando, enquanto para classes de novo estilo, sempre chama o método do operando da subclasse, independentemente da ordem dos operandos .

Então, aqui, se Numberé uma classe de estilo clássico:

  • n1 == n3chamadas n1.__eq__;
  • n3 == n1chamadas n3.__eq__;
  • n1 != n3chamadas n1.__ne__;
  • n3 != n1chamadas n3.__ne__.

E se Numberé uma classe de novo estilo:

  • ambos n1 == n3e n3 == n1ligar n3.__eq__;
  • ambos n1 != n3e n3 != n1ligue n3.__ne__.

Para corrigir o problema de não comutatividade dos operadores ==e !=para as classes de estilo clássico do Python 2, os métodos __eq__e __ne__devem retornar o NotImplementedvalor quando um tipo de operando não for suportado. A documentação define o NotImplementedvalor como:

Métodos numéricos e métodos de comparação avançados podem retornar esse valor se eles não implementarem a operação para os operandos fornecidos. (O intérprete tentará a operação refletida ou algum outro fallback, dependendo do operador.) Seu valor verdadeiro é verdadeiro.

Nesse caso, o operador delega a operação de comparação para o método refletido do outro operando. A documentação define métodos refletidos como:

Não há versões de argumento trocado desses métodos (a serem usadas quando o argumento esquerdo não suporta a operação, mas o argumento correto); sim, __lt__()e __gt__()são reflexo um do outro, __le__()e __ge__()são reflexo um do outro, e __eq__()e __ne__()são a sua própria reflexão.

O resultado fica assim:

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

Retornar o NotImplementedvalor em vez de Falseé a coisa certa a fazer, mesmo para as classes de novo estilo, se a comutatividade dos operadores ==e !=for desejada quando os operandos forem de tipos não relacionados (sem herança).

Já estamos lá? Nem tanto. Quantos números únicos temos?

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

Os conjuntos usam os hashes dos objetos e, por padrão, o Python retorna o hash do identificador do objeto. Vamos tentar substituí-lo:

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

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

O resultado final é semelhante a este (adicionei algumas afirmações no final para validação):

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())))não funcionará se houver objetos não hashable entre os valores de self.__dict__(ou seja, se algum dos atributos do objeto estiver definido como, digamos, a list).
máximo

3
É verdade, mas, em seguida, se você tem tais objetos mutáveis em suas vars () os dois objetos não são realmente iguais ...
Tal Weiss


1
Três observações: 1. No Python 3, não é mais necessário implementar __ne__: "Por padrão, __ne__()delega __eq__()e inverte o resultado, a menos que seja NotImplemented". 2. Se alguém ainda quer implementar __ne__, uma implementação mais genérico (aquele usado pelo Python 3 eu acho) é: x = self.__eq__(other); if x is NotImplemented: return x; else: return not x. 3. O dado __eq__e __ne__implementações são abaixo do ideal: if isinstance(other, type(self)):dá 22 __eq__e 10 __ne__chamadas, enquanto if isinstance(self, type(other)):daria 16 __eq__e 6 __ne__chamadas.
Maggyero 9/11

4
Ele perguntou sobre elegância, mas ficou robusto.
GregNash

201

Você precisa ter cuidado com a herança:

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

Verifique os tipos com mais rigor, assim:

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

Além disso, sua abordagem funcionará bem, é para isso que existem métodos especiais.


Este é um bom argumento. Suponho que vale a pena notar que a subclassificação de tipos incorporados ainda permite a igualdade em qualquer direção e, portanto, verificar se é do mesmo tipo pode até ser indesejável.
gotgenes 5/08/09

12
Eu sugiro retornar NotImplemented se os tipos forem diferentes, delegando a comparação aos rhs.
Max

4
A comparação @max não é necessariamente feita do lado esquerdo (LHS) para o lado direito (RHS), depois RHS para LHS; consulte stackoverflow.com/a/12984987/38140 . Ainda assim, retornar NotImplementedcomo você sugere sempre causará superclass.__eq__(subclass), qual é o comportamento desejado.
gotgenes 14/05

4
Se você tem uma tonelada de membros e não há muitas cópias de objetos por aí, geralmente é bom adicionar um teste inicial de identidade if other is self. Isso evita a comparação mais longa do dicionário e pode ser uma grande economia quando os objetos são usados ​​como chaves do dicionário.
Branco Dane

2
E não se esqueça de implementar__hash__()
Dane White

161

O jeito que você descreve é ​​o que eu sempre fiz. Como é totalmente genérico, você sempre pode dividir essa funcionalidade em uma classe mixin e herdá-la nas classes em que deseja essa funcionalidade.

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: Padrão de estratégia para facilitar a substituição nas subclasses.
24568 S.Lott

3
uma instância é péssima. Por que verificar isso? Por que não apenas self .__ dict__ == other .__ dict__?
Nosklo 26/12/08

3
@nosklo: Eu não entendo .. e se dois objetos de classes completamente independentes tiverem os mesmos atributos?
max

1
Eu pensei que nokslo sugeriu pular é uma instância. Nesse caso, você não sabe mais se otheré de uma subclasse de self.__class__.
max

10
Outro problema com a __dict__comparação é o que acontece se você tiver um atributo que não deseja considerar em sua definição de igualdade (por exemplo, um ID de objeto exclusivo ou metadados como um carimbo criado no tempo).
Adam Parkin

14

Não é uma resposta direta, mas parecia relevante o suficiente para ser abordada, pois economiza um pouco de tédio detalhado na ocasião. Corte direto dos documentos ...


functools.total_ordering (cls)

Dada uma classe que define um ou mais métodos avançados de pedido de comparação, esse decorador de classes fornece o restante. Isso simplifica o esforço envolvido na especificação de todas as operações possíveis de comparação rica:

A classe deve definir um dos __lt__(), __le__(), __gt__(), ou __ge__(). Além disso, a classe deve fornecer um __eq__()método.

Novo na versão 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
No entanto, o total_ordering tem armadilhas sutis: regebro.wordpress.com/2010/12/13/… . Estar ciente !
Mr_and_Mrs_D

8

Você não precisa substituir os dois __eq__e __ne__pode substituir apenas, __cmp__mas isso implicará no resultado de ==,! ==, <,> e assim por diante.

istestes para identidade de objeto. Isso significa que a isb ocorrerá Truequando aeb tiverem a referência ao mesmo objeto. No python, você sempre mantém uma referência a um objeto em uma variável e não no objeto real; portanto, essencialmente para a ser b, os objetos neles devem estar localizados no mesmo local da memória. Como e mais importante, por que você substitui esse comportamento?

Edit: Eu não sabia que __cmp__foi removido do python 3, para evitá-lo.


Porque às vezes você tem uma definição diferente de igualdade para seus objetos.
23468 Ed S.

o operador is dá-lhe a resposta intérpretes a identidade do objeto, mas você ainda está livre para expressar-lhe visualizar na igualdade, substituindo CMP
Vasil

7
No Python 3, "A função cmp () desapareceu e o método especial __cmp __ () não é mais suportado." is.gd/aeGv
gotgenes


2

Eu acho que os dois termos que você procura são igualdade (==) e identidade (é). Por exemplo:

>>> 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
Talvez, exceto que se possa criar uma classe que compare apenas os dois primeiros itens em duas listas, e se esses itens forem iguais, ele será avaliado como True. Acho que isso é equivalência, não igualdade. Perfeitamente válido em eq , ainda.
gotgenes 23/12/08

Concordo, no entanto, que "é" é um teste de identidade.
gotgenes 23/12/08

1

O teste 'is' testará a identidade usando a função 'id ()' incorporada, que basicamente retorna o endereço de memória do objeto e, portanto, não pode ser sobrecarregado.

No entanto, no caso de testar a igualdade de uma classe, você provavelmente deseja ser um pouco mais rigoroso sobre seus testes e comparar apenas os atributos de dados em sua 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

Este código compara apenas membros de dados não funcionais da sua classe, além de ignorar qualquer coisa particular que geralmente seja o que você deseja. No caso de Plain Old Python Objects, tenho uma classe base que implementa __init__, __str__, __repr__ e __eq__, para que meus objetos POPO não carreguem o fardo de toda essa lógica extra (e na maioria dos casos idêntica).


Bit nitpicky, mas 'is' testa usando id () apenas se você não definiu sua própria função de membro is_ () (2.3+). [ docs.python.org/library/operator.html]
passou

Eu suponho que "substituir" você realmente quer dizer "patch-monkey" no módulo do operador. Nesse caso, sua declaração não é totalmente precisa. O módulo de operadores é fornecido por conveniência e a substituição desses métodos não afeta o comportamento do operador "is". Uma comparação usando "is" sempre usa o id () de um objeto para a comparação; esse comportamento não pode ser substituído. Além disso, uma função de membro is_ não tem efeito na comparação.
mcrute

mcrute - falei cedo demais (e incorretamente), você está absolutamente certo.
spenthil

Essa é uma solução muito boa, especialmente quando a __eq__declaração for declarada em CommonEqualityMixin(veja a outra resposta). Achei isso particularmente útil ao comparar instâncias de classes derivadas de Base em SQLAlchemy. Para não comparar _sa_instance_state, mudei key.startswith("__")):para key.startswith("_")):. Eu também tinha algumas referências anteriores e a resposta de Algorias gerou uma recursão infinita. Então, nomeei todas as referências anteriores, começando com, '_'para que elas também sejam ignoradas durante a comparação. NOTA: no Python 3.x, mude iteritems()para items().
Wookie88

@mcrute Normalmente, __dict__uma instância não tem nada que comece com, a __menos que tenha sido definida pelo usuário. Coisas como __class__, __init__etc. não estão nas instâncias __dict__, mas na classe ' __dict__. OTOH, os atributos privados podem facilmente começar __e provavelmente devem ser usados __eq__. Você pode esclarecer o que exatamente estava tentando evitar ao ignorar __atributos predefinidos?
máximo

1

Em vez de usar subclasses / mixins, eu gosto de usar um decorador de classe genérico

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

Uso:

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

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

0

Isso incorpora os comentários na resposta de Algorias e compara objetos por um único atributo, porque eu não ligo para o ditado inteiro. hasattr(other, "id")deve ser verdade, mas eu sei que é porque eu o defini no construtor.

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
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.