НейроАгент

Аргументы по умолчанию в Python: объяснение привязки во время определения

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

Вопрос

Какова логика дизайна Python, согласно которой аргументы по умолчанию привязываются во время определения функции, а не во время её выполнения, и как это связано с концепцией “Принципа наименьшего удивления” в дизайне языков программирования?

В Python следующая функция демонстрирует неожиданное поведение для начинающих:

python
def foo(a=[]):
    a.append(5)
    return a

При многократном вызове без аргументов вместо того, чтобы всегда возвращать [5], она produces:

python
>>> foo()
[5]
>>> foo()
[5, 5]
>>> foo()
[5, 5, 5]

Это поведение кажется контринтуитивным, потому что:

  1. Новички ожидают, что аргумент по умолчанию будет переоцениваться при каждом вызове функции
  2. Это может приводить к ошибкам при использовании изменяемых объектов в качестве аргументов по умолчанию
  3. Поведение отличается от того, чего можно ожидать на основе других парадигм языков программирования

Рассмотрим расширенный пример:

python
def a():
    print("a executed")
    return []

def b(x=a()):
    x.append(5)
    print(x)

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

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

НейроАгент

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

Содержание

Что происходит под капотом

Когда Python обрабатывает определение функции, он создает объект функции и оценивает все аргументы по умолчанию в этот момент. Это означает, что значения по умолчанию вычисляются один раз во время определения функции и хранятся как атрибут __defaults__ функции. Как объясняется в обсуждении на Reddit, “привязка значения аргумента по умолчанию происходит во время компиляции, а не во время выполнения”.

python
def foo(a=[]):
    a.append(5)
    return a

# Значение по умолчанию [] создается здесь, во время определения функции
print(foo.__defaults__)  # ([],) - один и тот же объект списка используется каждый раз

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

Техническая реализация показывает, что аргументы по умолчанию рассматриваются как часть метаданных сигнатуры функции, а не как часть исполняемого кода функции. Именно они оцениваются один раз, когда функция определяется.

Обоснование привязки времени проектирования

Основная причина привязки значений по умолчанию во время проектирования — согласованность в модели определения функции. В философии дизайна Python все в строке определения функции должно оцениваться в одно и то же время — во время создания функции. Это создает четкую границу между тем, что происходит во время определения, а что — во время выполнения.

Согласно обсуждению на Stack Overflow, этот подход избегает создания “гибридной” модели, когда часть привязки происходит во время определения, а часть — во время вызова. Гибридная модель была бы сложнее в реализации и понимании.

Документация Python объясняет, что “из-за вышеупомянутого поведения Python относительно оценки аргументов по умолчанию для функций”, система поддерживает согласованность, оценивая все значения по умолчанию как часть процесса определения функции. Эта согласованность упрощает семантику и реализацию языка.

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

Модели времени определения и выполнения

Различие между привязкой во время определения и выполнения представляет два разных философских подхода к аргументам по умолчанию в функциях:

Привязка во время определения (подход Python):

  • Все значения по умолчанию вычисляются один раз, когда функция определяется
  • Значения по умолчанию становятся частью неизменяемой сигнатуры функции
  • Объект функции содержит ссылки на значения по умолчанию
  • Эта модель последовательна, но может быть удивительна при работе с изменяемыми объектами

Привязка во время выполнения (альтернативный подход):

  • Значения по умолчания вычислялись бы каждый раз при вызове функции
  • Это соответствовало бы интуитивным ожиданиям большинства начинающих
  • Однако это создало бы гибридную модель выполнения
  • Это могло бы иметь последствия для производительности

Как отмечено в комментарии на Reddit, “не существует такого понятия, как ‘время определения’, все время — это время выполнения” — но реализация Python рассматривает определение функции как особый случай, когда определенные операции происходят во время компиляции, а не во время чистого выполнения.

Блог Digital Cat объясняет, что “пока обычные значения жестко закодированы и, следовательно, не требуют оценки, кроме той, что производится во время компиляции, вызовы функций ожидаются к выполнению во время выполнения”. Это подчеркивает философское различие между статической и динамической оценкой.

Принцип наименьшего удивления в Python

Принцип наименьшего удивления (или наименьшего удивления) предполагает, что поведение языка программирования должно быть интуитивно понятным и предсказуемым для пользователей. В контексте аргументов по умолчанию в Python этот принцип на самом деле нарушается текущей реализацией.

Как отмечено в обсуждении на Stack Overflow, “в качестве дополнительного доказательства того, что это дефект дизайна, если вы погуглите ‘Python gotchas’, этот дизайн упоминается как ловушка, обычно первая в списке”. Это указывает на то, что поведение нарушает интуитивные ожидания большинства программистов.

Ресурс W3Docs гласит, что “принцип ‘наименьшего удивления’ предполагает, что поведение функции должно быть настолько предсказуемым и не удивительным, насколько возможно. В контексте аргументов по умолчанию в Python этот принцип предполагает, что значение аргумента по умолчанию не должно меняться между вызовами функции”.

