По колено вникаем во внутренности Python

Введение

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

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

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

Функции — это объекты

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

Как объекты, функции абсолютно такие же, как:

  • струны
  • целые числа и числа с плавающей запятой
  • Панды DataFrames
  • списки, кортежи, словари
  • такие модули, как os, datatime, numpy

Вы можете назначать функции новой переменной и использовать ее для вызова функции:

>>> new_func = my_func
>>> new_func()
Printing the function's argument

Теперь эта переменная также содержит атрибуты функции:

Вы также можете хранить каждую функцию в других объектах, таких как списки и словари, и вызывать их:

Объем

Рассмотрим этот разговор между Бобом и Джобом:

  • Боб: «Джон, почему ты вчера не пришел на урок?»
  • Джон: «У меня был грипп….»

Не самая лучшая история, но когда Боб спрашивает о причине отсутствия Джона на вчерашнем уроке, мы знаем, что он имеет в виду Джона, стоящего рядом с ним, а не какого-то случайного Джона из другой страны. Людям это нетрудно заметить, но языки программирования используют так называемую область действия, чтобы указать, какое имя мы используем в своих программах.

В Python именами могут быть переменные, функции, имена модулей и т. д.

Рассмотрим эти две переменные:

>>> a = 24
>>> b = 42
>>> print(a)
24

Здесь print нетрудно было сказать, что мы имеем в виду a, который мы только что определили. Теперь подумайте об этом:

>>> def foo():
...     a = 100
...     print(a)

Как вы думаете, что произойдет, если мы запустим foo? Будет ли он печатать 24 или 100?

>>> foo()
100

Как Python различал a, которые мы определили в начале функции? Здесь область видимости становится интересной, потому что мы представляем разные уровни области видимости:

На изображении выше показана область применения этого маленького скрипта:

Глобальная область действия — это общая область действия вашего скрипта/программы. Переменные, функции, модули с тем же уровнем отступа, что и a и b, будут в глобальной области видимости. Например, функция foo находится в глобальной области, но ее переменная a находится в области, локальной для foo.

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

Существует также более высокий уровень охвата за пределами global:

Встроенная область содержит все модули и пакеты, которые вы установили с помощью Python, pip или conda.

Теперь давайте рассмотрим другой случай. В нашей функции foo мы хотим изменить значение глобального a. Мы хотим, чтобы это была строка, но если мы напишем a = 'some text' внутри foo, Python создаст новую переменную без изменения глобального a.

Python предоставляет нам ключевое слово, которое позволяет указать, что мы имеем в виду имена в области видимости global:

Запись global <name> позволит нам изменить значения имен в области global.

Кстати, плохие новости 😁, я пропустил один уровень масштаба на изображении выше. Между global и local есть один уровень, который мы не рассмотрели:

Область действия nonlocal вступает в игру, когда у нас есть вложенные функции:

Во вложенной функции outer мы сначала создаем переменную с именем my_var и присваиваем ее строке Python. Затем мы решили создать новую функцию inner и хотим присвоить my_var новое значение — Data Science и распечатать его. Но если мы запустим его, мы увидим, что my_var по-прежнему назначено «Python». Мы не можем использовать ключевое слово global, поскольку my_var не входит в глобальную область видимости.

В таких случаях вы можете использовать ключевое слово nonlocal, которое дает доступ ко всем именам в области действия внешней функции (нелокальной), но не к global:

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

  • Встроенный: все имена пакетов, установленных вместе с Python, pip и conda
  • Global: общая область действия, все имена, не имеющие отступа в сценарии.
  • Локальный: содержит локальные переменные в блоках кода, таких как функции, циклы, списки и т. д.
  • Нелокальный: дополнительный уровень области действия между global и local в случае вложенных функций.

Закрытия

Прежде чем я объясню, как работают декораторы, нам нужно поговорить и о замыканиях. Начнем с примера:

Мы создаем вложенную функцию bar внутри foo и возвращаем ее. bar пытается напечатать значение of x:

