Статьи

Как реализовать свою собственную структуру данных в Python

Python предоставляет полноценную поддержку для реализации вашей собственной структуры данных с использованием классов и пользовательских операторов. В этом руководстве вы будете реализовывать пользовательскую структуру данных конвейера, которая может выполнять произвольные операции над своими данными. Мы будем использовать Python 3.

Структура данных конвейера интересна тем, что она очень гибкая. Он состоит из списка произвольных функций, которые можно применить к коллекции объектов и создать список результатов. Я воспользуюсь расширяемостью Python и использую символ конвейера («|») для построения конвейера.

Прежде чем углубляться во все детали, давайте рассмотрим очень простой конвейер в действии:

1
2
3
4
x = range(5) |
print(x)
 
[0, 2, 4, 6, 8]

Что тут происходит? Давайте разберем это шаг за шагом. Первый элемент range(5) создает список целых чисел [0, 1, 2, 3, 4]. Целые числа передаются в пустой конвейер, обозначенный Pipeline() . Затем в конвейер добавляется «двойная» функция, и, наконец, классная функция Ω завершает конвейер и заставляет его оценивать себя.

Оценка состоит из получения входных данных и применения всех функций в конвейере (в данном случае это просто двойная функция). Наконец, мы сохраняем результат в переменной с именем x и печатаем его.

Python поддерживает классы и имеет очень сложную объектно-ориентированную модель, включающую множественное наследование, миксины и динамическую перегрузку. Функция __init__() служит конструктором, который создает новые экземпляры. Python также поддерживает расширенную модель метапрограммирования, о которой мы не будем рассказывать в этой статье.

Вот простой класс с конструктором __init__() который принимает необязательный аргумент x (по умолчанию 5) и сохраняет его в self.x Он также имеет метод foo() который возвращает атрибут self.x умноженный на 3:

1
2
3
4
5
6
class A:
   def __init__(self, x=5):
       self.x = x
 
   def foo(self):
       return self.x * 3

Вот как создать его экземпляр с явным аргументом x и без него:

1
2
3
4
5
6
7
>>> a = A(2)
>>> print(a.foo())
6
 
a = A()
print(a.foo())
15

С Python вы можете использовать пользовательские операторы для ваших классов для лучшего синтаксиса. Существуют специальные методы, известные как «более сложные». «Dunder» означает «двойное подчеркивание». Эти методы, такие как «__eq__», «__gt__» и «__or__», позволяют использовать такие операторы, как «==», «>» и «|» с вашими экземплярами класса (объекты). Давайте посмотрим, как они работают с классом А.

Если вы попытаетесь сравнить два разных экземпляра A друг с другом, результат всегда будет False независимо от значения x:

1
2
>>> print(A() == A())
False

Это потому, что Python сравнивает адреса памяти объектов по умолчанию. Допустим, мы хотим сравнить значение х. Мы можем добавить специальный оператор «__eq__», который принимает два аргумента «self» и «other» и сравнивает их атрибут x:

1
2
def __eq__(self, other):
       return self.x == other.x

Давайте проверим:

1
2
3
4
5
>>> print(A() == A())
True
 
>>> print(A(4) == A(6))
False

Теперь, когда мы рассмотрели основы классов и пользовательских операторов в Python, давайте использовать его для реализации нашего конвейера. Конструктор __init__() принимает три аргумента: функции, ввод и терминалы. Аргумент «функции» — это одна или несколько функций. Эти функции являются этапами в конвейере, которые работают с входными данными.

Аргумент «input» — это список объектов, с которыми будет работать конвейер. Каждый элемент ввода будет обрабатываться всеми функциями конвейера. Аргумент «Terminal» представляет собой список функций, и при обнаружении одной из них конвейер оценивает себя и возвращает результат. Терминалы по умолчанию являются просто функцией печати (в Python 3 «печать» — это функция).

Обратите внимание, что внутри конструктора к терминалам добавляется таинственное «Ω». Я объясню это дальше.

Вот определение класса и конструктор __init__() :

01
02
03
04
05
06
07
08
09
10
11
class Pipeline:
   def __init__(self,
                functions=(),
                input=(),
                terminals=(print,)):
       if hasattr(functions, ‘__call__’):
           self.functions = [functions]
       else:
           self.functions = list(functions)
       self.input = input
       self.terminals = [Ω] + list(terminals)

