Функции

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

Слайды к главе. Материал главы изложен также в первых четырёх частях лекции «Функции в Python» (функция как объект, аргументы, области видимости, функциональный стиль), продолжение лекции относится к главе «Декораторы и functools»; слайды лекции доступны на сайте книги и в PDF.

Синтаксис объявления функций

Базовый синтаксис

Имя функции в Python может содержать буквы, цифры и символ подчёркивания _, но не может начинаться с цифры. Буквы допускаются из любого алфавита (PEP 3131), однако принято использовать латиницу и английские слова: PEP 8 требует этого от стандартной библиотеки и рекомендует открытым проектам.

def foo():
    return 42

foo()  # возвращает 42

Оператор return не является обязательным, и функция, обходящаяся без него, возвращает None.

def foo():
    42

print(foo())  # выводит None

Если хотя бы одна ветвь функции возвращает значение, PEP 8 требует единообразия: остальные ветви также завершаются явным return, при необходимости return None, и тогда отсутствие результата видно как предусмотренное.

Документирование функций

Для документирования используются строковые литералы (docstring), расположенные первыми в теле функции.

def foo():
    """I return 42."""
    return 42

Документация доступна через атрибут __doc__ или функцию help().

foo.__doc__  # 'I return 42.'
help(foo)    # показывает документацию

Для научного кода принят формат строк документации NumPy: после краткого описания следуют разделы Parameters (имя, тип, смысл и единицы измерения каждого параметра), Returns (тип и смысл результата), Raises (возбуждаемые исключения) и Examples (вызовы с ожидаемым выводом в формате интерактивной оболочки). Так документированы NumPy, SciPy и Astropy, а генератор документации Sphinx с расширением numpydoc собирает из таких строк справочник.

def kinetic_energy(m, v):
    """Кинетическая энергия тела.

    Parameters
    ----------
    m : float
        Масса, кг.
    v : float
        Скорость, м/с.

    Returns
    -------
    float
        Энергия, Дж.

    Examples
    --------
    >>> kinetic_energy(2.0, 3.0)
    9.0
    >>> kinetic_energy(1.0, 1.0)
    0.5
    """
    return m * v ** 2 / 2

Раздел Examples проверяется автоматически. Модуль doctest находит в строках документации фрагменты, начинающиеся с >>>, выполняет их и сравнивает вывод с записанным. Команда python3 -m doctest phys.py ничего не выводит, если все примеры сошлись, а с ключом -v печатает подробный отчёт. Если в формуле пропущено деление на два, отчёт указывает на расхождение.

Failed example:
    kinetic_energy(2.0, 3.0)
Expected:
    9.0
Got:
    18.0

Пример с известным ответом (тело массой 2 кг при скорости 3 м/с обладает энергией 9 Дж) одновременно документирует функцию и проверяет её формулу. Вывод сравнивается как текст, поэтому для вещественных результатов подбирают точно представимые ответы, такие как 9.0 и 0.5, или печатают округлённое значение. Расхождение документации с кодом обнаруживается при очередном запуске doctest, и его включают в проверки проекта наравне с тестами.

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

Инструкция def создаёт объект класса function и связывает его с именем. Функции в Python являются объектами первого класса: функцию связывают со вторым именем, хранят в списке или словаре, передают в аргументе другой функции и возвращают из функции. Интегратору scipy.integrate.quad(f, a, b) безразлично, как устроена переданная функция f, вычисляет ли она формулу или интерполирует таблицу: он лишь вызывает её в выбранных им точках.

Объект функции хранит сведения о ней в атрибутах.

АтрибутСодержимое
__name__, __qualname__имя и полное имя с учётом вложенности
__doc__строка документации
__defaults__, __kwdefaults__значения параметров по умолчанию
__code__объект кода: байт-код, имена локальных переменных
__annotations__аннотации параметров и результата
__closure__ячейки замыкания
__globals__словарь глобальных имён модуля

Аннотации типов

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

def mean(xs: list[float], w: list[float] | None = None) -> float:
    ...

mean.__annotations__
# {'xs': list[float], 'w': list[float] | None, 'return': <class 'float'>}

