Рассмотрите следующий фрагмент на Python: def add_to_list(x, lst=[]): lst.append(x) return lst Объясните семантику этого кода, почему он ведёт себя неожиданно при последовательных вызовах, какие фундаментальные концепции языка здесь задействованы, и предложите корректные альтернативы (с объяснением преимуществ и недостатков каждого варианта)?
Код def add_to_list(x, lst=[]): lst.append(x) return lst имеет семантику, которую часто считают «неожиданной», но она полностью соответствует правилам Python. Вот что важно знать и почему так происходит.
Что происходит (коротко)
Значение параметра по умолчанию lst = [] вычисляется один раз — в момент определения функции (когда выполняется оператор def), а не при каждом вызове.В результате каждый раз, когда вы вызываете add_to_list(… ) без явного второго аргумента, используется один и тот же объект списка. Поскольку список изменяем (mutable), изменения (append) сохраняются между вызовами.
Пример поведения
add_to_list(1) [1] add_to_list(2) [1, 2] add_to_list(3, []) [3] # передали новый список — всё нормально add_to_list(4) [1, 2, 4] # продолжает накапливаться в одном списке по умолчанию
Фундаментальные концепции языка, задействованные здесь
Значения параметров по умолчанию вычисляются один раз при определении функции (см. спецификацию Python).В Python имена в переменных ссылаются на объекты; списки — изменяемые объекты. Когда вы изменяете список, вы меняете сам объект, на который ссылаются все ссылки.Следствие: если дефолтный объект изменяемый, вызовы без параметра будут видеть эти изменения (разделяемое состояние).Дополнительно: этот дефолтный объект хранится в атрибуте функции defaults и живёт, пока живёт объект функции (т.е. не будет удалён сборщиком).
Когда такое поведение может быть желательным
Если вы намеренно хотите сохранить состояние между вызовами функции (например, кеширование, накопление), то использование изменяемого дефолта — допустимый приём. Но это должно быть явным и задокументированным.
Корректные альтернативы (с объяснением плюс/минус)
1) Сентинел None (рекомендуемый и самый распространённый) def add_to_list(x, lst=None): if lst is None: lst = [] lst.append(x) return lst
Преимущества:
Проста и понятна.Не разделяет состояние между вызовами.Работает корректно, когда ожидается «новый пустой список» по умолчанию.
Недостатки:
Если вы хотите позволить явно передавать None как валидный аргумент, этот приём не подходит (но обычно это не нужно).
Важное замечание: не стоит писать lst = lst or [] — это неправильно, если допустимым и значимым аргументом является пустой список (пустой список — falsy), такой код создаст новый список и перезапишет переданный пустой список.
2) Использовать уникальный объект-сентинел _SENTINEL = object() def add_to_list(x, lst=_SENTINEL): if lst is _SENTINEL: lst = [] lst.append(x) return lst
Преимущества:
Явно отличает «параметр не передан» от «передан None».Более формально и безопасно в редких случаях, когда None тоже может быть валидным входом.
Недостатки:
Немного более многословно.
3) Возвращать новый список (функциональный стиль, без изменения входного) def add_to_list(x, lst=None): if lst is None: lst = [] new = lst + [x] # или new = list(lst); new.append(x) return new
Преимущества:
Нет побочных эффектов на входной список.Подходит для иммутабильного/функционального стиля программирования.
Недостатки:
Создаётся копия списка при каждом вызове — накладные расходы по времени и памяти.
Немного необычно в API функции, и фабрика вызывается всегда (если нужно только при отсутствии аргумента — комбинируйте с None).
5) Если вы действительно хотите сохранять состояние между вызовами — используйте это сознательно def counter(val, storage={}): storage['sum'] = storage.get('sum', 0) + val return storage['sum']
Преимущества:
Удобный приём для кэша/накопления.
Недостатки:
Побочные эффекты, может путать пользователей функции, проблемы для многопоточности, тестируемости.
Дополнительные замечания
Можно посмотреть значение дефолтов через add_to_list.defaults — там хранится кортеж дефолтных объектов.Использование изменяемого дефолта удерживает объект в памяти (пока живёт функция), что иногда влияет на жизненный цикл объектов.В многопоточном окружении общий дефолтный объект — источник гонок, требуется синхронизация.
Краткий вывод Поведение функции обусловлено тем, что дефолтные значения вычисляются один раз при определении функции, а списки — изменяемые объекты. Если вы не хотите, чтобы состояние накапливалось между вызовами, используйте паттерн «None-сентинел + создание нового списка внутри функции» (или уникальный объект-сентинел), либо возвращайте новую копию списка. Если же вы хотите аккумулировать состояние — используйте изменяемый дефолт осознанно и документируйте это.
Код
def add_to_list(x, lst=[]):
lst.append(x)
return lst
имеет семантику, которую часто считают «неожиданной», но она полностью соответствует правилам Python. Вот что важно знать и почему так происходит.
Что происходит (коротко)
Значение параметра по умолчанию lst = [] вычисляется один раз — в момент определения функции (когда выполняется оператор def), а не при каждом вызове.В результате каждый раз, когда вы вызываете add_to_list(… ) без явного второго аргумента, используется один и тот же объект списка. Поскольку список изменяем (mutable), изменения (append) сохраняются между вызовами.Пример поведения
add_to_list(1)
[1]
add_to_list(2)
[1, 2]
add_to_list(3, [])
[3] # передали новый список — всё нормально
add_to_list(4)
[1, 2, 4] # продолжает накапливаться в одном списке по умолчанию
Фундаментальные концепции языка, задействованные здесь
Значения параметров по умолчанию вычисляются один раз при определении функции (см. спецификацию Python).В Python имена в переменных ссылаются на объекты; списки — изменяемые объекты. Когда вы изменяете список, вы меняете сам объект, на который ссылаются все ссылки.Следствие: если дефолтный объект изменяемый, вызовы без параметра будут видеть эти изменения (разделяемое состояние).Дополнительно: этот дефолтный объект хранится в атрибуте функции defaults и живёт, пока живёт объект функции (т.е. не будет удалён сборщиком).Когда такое поведение может быть желательным
Если вы намеренно хотите сохранить состояние между вызовами функции (например, кеширование, накопление), то использование изменяемого дефолта — допустимый приём. Но это должно быть явным и задокументированным.Корректные альтернативы (с объяснением плюс/минус)
1) Сентинел None (рекомендуемый и самый распространённый)
def add_to_list(x, lst=None):
if lst is None:
lst = []
lst.append(x)
return lst
Преимущества:
Проста и понятна.Не разделяет состояние между вызовами.Работает корректно, когда ожидается «новый пустой список» по умолчанию.Недостатки:
Если вы хотите позволить явно передавать None как валидный аргумент, этот приём не подходит (но обычно это не нужно).Важное замечание: не стоит писать lst = lst or [] — это неправильно, если допустимым и значимым аргументом является пустой список (пустой список — falsy), такой код создаст новый список и перезапишет переданный пустой список.
2) Использовать уникальный объект-сентинел
_SENTINEL = object()
def add_to_list(x, lst=_SENTINEL):
if lst is _SENTINEL:
lst = []
lst.append(x)
return lst
Преимущества:
Явно отличает «параметр не передан» от «передан None».Более формально и безопасно в редких случаях, когда None тоже может быть валидным входом.Недостатки:
Немного более многословно.3) Возвращать новый список (функциональный стиль, без изменения входного)
def add_to_list(x, lst=None):
if lst is None:
lst = []
new = lst + [x] # или new = list(lst); new.append(x)
return new
Преимущества:
Нет побочных эффектов на входной список.Подходит для иммутабильного/функционального стиля программирования.Недостатки:
Создаётся копия списка при каждом вызове — накладные расходы по времени и памяти.4) Передача фабрики (реже)
def add_to_list(x, factory=list):
lst = factory()
lst.append(x)
return lst
Преимущества:
Гибкость: можно передать кастомный класс/фабрику.Недостатки:
Немного необычно в API функции, и фабрика вызывается всегда (если нужно только при отсутствии аргумента — комбинируйте с None).5) Если вы действительно хотите сохранять состояние между вызовами — используйте это сознательно
def counter(val, storage={}):
storage['sum'] = storage.get('sum', 0) + val
return storage['sum']
Преимущества:
Удобный приём для кэша/накопления.Недостатки:
Побочные эффекты, может путать пользователей функции, проблемы для многопоточности, тестируемости.Дополнительные замечания
Можно посмотреть значение дефолтов через add_to_list.defaults — там хранится кортеж дефолтных объектов.Использование изменяемого дефолта удерживает объект в памяти (пока живёт функция), что иногда влияет на жизненный цикл объектов.В многопоточном окружении общий дефолтный объект — источник гонок, требуется синхронизация.Краткий вывод
Поведение функции обусловлено тем, что дефолтные значения вычисляются один раз при определении функции, а списки — изменяемые объекты. Если вы не хотите, чтобы состояние накапливалось между вызовами, используйте паттерн «None-сентинел + создание нового списка внутри функции» (или уникальный объект-сентинел), либо возвращайте новую копию списка. Если же вы хотите аккумулировать состояние — используйте изменяемый дефолт осознанно и документируйте это.