В данной главе мы рассмотрим строение и работу функций в Python, а также обсудим механизм замыканий.
Быстрый поиск
- Функции в Python
- Работа с параметрами
- Объекты внутри функций
- Функция как объект. Лямбда-функции
- Рекурсия
- Замыкание
Функции в Python
Как уже упоминалось в лекциях ранее, утиная типизация Python позволяет описывать объекты не исходя из их типов, а ориентируясь на протоколы, описывающие данные объекты. В такой парадигме мышления функции, о которых и пойдёт речь в данной главе, выступают просто в роли именованных алгоритмов, завязанных не на конкретных типах объектов-аргументов, а на их свойствах и возможности реализовать те операции, которые над ними производятся. К слову, у самих функций, как у объектов Python, также есть свой протокол. Он заключается в реализации метода __call__, вызывающегося оператором круглых скобок ().
Задание функции в Python ничем критически не выделяется от других языков: ключевое слово def для объявления функции, её имя, задание формальных параметров и сам алгоритм.
>>> def fun(a, b):
... return 2*a + b
...
>>> fun(2, 3)
7
>>> fun("qwer", "qwe")
'qwerqwerqwe'
Важной особенностью функций в Python является наличие возвращаемого значения у любой функции. В случае отсутствия её явного задания функция по умолчанию возвращает None. При этом особенность работы с объектами позволяет, вообще говоря, возвращать из функции сразу множество значений. Главное — запаковать их в один объект, а на выходе провести распаковку полученной последовательности.
>>> def NoneFun(x):
... print(x)
...
>>> NoneFun(5)
5
>>> print(NoneFun(5))
5
None
>>>
>>> def ManyValuesFun(a, b, c):
... return (a, b, c)
...
>>> d = ManyValuesFun(1, 2, 3)
>>> d
(1, 2, 3)
>>> d, e, f = ManyValuesFun(4, 5, 6)
>>> d
4
>>> e
5
>>> f
6
>>>
Работа с параметрами
Для начала не будем погружаться в тело и внутреннюю сущность функций, а разберёмся с их параметрами. Следует различать формальные параметры, описанные при определении функции, и фактические параметры, подаваемые функции при вызове. Вообще говоря, при вызове функции параметры проходят этап упаковки и распаковки, и при необходимости можно работать именно с упакованной последовательностью аргументов, как, например, поступает print (благодаря чему, собственно, и реализована возможность указание любого количества параметров в этой функции). Для работы с последовательностью (если быть точным, то в качестве последовательности аргументов выступает кортеж) фактических аргументов необходимо явно указать возможность упаковки в один из формальных аргументов при задании функции.
>>> def fun(a, *b):
... return a, b
...
>>> fun(2, 3)
(2, (3,))
>>> print(*fun(2, 3, 4, 5))
2 (3, 4, 5)
>>> print(*fun(2))
2 ()
>>>
>>> def fun(*args):
... return args
...
>>> print(*fun([1, 2, 3, 4]))
[1, 2, 3, 4]
>>> print(*fun(*[1, 2, 3, 4]))
1 2 3 4
>>> print(*fun())
>>> print(fun())
()
>>>
Как и большинство языков программирования, Python поддерживает задание параметров по умолчанию:
>>> def fun(a, b=1,c="QQ"):
... print(a, b, c)
...
>>> fun(1)
1 1 QQ
>>> fun(1111, 42)
1111 42 QQ
>>> fun(1111, 42, 345)
1111 42 345
>>> fun()
Traceback (most recent call last):
File "<python-input-20>", line 1, in <module>
fun()
~~~^^
TypeError: fun() missing 1 required positional argument: 'a'
>>>
Но есть и существенное отличие параметров Python от других — неявная возможность задавать параметры не только согласно порядку, но и по имени. То есть параметры могут выступать как позиционными, так и именными:
>>> fun(213, c="HAHAHA")
213 1 HAHAHA
>>> fun(a=100200)
100200 1 QQ
>>>
В старых версиях Python все параметры были позиционно-именными без возможности сделать их строго одной категории. Настолько свободное манипулирование аргументами в некоторых случаях было небезопасно, из-за чего в современном Python синтаксис ужесточён: в нём теперь можно явно задать строго позиционные, позиционно-именные и строго именные параметры, для этого при определении функции ставятся специальные разделители. При описании функций без разделителей все аргументы продолжают относиться к группе позиционно-именных.
>>> def fun(a, b=1, /, c="QQ", *, d=100500):
... print(a, b, c, d)
...
>> fun(a=90)
Traceback (most recent call last):
File "<python-input-68>", line 1, in <module>
fun(a=90)
~~~^^^^^^
TypeError: fun() got some positional-only arguments passed as keyword arguments: 'a'
>>> fun(1)
1 1 QQ 100500
>>> fun(1, 3)
1 3 QQ 100500
>>> fun(1, b=3)
Traceback (most recent call last):
File "<python-input-25>", line 1, in <module>
fun(1, b=3)
~~~^^^^^^^^
TypeError: fun() got some positional-only arguments passed as keyword arguments: 'b'
>>> fun(1, 3, 5)
1 3 5 100500
>>> fun(1, c=5)
1 1 5 100500
>>> fun(1, 2, 3, 4)
Traceback (most recent call last):
File "<python-input-28>", line 1, in <module>
fun(1, 2, 3, 4)
~~~^^^^^^^^^^^^
TypeError: fun() takes from 1 to 3 positional arguments but 4 were given
>>> fun(1, d=4)
1 1 QQ 4
>>>
Объекты внутри функций
От параметров перейдём к объектам, создаваемым непосредственно в пространстве имён функции. Получить его можно с помощью функции locals() (Отсюда и далее в коде будут встречаться locals() и globals() — функции, возвращающие пока не изученный нами тип данных «словарь» (dict). Его структуру и особенности мы рассмотрим совсем скоро, а пока для нас достаточно понимания, что globals() и locals() описывают все объекты соответствующих пространств имён, позволяя нам как взглянуть на него целиком, так и взять конкретный объект и посмотреть его наличие, значение и т.д.)
Поскольку пространство имён функции вложено в глобальное пространство имён, в функции должен присутствовать механизм определения локальных и глобальных объектов. Для новых объектов для этого используется принцип локальности по связыванию — имя (и, соответственно, объект, обозначаемый этим именем) считается локальным, как только в функции происходит связывание к этому имени. Сделано это для того, чтобы случайно не изменить глобальные параметры.
>>> def fun(a, b):
... print(locals())
... c = a + b
... print(locals(), globals()['c'])
... return c
...
>>> c = 100500
>>> fun(2, 3)
{'a': 2, 'b': 3}
{'a': 2, 'b': 3, 'c': 5} 100500
5
>>> c
100500
>>>
>>> def fun(a, b):
... print(locals())
... d = a + b
... print(locals(), globals()['c'])
... return c
...
>>> c = 100500
>>> fun(2, 3)
{'a': 2, 'b': 3}
{'a': 2, 'b': 3, 'd': 5} 100500
100500
>>>
Для изменения глобальных параметров внутри функции необходимо явно указать использование глобального имени
>>> def fun(a, b):
... global c
... print(locals(), globals()['c'])
... c = a + b
... print(locals(), globals()['c'])
... return c
...
>>> c = 100500
>>> fun(2, 3)
{'a': 2, 'b': 3} 100500
{'a': 2, 'b': 3} 5
5
>>> c
5
>>>
Причём данный способ позволяет не только модифицировать, но и создавать объекты в глобальном пространстве имён
>>> def fun(a, b):
... global g
... g = a + b
... print(locals(), globals()['g'])
... return g
...
>>> globals()['g']
Traceback (most recent call last):
File "<python-input-83>", line 1, in <module>
globals()['g']
~~~~~~~~~^^^^^
KeyError: 'g'
>>> fun(2, 3)
{'a': 2, 'b': 3} 5
5
>>> g
5
>>>
Несмотря на то, что по отдельности интерпретатор может определить, где находится глобальное имя, а где локальное, их сочетание в рамках одной функции невозможно:
>>> def fun(a, b):
... d = a + b + c
... c = a + b
... return c
...
>>> fun(4, 5)
Traceback (most recent call last):
File "<python-input-36>", line 1, in <module>
fun(4, 5)
~~~^^^^^^
File "<python-input-35>", line 2, in fun
d = a + b + c
^
UnboundLocalError: cannot access local variable 'c' where it is not associated with a value
>>>
При интерпретации Python просматривает весь текст функции не только на корректность, но и на связность имён, и закономерно сообщает о её нарушении. В противном случае программирование на языке, который прямо на ходу манипулирует идентичными локальными и глобальными именами в зависимости от их позиции в коде, было бы просто невозможным.
Ещё один особый объект внутри функции — повисшая строка. С точки зрения обычного пользователя она не несёт никакого смысла: объект создаётся, ни с чем не связывается и уничтожается. Однако для функций описан особый механизм самодокументирования, захватывающий такие строки в специальное поле __doc__ и позволяющий вести документирование кода из коробки:
>>> def fun(a, b):
... '''
... My special help
... '''
... return a + b
...
>>> fun.__doc__
'\nMy special help\n'
>>> print(fun.__doc__)
My special help
>>> help(fun)
Help on function fun in module __main__:
fun(a, b)
My special help
Функция как объект. Лямбда-функции
Как и всё в Python, функция также является объектом, которым можно манипулировать наравне со всеми другими:
>>> def fun(a, b):
... c = a + b
... return c
...
>>> fun
<function fun at 0x7ffb8f440220>
>>> type(fun)
<class 'function'>
>>> f = fun
>>> f(1, 2)
3
>>> a = [f, f, f, f]
>>> for i in a:
... print(i(3, 4))
...
7
7
7
7
>>>
Ровно также, как и другие объекты, функции могут выступать параметрами других функций, реализуя таким образом возможность функционального программирования на Python.
Особенную роль функции-параметры занимают в функциях обработки последовательностей, например, уже известная нам функция sorted имеет специальный строго именной параметр key, отвечающий за ключ сортировки объектов последовательности. И этот ключ как раз и задаётся некоторой функцией от объектов последовательности: для каждого объекта вычисляется его ключ, и сортировка производится согласно этому ключу, а не сравнением самих объектов (что, к слову, может быть даже невозможно, например, сравнение числа и строки).
>>> lst = list(range(20))
>>> lst
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
>>>
>>> def keyfun(item):
... return item % 3
...
>>> sorted(lst, key=keyfun)
[0, 3, 6, 9, 12, 15, 18, 1, 4, 7, 10, 13, 16, 19, 2, 5, 8, 11, 14, 17]
>>>
Поскольку такие специализированные функции в большинстве своём представляют просто некоторые выражения, для их реализации можно использовать специальные функции-выражения — лямбда-функции. Данные функции не имеют тела, не имеют имени, они представляют из себя лишь return-выражение, и они необходимы как раз в качестве фильтров-аргументов.
>>> def fun(a, b):
... c = 2*a + b
... return c
...
>>> def fun(a, b):
... return 2*a + b
...
>>> f = lambda a, b: 2*a + b
>>>
>>> fun(2, 3)
7
>>> f(2, 3)
7
>>> fun
<function fun at 0x7ffb8f4405e0>
>>> f
<function <lambda> at 0x7ffb8f440680>
>>>
>>> lst
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
>>> sorted(lst, key=lambda item: item % 3)
[0, 3, 6, 9, 12, 15, 18, 1, 4, 7, 10, 13, 16, 19, 2, 5, 8, 11, 14, 17]
>>>
Рекурсия
Говоря о функциях, нельзя не упомянуть о рекурсии — стеке вызовов функций из функций (вообще говоря, не обязательно тех же самых).
Работа с рекурсией, вообще говоря, проста лишь «на бумаге». Поскольку вызов новой функции требует создания отдельного фрейма (ограничение на количество которых, к слову, совсем небольшое — всего 1000 для классического интерпретатора), переключения на него, локальной обработки, а после свёртывания отработанного фрейма, использовать рекурсию вместо цикла неэффективно. Оптимальное использование рекурсии достигается лишь при сложности меньше линейной, иначе проще и эффективнее использовать цикл.
>>> def binsearch(N, left=0, right=100):
... middle = (left + right) // 2
... print(middle)
... if middle == N:
... print("DONE")
... return True
... elif middle > N:
... return binsearch(N, left, middle)
... return binsearch(N, middle, right)
...
>>> binsearch(47)
50
25
37
43
46
48
47
DONE
True
>>>
Поскольку рекурсия это буквально работа со стеком, любую рекурсию можно расписать c помощью собственного стека и цикла до его исчерпания, в рамках которого выполняется, как минимум, три действия:
- Проверка завершения — по достижении основания рекурсии со стека снимаются все данные, соответствующие текущему шагу, после чего происходит «выход» с данного шага
- Шаг рекурсии — выполняются полезные инструкции в рамках текущего шага
- Рекурсивный вызов — вычисляются и размещаются на стек данные, соответствующие новому шагу цикла
Рассмотрим пример задачи на рекурсию и реализуем её стеком. Условие: «Есть ли среди натуральных чисел seq такие, что в сумме дают s?» Рекурсивное решение заключается в постепенном вычислении суммы и рекурсивном решении подзадачи для меньшего размера входного списка и суммы
def subsR(seq, req, start=-1):
if req == 0:
return True
for start in range(start + 1, len(seq)):
if req >= seq[start] and subsR(seq, req - seq[start], start):
return True
Решение с циклом и стеком логически ничем не отличается, однако вместо создания фреймов рекурсивных вызовов добавляет связанные переменные в стек.
def subsS(seq, req):
stack = [[req, -1]] # Инициализация
while stack: # Цикл до исчерпания стека
stack[-1][-1] += 1 # Шаг рекурсии
req, start = stack[-1] # (необязательное именование)
if req == 0: # Глобальное отсечение
return True
if start < len(seq) and req >= seq[start]: # Основание не достигнуто и отсечение не сработало
stack.append([req - seq[start], start]) # Рекурсивный вызов
else: # Основание рекурсии достигнуто
stack.pop() # Выход из рекурсивного вызова
Замыкание
Для обсуждения последней темы — замыкания — ещё раз обратимся к функции, как к объекту. Поскольку функция может иметь возвращаемым значением любой объект, ничто не помешает написать функционал — функцию, которая изготавливает и возвращает функцию.
>>> def FUN(c):
... def fun(x):
... return x + c
... return fun
...
>>> FUN(100500)
<function FUN.<locals>.fun at 0x7fc4b3b04860>
>>> f = FUN(100500)
>>> f(123)
100623
>>>
Однако при детальном рассмотрении функционала возникает логичная цепочка рассуждений: пространство имён locals(), в рамках которого существует объект с, удаляется по выходе из функции FUN. При этом при вызове возвращённой им функции fun в ней остаётся информация о значении этой удалённой переменной. Что же это за ссылка на «удалённый» (ли) объект?
Данная конструкция и называется «замыкание» — это ссылка на безыменованный объект, который должен сохраниться после уничтожения локального пространства имён. Для явного просмотра параметров объекта fun и поиска замыкания необходимо обратиться к специальному полю __closure__, собственно, своим названием описывающему, что в нём хранится:
>>> dir(f)
['__annotations__', '__builtins__', '__call__', '__class__', '__closure__', '__code__', '__defaults__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__get__', '__getattribute__', '__getstate__', '__globals__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__kwdefaults__', '__le__', '__lt__', '__module__', '__name__', '__ne__', '__new__', '__qualname__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__type_params__']
>>> f.__closure__
(<cell at 0x7fc4b3bca9e0: int object at 0x7fc4b3af6630>,)
>>> f.__closure__[0]
<cell at 0x7fc4b3bca9e0: int object at 0x7fc4b3af6630>
>>> f.__closure__[0].cell_contents
100500
>>>
Для явного задания объектов, которые попадут в замыкание существует специальный модификатор nonlocal:
>>> def f(x):
... def g(y):
... nonlocal x
... x = 5 * y - 3
... g(9)
... return x
...
... x = 100500
... res = f(x)
... print(x, res)
...
100500 42
>>>
Однако вместе с удобством использования замыкания приходит и проблема её реализации — позднее связывание. Дело в том, что сохранение объектов, попадающих в замыкание, происходит по выходе из пространства имён функции, которой первоначально принадлежат объекты замыкания, а не при формировании этого пространства имён.
Рассмотрим пример генерации нескольких функций, основанных на свойства замыкания: на каждом шаге цикла генерируется функция с использованием замыкающегося параметра i (вроде как, для каждой функции разного). При этом после выполнения кода все функции оказываются абсолютно одинаковыми и равными последней реализованной:
>>> def make_adders(n):
... adders = []
... for i in range(n):
... def adder(x):
... return x + i
... adders.append(adder)
... return adders
...
>>> adders = make_adders(5)
>>> adders
[<function make_adders.<locals>.adder at 0x7fc4b3b9ff60>, <function make_adders.<locals>.adder at 0x7fc4b3b04900>, <function make_adders.<locals>.adder at 0x7fc4b3b04a40>, <function make_adders.<locals>.adder at 0x7fc4b3b04b80>, <function make_adders.<locals>.adder at 0x7fc4b3b04c20>]
>>> adders[-1](300)
304
>>> adders[1](300)
304
>>> adders[0](300)
304
Что же происходит:
- При определении очередного adder()-а выясняется, что i — нелокальное имя, потому что среди локальных его нет, зато оно локально для create_adders()
- Формируется специальное пространство имён, в котором имя ‘i’ «залипнет» после выхода из create_adders()
- В adder-ах будет сформировано замыкание, куда попадёт i из этого пространства имён
- Однако, логично, на момент выхода из create_adders() имя i указывает на 4, которое и замыкается во всех функциях:
>>> for j in range(len(adders)):
... print(adders[j].__closure__[0].cell_contents)
...
4
4
4
4
4
>>>