Python 3 полностью поддерживает Unicode в именах идентификаторов. Это означает, что мы можем использовать классные символы, такие как «Ω» для имен переменных и функций. Здесь я объявил тождественную функцию с именем «Ω», которая служит в качестве терминальной функции: Ω = lambda x: x

Я мог бы также использовать традиционный синтаксис:

1
2
def Ω(x):
   return x

Здесь идет ядро ​​класса Pipeline. Для того, чтобы использовать «|» (символ трубы), нам нужно переопределить пару операторов. «|» Символ используется Python для побитового или целого числа. В нашем случае мы хотим переопределить его для реализации цепочки функций, а также для подачи ввода в начало конвейера. Это две отдельные операции.

Оператор «__ror__» вызывается, когда второй операнд является экземпляром конвейера, если первый операнд не является. Он считает первый операнд входным и сохраняет его в self.input и возвращает экземпляр Pipeline обратно (self). Это позволяет связывать больше функций позже.

1
2
3
def __ror__(self, input):
   self.input = input
   return self

Вот пример, где оператор __ror__() будет вызван: 'hello there' | Pipeline() 'hello there' | Pipeline()

Оператор «__or__» вызывается, когда первый операнд является конвейером (даже если второй операнд также является конвейером). Он принимает операнд как вызываемую функцию и утверждает, что операнд «func» действительно может быть вызван.

Затем он добавляет функцию к self.functions и проверяет, является ли функция одной из функций терминала. Если это терминал, тогда оценивается весь конвейер и возвращается результат. Если это не терминал, сам конвейер возвращается.

1
2
3
4
5
6
def __or__(self, func):
   assert(hasattr(func, ‘__call__’))
   self.functions.append(func)
   if func in self.terminals:
       return self.eval()
   return self

Когда вы добавляете все больше и больше нетерминальных функций в конвейер, ничего не происходит. Фактическая оценка откладывается до eval() метода eval() . Это может произойти либо путем добавления терминальной функции к конвейеру, либо путем непосредственного вызова eval() .

Оценка состоит из итерации всех функций в конвейере (включая функцию терминала, если таковая имеется) и их выполнения по порядку на выходе предыдущей функции. Первая функция в конвейере получает элемент ввода.

1
2
3
4
5
6
7
def eval(self):
   result = []
   for x in self.input:
       for f in self.functions:
           x = f(x)
       result.append(x)
   return result

Один из лучших способов использования конвейера — это применить его к нескольким наборам входных данных. В следующем примере определяется конвейер без входов и терминальных функций. Он имеет две функции: печально известную double функцию, которую мы определили ранее, и стандартную math.floor .

Затем мы предоставляем три разных входа. Во внутреннем цикле мы добавляем функцию терминала Ω когда вызываем ее для сбора результатов перед их печатью:

01
02
03
04
05
06
07
08
09
10
11
p = Pipeline() |
 
for input in ((0.5, 1.2, 3.1),
              (11.5, 21.2, -6.7, 34.7),
              (5, 8, 10.9)):
    result = input |
    print(result)
     
[1, 2, 6]
[23, 42, -14, 69]
[10, 16, 21]

Вы можете использовать функцию терминала print напрямую, но тогда каждый элемент будет напечатан в отдельной строке:

1
2
3
4
5
6
7
keep_palindromes = lambda x: (p for p in x if p[::-1] == p)
keep_longer_than_3 = lambda x: (p for p in x if len(p) > 3)
 
p = Pipeline() |
((‘aba’, ‘abba’, ‘abcdef’),) |
 
[‘abba’]

Есть несколько улучшений, которые могут сделать конвейер более полезным:

  • Добавьте потоковую передачу, чтобы она могла работать с бесконечными потоками объектов (например, чтение из файлов или сетевых событий).
  • Предоставьте режим оценки, в котором весь ввод предоставляется в виде единого объекта, чтобы избежать громоздкого обходного пути предоставления коллекции из одного элемента.
  • Добавьте различные полезные функции конвейера.

Python является очень выразительным языком и хорошо подходит для разработки собственной структуры данных и пользовательских типов. Возможность переопределять стандартные операторы очень мощна, когда семантика поддается такой записи. Например, символ канала («|») является очень естественным для конвейера.

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