Повысьте свои навыки программирования

12 декораторов Python, которые выведут ваш код на новый уровень

Делайте больше с меньшим количеством кода без ущерба для качества

Декораторы Python — это мощные инструменты, помогающие создавать чистый, пригодный для повторного использования и поддерживаемый код.

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

Сегодня никаких громких вступлений и длинных теоретических определений.

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

Если вы разработчик Python, этот пост расширит ваш набор инструментов полезными сценариями, чтобы повысить вашу производительность и избежать дублирования кода.

Меньше разговоров, предлагаю перейти к коду прямо сейчас 💻.

Новичок в Medium? Вы можете подписаться за 5 долларов в месяц и разблокировать неограниченное количество статей, которые я пишу о программировании, MLOps и проектировании систем, чтобы помочь специалистам по данным (или инженерам ML) создавать более качественный код.



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

1 — @logger (для начала)✏️

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

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

Результат декорируемой функции будет выглядеть так:

some_function(args)

# ----- some_function: start -----
# some_function executing
# ----- some_function: end -----

Чтобы написать этот декроатор, сначала нужно выбрать подходящее имя: назовем его logger.

logger – это функция, которая принимает функцию на вход и возвращает функцию на выходе. Выходная функция обычно является расширенной версией входной. В нашем случае мы хотим, чтобы выходная функция окружала вызов входной функции операторами start и end.

Поскольку мы не знаем, какие аргументы использует входная функция, мы можем передать их из функции-оболочки, используя *args и **kwargs. Эти выражения позволяют передавать произвольное количество позиционных и ключевых аргументов.

Вот простая реализация декоратора logger:

def logger(function):
    def wrapper(*args, **kwargs):
        print(f"----- {function.__name__}: start -----")
        output = function(*args, **kwargs)
        print(f"----- {function.__name__}: end -----")
        return output
    return wrapper

Теперь вы можете применить logger к some_function или любой другой функции в этом отношении.

decorated_function = logger(some_function)

Python предоставляет для этого более питонический синтаксис, он использует символ @.

@logger
def some_function(text):
    print(text)

some_function("first test")
# ----- some_function: start -----
# first test
# ----- some_function: end -----

some_function("second test")
# ----- some_function: start -----
# second test
# ----- some_function: end -----

2 — @обертывания 🎁

Этот декоратор обновляет функцию-оболочку, чтобы она выглядела как исходная функция, и наследует ее имя и свойства.

Чтобы понять, что делает @wraps и почему вы должны его использовать, давайте возьмем предыдущий декоратор и применим его к простой функции, которая складывает два числа.

(Этот декоратор еще не использует @wraps)

def logger(function):
    def wrapper(*args, **kwargs):
        """wrapper documentation"""
        print(f"----- {function.__name__}: start -----")
        output = function(*args, **kwargs)
        print(f"----- {function.__name__}: end -----")
        return output
    return wrapper

@logger
def add_two_numbers(a, b):
    """this function adds two numbers"""
    return a + b

Если мы проверим имя и документацию оформленной функции add_two_numbers, вызвав атрибуты __name__ и __doc__, то получим… неестественные (и все же ожидаемые) результаты:

add_two_numbers.__name__
'wrapper'

add_two_numbers.__doc__
'wrapper documentation'

Вместо этого мы получаем название оболочки и документацию ⚠️

Это нежелательный результат. Мы хотим сохранить исходное имя функции и документацию. Вот тогда и пригодится @wrapsдекоратор.

Все, что вам нужно сделать, это украсить функцию-обертку.

from functools import wraps

def logger(function):
    @wraps(function)
    def wrapper(*args, **kwargs):
        """wrapper documentation"""
        print(f"----- {function.__name__}: start -----")
        output = function(*args, **kwargs)
        print(f"----- {function.__name__}: end -----")
        return output
    return wrapper

@logger
def add_two_numbers(a, b):
    """this function adds two numbers"""
    return a + b

Перепроверив имя и документацию, мы видим исходные метаданные функции.

add_two_numbers.__name__
# 'add_two_numbers'

add_two_numbers.__doc__
# 'this function adds two numbers'

3 — @lru_cache 💨

Это встроенный декоратор, который вы можете импортировать из functools.

Он кэширует возвращаемые функцией значения, используя алгоритм наименее использовавшихся (LRU) для отбрасывания наименее использованных значений при заполнении кеша.

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

В следующем примере я использую lru_cache для оформления функции, которая имитирует некоторую обработку. Затем я применяю функцию к одному и тому же входу несколько раз подряд.

import random
import time
from functools import lru_cache


@lru_cache(maxsize=None)
def heavy_processing(n):
    sleep_time = n + random.random()
    time.sleep(sleep_time)

# first time
%%time
heavy_processing(0)
# CPU times: user 363 µs, sys: 727 µs, total: 1.09 ms
# Wall time: 694 ms

# second time
%%time
heavy_processing(0)
# CPU times: user 4 µs, sys: 0 ns, total: 4 µs
# Wall time: 8.11 µs