Однако сторонники дизайна Python утверждают, что поведение на самом деле последовательное, как только оно понимается, и фактор удивления возникает из-за непонимания того, как работает Python. Статья на SourceBae объясняет, что “несмотря на первоначальные удивления, это естественно следует из философии дизайна Python, где аргументы по умолчанию оцениваются во время определения”.

Это создает напряжение между технической последовательностью и интуитивным поведением — дизайн Python отдает приоритет технической последовательности над тем, что может быть более интуитивно понятно для начинающих.

PEP 671: Решение для поздних привязок по умолчанию

Признав напряжение между последовательностью и наименьшим удивлением, Python представил PEP 671 — “Синтаксис для поздних привязок аргументов функции по умолчанию”. Как указано в документации PEP, “параметры функции могут иметь значения по умолчанию, которые вычисляются во время определения функции и сохраняются. Это предложение вводит новую форму аргумента по умолчанию, определяемую выражением, которое оценивается во время вызова функции”.

PEP 671 предлагает синтаксис вроде:

python
def foo(a=[]):  # Текущее поведение - оценивается во время определения
    pass

def foo(a=()):  # Предложение PEP 671 - оценивается во время вызова
    pass

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

PEP признает, что “текущее поведение удивительно для многих программистов” и что “поздние привязки по умолчанию предоставили бы способ выразить распространенный шаблон ‘используйте это выражение для вычисления значения по умолчанию во время вызова’”.

Однако PEP 671 все еще является предложением и не был реализован в Python по состоянию на 2024 год. Обсуждение вокруг него подчеркивает продолжающиеся дебаты о том, как сбалансировать последовательность с интуитивностью в дизайне языка.

Лучшие практики и обходные пути

Учитывая текущее поведение Python, разработчики приняли несколько лучших практик, чтобы избежать ловушки изменяемого аргумента по умолчанию:

  1. Используйте None в качестве значения-сentinela:
python
def foo(a=None):
    if a is None:
        a = []
    a.append(5)
    return a
  1. Используйте неизменяемые значения по умолчанию:
python
def foo(a=()):
    a = list(a)  # Преобразуйте в изменяемый, если необходимо
    a.append(5)
    return a

Статья на GeeksforGeeks рекомендует “всегда, когда изменяемый тип предоставляется в качестве аргумента по умолчанию, присваивать ему значение None в заголовке функции”.

Путеводитель Хitchhiker’s по Python предлагает “создавать замыкание, которое немедленно связывается со своими аргументами, используя аргумент по умолчанию” в качестве обходного пути.

Эти шаблоны стали настолько распространенными, что считаются стандартной практикой Python, эффективно обходя ограничение дизайна.

Сравнительный анализ с другими языками

Поведение Python отличается от многих других языков программирования:

  • JavaScript: Оценивает аргументы по умолчанию во время вызова
  • Ruby: Оценивает аргументы по умолчанию во время вызова
  • C++: Оценивает аргументы по умолчанию во время вызова
  • Java: Не имеет аргументов по умолчанию (вместо этого используется перегрузка методов)

Как отмечено в обсуждении на Stack Overflow, “в отличие от этого, если вы погуглите ‘JavaScript gotchas’, поведение аргументов по умолчанию в JavaScript не упоминается как ловушка даже один раз”.

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

Однако подход Python имеет и преимущества:

  • Он более эффективен (значения по умолчанию не нужно пересчитывать)
  • Он более согласован с моделью функции-объекта
  • Он избегает потенциальных состояний гонки в многопоточном коде

Компромисс между эффективностью и интуитивностью продолжает оставаться темой обсуждения в сообществе Python.

Источники

  1. Почему аргументы по умолчанию оцениваются во время определения? - Stack Overflow
  2. PEP 671 – Синтаксис для поздних привязок аргументов функции по умолчанию
  3. Наименьшее удивление и изменяемый аргумент по умолчанию - Stack Overflow
  4. Изменяемый аргумент по умолчанию в Python: Почему? - Software Engineering Stack Exchange
  5. Наименьшее удивление и изменяемый аргумент по умолчанию в Python - GeeksforGeeks
  6. Распространенные ловушки — Путеводитель Хitchhiker’s по Python
  7. Сегодня я переучился: Аргументы по умолчанию функции Python сохраняются между выполнениями - Reddit
  8. Наименьшее удивление в python: изменяемый аргумент по умолчанию - Reddit
  9. Недостаток изменяемых аргументов по умолчанию в Python - Medium
  10. Аргументы по умолчанию в Python - GeeksforGeeks

Заключение

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

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

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

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

  3. PEP 671 как потенциальное решение: Предложенный синтаксис поздних привязок по умолчанию позволил бы разработчикам выбирать между привязкой во время определения и выполнения, решая проблему наименьшего удивления.

  4. Установленные обходные пути: Сообщество Python разработало надежные шаблоны (такие как использование None в качестве значения-сентинела), которые эффективно обходят это ограничение.

  5. Уникальность среди языков: Подход Python отличается от большинства других популярных языков, которые в общем случае оценивают значения по умолчанию во время вызова.

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