По колено вникаем во внутренности 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. А если вы воспользуетесь моей реферальной ссылкой, то получите мою сверхновую благодарность и виртуальную пятерку за поддержку моей работы.