Синтаксис аннотаций появился в Python 3.0 (PEP 3107), их смысл как подсказок типов закрепил PEP 484 вместе с модулем typing (Python 3.5). С версии 3.9 встроенные коллекции указываются как list[float] без импорта из typing (PEP 585), с 3.10 объединение типов записывается через вертикальную черту: float | None (PEP 604). В Python 3.14 аннотации вычисляются отложенно, при первом обращении (PEP 649 и 749).

Интерпретатор аннотации сохраняет, но не проверяет. Вызов area("2") для функции area(r: float), возводящей радиус в квадрат, выполняется, и ошибка TypeError возникает уже внутри тела, на возведении строки в степень. Соответствие типов проверяют статические анализаторы mypy и pyright, а редакторы кода используют аннотации для подсказок. Единицы измерения аннотации не выражают, их по-прежнему указывают в документации.

Работа с аргументами

Позиционные и именованные аргументы

Аргументы могут передаваться двумя способами, свободно сочетаемыми в одном вызове. Позиционные разбираются по порядку, именованные — по имени параметра, поэтому их порядок роли не играет.

def min_of(x, y):
    return x if x < y else y

min_of(-5, 12)        # -5
min_of(x=-5, y=12)    # -5
min_of(y=12, x=-5)    # -5 (порядок не важен)

Функция названа min_of, а не min, чтобы не закрывать встроенную min (см. правило LEGB ниже). Позиционные аргументы следуют первыми. Запись min_of(x=-5, 12) недопустима и завершается ошибкой SyntaxError: positional argument follows keyword argument: интерпретатор не может определить, к какому параметру относится 12.

Упаковка позиционных аргументов

Произвольное количество аргументов принимает *args: звёздочка перед именем параметра собирает все лишние позиционные аргументы в кортеж под этим именем.

def min_of(*args):
    res = float("inf")
    for arg in args:
        if arg < res:
            res = arg
    return res

min_of(-5, 12, 13)  # -5
min_of()            # inf

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

def min_of(first, *args):
    res = first
    for arg in args:
        if arg < res:
            res = arg
    return res

min_of()  # TypeError: min_of() missing 1 required positional argument: 'first'

Распаковка аргументов

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

xs = {-5, 12, 13}
min_of(*xs)           # -5
min_of(*[-5, 12, 13]) # -5
min_of(*(-5, 12, 13)) # -5

С Python 3.5 (PEP 448) звёздочек в одном вызове может быть несколько, и между ними допускаются обычные аргументы: min_of(*xs, 0, *ys).

Параметры после *args и значения по умолчанию

Всё, что записано в объявлении после *args, может быть передано только по имени, поскольку позиции для этих параметров уже исчерпаны. Таким образом оформляются необязательные настройки со значением по умолчанию, которое вызывающая сторона изменяет явно.

def bounded_min(first, *args, lo=float("-inf"), hi=float("inf")):
    res = None
    for arg in (first,) + args:
        if lo <= arg <= hi and (res is None or arg < res):
            res = arg
    return res

bounded_min(-5, 12, 13, lo=0, hi=255)  # 12
bounded_min(-5, lo=0, hi=255)          # None

Из трёх чисел в отрезок [0, 255] попадают 12 и 13, минимальное из них равно 12; значение −5 отброшено как выходящее за нижнюю границу, которую задаёт параметр lo. Если в отрезок не попадает ни одно число, функция возвращает None: любое число в качестве ответа было бы неотличимо от настоящего минимума.

Опасность изменяемых значений по умолчанию

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

def unique(iterable, seen=set()):
    acc = []
    for item in iterable:
        if item not in seen:
            seen.add(item)
            acc.append(item)
    return acc

xs = [1, 1, 2, 3]
unique(xs)  # [1, 2, 3]
unique(xs)  # [] 😱
unique.__defaults__  # ({1, 2, 3},)

Второй вызов на тех же данных возвращает пустой список, поскольку множество seen уже содержит все числа с предыдущего вызова, что и показывает __defaults__. В объявлении указывается None, а настоящее множество создаётся внутри функции при каждом вызове.

def unique(iterable, seen=None):
    seen = set() if seen is None else set(seen)
    acc = []
    for item in iterable:
        if item not in seen:
            seen.add(item)
            acc.append(item)
    return acc

