Введение

Если вам когда-нибудь приходилось работать с 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. Также спикеры покажут пример небольшого приложения и осветят особенности развертывания эксплуатации.

Комментарии (9)


  1. avshkol
    12.05.2023 14:05
    +1

    Спасибо, до такого использования eq и им подобных сам бы не додумался…. Максимум, это просто реагировать на сравнение двух экземпляров классов между собой, выделяя для этого какой-то параметр класса.

    Как я понимаю, конструкция filtrable_l.filter(filtrable_l >= 2).filter(filtrable_l < 6) реализует AND на все условия, а если мы хотим OR реализовать?


    1. soymiguel
      12.05.2023 14:05
      +1

      Логические операции можно так же сделать через дандеры __or__ , __and__, __invert__. Посмотрите, например, как в джанге устроены Q-объекты https://github.com/django/django/blob/599f3e2cda50ab084915ffd08edb5ad6cad61415/django/db/models/query_utils.py#L35


    1. Dmitry89 Автор
      12.05.2023 14:05

      Вариантов как всегда 2, либо заколхозить метод orFilter(), либо, как коллега выше предлагал, переопределить еще парочку дандер-методов. Тут уж как вам по дизайну ближе)


      1. avshkol
        12.05.2023 14:05
        +1

        Есть ещё третий способ, реализованный в pandas query - передавать строку данных со специальными символами.


      1. avshkol
        12.05.2023 14:05
        +2

        Кстати, метод Filter(), реализующий AND, просто применяет условия к списку одно за одним, каждый раз отсекая лишнее.

        Метод orFilter(), чтобы реализовать OR, должен сначала запомнить все условия для OR, относящиеся к одному списку, и только потом применить их одновременно, что довольно нетривиальная задача!


        1. Dmitry89 Автор
          12.05.2023 14:05
          +1

          По идее, если уж делать полный вариант, OR и AND можно комбинировать, и надо что-то вроде expression tree построить. Кстати в SqlAlchemy в filter передается инстанс класса BinaryExpression.


  1. 0Bannon
    12.05.2023 14:05

    Насколько знаю, если __ eq__() был переопределён, то и __hash__() тоже надо переопределять. Или интсансы станут нехешируемы.


    1. ri_gilfanov
      12.05.2023 14:05

      Кажется в SQLAlchemy используются гибридные методы, по-разному работающие для класса и объекта.


  1. santjagocorkez
    12.05.2023 14:05

    assert при -O стирается. Зачем такое советовать?