3 дек. 2014 г.

Грязные технологии: фабрика классов на основе функции

Так уж получилось, что нам часто требуется определять одинаковые (или почти одинаковые) классы моделей SQLAlchemy для разных MetaData ну и, соответственно, с разными базовыми классами. Декларативно. Повсеместный copy-paste быстро надоел. Были мысли создавать второй класс путём копирования, но уж больно сложно получается: не так просто определить, где на какой глубине остановиться, а где заменить ссылки на что-то уже отзеркалированное. Гораздо проще сделать фабрику и создавать столько классов, сколько нужно. В питоне ж это просто:
def create_C(Base):
    class C(Base):
        id = Column(Integer, primary_key=True)
        # …
    return C
Всё хорошо, только вот отступ лишний появляется. Кому-о, может, и мелочь, а нам он сильно не понравился. Неужели нельзя без него? Ну типа класс задекорировать чем-о так, чтобы он в фабрику превратился. Проблема в том, что тело класса выполняется сразу и только один раз, и никакими декораторами это правило не отменить. Ну да, можно отменить: всего-то с байткодом чуть поколдовать. Только уж очень гразным хак получается и переносимость между версиями под большим вопросом.
Зато вот у функции тело можно выполнять когда захочешь и сколько угодно раз. Почему бы этим не воспользоваться и не превратить декоратором функцию в класс?
@create_class(Base)
def C():
    id = Column(Integer, primary_key=True)
    # …
    return locals()
Опять что-то лишнее, теперь return locals(). А в реальных задачах у нас появятся аргументы у функции (пространство имён с другими моделями, например — нам же надо как-то внешние ссылки да связи определять), которыми не захочется пространство имён класса засорять, так что строчка ещё усложнится.
Вот бы здорово было бы вернуть локально определённые переменные автоматически. И это как раз можно сделать. Немного оптимизации (зачем нам держать трейсер весь вызов, это же приличные накладные расходы?), немного уважения к тем, кто это будет потом отлаживать дебаггером или профилировать, и получается такой декоратор:
import sys, functools, inspect


def return_locals(func):
    '''Modifies decorated function to return its locals'''

    @functools.wraps(func)
    def wrap(*args, **kwargs):
        frames = []

        def tracer(frame, event, arg):
            frames.append(frame)
            sys.settrace(old_tracer)
            if old_tracer is not None:
                return old_tracer(frame, event, arg)

        old_tracer = sys.gettrace()
        sys.settrace(tracer)
        try:
            func(*args, **kwargs)
        finally:
            sys.settrace(old_tracer)
        assert len(frames) == 1
        argspec = inspect.getargspec(func)
        argnames = list(argspec.args)
        if argspec.varargs is not None:
            argnames.append(argspec.varargs)
        if argspec.keywords is not None:
            argnames.append(argspec.keywords)
        return {name: value 
                for name, value in frames.pop(0).f_locals.items()
                if name not in argnames}

    return wrap
Осталась мелочь, создать декоратор самой фабрики:
def create_class(*bases):
    def wrapper(func):
        return type(func.__name__, bases, return_locals(func)())
    return wrapper

3 комментария:

Andrew Svetlov комментирует...

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

Denis Otkidach комментирует...

Если исходить только из тех данных, которые я здесь привёл, то склонен с тобой согласиться. Есть только одно слабое оправдание: классов моделей бывает очень много и раздражение от лишнего отступа не такое уж и маленькое. Я постараюсь найти время, чтобы описать наши дальнейшие телодвижения, там оценка уже далеко не так очевидна.

Denis Otkidach комментирует...

Себе на будущее: в Python3 уже есть функция создания класса и можно поискать менее грязные решения.