xs = [1, 1, 2, 3]
unique(xs)  # [1, 2, 3]
unique(xs)  # [1, 2, 3] ✅

Переданная коллекция копируется в новое множество, и объект вызывающей стороны не изменяется. Проверка is None надёжнее распространённой записи seen or []: оператор or подставляет значение по умолчанию вместо любого ложного значения. Настройка k = k or 1.0 без сообщения об ошибке превратит переданный коэффициент 0.0 в 1.0, а для массива NumPy из нескольких элементов завершится ошибкой ValueError, поскольку истинность такого массива не определена.

Только именованные параметры

Можно потребовать, чтобы некоторые аргументы передавались только по имени. Одиночная звёздочка без имени отделяет такие параметры, не собирая лишних позиционных аргументов (PEP 3102).

def flatten(xs, *, depth=None):
    pass

flatten([1, [2], 3], depth=1)  # ✅
flatten([1, [2], 3], 1)        # TypeError

Так оформляют настройки расчёта. По вызову integrate(g, 0, 1, 1e-6, 100) не понять, где допуск, а где число узлов, тогда как запись integrate(g, 0, 1, tol=1e-6, n=100) понятна без документации, а ошибочный позиционный вызов завершается TypeError.

Только позиционные параметры

Обратное ограничение задаёт косая черта: параметры, записанные до /, передаются только по позиции (PEP 570, Python 3.8). Имена таких параметров не входят в интерфейс функции и могут меняться без последствий для вызывающего кода; так объявлены многие встроенные функции, например len(obj, /).

def f(a, b, /, c, *args, d=1, **kw):
    return a, b, c, args, d, kw

f(1, 2, 3)                  # (1, 2, 3, (), 1, {})
f(1, 2, 3, 4, 5, d=6, e=7)  # (1, 2, 3, (4, 5), 6, {'e': 7})
f(1, 2, c=3)                # (1, 2, 3, (), 1, {})

def g(a, /):
    return a

g(a=1)  # TypeError: g() got some positional-only arguments passed as keyword arguments: 'a'

Сигнатура f содержит все виды параметров в обязательном порядке: только позиционные, обычные, *args, только именованные, **kw.

Упаковка именованных аргументов

Две звёздочки выполняют с именованными аргументами то же, что одна с позиционными, собирая в словарь всё, что не опознано по именам. Аналогично они работают и в вызове, раскладывая словарь по именам параметров, поэтому отдельный набор настроек передаётся целиком.

def runner(cmd, **kwargs):
    if kwargs.get("verbose", True):
        print("Logging enabled")

runner("mysqld", limit=42)                    # ✅
runner("mysqld", **{"verbose": False})        # ✅
options = {"verbose": False}
runner("mysqld", **options)                   # ✅

Имена args и kwargs являются соглашением, а не требованием языка: значение имеют только звёздочки. Сочетание обеих даёт универсальную обёртку def wrapper(*args, **kwargs): return f(*args, **kwargs), которая передаёт любые аргументы без изменений; на ней построены декораторы.

Распаковка и присваивание

Базовая распаковка

Распаковка работает в любом присваивании. Слева записывается структура, составленная из имён, справа располагается итерируемый объект, и Python раскладывает второе по первому, причём форма слева может повторять вложенность справа.

x, y, z = [1, 2, 3]           # ✅
x, y, z = {1, 2, 3}           # ✅ (но порядок не гарантирован!)
x, y, z = "xyz"               # ✅

# Распаковка вложенных структур
rectangle = (0, 0), (4, 4)
(x1, y1), (x2, y2) = rectangle

С множеством код выполнится, но число, попавшее в x, не определено, поскольку порядка у множества нет.

Расширенная распаковка (Python 3.0+)

Звёздочка слева от знака равенства собирает «всё остальное» в список (PEP 3132). Имя, помеченное ею, может располагаться в начале, в конце или в середине; Python сначала распределяет значения по обычным именам, а остаток, не разобранный ими, достаётся звёздочке.

first, *rest = range(1, 5)           # first=1, rest=[2, 3, 4]
first, *rest, last = range(1, 5)     # first=1, rest=[2, 3], last=4

