"Наименьшее Удивление" и Изменяемый Аргумент По Умолчанию
Проблема с аргументами по умолчанию в Python
Здравствуйте, сообщество!
У каждого, кто достаточно долго работает с Python, возникала проблема, связанная с аргументами по умолчанию. Рассмотрим следующий пример:
def foo(a=[]):
a.append(5)
return a
Новички в Python ожидают, что вызов этой функции без параметров всегда будет возвращать список с единственным элементом: [5]
. Однако результат на самом деле оказывается весьма неожиданным:
>>> foo()
[5]
>>> foo()
[5, 5]
>>> foo()
[5, 5, 5]
>>> foo()
[5, 5, 5, 5]
>>> foo()
Мой менеджер однажды столкнулся с этой особенностью и назвал её "драматическим недостатком" языка. Я попытался объяснить, что за таким поведением кроется логика, но всё равно не смог ответить на вопрос: почему аргумент по умолчанию связывается при определении функции, а не при её вызове? Сомневаюсь, что это поведение имеет практическое применение (кто на самом деле использует статические переменные в C, не наткнувшись на ошибки?).
Редактирование:
Бацек привёл интересный пример. Вместе с большинством ваших комментариев, особенно комментариями Утаала, я задумался ещё больше:
def a():
print("a executed")
return []
def b(x=a()):
x.append(5)
print(x)
a executed
>>> b()
[5]
>>> b()
[5, 5]
Мне кажется, что решение о дизайне было связано с выбором области видимости параметров: оставить их внутри функции или "вместе" с ней?
Если связать значение по умолчанию внутри функции, это означало бы, что x
фактически связывается с указанным значением по умолчанию в момент вызова функции, а не её определения. Это создало бы странную конструкцию, где часть связывания (объект функции) происходит при определении, а часть (присвоение значений по умолчанию) — при вызове функции.
Текущее поведение более последовательное: всё, что находится в строке определения функции, вычисляется во время её выполнения, т.е. при определении функции.
Если у кого-то есть дополнительные идеи или объяснения этого поведения, буду рад их услышать!
3 ответ(ов)
Когда я вижу определение функции eat
, первое, что приходит в голову, это то, что если первый параметр не будет передан, он будет равен кортежу ("apples", "bananas", "loganberries")
. Это вполне ожидаемое поведение.
Однако, представьте, что позже в коде я делаю что-то вроде этого:
def some_random_function():
global fruits
fruits = ("blueberries", "mangos")
Если бы параметры по умолчанию определялись во время выполнения функции, а не во время её объявления, то было бы крайне неожиданно (в плохом смысле) узнать, что значение fruits
изменилось. Это было бы более удивительным, чем обнаружить, что ваша функция foo
изменяет список.
На самом деле проблема заключается в изменяемых переменных, и все языки программирования в той или иной степени сталкиваются с этой проблемой. Вот вопрос: предположим, в Java у меня есть следующий код:
StringBuffer s = new StringBuffer("Hello World!");
Map<StringBuffer,Integer> counts = new HashMap<StringBuffer,Integer>();
counts.put(s, 5);
s.append("!!!!");
System.out.println( counts.get(s) ); // сработает ли это?
Теперь вопрос: использует ли моя карта значение ключа StringBuffer
, когда он был помещен в карту, или она хранит ссылку на этот ключ? В любом случае, кто-то будет удивлён; либо это тот, кто пытался извлечь объект из Map
, используя значение, идентичное тому, с которым его вставили, либо тот, кто не может получить свой объект, даже если ключ, который он использует, по сути является тем же объектом, что использовался для вставки в карту (по этой причине в Python нельзя использовать изменяемые встроенные типы данных в качестве ключей словарей).
Ваш пример — хороший случай, когда новички в Python будут удивлены и столкнутся с проблемой. Но я бы поспорил, что если бы мы «исправили» это, то это лишь создало бы другую ситуацию, в которой они столкнулись бы с удивлением, причём оно было бы ещё менее интуитивным. Более того, это всегда происходит при работе с изменяемыми переменными; вы всегда сталкиваетесь с ситуациями, где кто-то может интуитивно ожидать одно поведение или противоположное, в зависимости от того, какой код они пишут.
Лично мне нравится текущее решение Python: параметры по умолчанию вычисляются во время определения функции, и этот объект всегда будет значением по умолчанию. Я полагаю, они могли бы сделать особый случай с пустым списком, но такие специальные случаи вызвали бы ещё большее удивление, не говоря уже о том, что это было бы несовместимо с предыдущими версиями.
Причина этого достаточно проста: привязки выполняются во время выполнения кода, а определение функции выполняется, ну... когда функция определяется.
Сравните следующий код:
class ГроздьБананов:
бананы = []
def добавитьБанан(self, банан):
self.баны.append(банан)
Этот код страдает от точно такой же неожиданной ситуации. бананы
является атрибутом класса, и, следовательно, когда вы добавляете в него элементы, они добавляются ко всем экземплярам этого класса. Причина здесь такая же.
Это просто "Как это работает", и сделать так, чтобы это работало иначе в случае функции, вероятно, было бы сложно, а для класса, скорее всего, невозможно, или это значительно замедлило бы создание объектов, так как вам пришлось бы сохранять класс и выполнять его код при создании объектов.
Да, это неожиданно. Но как только вы это поймете, это прекрасно впишется в то, как Python работает в целом. На самом деле, это хорошая обучающая тема, и как только вы поймете, почему это происходит, вы гораздо лучше разберетесь в Python.
Сказав это, это должно быть prominently представлено в любом хорошем учебнике по Python. Потому что, как вы упомянули, каждый сталкивается с этой проблемой рано или поздно.
Я раньше думал, что создавать объекты во время выполнения — это более предпочтительный подход. Сейчас я менее уверен в этом, так как вы теряете некоторые полезные функции, хотя это может быть оправдано с целью избежать путаницы у новичков. Недостатки такого подхода следующие:
1. Производительность
def foo(arg=something_expensive_to_compute()):
...
Если использовать оценку аргумента во время вызова, то дорогая функция будет вызываться каждый раз, когда ваша функция используется без аргумента. Это приводит либо к высокой цене на каждый вызов, либо к необходимости вручную кэшировать значение за пределами функции, что загрязняет ваше пространство имен и добавляет избыточность.
2. Привязка параметров
Полезный трюк — это привязка параметров лямбды к текущему значению переменной в момент создания лямбды. Например:
funcs = [lambda i=i: i for i in range(10)]
Это возвращает список функций, которые возвращают 0,1,2,3... соответственно. Если поведение изменится, они будут привязывать i
к значению при вызове i
, так что вы получите список функций, которые все возвращают 9
.
Единственный способ реализовать это иначе — создать дополнительное замыкание с привязанным i
, например:
def make_func(i): return lambda: i
funcs = [make_func(i) for i in range(10)]
3. Инспекция
Рассмотрим следующий код:
def foo(a='test', b=100, c=[]):
print(a, b, c)
Мы можем получить информацию о аргументах и значениях по умолчанию с помощью модуля inspect
, который позволяет сделать следующее:
>>> inspect.getargspec(foo)
(['a', 'b', 'c'], None, None, ('test', 100, []))
Эта информация очень полезна для таких вещей, как генерация документации, метапрограммирование, декораторы и т.д.
Теперь предположим, что поведение значений по умолчанию может быть изменено так, чтобы это соответствовало следующему коду:
_undefined = object() # значение-сентинел
def foo(a=_undefined, b=_undefined, c=_undefined):
if a is _undefined: a='test'
if b is _undefined: b=100
if c is _undefined: c=[]
Однако в этом случае мы теряем возможность инспекции и не можем увидеть, каковы значения по умолчанию. Поскольку объекты не создаются, мы не можем получить к ним доступ без фактического вызова функции. Единственное, что мы можем сделать, это сохранить исходный код и вернуть его в виде строки.
Почему используется string.join(list), а не list.join(string)?
Создание словаря с помощью генератора словарей
Как получить полный путь к директории текущего файла?
UnicodeDecodeError: Кодек 'charmap' не может декодировать байт X в позиции Y: символ отображается как <неопределённый>
Найти все файлы с расширением .txt в директории на Python