Непрактичный python — пишем декоратор в одну строку +14


AliExpress RU&CIS

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

Дисклеймер

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

Пролог

Идея написать декоратор в 4 строки меня изначально никак не трогала. Было просто интересно написать декоратор. Но в процессе спортивный интерес взял верх. Изначально все начиналось с простого кэширующего декоратора.

data = {}  # словарь для сохаренния кэшируемых данных

def decor_cache(func):
  
    def wrapper(*args):
        # создаем ключ из назвнии функции которую  
        # кэшируем и аргументов которые передаем
        key = f"{func.__name__}{args}" 
        # проверяем кэшировли дунную функцию с аргументами 
        if args in data:
            return data[key]
        else:
            # если кэшируем впервые
            response = func(args) # запускаем функцию с аргументами 
            data[key] = response # кэшируем результат 
            return response
  
    return wrapper

Сейчас задача из 18 строк кода, 11 если удалить пробелы и комментарии, сделать 4 строки. Первое что приходит на ум, записать конструкцию if…else в одну строчку.

data = {}  # словарь для сохаренния кэшируемых данных

def decor_cache(func):
  
    def wrapper(*args):
        # создаем ключ из назвнии функции которую  
        # кэшируем и аргументов которые передаем
        key = f"{func.__name__}{args}"
        if not args in data
            # если кэшируем впервые
            response = func(args) # запускаем функцию с аргументами 
            data[key] = response # кэшируем результат 
        return data.get(key) if key in data else response
     
    return wrapper

Теперь у нас 15 строк кода против 18, и появился ещё один if, что создает дополнительную вычислительную нагрузку, но сегодня мы собрались не для улучшению performance. Давайте добавим в этот мир энтропии и немного copy-paste и упростим переменную key.

data = {}  # словарь для сохаренния кэшируемых данных

def decor_cache(func):
  
    def wrapper(*args):
        if not args in data
            # если кэшируем впервые
            response = func(args) # запускаем функцию с аргументами 
            data[f"{func.__name__}{args}"] = response # кэшируем результат 
        return data.get(f"{func.__name__}{args}") if args in data else response
     
    return wrapper

Теперь мы имеем 12 строк, без пробелов и комментариев 8 строк. Нам пока этого не достаточно, цель 4 строчки, и надо упростить ещё. Мы помним что декоратор — это функция которая должна возвращать callable объект (функцию). Функцией может быть и lambda! Значит мы можем упростить и функцию wrapper и заменить её на lambda — анонимную функцию. И возвращать из функции "декоратора", анонимную функцию. 

data = {}  # словарь для сохаренния кэшируемых данных

def decor_cache(func):
  cache = lambda *args: data.get(f"{func.__name__}{args}") if args in data else data[f"{func.__name__}{args}"] = func(args) 
  
  return lambda *args: cache(*args) if cache(*args) else data.get(f"{func.__name__}{args}")

Цель достигнута! Декоратор в 4 строки, чистого кода — получился. Как можно увидеть одной lambda функцией не обошлось, пришлось создать две lambda функции. Первая lambda делает две вещи: если объект уже был закешировав возвращаем ранее закешировнанное значение и кэширует объект если он не был ранее кэширован но в таком случае мы нечего не возвращаем. 

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

Конструкция получилась громоздкой, но все равно читаемая, более или менее — при условии если вы легко читаете lambda выражения. Много условностей, но это ещё не ад но мы к нему приближаемся. После всего проделанного пути все ещё кажется что можно ещё сократить количество строк кода. Мы уже вошли во вкус. Например те-же две lambda выражения можно совместить в одно выражение. Давайте объединим две lambda функции в одну. 

