Языковая особенность, которая неприятно удивляет почти всех

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

Пример

Давайте рассмотрим простой алгоритм в целях иллюстрации. Предположим, у вас есть строка типа aaabbaccaaa, и вы хотите подсчитать, сколько букв каждой буквы содержится в этой строке. Вам нужна функция, которая возвращает словарь, ключи которого представляют собой буквы, а значения - положительные целые числа, подсчитывающие, сколько раз каждая буква появляется в строке.

Вот простая реализация этой идеи в виде функции Python.

Улучшение: кумулятивный подсчет

Эта функция работает. Он делает именно то, что мы хотели. Но теперь давайте добавим изюминку. Что, если иногда мы хотим подсчитать ряд строк и объединить результаты? Мы хотели бы иметь возможность вызывать Tally более одного раза и в итоге получить единственный словарь, в котором объединено количество букв.

Один из подходов - включить словарь count в качестве другого аргумента функции:

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

Краткий кодекс, наивный путь

Что, если нам чаще всего нужен раздельный подсчет? Конечно, было бы неплохо не вводить {} пустой аргумент словаря при каждом вызове. Разработчики, плохо знакомые с Python, но знакомые с аргументами по умолчанию в других языках программирования, обычно идут по следующему пути:

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

О нет, он сломан!

Это наиболее очевидный способ написать функцию Tally, так что вам не нужно передавать явно пустой словарь. Это выглядит правильно. Но это неправильно. Есть тонкий недостаток, который вас укусит! Давайте проверим, что из этого получится:

Первая строка работает именно так, как мы ожидали. Он находит один a, один b и один c. Но после этого все становится дурацким. Что, черт возьми, здесь происходит?

Объяснение ошибки

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

Чтобы прояснить проблему, поведение нашей сломанной функции такое же:

Почему Python действует таким образом?

Это не ошибка Python. То есть поведение преднамеренное, а не случайное.

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

Более того, это нарушает принцип наименьшего удивления. Такое поведение не соответствует ожиданиям большинства программистов, что приводит к ошибкам.

Естественно выражать разочарование опасными причудами инструмента. Но что есть, то есть. Это поведение Python, и оно не изменится. Слишком поздно изменять эту странность языка, потому что это нарушит работу многих существующих программ, которые от него зависят. Мы, профессиональные разработчики, должны разбираться в наших инструментах и ​​использовать их должным образом.

Как исправить проблему

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

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

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

Теперь у нас наконец-то есть функция, удовлетворяющая всем нашим требованиям. Когда задан единственный строковый аргумент, он подсчитывает различные символы в этой одной строке. Когда задан строковый аргумент и аргумент словаря, он добавляет количество букв строки к заданному словарю.

Последние мысли

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

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

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

Сноски

  1. В официальной документации Python об определениях функций говорится следующее: Значения параметров по умолчанию оцениваются слева направо при выполнении определения функции. Это означает, что выражение вычисляется один раз, когда функция определена, и что одно и то же предварительно вычисленное значение используется для каждого вызова. Это особенно важно понимать, когда параметр по умолчанию является изменяемым объектом, таким как список или словарь: если функция изменяет объект (например, добавляя элемент в список), значение по умолчанию фактически изменяется. Обычно это не то, что было задумано.
  2. См. Интересное обсуждение в этой ветке переполнения стека, где обсуждается этот выбор дизайна в Python. То, как работает область видимости символов в Python, отличается от других языков. Кроме того, объявления функций Python являются исполняемыми операторами, а не статическими объявлениями, как в других языках. Эти факторы приводят к техническим причинам, по которым Python ведет себя именно так. Мое обвинение в недостатке дизайна выдвигается при уважительном понимании того, что оценка выражений по умолчанию во время вызова привела бы к более серьезным проблемам для разработчиков Python.