# Можно использовать в любом месте
*_, (first, *rest) = [range(1, 5)] * 5

Имя со звёздочкой может получить и пустой список, тогда как обычные имена обязаны получить по значению. Если значений не хватает даже на них, возникает ошибка.

first, *rest, last = [42]  # ValueError

Строка файла данных вида «время, номер канала, отсчёты» разбирается одним присваиванием: t, ch, *counts = line.split().

Распаковка в цикле for

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

for a, *b in [range(4), range(2)]:
    print(b)
# Вывод:
# [1, 2, 3]
# [1]

Распаковка в литералах (Python 3.5+)

Звёздочки допускаются и внутри литералов коллекций (PEP 448): одна звёздочка раскладывает последовательность, две — словарь.

old, new = [1, 2], (3, 4)
points = [*old, *new]          # [1, 2, 3, 4]

defaults = {"n": 1000, "rule": "simpson"}
user = {"n": 10_000}
config = {**defaults, **user}  # {'n': 10000, 'rule': 'simpson'}

При совпадении ключей сохраняется значение из правого словаря, поэтому пользовательские настройки перекрывают значения по умолчанию; с Python 3.9 то же записывается как defaults | user.

Области видимости (Scopes)

Функции внутри функций

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

def wrapper():
    def identity(x):
        return x
    return identity

f = wrapper()
f(42)  # 42

Правило LEGB

Интерпретатор ищет имя в четырёх областях по порядку и останавливается на первой найденной:

  • Local (локальная), имена, объявленные внутри самой функции.
  • Enclosing (объемлющая), имена объемлющей функции, если эта вложена в другую.
  • Global (глобальная), имена уровня модуля.
  • Built-in (встроенная), имена, доступные всегда, например len, print и min.
min = 42  # global

def f(*args):
    min = 2  # enclosing
    def g():
        min = 4  # local
        print(min)

Здесь три разных min; g печатает свой, локальный. Если из g удалить строку с присваиванием, печататься начнёт min, объявленный в f; если удалить её и оттуда, будет напечатана глобальная 42. Поэтому переменные не называют именами встроенных функций: встроенный min, последнее звено цепочки, оказывается закрытым. Ошибка проявляется далеко от места присваивания: после len = 5 вызов len("abc") завершается ошибкой TypeError: 'int' object is not callable.

Замыкания и позднее связывание

Имя, которое функция не определяет сама, разрешается не при объявлении, а в момент вызова; это называется поздним связыванием. Функция f ниже не знает, откуда возьмётся i, и при каждом вызове обращается к значению, находящемуся под этим именем в текущий момент.

def f():
    print(i)

for i in range(4):
    f()
# Вывод:
# 0
# 1
# 2
# 3

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

def make_adder(step):
    def add(x):
        return x + step      # step взят из объемлющей функции
    return add

add5 = make_adder(5)
print(add5(10))                          # 15
print(add5.__closure__[0].cell_contents) # 5 — значение хранится в ячейке замыкания

Функция make_adder давно завершилась, её локальные переменные должны были исчезнуть, но step сохраняется благодаря add, ссылающейся на него. На этом механизме построены декораторы, рассматриваемые в главе «Декораторы и functools». Той же схемой пользуются для моделей с параметрами: фабрика make_gaussian(mu, sigma) возвращает функцию одной переменной x с закреплёнными параметрами распределения, пригодную для интегрирования и построения графика. Для подгонки curve_fit, наоборот, модель принимает подбираемые параметры аргументами: model(x, mu, sigma).

Позднее связывание и замыкания вместе образуют ловушку. Функции, созданные в цикле, захватывают переменную, а не значение, записанное в ней, и после цикла все они видят последнее значение.

funcs = [lambda: i for i in range(3)]
print([f() for f in funcs])          # [2, 2, 2], а не [0, 1, 2]

funcs = [lambda i=i: i for i in range(3)]
print([f() for f in funcs])          # [0, 1, 2]

