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

Абстрактные классы для коллекций

Пусть мы сделали какой-то класс для хранения набора данных. Например, настроек вида ключ -> значение:
class Settings:

    def __init__(self):
        self._data = {}

    def add_property(self, key, value):
        assert isinstance(key, str), key
        self._data[key] = value

    def get_property(self, key):
        assert isinstance(key, str), key
        return self._data[key]
И тут нам в голову приходит удачная идея, что было бы здорово вместо вызова settings.get_property('key') использовать квадратные скобки как для dict: settings['key']:
def __getitem__(self, key):
    return self.get_property(key)
Что не так?
То, что наш класс стал отчасти походить на readonly dict (он же mapping) -- но он не реализует весь предполагаемый контракт.
Так, я привык, что если класс похож на readonly dict, то он позволяет узнать количество элементов в нём. Добавляем __len__:
def __len__(self):
    return len(self._data)
Всё ещё не хорошо. Для mapping обычно можно итерироваться по ключам. Добавление __iter__ решает проблему:
def __iter__(self):
    return iter(self._data)
Всё? Нет! Хочется ещё проверять на наличие ключа: key in settings -- dict ведь это позволяет!
Можем добавить метод __contains__ -- а можем вспомнить, что есть класс collections.abc.Mapping.
Это абстрактный базовый класс, задающий контракт для неизменяемого словаря.
Описание того, что таке абстрактный базовый класс -- здесь
Просто наследуемся от Mapping:
from collections.abc import Mapping

class Settings(Mapping):

    # ...
В качестве бесплатного бонуса получам поддержку .get(), .keys(), .items(), .values(), __eq__ и __ne__.
Реализация этих методов не оптимальная с точки зрения производительности, но она уже есть из коробки. К тому же всегда можно добавть свой вариант, который будет работать быстрее стандартного (если мы знаем как это сделать).
Если мы забудем реализовать какой-то критически важный метод -- при создании экземпляра класса получим исключение:
>>> settings = Settings()
TypeError: Can't instantiate abstract class Settings with abstract methods __iter__
В стандартной библиотеке есть большой набор абстрактных базовых классов:
  • ByteString
  • Callable
  • Container
  • Hashable
  • ItemsView
  • Iterable
  • Iterator
  • KeysView
  • Mapping
  • MappingView
  • MutableMapping
  • MutableSequence
  • MutableSet
  • Sequence
  • Set
  • Sized
  • ValuesView
Очень рекомендую изучить набор методов, реализуемых этими классами -- помогает понять систему типов собственно Питона.
При необходимаости можно (и нужно) написать свои.
А в заключение забавный пример.
В библиотеке sqlalchemy есть класс RowProxy для строки-кортежа, получаемой в результате SQL запроса.
Класс выглядит как mapping: имеет длину, .keys(), .items(), .__contains__() и все прочие нужные методы. Позволяет получать значение как по позиционному номеру так и по названию колонки в базе данных.
При этом он реализует контракт Sequence (как у tuple).
Т.е. iter(row) возвращает данные, а не названия колонок. И это немного сбивает с толку: выглядит как утка, а крякает как поросёнок.
В оправдание sqlalchemy могу сказать, что RowProxy появился в самой первой версии алхимии, еще до того как в Питон добавили collections.abc. А потом что-то менять стало поздно.
Но сейчас при разработке собственных библиотек стоит придерживаться устоявшихся стандартов и активно применять абстрактные базовые классы для коллекций.

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

В питоне буквально все используют магические методы. Когда пишем конструктор класса -- называем его __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__.

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

Заключение

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

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

понедельник, 1 сентября 2014 г.

Вакансии в Levelup

Level Up, где я работаю, снова набирает питощиков.

Уровень специалистов этого набора -- от среднего и выше.

Работать предстоит над созданием внутренних продуктов (да-да, мы -- продуктовая компания).

В основном это REST сервисы с некоторым количеством Web интерфеса.

Новые продукты делаем на Python 3, асинхронное сетевое программирование на asyncio -- так что не скучаем.

Платят деньгами.
Работа в офисе на Подоле (Киев).
Традиционный полный рабочий день, зефир в холодильнике и абонементы на спортзал/бассен, всё такое прочее.
Если вакансия заинтересовала — пишите на andrey.svetlov@levelupers.com