Learning Platform
Глоссарий Troubleshooting
Урок 04.01 · 18 мин
Средний
FunctionsDefaultsPitfallPEP 570PEP 3102

Функции: positional, keyword, default — и mutable default trap

После двух ULTRA-DEEP модулей (M01 — primitives, M02 — структуры данных) мы переходим к функциям. Базовый синтаксис def знаком всем; его описание уложится в 5 минут. Но один pitfall — mutable default argument trap — настолько частый и настолько неочевидный, что ему посвящена половина урока.

Если вы запомните только одну вещь из этого урока — пусть это будет правило: никогда не пишите def f(x, target=[]):.


def синтаксис: positional / keyword / default

Базовая форма:

def f(a, b=10, c=20):
    return a + b + c
  • apositional (обязательный): передаётся по позиции.
  • b, cdefault: имеют значение по умолчанию, можно не передавать.

Вызовы:

f(1)              # 1 + 10 + 20 = 31  (b и c — defaults)
f(1, 2)           # 1 + 2 + 20 = 23   (b передан, c — default)
f(1, c=5)         # 1 + 10 + 5 = 16   (b — default, c — keyword)
f(a=1, b=2, c=3)  # 1 + 2 + 3 = 6     (все keyword)

Правило порядка в signature: все positional аргументы (без default) идут до default. То есть def f(a=1, b) — SyntaxError.

Правило порядка в call: positional аргументы идут до keyword. f(1, b=2) — OK; f(b=2, 1) — SyntaxError.


PEP 570 — positional-only / PEP 3102 — keyword-only

Python 3.8+ позволяет жёстко разделить, какие параметры могут передаваться только позиционно, а какие — только по имени:

def f(pos1, pos2, /, both, *, kw1, kw2):
    ...
  • Всё до / — positional-only (нельзя передавать по имени).
  • Всё между / и * — обычные параметры (можно по позиции или по имени).
  • Всё после * — keyword-only.

Примеры:

def f(pos1, pos2, /, both, *, kw1, kw2):
    print(pos1, pos2, both, kw1, kw2)

f(1, 2, 3, kw1=4, kw2=5)        # OK: pos1=1, pos2=2, both=3
f(1, 2, both=3, kw1=4, kw2=5)   # OK: both — обычный, можно по имени
f(pos1=1, pos2=2, both=3, kw1=4, kw2=5)
# TypeError: f() got some positional-only arguments passed as keyword arguments: 'pos1, pos2'

f(1, 2, 3, 4, 5)
# TypeError: f() takes 3 positional arguments but 5 were given
# (kw1, kw2 — keyword-only, нельзя передать по позиции)

Зачем positional-only? Для API stability: переименование параметра pos1 не сломает вызывающих, потому что они не могут передавать его по имени. Это используется во встроенных функциях: len(obj, /), pow(base, exp, mod=None, /). См. PEP 570.

Зачем keyword-only? Для self-documenting вызовов: subprocess.run(cmd, *, check=True, timeout=60)check и timeout обязаны передаваться по имени, что делает call-site читаемым. См. PEP 3102.

TIP

В новом коде используйте * для разделения позиционных и keyword-only — это улучшает читаемость и устойчивость API. Особенно для booleans: f(data, *, verbose=False) лучше, чем f(data, False).


Mutable default argument trap

DANGER

Один из самых вредных footguns в Python. Прочитайте до конца — этот pitfall настигает даже опытных разработчиков.

Симптом: функция с def f(x, target=[]): ведёт себя так, будто между вызовами помнит state.

def append_to(item, target=[]):  # WRONG
    target.append(item)
    return target

print(append_to(1))   # [1]
print(append_to(2))   # [1, 2]    — почему не [2]?!
print(append_to(3))   # [1, 2, 3] — список «накапливается»

Корень проблемы: default-значения evaluated один раз — at function-definition time, не при каждом вызове. Они хранятся в самом function-объекте, в поле __defaults__:

