1. Феномен: Списки с «памятью»

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

def add_item(item, storage=[]):
    storage.append(item)
    return storage

На первый взгляд, всё логично: если storage не указан, он инициализируется как []. Однако при последовательных вызовах мы сталкиваемся с аномалией:

print(add_item("яблоко"))  # Вывод: ['яблоко']
print(add_item("банан"))   # Вывод: ['яблоко', 'банан']
print(add_item("груша"))   # Вывод: ['яблоко', 'банан', 'груша']

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

В чем ловушка?
Проблема в нарушении ожидаемого поведения. В разработке мы полагаемся на то, что функция с параметрами по умолчанию каждый раз стартует из одного и того же «нулевого» состояния. В данном же случае мы получаем неявный побочный эффект. В реальном проекте это приводит к самым трудноотлавливаемым багам: данные одного пользователя могут внезапно оказаться в корзине другого просто потому, что они оба использовали один и тот же «дефолтный» список в памяти сервера.

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

2. Корень проблемы: Время определения vs Время выполнения

Чтобы понять, почему список «запоминает» значения, нужно разобраться в том, как Python обрабатывает инструкцию def.

В большинстве языков программирования аргументы по умолчанию вычисляются каждый раз в момент вызова функции. В Python всё иначе: значения по умолчанию вычисляются ровно один раз — в момент определения функции (Definition Time).

Как это работает «под капотом»:

  1. Когда интерпретатор читает файл и встречает def func(arg=[]), он выполняет это выражение.

  2. Он создает объект пустого списка в памяти.

  3. Он сохраняет ссылку на этот конкретный объект внутри самой функции — в скрытом атрибуте __defaults__.

Когда вы вызываете функцию без аргумента, Python не создает новый список. Он просто берет уже существующий объект из «кармана» __defaults__.

Доказательство:

Мы можем буквально заглянуть в «память» функции и увидеть, как она меняется:

def add_item(item, storage=[]):
    storage.append(item)

# Сразу после создания функции:
print(add_item.__defaults__)  # Вывод: ([],) — список уже здесь!

add_item("X")

# После одного вызова:
print(add_item.__defaults__)  # Вывод: (['X'],) — тот же самый объект изменился

Главный вывод: Значение по умолчанию в Python — это не шаблон для создания нового объекта, а статический объект, намертво привязанный к функции. Если этот объект можно изменить (как список), то все последующие вызовы будут работать с его обновленной версией. Именно здесь кроется фундаментальное различие между изменяемыми и неизменяемыми типами данных.

3. Изменяемость как главный враг

Естественный вопрос: почему мы не сталкиваемся с этой проблемой, когда используем в качестве аргументов числа, строки или None? Ведь мы постоянно пишем def func(count=0) или def func(name="Guest"), и это работает абсолютно предсказуемо.

Разгадка кроется в фундаментальном различии между изменяемыми (mutable) и неизменяемыми (immutable) типами данных.

Immutable-типы (Безопасные)

К ним относятся int, float, str, bool, tuple. Эти объекты невозможно изменить «на месте». Если вы попытаетесь «изменить» число или строку, Python на самом деле создаст в памяти новый объект и перенаправит локальную переменную на него.

Пример с числом:

def increment(count=0):
    count += 1  # Создается НОВОЕ число, локальная переменная count теперь ссылается на него
    return count

print(increment()) # 1
print(increment()) # 1 (в __defaults__ по-прежнему лежит старый 0)

Значение в __defaults__ остается неизменным, потому что число 0 физически нельзя превратить в 1.

Mutable-типы (Ловушки)

К ним относятся list, dict, set. Эти объекты поддерживают изменение «на месте» (in-place mutation). Когда вы вызываете метод .append(), .extend() или .update(), вы не создаете новый объект — вы модифицируете тот самый экземпляр, на который ссылается атрибут функции __defaults__.

Тип данных

В аргументах

Последствия модификации

int, str, None

Безопасно

Создается новая локальная копия

list, dict, set

Опасно

Изменяется общий объект для всех вызовов

Вердикт: Проблема «магических списков» — это прямое следствие мутабельности. Используя изменяемый объект как значение по умолчанию, вы создаете общее разделяемое состояние (shared state) там, где ожидали изоляцию. Любая модификация такого аргумента внутри функции «отравляет» её для всех последующих вызовов.

4. Каноничное решение: Идиома None

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

Реализация паттерна