# third time
%%time
heavy_processing(0)
# CPU times: user 5 µs, sys: 1 µs, total: 6 µs
# Wall time: 7.15 µs

Если вы хотите реализовать декоратор кеша самостоятельно с нуля, вот как вы это сделаете:

  • Вы добавляете пустой словарь в качестве атрибута функции-оболочки для хранения ранее вычисленных значений функцией ввода.
  • При вызове функции ввода вы сначала проверяете, присутствуют ли ее аргументы в кеше. Если это так, верните результат. В противном случае вычислите его и поместите в кеш.
from functools import wraps

def cache(function):
    @wraps(function)
    def wrapper(*args, **kwargs):
        cache_key = args + tuple(kwargs.items())
        if cache_key in wrapper.cache:
            output = wrapper.cache[cache_key]
        else:
            output = function(*args)
            wrapper.cache[cache_key] = output
        return output
    wrapper.cache = dict()
    return wrapper

@cache
def heavy_processing(n):
    sleep_time = n + random.random()
    time.sleep(sleep_time)


%%time
heavy_processing(1)
# CPU times: user 446 µs, sys: 864 µs, total: 1.31 ms
# Wall time: 1.06 s

%%time
heavy_processing(1)
# CPU times: user 11 µs, sys: 0 ns, total: 11 µs
# Wall time: 13.1 µs

4 — @повторить 🔁

Этот декоратор заставляет функцию вызываться несколько раз подряд.

Это может быть полезно для целей отладки, стресс-тестов или автоматизации повторения нескольких задач.

В отличие от предыдущих декораторов, этот ожидает входной параметр.

