Перейти к главному содержимому

Цепочка вызова методов в Python

Каждый программист сталкивался с цепочками вызова методов, но не все задумываются о том, как реализуется данный паттерн. Принцип использования цепочки вызова методов заложен в API множества программных продуктов, например, Django QuerySet, селекторы jQuery, elasticsearch-dsl Query и так далее.

Зачем это нужно? Такой подход повышает читабельность программы. Сравните два примера:

car = Car()
car.color('red')
car.mark('BMW')
car.model('M3')
car = Car().color('red').mark('BMW').model('M3')

Второй пример короче и проще читается.

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

Тут есть нюанс. В классической реализации методы изменяют состояние объекта, на котором вызываются, что приводит к нарушению принципа DRY и не позволяет делать такие вещи:

# задача — создать две красных машины разных моделей
car = Car().color('red').mark('BMW')
m3 = car.model('m3')
m4 = car.model('m4')

В данном случае объект класса Car — изменяемый (mutable), а поэтому id(car) == id(m3) == id(m4), то есть это один объект.

Данное поведение не является ошибкой, когда действительно нужно изменять состояние объекта.

В противном случае, на каждом шаге необходимо возвращать копию объекта (необходимо учитывать повышенный расход памяти и процессорного времени). Такой подход используется в Django QuerySet:

active_records = Record.objects.active()  # QuerySet с базовым состоянием
new_records = active_records.new()  # Копия предыдущего QuerySet с фильтром по новым записям
old_records = active_records.old()  # Ещё одна копия QuerySet с фильтром по старым записям

Итак, привожу мою реализацию, с тестами. Скачать исходник вы можете из этого gist:

import copy
from collections import namedtuple, Sequence


Item = namedtuple('Item', 'name, price')

items = [
    Item('apple', 10.0),
    Item('banana', 12.0),
    Item('orange', 8.0),
    Item('coconut', 50.0),
]


class Query(Sequence):
    def __init__(self, items):
        self.items = items

    def __getitem__(self, i):
        return self.items[i]

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

    def _clone(self):
        return copy.deepcopy(self)

    def first(self):
        q = self._clone()
        return next(iter(q), None)

    def last(self):
        q = self._clone()
        try:
            return q[-1]
        except KeyError:
            return None

    def values(self, attr):
        q = self._clone()
        return [getattr(o, attr) for o in q]

    def filter(self, cond):
        """
        Filter by condition.
        Condition must be a function taking one argument (an object),
        and returning True or False.
        """
        q = self._clone()
        return Query([o for o in q if cond(o)])

    @property
    def total(self):
        return sum(self.values('price'))


if __name__ == "__main__":
    q = Query(items)
    q2 = Query(items)
    assert id(q.items) != id(q2.items)

    assert q.first() == items[0]
    assert q.last() == items[-1]

    assert q.values('name') == ['apple', 'banana', 'orange', 'coconut']
    assert q.values('price') == [10.0, 12.0, 8.0, 50.0]

    assert q.filter(lambda x: x.price > 30).values('name') == ['coconut']
    assert q.filter(lambda x: x.price > 30).total == 50.0

    assert q.total == 80.0

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

Удачного кодинга!

Комментарии

Comments powered by Disqus