Когда мы пишем var = foo(), мы присваиваем функцию bar var. Теперь var можно использовать для вызова bar. Когда мы вызываем его, он выводит 42.

>>> var()
42

Но постойте, откуда var знает что-нибудь о x? x определяется в области действия foo, а не bar. Можно подумать, что x не может быть доступен за пределами foo. Вот где замыкания приходят.

Замыкание — это встроенная память функции, которая содержит все нелокальные имена (в кортеже), необходимые для запуска функции!

Таким образом, когда foo возвращает bar, он присоединяет все нелокальные переменные, которые bar должны выполняться вне области действия foo. Вы можете получить доступ к закрытию функции с атрибутом .__closure__:

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

В приведенном выше примере переменные x, y, z являются нелокальными переменными для child, поэтому они добавляются к замыканию функции. Любые другие имена, такие как value и outside, не входят в замыкание, поскольку они не находятся в нелокальной области.

Теперь рассмотрим более сложный пример:

Мы создаем функцию parent, которая принимает один аргумент, и вложенную функцию child, которая печатает любое значение, переданное parent. Мы вызываем parent с var ("фиктивным") и присваиваем результат func. Если мы назовем это:

>>> func()
dummy

Как и ожидалось, он выводит «пустышку». Теперь давайте удалим var и снова вызовем func:

>>> # Delete 'var'
>>> del var
>>> # call func again
>>> func()
dummy

Он по-прежнему печатает «пустышку». Почему?

Как вы уже догадались, это было добавлено к закрытию! Таким образом, когда значение из внешних уровней области видимости добавляется к замыканию, оно останется там неизменным, даже если мы удалим исходное значение!

>>> func.__closure__[0].cell_contents
‘dummy’

Если бы мы не удалили var и не изменили его значение, замыкание по-прежнему содержало бы его старое значение:

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

Давайте рассмотрим некоторые концепции, чтобы убедиться, что вы понимаете:

  • Замыкание является внутренней памятью вложенной функции и содержит все нелокальные переменные, хранящиеся в кортеже.
  • Как только значение сохраняется в замыкании, к нему можно получить доступ, но его нельзя переопределить, если исходное значение будет удалено или изменено.
  • Вложенная функция — это функция, определенная в другой, и следует этому общему шаблону:
>>> def parent(arg):
    
...     def child():
...        print(arg)
...    
...    return child

Наконец, декораторы

Декораторы — это функции, которые модифицируют другую функцию. Они могут изменить входные данные функции, ее выходные данные или даже ее поведение.

Начнем с очень простого декоратора:

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

Чтобы использовать функцию в качестве декоратора, просто поставьте @ symbol, а затем имя украшающей функции прямо над определением функции. Когда мы передали 5 оформленной функции square, вместо возврата 25 она выдает 36, потому что add_one принимает аргумент square, равный 5, добавляет к нему единицу и вставляет ее обратно в square:

>>> square(10)
121

Теперь давайте поближе познакомимся с add_one.

Во-первых, давайте начнем с add_one, который возвращает только любую переданную ему функцию:

Чтобы наш декоратор возвращал измененную функцию, обычно полезно определить возвращаемую вложенную функцию:

Наш декоратор по-прежнему ничего не делает. Внутри add_one мы определили вложенную функцию child. child принимает только один аргумент и вызывает любую функцию, переданную add_one. Затем add_one возвращает child.

В этом случае вложенной функции child мы предполагаем, что func, переданный в add_one, принимает точно такое же количество аргументов, что и child.

Теперь мы можем заставить все волшебство происходить внутри функции child. Вместо простого вызова func мы хотим изменить его аргументы, добавив к ним 1:

Обратите внимание func(a + 1)? Он вызывает любой аргумент, переданный add_one, с добавлением 1 к аргументу. На этот раз вместо создания новой переменной для хранения child мы переопределим square:

>>> square = add_one(square)
>>> square(5)
36

Теперь он возвращает 36 вместо 25, когда мы передаем 5.

Как он может использовать функцию square, даже если мы ее переопределяем? Хорошо, что мы изучили замыкания, потому что старый square теперь находится внутри замыкания child:

