Как сделать свою структуру данных в Python совместимой с фичами

Python

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Заключение

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

Понравилась статья? Поделиться с друзьями:
Добавить комментарий