def repeat(number_of_times):
    def decorate(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(number_of_times):
                func(*args, **kwargs)
        return wrapper
    return decorate

В следующем примере определяется декоратор с именем repeat, который принимает несколько раз в качестве аргумента. Затем декоратор определяет функцию с именем wrapper, которая обертывается вокруг декорируемой функции. Функция wrapper вызывает декорированную функцию количество раз, равное указанному числу.

@repeat(5)
def dummy():
    print("hello")

dummy()
# hello
# hello
# hello
# hello
# hello

5 — @timeit ⏲️

Этот декоратор измеряет время выполнения функции и выводит результат: это служит для отладки или мониторинга.

В следующем фрагменте декоратор timeit измеряет время, необходимое для выполнения функции process_data, и выводит прошедшее время в секундах.

import time
from functools import wraps

def timeit(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        end = time.perf_counter()
        print(f'{func.__name__} took {end - start:.6f} seconds to complete')
        return result
    return wrapper

@timeit
def process_data():
    time.sleep(1)

process_data()
# process_data took 1.000012 seconds to complete

6 — @повторить 🔁

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

Он принимает три аргумента: количество повторных попыток, исключение для перехвата и повторной попытки и время ожидания между повторными попытками.

Это работает следующим образом:

  • Функция-оболочка запускает цикл for из num_retries итераций.
  • На каждой итерации он вызывает функцию ввода в блоке try/except. Когда вызов успешен, он прерывает цикл и возвращает результат. В противном случае он приостанавливается на sleep_time секунд и переходит к следующей итерации.
  • Если вызов функции не удался после завершения цикла for, функция-оболочка вызывает исключение.
import random
import time
from functools import wraps

def retry(num_retries, exception_to_check, sleep_time=0):
    """
    Decorator that retries the execution of a function if it raises a specific exception.
    """
    def decorate(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for i in range(1, num_retries+1):
                try:
                    return func(*args, **kwargs)
                except exception_to_check as e:
                    print(f"{func.__name__} raised {e.__class__.__name__}. Retrying...")
                    if i < num_retries:
                        time.sleep(sleep_time)
            # Raise the exception if the function was not successful after the specified number of retries
            raise e
        return wrapper
    return decorate

@retry(num_retries=3, exception_to_check=ValueError, sleep_time=1)
def random_value():
    value = random.randint(1, 5)
    if value == 3:
        raise ValueError("Value cannot be 3")
    return value

random_value()
# random_value raised ValueError. Retrying...
# 1

random_value()
# 5

7 — @countcall 🔢

Этот декоратор подсчитывает количество вызовов функции.

Этот номер хранится в атрибуте оболочки count.

from functools import wraps

def countcall(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        wrapper.count += 1
        result = func(*args, **kwargs)
        print(f'{func.__name__} has been called {wrapper.count} times')
        return result
    wrapper.count = 0
    return wrapper

@countcall
def process_data():
    pass

process_data()
process_data has been called 1 times
process_data()
process_data has been called 2 times
process_data()
process_data has been called 3 times

8 — @rate_limited 🚧

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

import time
from functools import wraps

def rate_limited(max_per_second):
    min_interval = 1.0 / float(max_per_second)
    def decorate(func):
        last_time_called = 0.0
        @wraps(func)
        def rate_limited_function(*args, **kargs):
            elapsed = time.perf_counter() - last_time_called
            left_to_wait = min_interval - elapsed
            if left_to_wait > 0:
                time.sleep(left_to_wait)
            ret = func(*args, **kargs)
            last_time_called = time.perf_counter()
            return ret
        return rate_limited_function
    return decorate

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

Если истекшее время меньше минимального интервала, функция ждет left_to_wait секунд перед повторным выполнением.

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

Также существует сторонний пакет, реализующий ограничение скорости API: он называется ratelimit.

pip install ratelimit

Чтобы использовать этот пакет, просто украсьте любую функцию, которая делает вызов API:

from ratelimit import limits

import requests

FIFTEEN_MINUTES = 900

@limits(calls=15, period=FIFTEEN_MINUTES)
def call_api(url):
    response = requests.get(url)

    if response.status_code != 200:
        raise Exception('API response: {}'.format(response.status_code))
    return response

Если украшенная функция вызывается больше раз, чем разрешено, возникает ratelimit.RateLimitException.

Чтобы иметь возможность обрабатывать это исключение, вы можете использовать декоратор sleep_and_retry в сочетании с декоратором ratelimit.

@sleep_and_retry
@limits(calls=15, period=FIFTEEN_MINUTES)
def call_api(url):
    response = requests.get(url)

    if response.status_code != 200:
        raise Exception('API response: {}'.format(response.status_code))
    return response

Это приводит к тому, что функция приостанавливает оставшееся количество времени перед повторным выполнением.

9 — @dataclass 🗂️

Декоратор @dataclass в Python используется для оформления классов.

Он автоматически генерирует специальные методы, такие как __init__, __repr__, __eq__, __lt__ и __str__ для классов, которые в основном хранят данные. Это может уменьшить шаблонный код и сделать классы более читабельными и удобными в сопровождении.

Он также предоставляет отличные готовые методы для красивого представления объектов, преобразования их в формат JSON, их неизменяемости и т. д.

Декоратор @dataclass появился в Python 3.7 и доступен в стандартной библиотеке.

from dataclasses import dataclass, 

@dataclass
class Person:
    first_name: str
    last_name: str
    age: int
    job: str

    def __eq__(self, other):
        if isinstance(other, Person):
            return self.age == other.age
        return NotImplemented

    def __lt__(self, other):
        if isinstance(other, Person):
            return self.age < other.age
        return NotImplemented


john = Person(first_name="John", 
              last_name="Doe", 
              age=30, 
              job="doctor",)

anne = Person(first_name="Anne", 
              last_name="Smith", 
              age=40, 
              job="software engineer",)

print(john == anne)
# False

print(anne > john)
# True

asdict(anne)
#{'first_name': 'Anne',
# 'last_name': 'Smith',
# 'age': 40,
# 'job': 'software engineer'}

Если вас интересуют классы данных, вы можете проверить одну из моих предыдущих статей.

10 — @регистрация 🛑

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

from atexit import register

@register
def terminate():
    perform_some_cleanup()
    print("Goodbye!")

while True:
    print("Hello")

При запуске этого скрипта и нажатии CTRL+C,

мы видим вывод функции terminate.

11 — @собственность 🏠

Декоратор свойств используется для определения свойств класса, которые по существу являются методами getter, setter и deleter для атрибута экземпляра класса.

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

Это полезно, если вы хотите добавить некоторые ограничения и логику проверки для получения и установки значения.

В следующем примере мы определяем установщик для свойства рейтинга, чтобы применить ограничение на ввод (от 0 до 5).

class Movie:
    def __init__(self, r):
        self._rating = r

    @property
    def rating(self):
        return self._rating

    @rating.setter
    def rating(self, r):
        if 0 <= r <= 5:
            self._rating = r
        else:
            raise ValueError("The movie rating must be between 0 and 5!")

batman = Movie(2.5)
batman.rating
# 2.5
batman.rating = 4
batman.rating
# 4
batman.rating = 10

# ---------------------------------------------------------------------------
# ValueError                                Traceback (most recent call last)
# Input In [16], in <cell line: 1>()
# ----> 1 batman.rating = 10
# Input In [11], in Movie.rating(self, r)
#      12     self._rating = r
#      13 else:
# ---> 14     raise ValueError("The movie rating must be between 0 and 5!")
#
# ValueError: The movie rating must be between 0 and 5!

12 — @singledispatch

Этот декоратор позволяет функции иметь разные реализации для разных типов аргументов.

from functools import singledispatch

@singledispatch
def fun(arg):
    print("Called with a single argument")

@fun.register(int)
def _(arg):
    print("Called with an integer")

@fun.register(list)
def _(arg):
    print("Called with a list")

fun(1)  # Prints "Called with an integer"
fun([1, 2, 3])  # Prints "Called with a list"

Заключение

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

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

Вот список потрясающих декораторов для вдохновения.

Спасибо за прочтение!

Ресурсы:

Новый пользователь Medium? Вы можете подписаться за 5 долларов в месяц и разблокировать неограниченное количество статей на различные темы (технологии, дизайн, предпринимательство...). Вы можете поддержать меня, нажав на мою реферальную ссылку