View on GitHub

Methodics of Python Intro

Practical materials for learning 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 помощью собственного стека и цикла до его исчерпания, в рамках которого выполняется, как минимум, три действия:

  1. Проверка завершения — по достижении основания рекурсии со стека снимаются все данные, соответствующие текущему шагу, после чего происходит «выход» с данного шага
  2. Шаг рекурсии — выполняются полезные инструкции в рамках текущего шага
  3. Рекурсивный вызов — вычисляются и размещаются на стек данные, соответствующие новому шагу цикла

Рассмотрим пример задачи на рекурсию и реализуем её стеком. Условие: «Есть ли среди натуральных чисел 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

Что же происходит:

>>> for j in range(len(adders)):  
...     print(adders[j].__closure__[0].cell_contents)
...
4  
4  
4  
4  
4  
>>>