Вместо того чтобы создавать список в момент определения функции, мы используем None в качестве «заглушки». Создание же реального объекта переносится непосредственно в тело функции — туда, где код выполняется при каждом вызове.

def add_item(item, storage=None):
    if storage is None:
        storage = []
    storage.append(item)
    return storage

Почему это работает?

  1. Безопасность None: Объект None является неизменяемым (immutable). Даже если функция вызывается тысячи раз, ссылка в __defaults__ всегда указывает на один и тот же None, который невозможно «отравить» или изменить.

  2. Runtime-инициализация: Инструкция storage = [] теперь выполняется в Runtime (во время выполнения). Это гарантирует, что при каждом вызове, где не передан аргумент storage, будет создаваться новый, чистый экземпляр списка в памяти.

  3. Явное поведение: Теперь функция ведет себя детерминировано. Первый вызов вернет ['яблоко'], второй — ['банан'], так как их области видимости больше не связаны общим объектом из этапа компиляции.

Важный нюанс: is None против if not

Обратите внимание, что проверку стоит делать именно через оператор идентичности: if storage is None:.

Иногда разработчики пишут if not storage:, но это плохая практика. Если пользователь намеренно передаст в функцию пустой список (который является False в логическом контексте), проверка if not storage сработает, и код создаст еще один новый список внутри, проигнорировав объект пользователя. Проверка is None гарантирует, что мы создаем список только тогда, когда аргумент действительно был опущен.

Заключение

Понимание разницы между временем определения и временем выполнения — это важный этап перехода от новичка к крепкому Python-разработчику. Помните простое правило: никогда не используйте изменяемые объекты (списки, словари, множества) в качестве аргументов по умолчанию. Используйте None, и ваш код станет предсказуемым, чистым и профессиональным.

Анонсы новых статей, полезные материалы, а так же если в процессе у вас возникнут сложности, обсудить их или задать вопрос по этой статье можно в моём Telegram-сообществе. Смело заходите, если что-то пойдет не так, — постараемся разобраться вместе.