Во втором варианте значение фиксируется аргументом по умолчанию, вычисляемым, в отличие от тела функции, сразу при её создании. Тот же результат дают functools.partial, закрепляющий значения аргументов в момент создания, и фабрика функций, создающая на каждый вызов отдельную ячейку замыкания. При переборе параметров физической модели ловушка не сопровождается сообщением об ошибке: список моделей, построенный в цикле лямбдами и вызываемый после цикла, вычисляет всё с последним значением параметра, и ошибка видна только по совпадению результатов. Лямбда, вызванная в той же итерации, например интеграл quad(lambda x: s * x, 0, 1) внутри цикла по s, вычисляется верно.

Присваивание и области видимости

Чтение имени возможно из любой внешней области, а присваивание всегда создаёт локальную переменную. Решение принимается при компиляции функции и распространяется на всё её тело, поэтому min += 1 завершается ошибкой не на присваивании, а на попытке прочитать локальную min, ещё не получившую значения; начиная с Python 3.11 сообщение звучит как cannot access local variable 'min' where it is not associated with a value, в прежних версиях — local variable 'min' referenced before assignment.

min = 42

def f():
    min += 1  # UnboundLocalError!
    return min

Оператор global

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

min = 42

def f():
    global min
    min += 1
    return min

f()  # 43
f()  # 44

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

Оператор nonlocal (Python 3+)

nonlocal выполняет то же самое, но для объемлющей функции, а не для модуля (PEP 3104). Именно он позволяет вложенной функции присвоить новое значение переменной той функции, в которую она вложена; на этом основаны счётчики и накопители внутри декораторов.

def cell(value=None):
    def get():
        return value
    def set(update):
        nonlocal value
        value = update
    return get, set

get, set = cell()
set(42)
get()  # 42

Две функции разделяют одну переменную value, существующую столько же, сколько они сами. Интерпретатор хранит её в ячейке, на которую ссылается кортеж get.__closure__; помимо get и set, к ней обращаются только через этот служебный атрибут, предназначенный для отладки: get.__closure__[0].cell_contents.

Функциональное программирование

Анонимные функции (lambda)

Функцию в одну строку, передаваемую в sorted или map, задаёт lambda. Её тело представляет собой одно выражение, и его значение становится результатом без return.

lambda arguments: expression

# Эквивалентно:
def <lambda>(arguments):
    return expression

Список параметров у лямбды такой же, как у обычной функции, со звёздочками и значениями по умолчанию.

lambda x: x ** 2
lambda foo, *args, bar=None, **kwargs: 42

Как только логика перестаёт умещаться в одну строку, ей дают имя через def. PEP 8 рекомендует всегда использовать def вместо присваивания лямбды имени: запись f = lambda x: ... ничем не лучше def f(x): ..., но у такой функции нет строки документации, а в трассировке ошибки вместо имени стоит <lambda>.

Функции map, filter, zip

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

map применяет функцию к каждому элементу, поступающему из источника.

list(map(lambda x: x % 7, [1, 9, 16, -1, 2, 5]))  # [1, 2, 2, 6, 2, 5]

filter оставляет только элементы, для которых функция вернула истину. Если вместо функции передать None, фильтр отсеет всё ложное: нули, пустые коллекции и пустые строки.

list(filter(lambda x: x % 2 != 0, range(10)))  # [1, 3, 5, 7, 9]

# С None - оставляет только truthy значения
xs = [0, None, [], {}, set(), "", 42]
list(filter(None, xs))  # [42]

zip проходит по нескольким последовательностям параллельно и на каждом шаге возвращает кортеж, составленный из их элементов. Останавливается он по самой короткой, и лишние элементы длинных последовательностей теряются; с Python 3.10 параметр strict=True превращает несовпадение длин в ошибку ValueError.

list(zip("abc", range(3), [42j, 42j, 42j]))
# [('a', 0, 42j), ('b', 1, 42j), ('c', 2, 42j)]

Функцию в аргументе принимают и другие встроенные функции. sorted, min и max упорядочивают и выбирают элементы по функции-ключу key, а any и all прекращают перебор, как только результат известен. Модуль operator содержит готовые ключи: itemgetter выбирает элемент по индексу или ключу словаря, attrgetter — атрибут объекта.

from operator import itemgetter

