Вложенные функции, замыкания и декораторы

1. Функции

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

Все в Python объекты. И даже функции. Это значит, что у функций есть

  • атрибуты
  • и методы.

От остальных объектов функции отличаются тем, что их можно вызвать*. Объекты, которые можно вызвать, называют Callable-объектами. У них есть метод __call__().

* С точки зрения синтаксиса еще можно вызывать классы

Как определить функцию

In [1]:
# Функция определяется таким синтаксисом
def plus_one(x: int) -> int:
    """Функция возвращает увеличенное на 1 целое число"""
    return x+1

Это избыточное определение. Из избыточного здесь использованы:

  • строка документирования — docstring,
  • и анотация функции.

На самом деле можно описать эту же функцию компактней.

In [2]:
# Функция plus_one без анотаций и документации
def plus_one_simple(x): return x+1

Функция как объект

Как у любого объекта в python, у функции есть:

  • идентификатор,
  • тип.
In [3]:
# У функции plus_one эти параметры выглядят так
id(plus_one), type(plus_one)
Out[3]:
(140144394334992, function)

В CPython идентификатор — адрес объекта в виртуальной памяти

In [4]:
# Идентификатор в шестнадцатиричном формате — адрес функции plus_one
hex(id(plus_one))
Out[4]:
'0x7f75e8d77310'

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

In [5]:
import inspect
list(filter(lambda x: x[0] != "__globals__", sorted(inspect.getmembers(plus_one))))
# Здесь мы выбросили поле "__globals__", чтобы не засорять вывод
Out[5]:
[('__annotations__', {'x': int, 'return': int}),
 ('__call__',
  <method-wrapper '__call__' of function object at 0x7f75e8d77310>),
 ('__class__', function),
 ('__closure__', None),
 ('__code__',
  <code object plus_one at 0x7f75e8d642f0, file "<ipython-input-1-188af9b6c882>", line 2>),
 ('__defaults__', None),
 ('__delattr__',
  <method-wrapper '__delattr__' of function object at 0x7f75e8d77310>),
 ('__dict__', {}),
 ('__dir__', <function function.__dir__()>),
 ('__doc__', 'Функция возвращает увеличенное на 1 целое число'),
 ('__eq__', <method-wrapper '__eq__' of function object at 0x7f75e8d77310>),
 ('__format__', <function function.__format__(format_spec, /)>),
 ('__ge__', <method-wrapper '__ge__' of function object at 0x7f75e8d77310>),
 ('__get__', <method-wrapper '__get__' of function object at 0x7f75e8d77310>),
 ('__getattribute__',
  <method-wrapper '__getattribute__' of function object at 0x7f75e8d77310>),
 ('__gt__', <method-wrapper '__gt__' of function object at 0x7f75e8d77310>),
 ('__hash__',
  <method-wrapper '__hash__' of function object at 0x7f75e8d77310>),
 ('__init__',
  <method-wrapper '__init__' of function object at 0x7f75e8d77310>),
 ('__init_subclass__', <function function.__init_subclass__>),
 ('__kwdefaults__', None),
 ('__le__', <method-wrapper '__le__' of function object at 0x7f75e8d77310>),
 ('__lt__', <method-wrapper '__lt__' of function object at 0x7f75e8d77310>),
 ('__module__', '__main__'),
 ('__name__', 'plus_one'),
 ('__ne__', <method-wrapper '__ne__' of function object at 0x7f75e8d77310>),
 ('__new__', <function function.__new__(*args, **kwargs)>),
 ('__qualname__', 'plus_one'),
 ('__reduce__', <function function.__reduce__()>),
 ('__reduce_ex__', <function function.__reduce_ex__(protocol, /)>),
 ('__repr__',
  <method-wrapper '__repr__' of function object at 0x7f75e8d77310>),
 ('__setattr__',
  <method-wrapper '__setattr__' of function object at 0x7f75e8d77310>),
 ('__sizeof__', <function function.__sizeof__()>),
 ('__str__', <method-wrapper '__str__' of function object at 0x7f75e8d77310>),
 ('__subclasshook__', <function function.__subclasshook__>)]

Как вызвать функцию

In [6]:
#  Вызов функции, ожидаем ответ 2
plus_one(1)
Out[6]:
2
In [7]:
#  Можно явно вызвать метод call, ожидаем ответ 2
plus_one.__call__(1)
Out[7]:
2

Как функции устроены

In [8]:
# Байт-код функции function_name
plus_one.__code__.co_code
Out[8]:
b'|\x00d\x01\x17\x00S\x00'
In [9]:
# Дизассемблированное тело функции function_name
import dis
dis.dis(plus_one)
  4           0 LOAD_FAST                0 (x)
              2 LOAD_CONST               1 (1)
              4 BINARY_ADD
              6 RETURN_VALUE

Если заглянуть во внутренности интерпретатора (CPython), то функция описывается следующей струтурой: https://github.com/python/cpython/blob/3.7/Include/funcobject.h

2. Вложенные функции

Вложенная функция — функция, которая определена внутри другой функции.

При работе с вложенными функциями надо учитывать области видимости.