Комментарии (41)


  1. dyadyaSerezha
    03.01.2026 13:18

    Вопрос только один: зачем это так реализовано в Питоне? Так контринтуитивно для всех остальных языков. Следствие ли это того, что Питон изначально делался как простой скриптовый язык, и потом, для обеспечения обратной совместимости или старых внутренних болячек так была сделана передача параметров с дефолтный значением, или это сознательный выбор с самого начала и он даёт некие преимущества? Тогда какие?


    1. rSedoy
      03.01.2026 13:18

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


      1. dyadyaSerezha
        03.01.2026 13:18

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


      1. Octagon77
        03.01.2026 13:18

        Есть гипотеза почему - Пайтон делался давно, следовательно, для программистов совсем другого уровня. Видно же - он катастрофически медленный не случайно, скоростью пожертвовали ради чего-то ещё. Чего именно - не скажу, но вижу - что бы оно ни было, сейчас уже не упёрлось - до аннотаций типа дожили. На варворов найденное в Риме не по назначению пользующих похоже, потому и открытия. Одно успокаивает - такое непотребство продолжалось только пока вода по акведукам текла...


    1. ri_gilfanov
      03.01.2026 13:18

      Следствие ли это того, что Питон изначально делался как простой скриптовый язык

      Python -- это мультпарадигмальный язык общего назначения. Он не создавался как "скриптовой язык".

      или это сознательный выбор с самого начала и он даёт некие преимущества? Тогда какие?

      Простота и последовательность реализации. Текущая реализация соответствует простым архитектурным решениям:

      1. Идентификаторы (имена) -- это ссылки на объекты.

      2. Аргумент функции -- это идентификатор.

      3. Все объекты делятся на изменяемые (mutable) и неизменяемые (immutable).

      4. Значения по умолчанию для аргументов функций и методов инициализируются один раз.

      5. Модификация изменяемого (mutable) объекта не требует инициализации нового объекта.


      1. dyadyaSerezha
        03.01.2026 13:18

        Значения по умолчанию для аргументов функций и методов инициализируются один раз.

        Увидел ответы на множество вопросов, кроме моего: зачем/почему они инициализируются один раз, что противоречит практике в других языках? Какие преимущества даёт такой подход?


        1. ri_gilfanov
          03.01.2026 13:18

          Так Вы ни разу не задали этот вопрос.

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

          В этом Python не противоречит практике других языков


          1. tenzink
            03.01.2026 13:18

            Тут далеко ходить не нужно. Тот же C++ ориентирован на value-семантику. Поэтому там значение по умолчанию не привязано к функции, а создаётся при каждом вызове. В примере ниже f1 и f2 - разные объекты.

            std::vector<int> foo(std::vector<int> a = {}) {
              a.push_back(1);
              return a;
            }
            
            auto f1 = foo();
            auto f2 = foo(); 

            Но на C++ никто и не станет писать функции в стиле примера из статьи с передачей списка по значению для его модификации.


        1. Gadd
          03.01.2026 13:18

          Потому что функция в Python - объект первого класса. Объявление функции с помощью оператора def создаёт этот объект и он начинает существовать в своей области видимости с проинициализированными значениями по-умолчанию.
          При дальнейших вызовах этой функции используется этот самый ранее созданный объект функции. Так что тут как раз все очень даже логично.


          1. dyadyaSerezha
            03.01.2026 13:18

            Нет, не вижу логики в объяснении. С тем же успехом можно было написать не первого, в 18-го класса. Ниоткуда из объяснения не вытекает, почему дефолтные значения создаются 1 раз.


        1. Dhwtj
          03.01.2026 13:18

          Когда появился питон практики многих других языков ещё не было


    1. Tishka17
      03.01.2026 13:18

      Скажите, а в каких языках можно сделать так?

      x=[]
      def foo(a=x): 
         a.append(1)

      С точки зрения бытовой логики это ничем не отличается от

      def foo(a=[]):
          a.append(1)

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

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


      1. dyadyaSerezha
        03.01.2026 13:18

        С точки зрения бытовой логики это ничем не отличается от

        С точки зрения программистской логики это сильно отличается. В первом def мы присваиваем параметру дефолтную ссылку на некий объект. Во втором def мы создаём дефолтный объект и присваиваем его параметру.


        1. Tishka17
          03.01.2026 13:18

          В обоих случаях после = стоит выражение. Вопрос в какой момент оно выполняется? Захватывает ли оно переменные? Есть ли у него свой скоуп?

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


          1. dyadyaSerezha
            03.01.2026 13:18

            Да, в идеале было бы запрещено писать def abc(a = x), но почему-то был выбран самый неочевидный вариант.


            1. Tishka17
              03.01.2026 13:18

              А сможете строго сформулировать что именно мы запрещаем?


              1. alex88django_novice
                03.01.2026 13:18

                Запретить использование ссылок на мутабельные объекты в качестве дефолтных значений для аргументов функций


                1. Tishka17
                  03.01.2026 13:18

                  В питоне нет деления на мутабельные и иммутабельные объекты. Это известно для нескольких встроенных типов и всё. В отличие от плюсов где есть const типы

                  К слову, функции и классы - мутабельные объекты


                  1. alex88django_novice
                    03.01.2026 13:18

                    В питоне есть типы, объекты которых можно модифицировать, а есть типы, объекты которых модифицировать нельзя. Завести доп. флаг и хранить его на уровне PyType_Object - не самое сложное решение, правда?

                    А const - это не про типы, а про переменные


                    1. Tishka17
                      03.01.2026 13:18

                      1. Вы только что запретили использовать ссылку на функцию как дефолт

                      2. Кортеж неизменяемый, а кортеж со списком - уже изменяемый

                      3. Это сразу требует введения апи для того чтобы пользовательские классы помечать иммутабельными

                      4. const T это буквально тип.

                      5. Непонятно что делать есди в дефолте написано x+1, в какой момент его считать, в какой момент делать проверку.


                      1. alex88django_novice
                        03.01.2026 13:18

                        Согласен, тут определенно есть ряд корнер-кейсов, с другой стороны - запретить использовать в кач-ве дефолтов хотя бы самые базовые кейсы: `[], {}, set()`, а такой кейс например как ([]) - настолько вычурный и около-нереальный, что его имхо можно не учитывать. Что касательно изменяемости функций: возможность навесить на объект функции новый атрибут в рантайме вообще никак не влияет на состояние объекта функции (я про code object). С класс-объектами да, тут сложнее конечно.


                      1. Tishka17
                        03.01.2026 13:18

                        А возможность в список добавить элементы никак не влияет на какой-нибудь его другой аспект. Нет, уж, если можно менять, то можно менять.

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


                      1. alex88django_novice
                        03.01.2026 13:18

                        нет уж, если можно менять, то можно менять.

                        И тем не менее, список unhashable, а функция вполне себе hashable. Т.е. разработчики языка когда то явно сделали такое вот разделение в рамках одной группы мутабельных объектов, ну потому что в одном случае мутабельность - это очень высокая гарантия сайд-эффектов, а в другом - просто свойство, которое есть


                      1. Tishka17
                        03.01.2026 13:18

                        А при чем тут hashable? Изменяемые типы вполне могут быть хэшируемы. Зависит от того как мы определим равенство и хэш. Для многих типов равенство определено как проверка что этот тот же объект, в этом случае изменяемость ничего не ломает. Для других же типов равенство определяется на основе содержащихся там данных и поэтому хэшируемость невозможна если они меняются.


                      1. alex88django_novice
                        03.01.2026 13:18

                        изменяемые объекты вполне могут быть хэшируемыми

                        Я об этом и сказал выше, на примере функций

                        в этом случае изменяемость ничего не ломает

                        Изменяемость функций-объектов вообще никогда и ничего не ломает))

                        поэтому хэшируемость невозможна

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


                  1. alex88django_novice
                    03.01.2026 13:18

                    Да и, банально, IDE / линтеры умеют идентифицировать мутабельные дефолты в аргументах


                    1. Tishka17
                      03.01.2026 13:18

                      Не умеют. Только некоторые очевидные случаи


                      1. alex88django_novice
                        03.01.2026 13:18

                        Очевидных случаев достаточно) Раз в пайтоне можно и некоторые «официально» иммутабельные объекты модифицировать (например, экземпляры frozen датаклассов/пайдентик моделей), то говорить о каком-то 100-процентном покрытии не приходится


  1. Driver86
    03.01.2026 13:18

    Ппц. А ещё говорят php дно, в отличие от python.

    Естественный вопрос

    Естественный вопрос тут только один: о чём думал Гвидо ван Россум, создавая настолько контринтуитивное поведение, которое к тому же и с разными типами работает по разному? Наверное, о том же, о чём и Расмус Лердорф, когда добавлял знак доллара к именам переменных и создавал не всегда очевидный порядок аргументов или имён функций.

    Разница между php и python только в том, что последний в своё время хорошо так популяризировал Google.


    1. ri_gilfanov
      03.01.2026 13:18

      Поведение Python отличается от многих языков программирования. Однако, оно логично и последовательно в рамках принятых архитектурных решений.

      В этом примере то же самое поведение:

      a = []
      b = a
      a.append(1)
      1 in b  # True

      И в этом примере:

      a = {}
      b = a
      a['key'] = 'value'
      'key' in b  # True
      b['key'] == 'value'  # True


  1. ri_gilfanov
    03.01.2026 13:18

    Иллюстрация этой же "ловушки" Python на примере словаря:

    call_counter = {'foo': 0, 'bar': 0}
    
    def foo(x = call_counter):
        x['foo'] += 1
    
    def bar(y = call_counter):
        y['bar'] += 1
    
    foo()
    foo()
    bar()
    
    call_counter['foo'] == 2  # True
    call_counter['bar'] == 1  # True


  1. 0Bannon
    03.01.2026 13:18

    Но ведь питон "простой" язык и многие рекомендуют начинать именно с него. Как же так?


    1. Tishka17
      03.01.2026 13:18

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


  1. Armann
    03.01.2026 13:18

    в сообществе Python выработался стандарт (идиома), который считается единственно верным способом инициализации изменяемых аргументов

    Кем выработался? И кем считается? Дайте ссылку на PEP, если возможно.

    Вообще вариантов больше, и они зависят от конкретной ситуации.

    1. Самый радикальный - подумать еще раз. Возможно что интерфейс с опциональным параметром-коллекцией, изменяемой внутри функции, не самое лучшее решение.

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

      def add_item(item, storage=[]):
          storage = list(storage)
          storage.append(item)
          return storage
    3. Если опциональный параметр не предполагается менять (или он клонируется как в предыдущем примере):

      def log(message, additional_files=()):
          print(message)
          for f in additional_files:
            print(text, file=f)
      
    4. Если опциональный параметр не предполагается менять и это словарь:

      def log(message, additional_data=MappingProxyType({})):
          ...

    Два последних варианта, помимо отсутствия условия на None, выполняют еще декларативную функцию - показывают что аргумент не изменяется внутри функции.


    1. alex88django_novice
      03.01.2026 13:18

      первый вариант не самый радикальный, а самый разумный, чистые функции - наше все :)

      Проблема подхода в 2-х последних примерах VS дефолтного None в том, что в зависимости от типа (мутабельной) коллекции на вход вам нужно подобрать иммутабельный аналог этой коллекции для дефолта: list -> tuple, set -> frozenset, dict -> frozendict.

      А для «декларативности» существуют аннотации типов :)

      P.S. Никогда не понимал, зачем (даже опытные) разработчики пишут в сигнатуре функции например `x: list[str]`, когда можно написать `х: Sequence[str]` тем самым позволяя стороне, вызывающей функцию, передать в нее не только список, но и кортеж строк, при этом запрещая (формально) совершать в теле функции модифицирующие операции над х.Но да, это уже оффтоп и не про дефолты)


  1. RaptorTV
    03.01.2026 13:18

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


    1. alex88django_novice
      03.01.2026 13:18

      Давеча на Хабре была статья что list в питоне - это, оказывается, динамический массив! А я то всю жизнь думал, что это linked list (сарказм)


  1. jakobz
    03.01.2026 13:18

    Всегда знал, что питон - всратый, бесполезный язык.


  1. ef_end_y
    03.01.2026 13:18

    Как повезло, что в питоне есть этот архитектурный баг: [ ]! Теперь эту тему можно мусолить миллион раз на радость "ура, в питоне есть проблема, значит это перекрывает все мои мучения с моим языком". Реально миллионный раз. Каждое второе собеседование включает этот прикол, уже не баг, а прикол


  1. IVA48
    03.01.2026 13:18

    Не документированные особенности конкретного компилятора (интерпретатора) для любого языка программирования - источник серьезных ошибок, которых вроде как "по умолчанию" и не должно быть. Все зависит от профессионализма разработчика, который вне зависимости от ЯП должен знать и следовать фундаментальным правилам надёжного программирования.

    В данном примере функция добавления элемента в массив может выглядеть примерно так:

    Function AddItem (byVal Item, byRef kol_items, byRef Arr(), byRef error) as Boolean

    /// Функция возвращает True если операция выполнена успешно, False если не выполнена и в еггог возвращается код ошибки. Добавляемый элемент Item объявлен как входной параметр передающийся значением. Остальные параметры объявлены как ссылки (адреса) на переменные обьявленные в вызывающей процедуре: текущее кол-во элементов в массиве, сам массив элементов и код возвращаемой ошибки. Причём текущее значение кол-ва элементов в массиве kol_items является одновременно и входным и выходным параметром передаваемые в функцию по ссылке. ///

    AddItem = False

    error = 0

    If kol_items < 0 then error=1 Exit Function EndIf ///выход при отрицательном kol_items///

    kol_items = kol_items + 1

    AddDimention Arr, kol_items ///увеличение размерности массива до kol_items

    Arr (kol_items) = Item ///непосредственное добавление нового элемента

    AddItem = True ///успешно завершение функции

    End Function

    Здесь AddDimention есть оператор или функция ЯП который(ая) увеличивает текущую размерность массива до заданного значения при сохранении уже имеющихся в нем элементов. Нумерация элементов массива логично начинается с 1. В принципе можно обойтись и БЕЗ error, но в этом случае вызывающая процедура должна сама убедиться в добавлении элемента, сравнив отправленное значение kol_items с полученным. Если оно увеличилось на 1, то добавление прошло успешно. Но для соблюдения стиля надёжного программирования, любая процедура (функция), помимо обязательной проверки значений входных параметров на корректность, должна возвращать код ошибки (лучше вместе с текстом) возникающей при её выполнении.


  1. vitiok78
    03.01.2026 13:18

    Я в очередной раз вспоминаю, почему я не люблю Python, JavaScript и другие подобные языки. Когда указатели скрываются от программиста якобы для какого-то "удобства" и "простоты" - это медвежья услуга. Они настолько стесняются слова "указатель", что даже заменили его на "ссылка". В результате, вместо того, чтобы прямо из кода понять, что происходит, надо изучать кучу неявных правил, рассказывающих тебе о том, когда переменная передается по ссылке, а когда по значению. И именно сама эта концепция привела к тому, что этот "баг" вообще появился. Явное всегда лучше неявного, коллеги!