Skip to content

22

Содержание статьи

Если ты пишешь на Python, то наверняка видел в стандартных библиотеках определения методов, обернутых в двойные подчеркивания. Эти «магические» методы образуют многие из полезных интерфейсов, которыми ты постоянно пользуешься, — например, когда получаешь значение по номеру элемента или выводишь что-то через print. Эти методы можно и нужно использовать и в своих программах. Как — сейчас покажу.

Вообще, любой хорошо спроектированный язык определяет набор соглашений и применяет их в своей стандартной библиотеке. Соглашения могут касаться как чисто внешних признаков, вроде синтаксиса названий (CamelCase, snake_case), так и поведения объектов. Язык Python в этом смысле — весьма последовательный.

Синтаксис в Python нерасширяем, но зато интерфейсы взаимодействия между объектами хорошо определены и доступны любому разработчику. В отличие от Java в Python нет формальной концепции интерфейсов класса, любой класс может предоставлять любой интерфейс, достаточно определить методы с нужными именами и аргументами и убедиться, что их поведение соответствует ожидаемому.

INFO

Везде в статье речь идет о Python 3. Python 2.7 уже можно считать мертвым.

Интерфейсы в Python

Поскольку Python динамически типизирован, проверить соответствие класса объекта на этапе компиляции невозможно. Возможности для указания аннотаций типов из Python 3.5 предназначены прежде всего для внешних статических анализаторов и не используются во время выполнения. Явная проверка класса с помощью type() считается дурным тоном.

В крайнем случае можно использовать isinstance() — в отличие от type() эта функция возвращает True не только для самого класса, но и для всех его потомков. Проверка с помощью type() сломается при наследовании, именно поэтому люди к ней так плохо относятся.

Интерфейсы объектов определяются так называемыми магическими методами. По соглашению их имена окружаются двойным подчеркиванием. Метод __init__(), который служит конструктором класса, — пример, известный каждому. Почти каждая стандартная операция, включая форматированный вывод и арифметику, реализуется каким-то магическим способом.

WWW

Полное описание стандартных магических методов можно найти в разделе документации Data Model.

Для демонстрации мы напишем примитивную и медленную реализацию ассоциативного массива на основе списка из кортежей, «идентичную натуральной» в смысле интерфейса.

Делаем свой ассоциативный массив

Реализация будет очень простой — связный список из пар «ключ — значение». Например, эквивалент ассоциативного массива {1: 2, 3: 4} будет [(1, 2), (3, 4)]. Она значительно медленнее встроенной: например, поиск значения элемента по ключу будет требовать O(n) операций, в то время как встроенная требует O(1). Для демонстрации, впрочем, вполне сойдет.

Свой класс мы назовем Assoc. Определим класс и его конструктор:

class Assoc(object):
    def __init__(self, contents=[]):
        self._contents = contents

Для удобства тестирования мы сделали, чтобы начальное значение можно было передать в конструкторе, вроде Assoc([(1,2), (3,4)]).

Теперь приступим к магии! Будем считать, что код мы сохранили в файл assoc.py.

Добавляем строковые представления

В Python существуют два разных метода для получения строкового представления объектов: __repr__ и __str__. Различие между ними довольно тонкое, но существенное: __repr__, по замыслу, должен выдавать допустимое выражение Python, с помощью которого можно создать такой же объект. Это не всегда возможно, поэтому на практике у многих объектов он возвращает просто что-то такое, что позволяет разработчику идентифицировать объект, вроде <Foo object at 0x7f94fe2f22e8>. Именно он вызывается, если ввести имя переменной в интерактивном интерпретаторе.

Метод __str__ предназначен для вывода человекочитаемых данных. Его вызывают print и format, если он есть у объекта. Если его нет, они тоже обращаются к __repr__. Вызвать эти методы вручную можно с помощью функций str() и repr() соответственно.

В нашем случае вполне возможно выдать как машинное, так и человекочитаемое представление. Добавим в наш класс следующие методы:

def __repr__(self):
    return "Assoc({0})".format(self._contents)

def __str__(self):
    contents_string = ", ".join(map(lambda x: "{0}: {1}".format(repr(x[0]), repr(x[1])), self._contents))
    return "{{{0}}}".format(contents_string)

Теперь протестируем наши функции:

>>> from assoc import *
>>> a = Assoc([("foo", 1), ("bar", 2)])
>>> a

Assoc([('foo', 1), ('bar', 2)])

>>> print(a)
{foo: 1, bar: 2}

