пятница, 5 сентября 2014 г.

Перегрузка операций

В питоне буквально все используют магические методы. Когда пишем конструктор класса -- называем его __init__ и т.д.

Надеюсь, все умеют писать такие вещи, у меня нет желания останавливаться на основах подробней.

Поговорим о правильной перегрузке математических операций.

Создаем класс-точку

Итак, имеем точку в двухмерном пространстве:

class Point(object):

    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return 'Point({}, {})'.format(self.x, self.y)

В этом варианте сразу бросается в глаза недочет: точка позволяет изменять свои координаты. Это плохо, ведь точка с другими координатами -- это уже другая точка. И число int, в отличие от списка, не позволяет изменять себя -- только создавать новые в результате арифметических операций.

Обновленная версия:

class Point(object):

    def __init__(self, x, y):
        self._x = x
        self._y = y

    @property
    def x(self):
        return self._x

    @property
    def y(self):
        return self._y

Как взрослые люди мы используем джентельменское соглашение, по которому внешний пользователь класса не должен обращаться к именам, начинающимся с подчеркивания.

Над точками нужно производить какие-то операции. Самая, наверное, распространенная -- это сравнение.

Сравнение

class Point:

    # ...

    def __eq__(self, other):
        return self._x == other._x and self._y == other._y

Что плохо? То, что попытка сравнить точку с не-точкой (Point(1, 2) == 1) выбросит исключение AttributeError:

>>> Point(1, 2) == 1
AttributeError: 'int' object has no attribute '_x'

в то время как стандартные питоновские типы ведут себя иначе:

>>> 1 == 'a'
False

Меняем сравнение:

def __eq__(self, other):
    if not isinstance(other, Point):
        return False
    return self._x == other._x and self._y == other._y

Теперь сравнивание работает почти правильно:

>>> Point(1, 2) == Point(1, 2)
True

>>> Point(1, 2) == 1
False

Слово почти я употребил потому, что Питон работает так:

  • сначала пытается сделать сравнение a == b
  • если сравнение не дает результата -- делается вторая попытка с перестановкой операторов b == a

Чтобы сказать, что операция сравнения не дает результата -- нужно вернуть константу NotImplemented (не путать с исключением NotImplementedError):

def __eq__(self, other):
    if not isinstance(other, Point):
        return NotImplemented
    return self._x == other._x and self._y == other._y

В паре с == всегда идет оператор !=, не нужно про него забывать:

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

На самом деле Питон будет сам использовать метод __eq__ если __ne__ не определен, но я считаю что лучше и понятней написать __ne__ самому, тем более что это не трудно.

hash

Наши точки сравниваются, всё прекрасно. Но если мы захотим, скажем, использовать их как ключи в словаре -- получим ошибку:

>>> {Point(1, 2): 0}
TypeError: unhashable type: 'Point'

Нужно определить метод __hash__. Питон прекрасно умеет считать хэш для кортежа, чем мы и воспользуемся:

def __hash__(self):
    return hash((self._x, self._y))

Результат:

>>> {Point(1, 2): 0}
{Point(1, 2): 0}

Определять только __hash__ без __eq__/__ne__ неправильно: в случае коллизии задействуются операторы сравнения. Если они не определены -- можно получить некорректный результат.

Упорядочивание

Как говорил один преподаватель, не используйте слово "сортировка" -- оно очень созвучно слову "сортир".

Точки на плоскости не имеют естественного порядка. Поэтому реализовывать операторы упорядочивания (<, >, <=, >=) не нужно.

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

Если для какой-то цели вы придумали принцип упорядочивания для точек на плоскости -- сделайте это нормальным методом класса со своим именем, не нужно вводить в изумление пользователей.

Арифметика

Точки можно складывать и вычитать.

def __add__(self, other):
    if not isinstance(other, Point):
        return NotImplemented
    return Point(self._x + other._x, self._y + other._y)

def __sub__(self, other):
    if not isinstance(other, Point):
        return NotImplemented
    return Point(self._x - other._x, self._y - other._y)

Пример:

>>> Point(1, 2) + Point(2, 3)
Point(3, 5)

>>> Point(1, 2) - Point(2, 3)
Point(-1, -1)

Так как точки неизменяемые, то возвращается новый объект.

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

Как и для сравнения, если не знаем что делать -- возвращаем NotImplemented. Тогда Питон попробует переставить аргументы местами, но вызовет уже __radd__:

res = a.__add__(b)
if res is NotImplemented:
    res = b.__radd__(a)

Реализуем и эти методы:

def __radd__(self, other):
    if not isinstance(other, Point):
        return NotImplemented
    return other + self

def __rsub__(self, other):
    if not isinstance(other, Point):
        return NotImplemented
    return other - self

Зачем это нужно? Допустим, мы хотим складывать наши точки с QPoint из библиотеки PyQt, полуая в результате опять объекты класса Point.

Тогда нужно расширить наши __add__ и __radd__:

def __add__(self, other):
    if isinstance(other, Point):
        return Point(self._x + other._x, self._y + other._y)
    elif isinstance(other, QPoint):
        return Point(self._x + other.x(), self._y + other.y())
    return NotImplemented

def __radd__(self, other):
    if isinstance(other, Point):
        return Point(self._x + other._x, self._y + other._y)
    elif isinstance(other, QPoint):
        return Point(self._x + other.x(), self._y + other.y())
    return NotImplemented

Реализацию __iadd__/__isub__ рассматривать не буду, там всё очевидно. К тому же Питон сам способен сделать как надо, сконструировав нужный код на основе вызовов __add__/__sub__.

Умножение и деление для точек не имеют смысла, поэтому их просто не делаем. Если бы делали, скажем, операции над векторами -- ввели бы скалярное и векторное произведения. "Просто точкам" эти излишества не нужны.

Заключение

Вот и всё, набросок для класса точки готов.

Надеюсь, хотя бы некоторым читателям написанное окажется полезным.