>>> square.__closure__[0].cell_contents
<function __main__.square(a)>

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

Реальные примеры с декораторами

Думаю, было бы обидно, если бы я не показал вам, как создать декоратор timer:

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

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

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

В основной функции cache мы хотим создать словарь, в котором все аргументы в кортежах хранятся как ключи и их результаты. Кэширующий словарь будет выглядеть так:

cache = {
    (arg1, arg2, arg3): func(arg1, arg2, arg3)
}

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

Теперь давайте посмотрим, что произойдет, если мы украсим нашу спящую функцию как cache, так и timer:

Во-первых, давайте попробуем заснуть на 10 секунд:

>>> sleep(10)
sleep took 10.0001 seconds to run!

Как и ожидалось, запуск занял 10 секунд. Теперь, как вы думаете, что произойдет, если мы снова запустим sleep с 10 в качестве аргумента:

>>> sleep(10)
sleep took 0.0 seconds to run!

Это заняло 0 секунд! Наш кеширующий декоратор работает!

Декораторы, принимающие аргументы

На данный момент наши знания о декораторах довольно солидны. Однако реальная сила декораторов проявляется, когда вы позволяете им принимать аргументы.

Рассмотрим этот декоратор, который проверяет, имеет ли результат функции тип str:

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

Это работает. Однако было бы здорово, если бы у нас был способ проверить возвращаемый функцией тип для любого типа данных? Вот, проверьте это:

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

Во-первых, давайте просто создадим простой декоратор, который вызывает любую переданную ему функцию:

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

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

Обратите внимание, как мы только что обернули наш декоратор в более крупную родительскую функцию? Он принимает тип данных в качестве аргумента, передает его нашему декоратору и возвращает. В wrapper мы написали type(result) == dtype, которое оценивается как True или False независимо от того, совпадают типы данных или нет. Теперь вы можете использовать эту функцию для проверки типов любой функции:

Сохранение метаданных оформленной функции

До этого момента мы никогда не проверяли одну вещь — во всех ли смыслах сохраняется декорированная функция? Например, вернемся к нашей функции sleep, которую мы украсили timer:

Давайте вызовем его и проверим его метаданные:

Мы проверяем три атрибута метаданных функции. Первые два вернули None , но они должны были что-то выдать. Я имею в виду, что у sleep была длинная строка документации и аргумент по умолчанию, равный 5. Куда они делись? Мы получили ответ, когда вызвали __name__ и получили wrapper в качестве имени функции.

Если мы рассмотрим определение timer:

Мы видим, что на самом деле мы не возвращаем переданную функцию, а возвращаем ее внутри wrapper. Очевидно, что wrapper не имеет строки документации или каких-либо аргументов по умолчанию, поэтому мы получили None выше.

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

Использование wraps в функции wrapper позволяет нам сохранить все метаданные, прикрепленные к func. Обратите внимание, как мы передаем func в wraps над определением функции.

Если мы воспользуемся этой модифицированной версией timer, мы увидим, что она работает так, как ожидалось:

Использование wraps(func) — хорошая практика для написания декораторов, так что добавьте его ко всем декораторам, которые мы определили сегодня!

Заключение

Прочитав этот пост, вы хорошо разбираетесь в создании декораторов. Что еще более важно, вы знаете, как они работают и как как они работают.

В заключение я предлагаю использовать декораторы всякий раз, когда у вас повторяется код, выполняющий аналогичные задачи в ваших функциях. Декораторы могут стать еще одним шагом к тому, чтобы сделать ваш код СУХИМ (не повторяйтесь).

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

Понравилась эта статья и, скажем прямо, ее причудливый стиль написания? Представьте себе, что у вас есть доступ к десяткам таких же, написанных блестящим, обаятельным, остроумным автором (кстати, это я :).

Всего за 4,99 $ членства вы получите доступ не только к моим историям, но и к сокровищнице знаний от лучших и самых ярких умов на Medium. А если вы воспользуетесь моей реферальной ссылкой, то получите мою сверхновую благодарность и виртуальную пятерку за поддержку моей работы.