Для этого нам надо пойти на некоторое ухищрение, использовать тернарный оператор or. Тернарный оператор принимает два значения, справа и слева относительно себя, и пытается получить логический ответ True или False. Как оператор сравнения. Для того чтобы вычислить конструкцию слева и справа интерпретатор python выполнит код справа и слева. Слева у нас конструкция memory.update({f"{func.name}_{args[0]}": func(args[0])}) данное выражение вернет нам None метод update всегда будет возвращать нам None тернарный оператор воспримит этого как False и не будет это выводить, но главное что он выполнит этот код и мы обновим переменную memory. Справа у нас конструкция получения элемента по индексу из tupla, выражение простое и всегда будет давать результат, если в tuple будет запрашиваемый индекс.

data = {}  # словарь для сохаренния кэшируемых данных

def decor_cache(func):
    return lambda *args: memory.get(f"{func.__name__}_{args[0]}") if f"{func.__name__}_{args[0]}" in memory else (lambda: memory.update({f"{func.__name__}_{args[0]}": func(args[0])}) or args[0])()

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

data = {}  # словарь для сохаренния кэшируемых данных

decor_cache = lambda func: lambda *args: memory.get(f"{func.__name__}_{args[0]}") if f"{func.__name__}_{args[0]}" in memory else (lambda: memory.update({f"{func.__name__}_{args[0]}": func(args[0])}) or args[0])()

Нарушая все паттерны, нам удалось создать кэширующий декоратор, почти в одну строку. Почти, потому что формально у нас есть строка объявления переменной data. Это мне не давало покоя... примерно 10 минут, пока не вспомнил что в python есть функция globals().

Функция globals() возвращает словарь с глобальной таблицей символов, определённых в модуле. По сути выдает словарь глобальных переменных (ключ — имя переменной, значение — ссылка на объект). Так мы получаем возможность создавать переменные в одно выражение, одной строкой. Давайте тогда для создания переменной с пустым словарем, будем использовать следующую конструкцию:

globals().update({“memory”: {}})

И для получения значения переменной конструкцию с get:

globals().get(“memory”)

После чего мы применили это на наш декоратор, и теперь действительно все получилось уместить в одну строку.

decor_cache = lambda func: lambda *args: globals().get("memory").get(f"{func.__name__}_{args[0]}") if f"{func.__name__}_{args[0]}" in globals().get("memory") else (lambda : globals().get("memory").update({f"{func.__name__}_{args[0]}": func(args[0])}) or args[0])()

Отлично, казалось мы сделали не невозможное говнокод описали декоратор в одну строку. Немного сложно, и плохо читаемо, но конкретный пример нарачито спецально был доведен до предела, для демонстрации что lambda выражение это не сложный инструмент, и в качестве доказательства продемонстривать что любой код можно записать в одну строку.

Итоги

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

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




