сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Рантайм Python

Модель памяти Python

Модель значений: везде ссылки

Три опыта подряд. Функция со списком в значении по умолчанию: первый вызов вернул один элемент, второй - два, третий - три. Список списков, сделанный умножением, после правки одной ячейки изменился во ВСЕХ трёх строках. Поверхностная копия словаря после правки оригинала показала изменения, глубокая - нет.

Стержень: переменная хранит ссылку на объект, а не сам объект; отсюда и все три сюрприза, и способ их избежать.

// Формулировки: «почему список в аргументе по умолчанию накапливает?», «чем copy отличается от deepcopy?», «когда is, а когда ==?»

Изменяемое значение по умолчанию

Значения по умолчанию вычисляются ОДИН раз, когда создаётся функция, а не при каждом вызове. Если это изменяемый объект - список, словарь, множество, - все вызовы получают один и тот же объект, и правки копятся.

Мой замер показывает это буквально: один элемент, два, три при трёх одинаковых вызовах без аргументов. Ошибка тем неприятнее, что в тестах с явным аргументом она не проявляется вовсе, а в проде даёт медленно растущий список, который однажды становится проблемой.

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

значение по умолчанию
вычисляется один раз при создании функции, а не при вызове

Копии: поверхностная и глубокая

Присваивание не копирует ничего - обе переменные указывают на один объект. Поверхностная копия создаёт новый контейнер, но КЛАДЁТ В НЕГО ТЕ ЖЕ ссылки: у меня после правки вложенного списка в оригинале копия показала изменение. Глубокая копия обходит структуру целиком и копирует всё вложенное - она изменений не увидела.

Та же механика в другом виде - список списков через умножение. Умножение повторяет ССЫЛКУ на один и тот же внутренний список, поэтому правка одной ячейки изменила все три строки. Через генератор каждая строка создаётся заново, и правка затрагивает одну.

// Практическое правило: глубокая копия дорогая, применяйте её осознанно. Чаще правильнее не копировать вовсе, а не менять входные данные: функция, которая принимает список и возвращает НОВЫЙ, а не правит переданный, снимает весь этот класс ошибок и заодно делает код предсказуемым в многопоточности.

поверхностная копия
новый контейнер с теми же ссылками внутри
глубокая копия
копируется вся структура целиком; дорого

Равенство против тождества

Знак равенства сравнивает ЗНАЧЕНИЯ, слово is - тождество объектов, то есть один ли это объект в памяти. Обычно это не совпадает, и полагаться на совпадение нельзя.

Мои замеры. Две переменные с числом 257, записанные константами в одном блоке кода, оказались одним объектом. А то же число, полученное разбором строки в рантайме, - уже другим объектом, хотя значение равно. Строки: собранная из кусков строка равна исходной по значению, но это разные объекты.

// Отсюда правило без исключений: числа и строки сравниваем через равенство. Слово is оставляем для None и для случаев, где важна именно личность объекта. Ошибка на этом месте коварна тем, что на маленьких значениях она работает - маленькие числа интернируются интерпретатором, и сравнение через is «проходит» до тех пор, пока в проде не приедет число побольше.

тождество
один ли это объект в памяти; проверяется словом is
интернирование
интерпретатор переиспользует объекты для маленьких чисел и некоторых строк

Как отвечать: «Почему список в аргументе по умолчанию накапливает значения?»

Потому что значение по умолчанию вычисляется один раз - в момент создания функции, а не при каждом вызове. Если это изменяемый объект, все вызовы работают с одним и тем же списком. Я проверял: три одинаковых вызова без аргументов вернули один элемент, потом два, потом три. Лечится тем, что в значение по умолчанию ставят None, а внутри функции создают новый список, если ничего не передали. Это частный случай общего правила: переменная хранит ссылку на объект, а не сам объект. Отсюда же и список списков через умножение, где правка одной ячейки меняет все строки, потому что умножение повторяет ссылку, и разница между поверхностной и глубокой копией. Самый надёжный способ не наступать на это - не менять входные данные вовсе, а возвращать новый объект.

Ответ объясняет момент вычисления, даёт лечение и обобщает до модели ссылок с двумя родственными примерами. Совет не менять входные данные - зрелый вывод, а не заучивание частного случая.

