Python. Выражения в методах и индексаторах

ca3b561e8d60af8cc441c5bde55fde1a.png

Введение

Если вам когда-нибудь приходилось работать с NumPy, то вы скорее всего знаете, что в индексатор массива можно передать не только индексы начала, конца, и шага. Потрясающая возможность получить срез массива по некоторому условию, в виде data[data > 0] предает массивам NumPy некоторое сходство с СУБД.

Тут же можно вспомнить про SqlAlchemy и возможность передать в функцию filter некоторое условие для отбора записей session.query(MyModel).filter(MyModel.field == 10).

Отличные, в общем-то возможности, не так ли? Не возникало ли у вас вопроса как они работают внутри? data > 0 и MyModel.field == 10 с точки зрения грамматики языка являются выражениями, и при передаче куда-либо Python попытается вычислить их значения. Можно даже проиллюстрировать это на простом примере:

class A:
    b = None

def filter(*args, **kwargs):
    print(f"args: {args}")
    print(f"kwargs: {kwargs}")

filter(A.b == 1)

Результат будет следующим:

args: (False,)
kwargs: {}

Выражение A.b == 1 было вычислено, и результат стал аргументом, что и не удивительно, однако в SqlAlchemy это как-то работает. Попробуем разобраться как.

Magic (or dunder) methods

В Python (как и в некоторых других языках) присутствует концепция т.н. «магических методов» (их еще называют dunder из-за двойных нижних подчеркиваний). Суть магических методов — предоставлять реализацию некоторого поведения объекта, при использовании этого объекта в различных контекстах. Если обратиться к достаточно заезженному, в части разъяснения аспектов ООП, примеру про класс Animal, и попытаться ответить себе на вопрос, что будет, если животное прибавить к животному — то в первую очередь следует подумать не о результате операции, а о том коде, который будет вызван при выполнении операции сложения.

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

В примерах выше работа происходит в магических методах, отвечающих за реализацию операций сравнения. Это методы __eq__, __ne__, и другие, реализующие логику операций ==, !=, >, <, >=, <=. С одной стороны результатом любой логической операции должно являться логическое значение, однако Python не накладывает на возвращаемые магическими методами значения жестких ограничений. Фактически, вернуть можно все, что угодно, однако, при использовании объектов с предопределенными таким образом магическими методами в коде, где предполагается именно булево значение (например условия), Python будет приводить возвращаемое значение к bool.

Допустим, мы хотим сделать класс-обертку на коллекцией (над списком, для упрощения). Обертка должна иметь метод filter, принимающий некоторое условие отбора, и накладывающий это условие на оборачиваемую коллекцию. Сделаем следующее:

  1. Переопределим метод __eq__ таким образом, чтобы он возвращал не результат сравнения, а объект, хранящий информацию об операции и операндах

  2. Метод filter научим принимать в объект с такой информацией, и хранить его внутри фильтруемой коллекции

На выходе получается такой код:

class Criteria:
    def __init__(self, operation, argument):
        self.operation = operation
        self.argument = argument

class FiltrableCollection:
    def __init__(self, wrapped_collection: list):
        self._conditions = []
        self._collection = iter(wrapped_collection)

    def __iter__(self):
        return self

    def __next__(self):
        while True:
            element = next(self._collection)
            for cond in self._conditions:
                if cond.operation == 'eq':
                    if element == cond.argument:
                        return element

    def __eq__(self, other):
        return Criteria('eq', other)

    def filter(self, criteria):
        self._conditions.append(criteria)
        return self

Класс Criteria будет хранить информацию о типе операции сравнения, и втором операнде, а класс FiltrableCollection оборачивает коллекцию, предоставляет итератор, и выполняет фильтрацию.

Использовать FiltrableCollection можно следующим образом:

l = [1,2,1,4,1,6]
filtrable_l = FiltrableCollection(l)

for i in filtrable_l.filter(filtrable_l == 1):
    print(i)

В этом примере filtrable_l == 1 за счет возврата методом __eq__ инстанса класса Criteria, а не булева типа, передает в filter критерий отбора. Метод filter сохраняет его внутри инстанса FiltrableCollection. Далее, при проходе циклом по инстансу FiltableCollection, логика метода __next__ применяет условие к очередному элементу исходной коллекции. Но, одной эквивалентностью сыт не будешь. Можно доопределить реализацию FiltrableCollection, чтобы поддержать все условия сравнения.

class Criteria:
    def __init__(self, operation, argument):
        self.operation = operation
        self.argument = argument

class FiltrableCollection:
    def __init__(self, wrapped_collection: list):
        self._conditions = []
        self._collection = iter(wrapped_collection)

    def __iter__(self):
        return self

    def __next__(self):
        while True:
            element = next(self._collection)
            try:
                for cond in self._conditions:
                    if cond.operation == 'eq':
                        assert element == cond.argument
                    elif cond.operation == 'ne':
                        assert element != cond.argument
                    elif cond.operation == 'lt':
                        assert element < cond.argument
                    elif cond.operation == 'le':
                        assert element <= cond.argument
                    elif cond.operation == 'gt':
                        assert element > cond.argument
                    elif cond.operation == 'ge':
                        assert element >= cond.argument
                return element
            except AssertionError:
                pass

    def __eq__(self, other):
        return Criteria('eq', other)

    def __ne__(self, other):
        return Criteria('ne', other)

    def __lt__(self, other):
        return Criteria('lt', other)

    def __le__(self, other):
        return Criteria('le', other)

    def __gt__(self, other):
        return Criteria('gt', other)

    def __ge__(self, other):
        return Criteria('ge', other)

    def filter(self, criteria):
        self._conditions.append(criteria)
        return self

Теперь в методе filter можно использовать разные операции сравнений, и накладывать несколько условий одновременно.

l = [1,2,1,4,1,6]
filtrable_l = FiltrableCollection(l)

for i in filtrable_l.filter(filtrable_l >= 2).filter(filtrable_l < 6):
    print(i)

Условие для отбора в индексаторе делается не сложнее. За доступ к элементу по ключу отвечает магический метод __getitem__. Метод принимает на вход в качестве аргумента ключ, по которому и производится поиск. Собственно, как и в истории со сравнением, в качестве ключа можно передать инстанс уже реализованного класса Criteia, по которому и будет проведен отбор.

def __getitem__(self, key):
    result = []
    while True:
        try:
            element = next(self._collection)
        except StopIteration:
            break
            
        try:
            if key.operation == 'eq':
                assert element == cond.argument
            elif key.operation == 'ne':
                assert element != cond.argument
            elif key.operation == 'lt':
                assert element < cond.argument
            elif key.operation == 'le':
                assert element <= cond.argument
            elif key.operation == 'gt':
                assert element > cond.argument
            elif key.operation == 'ge':
                assert element >= cond.argument
            result.append(element)
        except AssertionError:
            pass
    return result

Теперь для FiltrableCollection можно использовать индексатор с условием, который выдаст все подходящие элементы:

l = [1,2,1,4,1,6]

filtrable_l = FiltrableCollection(l)
filtrable_l[filtrable_l > 1]

Несмотря на успешную реализацию, следует учитывать алгоритмическую сложность такого подхода. Для всех примеров кода, приведенных в статье, O (N) (и это как минимум) — наш друг, товарищ, и брат.

References

В завершение хочу порекомендовать вам бесплатный вебинар, на котором будут рассмотрены основы разработки API с помощью фреймворка FastAPI. Также спикеры покажут пример небольшого приложения и осветят особенности развертывания эксплуатации.

© Habrahabr.ru