Комментарии (20):

  1. raven19
    /#23147538 / +2

    Может изменить заголовок на «Не---практичный ...»
    Будет ещё круче!
    Чтобы с самого начала было всем и совсем понятно, что правила русского языка к этому опусу не имеют никакого отношения!

  2. zazar
    /#23147828

    А по мне, так просто замечательно! Не знаю, вон, Думу на лампочке или пианино аплодируют, а однострочнику в Питоне — нет, что ли? Тоже имеет право на полчаса чьего-то времени.

    P.S. От "читаймо", конечно, чуть глаз не лопнул, но я их просто руками придерживал, пока не долистал до однострочника, ничего страшного.

  3. ovalsky
    /#23147876

    Я то ожидал сложность в ограничении 4 строки будет в соблюдении PEP8: E501

  4. Dartt0n
    /#23147878

    В питоне метод .get у dict возвращает значение, а если ключа не существует - None.

    Так же есть or, которая возвращает 1ое, если оно приводится к True, иначе 2ое.

    Таким обзразом None or default_value вернёт default_value.

    Это очень полезно для работы со словарями, ТК вы можете использовать такую связку

    data[key] = data.get(key) or func(args)

    return data[key]

    Превратить это в функцию, или если вам угодно, лямбда функцию, не составит труда, однако читаемость и лаконичность кода значительно повышаются.

    Если вам очень важно число обращений к data, лучше использовать if key in data.

    • egorro_13
      /#23148356

      data[key] = data.get(key) or func(args)

      Тут еще надо учитывать, что в словаре могут быть «пустые» значения ('', 0, False, [] и т.д.), тогда этот вариант будет делать не совсем то, что требуется

      • Dartt0n
        /#23148404

        Согласен, не учел. Тогда это можно сделать так:

        data[key] = func(args) if data.get(key) is None else data[key]

        Что уже не так лаконично (с ? : выглядело бы лучше, но это уже другой вопрос), но все ещё сокращает код.

        • ZyXI
          /#23148938

          None тоже можно поместить в словарь… Если писать код не в одну строку, то всегда лучше обрабатывать KeyError.

    • lorc
      /#23148446

      Вообще-то dict.get принимает два параметра. Второй — значение по умолчанию.
      Так что можно написать data[key] = data.get(key, func(args))

      • egorro_13
        /#23148810

        В этом случае func будет вызываться при каждом вызове get, а в случае or — только при False в первом условии

      • saatanaperkele
        /#23151196

        Сдается мне, что func(args) в этом случае вызовется до вызова метода get, поэтому «вообще то» тут не аргумент

    • thealfest
      /#23148738

      data[key] = data.get(key, func(args))

      • ZyXI
        /#23148944

        Ленивого исполнения аргументов в Python как-то не завезли. Вы в этом варианте всегда вызываете func(args).

  5. telesis
    /#23148008

    мне кажется такие «размышления» нужно публиковать, вопрос академический, но полезный для знакомства

  6. zazar
    /#23148082

    key = f"{func.__name__}{args}" 
    # проверяем кэшировли дунную функцию с аргументами 
    if args in data:
        return data.get(key)

    А разве не if key in data? Разве можно по части ключа в словаре что-то найти?

  7. void_one
    /#23148108

    В рамках академического изыскания хорошо еще почитать про stdlib: @functools.cache.

  8. ya_ne_znau
    /#23150986

    Вот моя версия, надеюсь, вы это вынесите:

    def cached(func, data={}, _s=object()): return lambda *args: ((data.__setitem__(f"{func.__name__}({args})", func(*args)), data[f"{func.__name__}({args})"])[1]) if data.get(f"{func.__name__}({args})", _s) is _s else data[f"{func.__name__}({args})"]

    Зачем здесь всё?

    1. data - собственно кеш, так потому что в 1 строку

    2. _s как sentinel тоже самое, но чтобы различать None после вызова и отсутствие результата

    Можно было бы впихнуть в параметры лямбду по получению ключа, но пока зачем.

    (data.__setitem__("<>", func(*args)), data["<>")[1]

    Это создание кортежа, в котором происходит запись результата функции и тут же рядом достается, потому что не везде есть walrus operator и вообще я его не очень люблю, зато __setitem__ является обычной функцией и возвращает ничего, то есть является валиднвм выражением в отличие от data["<>"] = func(*args).

    • Lol4t0
      /#23162460

      def cache(func): return lambda *a, **k: func.__dict__.setdefault(f'{a}:{k}', f'{a}:{k}' in func.__dict__ or func(*a, **k)) or func.__dict__[f'{a}:{k}']

      3 действия


      1. вызываем функцию только если элемента нет в кеше
        f'{a}:{k}' in func.__dict__ or func(*a, **k))
      2. записываем новое значение, если его нет в кеше
        func.__dict__.setdefault(f'{a}:{k}', ...
      3. возвращаем значение из кеша
        or func.__dict__[f'{a}:{k}']

  9. Nich_Chieftain
    /#23151124

    Чувак, который изучает python: «да кто это такие ваши декораторы и лямбды». Вот, держи, чисто академический пример:
    image

  10. n0str0m0
    /#23159530 / +1

    Такие штуки интересно поделать парой. Спасибо за пример :)