На чём валятся

  • Ставят список или словарь в значение по умолчанию.
  • Кладут в значение по умолчанию вычисление текущего времени.
  • Делают список списков умножением и получают общие строки.
  • Сравнивают числа через is: на маленьких работает, на больших нет.
  • Считают поверхностную копию защитой от изменения вложенных данных.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 13, остальные разбираются в тренажёре.

  1. #py_memory_model1 / 5
    a = int(input()); b = int(input()), ввели по 257. Что даст a is b?
    A)Не гарантировано True — крупные int не кэшируются
    B)True, ведь одинаковые числа — это один объект в Python
    C)False, потому что is для чисел не работает
    D)Ошибка: сравнивать числа через is синтаксически запрещено
    показать ответ и разбор
    +A)Не гарантировано True — крупные int не кэшируются

    // разбор: CPython кэширует малые целые -5..256 как синглтоны, поэтому для них is даёт True. Для 257 и больше одинаковые значения — обычно разные объекты, и is вернёт False (хотя в одном литеральном блоке компилятор может их свернуть). Мораль: сравнивать числа по значению через ==, а не is.

  2. #py_memory_model2 / 5
    def add(x, items=[]): items.append(x); return items — вызвали дважды. Что не так?
    A)Список создаётся заново, но только если явно передать пустой список
    B)Ошибка синтаксиса: изменяемый объект не поставить дефолтом
    C)Дефолт [] один на все вызовы — список копит значения
    D)Ничего: Python создаёт новый список на каждый вызов функции
    показать ответ и разбор
    +C)Дефолт [] один на все вызовы — список копит значения

    // разбор: Дефолтное значение вычисляется один раз — в момент определения функции, а не на каждый вызов. Изменяемый дефолт [] становится общим для всех вызовов без аргумента и накапливает мутации между ними. Правильно: items=None, а внутри if items is None: items = [].

  3. #py_memory_model3 / 5
    Скопировали список b = a[:], но правка вложенного словаря в b видна и в a. Почему?
    A)Изменение словаря затрагивает оба, потому что словари неизменяемы
    B)Списки в Python не скопировать без сторонних библиотек
    C)Срез делает поверхностную копию — вложенные объекты общие
    D)Срез вообще не копирует, b и a — это одно и то же имя списка
    показать ответ и разбор
    +C)Срез делает поверхностную копию — вложенные объекты общие

    // разбор: a[:] (как и list(a), copy.copy) — поверхностная копия: новый внешний список, но его элементы — те же объекты, что и в оригинале. Вложенный словарь один на оба списка, поэтому правка видна и там, и там. Нужна независимость на всю глубину — copy.deepcopy.

  4. #py_memory_model4 / 5
    Чем отличаются is и == в Python?
    A)is сравнивает значения, а == проверяет, что это буквально один и тот же объект
    B)is сравнивает идентичность (тот же объект), == — значения
    C)Это синонимы: оба проверяют равенство значений, различаясь лишь стилем написания
    D)is работает только с числами, а == — исключительно со строками и коллекциями
    показать ответ и разбор
    +B)is сравнивает идентичность (тот же объект), == — значения

    // разбор: == вызывает __eq__ и сравнивает значения (два разных списка с одинаковым содержимым равны). is проверяет идентичность — что это буквально один и тот же объект в памяти (одинаковый id). Для None, True, False сравнивают через is (они синглтоны). Путаница возникает из-за кеширования мелких объектов, когда is случайно совпадает с ==; полагаться на это нельзя.

  5. #py_memory_model5 / 5
    a = [1,2]; b = a; b.append(3). Чему равен a и почему?
    A)Будет ошибка: изменять список через вторую переменную в Python не разрешается
    B)[3]: append заменяет содержимое списка новым элементом, стирая прежние значения
    C)[1,2,3]: b и a — две ссылки на один список, изменение видно обоим
    D)[1,2]: присваивание b=a создаёт независимую копию списка, и a не меняется
    показать ответ и разбор
    +C)[1,2,3]: b и a — две ссылки на один список, изменение видно обоим

    // разбор: Присваивание b = a не копирует объект, а связывает ещё одно имя с тем же списком. append изменяет этот единственный объект «на месте», поэтому изменение видно и через a, и через b — обе печатают [1,2,3]. Чтобы получить независимую копию, нужно явно: b = a.copy() (поверхностная) или copy.deepcopy(a) (глубокая). Это ключ к пониманию модели ссылок Python.

дальше

Теорию прочитали. Навык ставится повторением

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