python

Python 3.1: __cmp__

Für den Vergleich von eigenen Objekten genügte es mit Python2 die Methode __cmp__(self, other) zu implementieren, die einen Wert kleiner, gleich oder größer Null zurück gibt, je nach dem wie der Vergleich ausfällt. Das erinnert sehr an die guten alten C-Zeiten. Aus verschiedenen Gründen wurde das mit Python 3 geändert, u.a. möchte z.B. NumPy beim Vergleich von Vektoren kein einfaches bool zurück geben, sondern einen Vektor komponentenweise vergleichen und selber einen Vektor mit dem Ergebnis pro Komponente zurückzugeben. Das war mit der alten Syntax von __cmp__() nicht machbar.

Schon zu Python 2-Zeiten konnte man alternativ die Rich Comparison-Operatoren __lt__|__le__|__eq__|__ne__|__ge__|__gt__(self, other) für den Vergleich < | <= | = | != | >= | > implementieren. Das Implementieren der 6 statt einer Methode ist leider deutlich aufwendiger. Hilfreich dabei ist der Dekorator für Klassen @functools.total_ordering, womit es reicht, nur eine Methode zu implementieren, was aber zu Performance-Problemen führen kann.

Empfehlungen

__cmp__(self, other) schon jetzt durch die anderen Methoden ersetzen. Ansonsten läuft man Gefahr, dass der Vergleich sich unterschiedlich verhält, je nach dem ob man den Code unter Python 2 oder Python 3 ausführt.

Einen Unit-Test dafür schreiben, ob der Vergleich auch wirklich funktioniert.

Beispiel

#!/usr/bin/python3
class Person(object):
  def __init__(self, lastname, firstname):
    self.lastname = lastname
    self.firstname = firstname

  def __lt__(self, other):
    """
    >>> Person("Hahn", "Philipp") < Person('Schwardt', 'Sönke')
    True
    """
    return (self.lastname, self.firstname) < (other.lastname, other.firstname) if isinstance(other, Person) else NotImplemented

  def __le__(self, other):
    return (self.lastname, self.firstname) <= (other.lastname, other.firstname) if isinstance(other, Person) else NotImplemented

  def __eq__(self, other):
    return isinstance(other, Person) and (self.lastname, self.firstname) == (other.lastname, other.firstname)

  def __ne__(self, other):
    return not isinstance(other, Person) or (self.lastname, self.firstname) != (other.lastname, other.firstname)

  def __ge__(self, other):
    return (self.lastname, self.firstname) >= (other.lastname, other.firstname) if isinstance(other, Person) else NotImplemented

  def __gt__(self, other):
    return (self.lastname, self.firstname) > (other.lastname, other.firstname) if isinstance(other, Person) else NotImplemented

if __name__ == "__main__":
  import doctest
  doctest.testmod()

Und mann sollte tunlichst aufpassen, dass man den Vergleich richtig implementiert:

# Falsch:
def __lt__(self, other):
  """
  >>> Person("Hahn", "Philipp") < Person("Requate", "Arvid")
  True
  """
  return self.lastname < other.lastname or self.firstname < other.firstname

# Richtig(er):
def __lt__(self, other):
  return self.lastname < other.lastname or (self.lastname == other.lastname and self.firstname < other.firstname)

# Richtig (als Alternative zu oben):
def __lt__(self, other):
  if not isinstance(other, self.__class__):
    return NotImplemented
  if self.lastname < other.lastname:
    return True
  if self.lastname > other.lastname:
    return False
  assert self.lastname == other.lastname
  if self.firstname < other.firstname:
    return True
  assert self.firstname >= other.firstname
  return False

Achtung

In diesem Zusammenhang noch ein wichtiger Unterschied: Es gibt die Klasse NotImplementedError und eine Konstante NotImplemented. Erstere (Klasse) dient dazu, damit bei abstrakten Klassen zu signalisieren, dass eine Methode (noch) nicht implementiert ist:

class AbstractBase(object):
  def method(self):
    raise NotImplementedError

Die zweite (Konstante) dient dazu bei Vergleich zu signalisieren, dass dieser Vergleich nicht implementiert ist:

class GeometricObject(object):
  def __eq__(self, other):
    if not isinstance(other, GeometricObject):
      return NotImplemented
    return True

class Circle(GeometricObject):
  def __eq__(self, other):
    return False

Der Aufruf GeometricObject() == Circle() führt zu dem Aufruf GeometricObject().__eq__(Circle()), was zunächst NotImplemented liefert. Das führt dann dazu, dass Python automatisch den Vergleich umdreht und statt dessen Circle.__eq__(GeometricObject) eine 2. Chance gibt, den Vergleich in umgedrehter Weise nochmals durchzuführen. Das Ergebnis ist deswegen False.

Inzwischen erkennt Python auch eine Rekursion in diesen Aufrufen, aber man sollte trotzdem ein Auge darauf haben.

Merke:

  • raise NotImplemented ist immer falsch
  • return NotImplementedError ist auch falsch
Written on March 10, 2020