events = [{"id": 1, "energy": 3.2}, {"id": 2, "energy": 7.9}]
max(events, key=itemgetter("energy"))  # {'id': 2, 'energy': 7.9}

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

Включения коллекций

То же самое записывается через включения. Форма ниже читается как «взять x ** 2 для каждого x из range(10), если x нечётно». Второй пример показывает ту же операцию через map и filter. Два for подряд разворачивают вложенную структуру в плоский список; их порядок такой же, как во вложенных циклах.

[x ** 2 for x in range(10) if x % 2 == 1]  # [1, 9, 25, 49, 81]

# Эквивалент с map/filter:
list(map(lambda x: x ** 2, filter(lambda x: x % 2 == 1, range(10))))

# Вложенные включения:
nested = [range(5), range(8, 10)]
[x for xs in nested for x in xs]  # [0, 1, 2, 3, 4, 8, 9]

Тот же синтаксис применяется для множеств и словарей, отличаются только скобки. Фигурные скобки с одним выражением дают множество, и в первом примере совпавшие между собой остатки объединились; а с парой, разделённой двоеточием, получается словарь.

{x % 7 for x in [1, 9, 16, -1, 2, 5]}  # {1, 2, 5, 6}

date = {"year": 2014, "month": "September", "day": ""}
{k: v for k, v in date.items() if v}  # {'year': 2014, 'month': 'September'}

{x: x ** 2 for x in range(4)}  # {0: 0, 1: 1, 2: 4, 3: 9}

Во втором примере условие if v отбрасывает записи с пустым значением, и здесь действует та же истинность объектов, применяемая и в filter(None, ...).

map остаётся уместен с готовой функцией: list(map(float, fields)) короче включения [float(s) for s in fields]. Выражение-генератор в круглых скобках вычисляет значения по одному и не строит промежуточный список: sum(x * x for x in data) суммирует квадраты, не выделяя память под список квадратов. Генераторы рассматриваются в главе «Итераторы, генераторы и корутины».

Чистые функции

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

total = 0.0

def add_sample(x):     # нечистая: изменяет глобальное состояние
    global total
    total += x

def mean(xs):          # чистая
    return sum(xs) / len(xs)

Программа целиком из чистых функций не состоит: чтение данных и запись результатов являются побочными эффектами. Расчётное ядро удобно держать чистым, а ввод и вывод сосредоточить на его границе. Для моделирования со случайными числами это означает передачу генератора аргументом: функция, использующая глобальный генератор, даёт разные результаты при каждом запуске, а генератор numpy.random.default_rng(42), созданный с фиксированным зерном и переданный в функцию, делает расчёт воспроизводимым. Чистой такая функция не становится, поскольку каждый вызов меняет состояние генератора, но её результат определяется аргументами, включая генератор.

Параметры модели для интеграторов

Интеграторы, оптимизаторы и решатели SciPy ожидают функцию одной переменной (или вектора), а модель обычно зависит ещё и от параметров. Закрепить параметры можно несколькими способами.

import math
from functools import partial
from scipy.integrate import quad

def gauss(x, mu, sigma):
    return math.exp(-(x - mu) ** 2 / (2 * sigma ** 2))

quad(gauss, -5, 5, args=(0.0, 1.0))             # параметр args
quad(lambda x: gauss(x, 0.0, 1.0), -5, 5)       # лямбда
quad(partial(gauss, mu=0.0, sigma=1.0), -5, 5)  # partial

Многие функции SciPy (quad, minimize, solve_ivp) принимают дополнительные аргументы в параметре args и передают их вызываемой функции после основных переменных. Лямбда подставляет параметры при каждом вызове: литералы — как записаны, имена — по значению на момент вызова. functools.partial закрепляет значения при создании и, в отличие от лямбды, сериализуется модулем pickle, если сериализуемы исходная функция и закреплённые аргументы; это требуется при распараллеливании через multiprocessing, и сама функция при этом определяется в импортируемом модуле (см. главу «Многопоточность и GIL»). Замыкание или фабрика функций уместны, когда модель содержит собственную логику или состояние, но pickle их не сериализует.

Стоимость вызова функции