>>> print("My dict: {0}".format(a))
My dict: {foo: 1, bar: 2}

Длина и логическое представление

Для определения длины структуры данных применяется метод __len__.
Добавим в наш класс следующий тривиальный метод:

def __len__(self):
    return len(self._contents)

Убедимся, что он работает как ожидалось:

>>> len(Assoc([(1,2), (2,3)]))
2

>>> len(Assoc())
0

Мы привыкли, что любое значение в Python можно использовать в условном операторе. По соглашению пустые структуры данных (пустая строка, пустой список и так далее) в логическом контексте эквивалентны False. В общем случае Python преобразует объект в логическое значение с помощью метода __bool__.

Если метод __bool__ не определен, объект считается эквивалентным True. Мы могли бы легко определить его, например:

def __bool__(self):
    return (True if self._contents != [] else False)

Можно написать даже просто return bool(self._contents), пользуясь тем, что внутри это список и его пустота и так отлично определяется функцией bool().

Но мы воспользуемся особым случаем и совместим два метода в одном. Дело в том, что если у объекта есть метод __len__, но нет метода __bool__, то сначала вызывается __len__, и если он возвращает ноль, то значение объекта считается ложным. Убедимся в этом на практике:

>>> "Non-empty" if Assoc([1,2]) else "Empty"
'Non-empty'

>>> "Non-empty" if Assoc() else "Empty"
'Empty'

Работаем с индексами элементов

Поиск и присвоение значений элементам по индексу тоже не являются особой возможностью встроенных структур данных, а реализуются магическими методами, и каждый может определить их сам. Эти методы — __getitem__ и __setitem__.

Начнем с получения элементов по индексу. Поскольку наш словарь — просто связный список, мы воспользуемся встроенной функцией filter(). Если она вернет пустой список, мы будем считать, что такого элемента нет, и вызовем исключение KeyError, такое же, какое выдает встроенный ассоциативный массив.

def __getitem__(self, key):
    items = list(filter(lambda x: x[0] == key, self._contents))
    if not items:
        raise KeyError(key)
    else:
        return items[0][1]

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

def __setitem__(self, key, value):
    index = 0
    found = False
    for i in self._contents:
        if i[0] == key:
            found = True
            break
        else:
            index += 1
    if found:
        self._contents[index] = (key, value)
    else:
        self._contents.append((key, value))

Убедимся, что все работает:

>>> a = Assoc([("foo", 1), ("bar", 2)])

>>> a["bar"]
2

>>> a["bar"] = 3

>>> print(a)
{'foo': 1, 'bar': 3}

Работаем с циклами и проверяем принадлежность массиву

В текущем виде наш ассоциативный массив нельзя использовать в циклах — у него еще нет соответствующего интерфейса. Для этого нужно добавить метод __iter__. Документация говорит, что для ассоциативных массивов (mapping types) итерация идет по ключам.

Метод __iter__ должен возвращать объект-итератор с методом __next__(), который вызывает исключение StopIteration, если элементов не осталось. Такой объект можно создать с помощью yield, но мы не будем создавать свой. Извлечь ключи из нашего списка кортежей проще всего с помощью map(), а объект map и так является итератором:

def __iter__(self):
    return map(lambda x: x[0], self._contents)

Проверим на практике:

>>> a = Assoc([("foo", 1), ("bar", 2)])

>>> for k in a:
...     print(a[k])
...
1
2

Для проверки принадлежности элемента массиву мы могли бы определить метод __contains__(self, item). Но и здесь стандартная библиотека предоставляет нам возможность срезать углы: если у объекта есть метод __iter__, но нет __contains__, то оператор in проверит наличие элемента в том объекте-итераторе, который вернет __iter__.

>>> "foo" in a
True

Добавляем арифметические операции

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

Чтобы воспроизвести поведение обычных арифметических операций, которые не модифицируют свои аргументы, нам потребуется создать копию нашего массива. Добавим import copy в заголовок модуля.

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

Теперь добавим в наш класс магический метод __add__.

def __add__(self, other):
    if (not isinstance(other, dict)) or (not isinstance(other, Assoc)):
        raise TypeError("Cannot merge an Assoc with {0}".format(type(other)))
    tmp = copy.copy(self)
    for k in other:
        tmp[k] = other[k]
    return tmp

Убедимся, что все работает.

>>> a = Assoc([("foo", 1), ("bar", 2)])
>>> b = Assoc([("foo", 3), ("baz", 4)])

>>> print(a + b)
{'foo': 3, 'bar': 2, 'baz': 4}

Заключение

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

Back to top