Область видимости в Python — LEGB

В Python есть 4 области видимости. Расположены они как показано на рисунке.

title

Стрелки на рисунке показывают в какой последовательности Python обходит области видимости. Следующий код показывает как распределены области относительно вложенной функции inner.

In [10]:
# (built-in) — область системных имен

# global — область модуля
def outer():
    # enclosed — область функции-обёртки 
    def inner():
        # local — область внутри функции
        pass

Зачем нужны вложенные функции?

Зачем это может быть нужно? Можно выделить 3 примера:

  1. чтобы скрыть функцию в глобальной области видимости,
  2. чтобы вынести «лишний» код из функцию в обёртку,
  3. чтобы реализовать замыкания (см. следующий раздел).

Пример 1. Чтобы скрыть функцию — инкапсуляция

In [11]:
# Вложенная функция inner внутри plus_one_outer
def plus_one_outer(x):
    """Функция возвращает увеличенное на 1 целое число"""
    def inner(y): return y+1
    return inner(x)
    
In [12]:
#  Вызов функции, ожидаем ответ 2
plus_one_outer(1)
Out[12]:
2
In [13]:
# Вложенная функция недоступна (должна быть ошибка)
inner(1)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
<ipython-input-13-9439a6ed8a3c> in <module>
      1 # Вложенная функция недоступна (должна быть ошибка)
----> 2 inner(1)

NameError: name 'inner' is not defined

Вложенные функции дают накладные расходы

In [14]:
import dis
dis.dis(plus_one_outer)
  4           0 LOAD_CONST               1 (<code object inner at 0x7f75e851fc90, file "<ipython-input-11-c887b5072665>", line 4>)
              2 LOAD_CONST               2 ('plus_one_outer.<locals>.inner')
              4 MAKE_FUNCTION            0
              6 STORE_FAST               1 (inner)

  5           8 LOAD_FAST                1 (inner)
             10 LOAD_FAST                0 (x)
             12 CALL_FUNCTION            1
             14 RETURN_VALUE

Disassembly of <code object inner at 0x7f75e851fc90, file "<ipython-input-11-c887b5072665>", line 4>:
  4           0 LOAD_FAST                0 (y)
              2 LOAD_CONST               1 (1)
              4 BINARY_ADD
              6 RETURN_VALUE

Пример 2. Чтобы вынести «лишний» код из функции в обёртку

In [15]:
def factorial(x):
    """Функция вычисляет факториал целого числа"""
    def calc_factorial(y): return y * calc_factorial(y-1) if y!=0 else 1
    if x<0:
        return -1
    return calc_factorial(x)
    
In [16]:
factorial(4)
Out[16]:
24

3. Замыкания

Замыкание — вложенная функция, которая запоминает значения окружения, с которым она была вызвана. Говорят, что функция «замыкается» на значения переменных окружения. По сути это техника параметризированной генерации функций.

Рассмотрим простой пример замыкания

In [17]:
# Функция-обёртка принимает возвращает внутренную функцию, которая «замкнута» на значение a
def gen_mul(a):
    def inner(b):
        return a*b
    return inner
In [18]:
# gen_mul возвращает функцию, которая будет всегда умножать на 2
double = gen_mul(2)
In [19]:
# Проверим (должно быть 6)
double(3)
Out[19]:
6
In [20]:
# Можно возвращаемую функцию не сохранять
gen_mul(2)(3)
Out[20]:
6

Функции, которые возвращают другие функции, называются «фабриками функций».

Проблема late binding

В замыканиях все переменные внутри вложенной функции вычисляются в момент её вызова, а не создания. Это называется позднее связывание (англ. late binding). Из-за него могут возникнуть проблемы, как в примере ниже.

In [21]:
# Проблема с late binding
powers = []
for i in (1,2):
    def inner(x):
        return x**i
    powers.append(inner)
    
# Хотим получить список степеней 5-ки
for p in powers:
    print(p(5))
25
25

Вместо ожидаемых двух степеней 5-ки получи два раза возведение в последнюю степень из списка. Это происходит из-за того, что в момент вызова p(5) в теле функции переменная i «смотрит» на последнее своё значение, т. е. 2.

Это можно исправить, если создавать копии i при создании замыканий. Это можно сделать с помощью обёртки. При каждом вызове функции параметры указывают на значения аргументов функции. Поэтому замыкания «замыкаются» на текущие значения счётчика.

In [22]:
# Решение проблемы late binding через обёртку (здесь make_inner)
powers = []
for i in (1,2):
    def make_inner(j):
        def inner(x):
            return x**j
        return inner
    powers.append(make_inner(i))
    
for p in powers:
    print(p(5))
5
25

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

In [23]:
# Решение проблемы late binding через стандарное знаечение аргумента функции
powers = []
for i in (1,2):
    def inner(x, i=i):
        return x**i
    powers.append(inner)
    
for p in powers:
    print(p(5))
5
25

4. Декораторы

Декоратор — «синтаксический сахар» для функции-обёртки вокруг другой функции. Обычно декоратор используют, чтобы добавить новое поведение другой функции без изменения ее тела.