def append_to(item, target=[]):
    target.append(item)
    return target

print(append_to.__defaults__)   # ([],) — tuple с одним пустым list

append_to(1)
print(append_to.__defaults__)   # ([1],) — тот же list, теперь не пустой

Все вызовы без явного target получают один и тот же list. Это не bug — это документированное поведение CPython, прямое следствие того, что function — это объект, и default-tuple хранится в нём.

f.__defaults__ — где живёт default
def append_toPyFunctionObjectFunction object на куче. Содержит func_code (bytecode), func_globals, func_defaults — tuple дефолтных значений, оцененных один раз at def-time.
func_defaults
tuple([],)Tuple, содержащий один mutable list. Все calls без явного target получают именно этот list — не копию. Mutations накапливаются.
указывает на
PyListObject[1, 2, 3]Один и тот же mutable list, переживающий между вызовами. Растёт при каждом append, потому что shared.

См. Objects/funcobject.c — структура PyFunctionObject, поля func_defaults (positional defaults) и func_kwdefaults (keyword-only defaults).


Правильное решение — sentinel pattern

Идиома: использовать None (или другой sentinel) как default, и создавать mutable объект внутри функции:

def append_to(item, target=None):
    if target is None:
        target = []
    target.append(item)
    return target

print(append_to(1))   # [1]
print(append_to(2))   # [2]
print(append_to(3))   # [3]

Каждый вызов без явного target создаёт свежий list — как и ожидается.

Почему is None, а не == None? Это identity check (см. M01 урок 01: isid(x) == id(y), == — вызов __eq__). None — singleton, и is None — каноничная проверка sentinel’а. Кроме того, == может быть переопределён в кастомных классах, что приведёт к surprise (например, NumPy array возвращает массив bool из ==).

Альтернативные sentinels: для случаев, где None — валидное значение аргумента, используют _sentinel = object():

_MISSING = object()

def lookup(key, default=_MISSING):
    val = data.get(key)
    if val is None and default is _MISSING:
        raise KeyError(key)
    return val if val is not None else default

object() — уникальный объект, никогда не равный ничему другому по is. Это позволяет различать «пользователь не передал default» и «пользователь передал default=None».


Когда mutable default OK?

Очень редко. Один валидный паттерн — micro-optimization memoization, использующая default как cache:

def memoized(x, _cache={}):
    if x not in _cache:
        _cache[x] = expensive_compute(x)
    return _cache[x]

Underscore prefix (_cache) сигнализирует «private internal, не передавайте сами». Но для production используйте functools.lru_cache (см. урок 4 этого модуля) — он чище и thread-safe.

Anti-pattern: mutable default ради «default empty list». Всегда используйте None sentinel для этого.


Type hints для defaults (preview)

Modern Python coders пишут signature с type hints (PEP 526):

def append_to(item: int, target: list[int] | None = None) -> list[int]:
    if target is None:
        target = []
    target.append(item)
    return target

Type hints не enforced рантаймом (это hints для IDE / mypy / pyright), но делают signature self-documenting и помогают избежать ошибок ещё на этапе редактирования. Подробнее — в Phase 67 (typing depth dive); здесь — для контекста.


Cross-course context


Ключевые выводы

  1. Default values evaluated один раз at function-definition time; хранятся в func.__defaults__ tuple.
  2. Mutable defaults shared across calls — это not bug, а documented поведение. Используйте None sentinel pattern + создание объекта внутри функции.
  3. PEP 570 (/) — positional-only маркер; PEP 3102 (*) — keyword-only маркер. Используйте для API stability и self-documenting вызовов.
  4. is None вместо == None — identity check, каноничный способ проверки sentinel’а.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. После двух вызовов `f(1)` и `f(2)` функции `def f(x, lst=[]): lst.append(x); return lst`, что вернёт второй вызов?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6