Вызов функции в Python имеет собственную стоимость: интерпретатор создаёт кадр, связывает аргументы и возвращает результат. Замеры python3 -m timeit на Python 3.12 для списка xs из 100 тысяч чисел и функции sq = lambda x: x * x показывают порядок этих затрат.

Операция над 100 тысячами чиселВремя
[x * x for x in xs]1,64 мс
[sq(x) for x in xs]2,66 мс
list(map(sq, xs))3,10 мс
цикл for с накоплением t += x1,17 мс
sum(xs)0,25 мс

Вызов функции добавил около 10 нс на элемент: 2,66 мс против 1,64 мс. Соотношение map и включения с вызовом зависит от версии интерпретатора: в других версиях map бывает быстрее. Встроенная sum в четыре-пять раз быстрее явного цикла, поскольку её цикл выполняется в функции на C, без исполнения байт-кода для каждого элемента. Отсюда не следует, что функций нужно избегать: 10 нс заметны только в цикле по миллионам элементов. Участок, на который приходится основное время работы, переносят во встроенные функции или в векторные операции NumPy, обрабатывающие весь массив одним вызовом; подробно это рассматривается в главе «Оптимизация средствами самого Python».

PEP 8 и стиль кода

Код читают чаще, чем пишут, и единый стиль экономит время всем участникам. PEP 8 является официальным соглашением о том, как выглядит код на Python; его соблюдение проверяют линтеры, например pycodestyle и ruff.

Базовые рекомендации

  • 4 пробела для отступов, задающих структуру блока
  • Максимум 79 символов в строке кода (72 символа для комментариев и строк документации)
  • lower_case_with_underscores для переменных и функций
  • UPPER_CASE_WITH_UNDERSCORES для констант

Выражения и операторы

Пробелы вокруг операторов расставляются по приоритету, чтобы структура выражения была видна визуально. Тело if переносится на новую строку, поскольку однострочную запись труднее заметить при беглом чтении. Отрицание принадлежности записывается оператором not in, а не через not ... in: PEP 8 формулирует это правило для is not, а линтеры распространяют его и на in. Сравнение принято записывать в естественном порядке, с проверяемой переменной слева от значения. PEP 8 прямо этого не оговаривает, однако обратный порядок, так называемые условия Йоды, в Python не нужен: в C он защищает от случайного присваивания if (x = 5), а в Python присваивание = внутри условия является синтаксической ошибкой.

exp = -1.05
value = (item_value / item_count) * offset / exp
hypot2 = x*x + y*y

if bar:
    x += 1

if method == 'md5':
    pass

if key not in d:
    pass

Ниже приведены те же четыре конструкции в нерекомендуемой записи.

value = ( item_value/item_count )*offset/exp

if bar: x += 1

if 'md5' == method:
    pass

if not key in d:
    pass

Функции

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

def something_useful(arg, **options):
    """One-line summary.

    Optional longer description.
    """
    pass

Вокруг знака = у значения по умолчанию пробелы не ставятся, если параметр не аннотирован, и ставятся, если аннотирован; стрелка перед типом результата также окружается пробелами.

def scale(x, factor=2.0): ...
def scale(x: float, factor: float = 2.0) -> float: ...

Резюме

  • Функция является объектом: её передают в аргументах, хранят и возвращают; аннотации документируют типы, но не проверяются
  • Функции принимают произвольное количество позиционных (*args) и именованных (**kwargs) аргументов; параметры до / передаются только по позиции, после * — только по имени
  • Синтаксис распаковки работает в вызовах функций, присваивании, циклах и литералах коллекций
  • Имена ищутся по правилу LEGB: локальная, объемлющая, глобальная, встроенная область
  • Присваивание создаёт локальную переменную; поведение, заданное по умолчанию, изменяется через global и nonlocal
  • Значение по умолчанию вычисляется один раз, при выполнении def, поэтому изменяемым оно быть не должно
  • Для функций, созданных в цикле, значение фиксируют параметром по умолчанию, partial или фабрикой
  • Python поддерживает элементы функционального программирования: lambda, map, filter, zip, включения коллекций
  • Вызов функции стоит порядка 10 нс, что заметно только в циклах по миллионам элементов
  • PEP 8 описывает, как должен выглядеть читаемый код, и правила, записанные в нём, проверяются линтерами