Рассмотрите следующий фрагмент на Python: def add_to_list(x, lst=[]): lst.append(x) return lst Объясните семантику этого кода, почему он ведёт себя неожиданно при последовательных вызовах, какие фундаментальные концепции языка здесь задействованы, и предложите корректные альтернативы (с объяснением преимуществ и недостатков каждого варианта)?
Код def add_to_listx,lst=[]x, lst=[]x,lst=[]: lst.appendxxx
return lst имеет семантику, которую часто считают «неожиданной», но она полностью соответствует правилам Python. Вот что важно знать и почему так происходит.
Что происходит короткокороткокоротко
Значение параметра по умолчанию lst = вычисляется один раз — в момент определения функции когдавыполняетсяоператорdefкогда выполняется оператор defкогдавыполняетсяоператорdef, а не при каждом вызове.В результате каждый раз, когда вы вызываете add_to_list…… … без явного второго аргумента, используется один и тот же объект списка. Поскольку список изменяем mutablemutablemutable, изменения appendappendappend сохраняются между вызовами.
Пример поведения
add_to_list111111
add_to_list2221,21, 21,2
add_to_list3,[]3, []3,[]333 # передали новый список — всё нормально add_to_list4441,2,41, 2, 41,2,4 # продолжает накапливаться в одном списке по умолчанию
Фундаментальные концепции языка, задействованные здесь
Значения параметров по умолчанию вычисляются один раз при определении функции см.спецификациюPythonсм. спецификацию Pythonсм.спецификациюPython.В Python имена в переменных ссылаются на объекты; списки — изменяемые объекты. Когда вы изменяете список, вы меняете сам объект, на который ссылаются все ссылки.Следствие: если дефолтный объект изменяемый, вызовы без параметра будут видеть эти изменения разделяемоесостояниеразделяемое состояниеразделяемоесостояние.Дополнительно: этот дефолтный объект хранится в атрибуте функции defaults и живёт, пока живёт объект функции т.е.небудетудалёнсборщикомт.е. не будет удалён сборщикомт.е.небудетудалёнсборщиком.
Когда такое поведение может быть желательным
Если вы намеренно хотите сохранить состояние между вызовами функции например,кеширование,накоплениенапример, кеширование, накоплениенапример,кеширование,накопление, то использование изменяемого дефолта — допустимый приём. Но это должно быть явным и задокументированным.
1) Сентинел None рекомендуемыйисамыйраспространённыйрекомендуемый и самый распространённыйрекомендуемыйисамыйраспространённый
def add_to_listx,lst=Nonex, lst=Nonex,lst=None: if lst is None: lst =
lst.appendxxx
return lst
Преимущества:
Проста и понятна.Не разделяет состояние между вызовами.Работает корректно, когда ожидается «новый пустой список» по умолчанию.
Недостатки:
Если вы хотите позволить явно передавать None как валидный аргумент, этот приём не подходит нообычноэтоненужноно обычно это не нужнонообычноэтоненужно.
Важное замечание: не стоит писать lst = lst or — это неправильно, если допустимым и значимым аргументом является пустой список пустойсписок—falsyпустой список — falsyпустойсписок—falsy, такой код создаст новый список и перезапишет переданный пустой список.
2) Использовать уникальный объект-сентинел _SENTINEL = object
def add_to_listx,lst=SENTINELx, lst=_SENTINELx,lst=SENTINEL: if lst is _SENTINEL: lst =
lst.appendxxx
return lst
Преимущества:
Явно отличает «параметр не передан» от «передан None».Более формально и безопасно в редких случаях, когда None тоже может быть валидным входом.
Недостатки:
Немного более многословно.
3) Возвращать новый список функциональныйстиль,безизменениявходногофункциональный стиль, без изменения входногофункциональныйстиль,безизменениявходного
def add_to_listx,lst=Nonex, lst=Nonex,lst=None: if lst is None: lst =
new = lst + xxx # или new = listlstlstlst; new.appendxxx
return new
Преимущества:
Нет побочных эффектов на входной список.Подходит для иммутабильного/функционального стиля программирования.
Недостатки:
Создаётся копия списка при каждом вызове — накладные расходы по времени и памяти.
Немного необычно в API функции, и фабрика вызывается всегда еслинужнотолькоприотсутствииаргумента—комбинируйтесNoneесли нужно только при отсутствии аргумента — комбинируйте с Noneеслинужнотолькоприотсутствииаргумента—комбинируйтесNone.
5) Если вы действительно хотите сохранять состояние между вызовами — используйте это сознательно def counterval,storage=val, storage={}val,storage=: storage′sum′'sum'′sum′ = storage.get′sum′,0'sum', 0′sum′,0 + val return storage′sum′'sum'′sum′
Преимущества:
Удобный приём для кэша/накопления.
Недостатки:
Побочные эффекты, может путать пользователей функции, проблемы для многопоточности, тестируемости.
Дополнительные замечания
Можно посмотреть значение дефолтов через add_to_list.defaults — там хранится кортеж дефолтных объектов.Использование изменяемого дефолта удерживает объект в памяти покаживётфункцияпока живёт функцияпокаживётфункция, что иногда влияет на жизненный цикл объектов.В многопоточном окружении общий дефолтный объект — источник гонок, требуется синхронизация.
Краткий вывод Поведение функции обусловлено тем, что дефолтные значения вычисляются один раз при определении функции, а списки — изменяемые объекты. Если вы не хотите, чтобы состояние накапливалось между вызовами, используйте паттерн «None-сентинел + создание нового списка внутри функции» илиуникальныйобъект−сентинелили уникальный объект-сентинелилиуникальныйобъект−сентинел, либо возвращайте новую копию списка. Если же вы хотите аккумулировать состояние — используйте изменяемый дефолт осознанно и документируйте это.
Код
def add_to_listx,lst=[]x, lst=[]x,lst=[]:
lst.appendxxx return lst
имеет семантику, которую часто считают «неожиданной», но она полностью соответствует правилам Python. Вот что важно знать и почему так происходит.
Что происходит короткокороткокоротко
Значение параметра по умолчанию lst = вычисляется один раз — в момент определения функции когдавыполняетсяоператорdefкогда выполняется оператор defкогдавыполняетсяоператорdef, а не при каждом вызове.В результате каждый раз, когда вы вызываете add_to_list…… … без явного второго аргумента, используется один и тот же объект списка. Поскольку список изменяем mutablemutablemutable, изменения appendappendappend сохраняются между вызовами.Пример поведения
add_to_list111 111 add_to_list222 1,21, 21,2 add_to_list3,[]3, []3,[] 333 # передали новый список — всё нормально
add_to_list444 1,2,41, 2, 41,2,4 # продолжает накапливаться в одном списке по умолчанию
Фундаментальные концепции языка, задействованные здесь
Значения параметров по умолчанию вычисляются один раз при определении функции см.спецификациюPythonсм. спецификацию Pythonсм.спецификациюPython.В Python имена в переменных ссылаются на объекты; списки — изменяемые объекты. Когда вы изменяете список, вы меняете сам объект, на который ссылаются все ссылки.Следствие: если дефолтный объект изменяемый, вызовы без параметра будут видеть эти изменения разделяемоесостояниеразделяемое состояниеразделяемоесостояние.Дополнительно: этот дефолтный объект хранится в атрибуте функции defaults и живёт, пока живёт объект функции т.е.небудетудалёнсборщикомт.е. не будет удалён сборщикомт.е.небудетудалёнсборщиком.Когда такое поведение может быть желательным
Если вы намеренно хотите сохранить состояние между вызовами функции например,кеширование,накоплениенапример, кеширование, накоплениенапример,кеширование,накопление, то использование изменяемого дефолта — допустимый приём. Но это должно быть явным и задокументированным.Корректные альтернативы собъяснениемплюс/минусс объяснением плюс/минуссобъяснениемплюс/минус
1) Сентинел None рекомендуемыйисамыйраспространённыйрекомендуемый и самый распространённыйрекомендуемыйисамыйраспространённый def add_to_listx,lst=Nonex, lst=Nonex,lst=None:
if lst is None:
lst = lst.appendxxx return lst
Преимущества:
Проста и понятна.Не разделяет состояние между вызовами.Работает корректно, когда ожидается «новый пустой список» по умолчанию.Недостатки:
Если вы хотите позволить явно передавать None как валидный аргумент, этот приём не подходит нообычноэтоненужноно обычно это не нужнонообычноэтоненужно.Важное замечание: не стоит писать lst = lst or — это неправильно, если допустимым и значимым аргументом является пустой список пустойсписок—falsyпустой список — falsyпустойсписок—falsy, такой код создаст новый список и перезапишет переданный пустой список.
2) Использовать уникальный объект-сентинел
_SENTINEL = object def add_to_listx,lst=SENTINELx, lst=_SENTINELx,lst=S ENTINEL:
if lst is _SENTINEL:
lst = lst.appendxxx return lst
Преимущества:
Явно отличает «параметр не передан» от «передан None».Более формально и безопасно в редких случаях, когда None тоже может быть валидным входом.Недостатки:
Немного более многословно.3) Возвращать новый список функциональныйстиль,безизменениявходногофункциональный стиль, без изменения входногофункциональныйстиль,безизменениявходного def add_to_listx,lst=Nonex, lst=Nonex,lst=None:
if lst is None:
lst = new = lst + xxx # или new = listlstlstlst; new.appendxxx return new
Преимущества:
Нет побочных эффектов на входной список.Подходит для иммутабильного/функционального стиля программирования.Недостатки:
Создаётся копия списка при каждом вызове — накладные расходы по времени и памяти.4) Передача фабрики режережереже def add_to_listx,factory=listx, factory=listx,factory=list:
lst = factory lst.appendxxx return lst
Преимущества:
Гибкость: можно передать кастомный класс/фабрику.Недостатки:
Немного необычно в API функции, и фабрика вызывается всегда еслинужнотолькоприотсутствииаргумента—комбинируйтесNoneесли нужно только при отсутствии аргумента — комбинируйте с Noneеслинужнотолькоприотсутствииаргумента—комбинируйтесNone.5) Если вы действительно хотите сохранять состояние между вызовами — используйте это сознательно
def counterval,storage=val, storage={}val,storage=:
storage′sum′'sum'′sum′ = storage.get′sum′,0'sum', 0′sum′,0 + val
return storage′sum′'sum'′sum′
Преимущества:
Удобный приём для кэша/накопления.Недостатки:
Побочные эффекты, может путать пользователей функции, проблемы для многопоточности, тестируемости.Дополнительные замечания
Можно посмотреть значение дефолтов через add_to_list.defaults — там хранится кортеж дефолтных объектов.Использование изменяемого дефолта удерживает объект в памяти покаживётфункцияпока живёт функцияпокаживётфункция, что иногда влияет на жизненный цикл объектов.В многопоточном окружении общий дефолтный объект — источник гонок, требуется синхронизация.Краткий вывод
Поведение функции обусловлено тем, что дефолтные значения вычисляются один раз при определении функции, а списки — изменяемые объекты. Если вы не хотите, чтобы состояние накапливалось между вызовами, используйте паттерн «None-сентинел + создание нового списка внутри функции» илиуникальныйобъект−сентинелили уникальный объект-сентинелилиуникальныйобъект−сентинел, либо возвращайте новую копию списка. Если же вы хотите аккумулировать состояние — используйте изменяемый дефолт осознанно и документируйте это.