In [24]:
# Возьмем простую функцию возведения в квадрат
def sqr(x): return x*x
sqr(5)
Out[24]:
25

Пример элементарного декоратора

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

In [25]:
from time import perf_counter_ns

# Фабрика функций, которая генерирует обернутые функции func для отладки вызова и результата
def make_debugable(func):
    def wrapper(x):
        print(f"[DEBUG] Launch function {func} with x={x}")
        start_time_ns = perf_counter_ns()
        result = func(x)
        stop_time_ns = perf_counter_ns()
        duration_ns = stop_time_ns-start_time_ns
        print(f"[DEBUG] Time: {duration_ns}ns")
        return result
    return wrapper

Обернём функцию sqr, сгенерированное значение будем хранить в sqrt2.

In [26]:
sqrt2 = make_debugable(sqr)
sqrt2(5)
[DEBUG] Launch function <function sqr at 0x7f75e84a69d0> with x=5
[DEBUG] Time: 11200ns
Out[26]:
25

Синтаксический сахар декораторов позволяет описать такое поведение короче. При этом декоратор генерирует обёрнутую функцию с таким же именем как у обораичиваемой.

In [27]:
# Следующий код эквивалентен: sqr3 = make_debugable(sqr3)
@make_debugable
def sqr3(x): return x*x

sqr3(4)
[DEBUG] Launch function <function sqr3 at 0x7f75e8539820> with x=4
[DEBUG] Time: 12900ns
Out[27]:
16

Практичный пример применения декоратора make_debugable

Сравним скорость работы встроенной функции sum с написанной «руками» с помощью декоратора make_debugable.

In [28]:
@make_debugable
def sum_1(n):
    """Суммирование чисел от 1 до n в цикле for"""
    s = 0
    for i in range(n):
        s += i
    return s
In [29]:
@make_debugable
def sum_2(n):
    """Суммирование чисел от 1 до n встроенной функцией"""
    return sum(range(n))
In [30]:
sum_1(10000)
[DEBUG] Launch function <function sum_1 at 0x7f75e85399d0> with x=10000
[DEBUG] Time: 1849400ns
Out[30]:
49995000
In [31]:
sum_2(10000)
[DEBUG] Launch function <function sum_2 at 0x7f75e844b310> with x=10000
[DEBUG] Time: 437100ns
Out[31]:
49995000

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

Пример рабочего декоратора

По сути декоратор возвращает другую функцию. Если проверить документацию к функции:

In [32]:
# Проверка справки (не должен вернуть строку)
help(sum_1)
Help on function wrapper in module __main__:

wrapper(x)

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

In [33]:
from functools import wraps
from time import perf_counter_ns

# Улучшенная фабрика функций, которая генерирует обернутые функции func для отладки вызова и результата
def make_debugable_real(func):
    @wraps(func) # Декоратор из библиотеки для копирования внутренних атрибутов
    def wrapper(x):
        print(f"[DEBUG] Launch function {func} with x={x}")
        start_time_ns = perf_counter_ns()
        result = func(x)
        stop_time_ns = perf_counter_ns()
        duration_ns = stop_time_ns-start_time_ns
        print(f"[DEBUG] Time: {duration_ns}ns")
        return result
    return wrapper
In [34]:
@make_debugable_real
def sum_3(n):
    """Суммирование чисел от 1 до n встроенной функцией"""
    return sum(range(n))
In [35]:
# Проверка справки (теперь должен вернуть строку)
help(sum_3)
Help on function sum_3 in module __main__:

sum_3(n)
    Суммирование чисел от 1 до n встроенной функцией

Пример рабочего декоратора с параметрами

Чтобы декоратор принимал аргументы, надо сформировать замыкание фабрики обёрток с параметром декоратора.

In [36]:
from functools import wraps

# Фабрика генераторов функций, которая позволяет использовать параметры, функция умножает результат функции на число.
def mul(p):
    def decorator(func):
        @wraps(func)
        def wrapper(*args): # Упаковали параметры (см. ниже)
            return p*func(*args) # Распаковали параметры обратно (см. ниже)
        return wrapper
    return decorator

Объявим функцию с двумя декораторами. Декораторы применяются последовательнос снизу вверх.

In [37]:
@mul(2)
@mul(4)
def f(x: int, y: int):
    return x+y
In [38]:
# Должно быть 24 так как 2(4(1+2)) = 24
f(1,2)
Out[38]:
24

Упаковка и распаковка параметров

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

In [39]:
a, *b, c = [2, 7, 5, 6, 3, 4, 1]

В b теперь список всего того, что не попало в a и c

In [40]:
print(b)
[7, 5, 6, 3, 4]

Эти значения можно подставить используя распаковку.

In [41]:
print(*b)
7 5 6 3 4

Результат вывода разный. В первом случае вызов эквивалентен print([b1, b2, b3]), во втором — print(b1, b2, b3).

NB! Упаковка и распоковка доступна и для словарей. Для этого используется **.

Watermark

In [42]:
%load_ext watermark
%watermark -d -u -v -iv
last updated: 2019-10-21 

CPython 3.8.0
IPython 7.8.0