Модель памяти Python
Три опыта подряд. Функция со списком в значении по умолчанию: первый вызов вернул один элемент, второй - два, третий - три. Список списков, сделанный умножением, после правки одной ячейки изменился во ВСЕХ трёх строках. Поверхностная копия словаря после правки оригинала показала изменения, глубокая - нет.
Стержень: переменная хранит ссылку на объект, а не сам объект; отсюда и все три сюрприза, и способ их избежать.
// Формулировки: «почему список в аргументе по умолчанию накапливает?», «чем copy отличается от deepcopy?», «когда is, а когда ==?»
Изменяемое значение по умолчанию
Значения по умолчанию вычисляются ОДИН раз, когда создаётся функция, а не при каждом вызове. Если это изменяемый объект - список, словарь, множество, - все вызовы получают один и тот же объект, и правки копятся.
Мой замер показывает это буквально: один элемент, два, три при трёх одинаковых вызовах без аргументов. Ошибка тем неприятнее, что в тестах с явным аргументом она не проявляется вовсе, а в проде даёт медленно растущий список, который однажды становится проблемой.
// Правильный приём: в значении по умолчанию ставить None, а внутри функции создавать новый объект, если ничего не передали. То же самое касается вычислений в значении по умолчанию: текущее время, посчитанное там, будет временем импорта модуля, а не временем вызова, - и это ловят месяцами.
- значение по умолчанию
- вычисляется один раз при создании функции, а не при вызове
Копии: поверхностная и глубокая
Присваивание не копирует ничего - обе переменные указывают на один объект. Поверхностная копия создаёт новый контейнер, но КЛАДЁТ В НЕГО ТЕ ЖЕ ссылки: у меня после правки вложенного списка в оригинале копия показала изменение. Глубокая копия обходит структуру целиком и копирует всё вложенное - она изменений не увидела.
Та же механика в другом виде - список списков через умножение. Умножение повторяет ССЫЛКУ на один и тот же внутренний список, поэтому правка одной ячейки изменила все три строки. Через генератор каждая строка создаётся заново, и правка затрагивает одну.
// Практическое правило: глубокая копия дорогая, применяйте её осознанно. Чаще правильнее не копировать вовсе, а не менять входные данные: функция, которая принимает список и возвращает НОВЫЙ, а не правит переданный, снимает весь этот класс ошибок и заодно делает код предсказуемым в многопоточности.
- поверхностная копия
- новый контейнер с теми же ссылками внутри
- глубокая копия
- копируется вся структура целиком; дорого
Равенство против тождества
Знак равенства сравнивает ЗНАЧЕНИЯ, слово is - тождество объектов, то есть один ли это объект в памяти. Обычно это не совпадает, и полагаться на совпадение нельзя.
Мои замеры. Две переменные с числом 257, записанные константами в одном блоке кода, оказались одним объектом. А то же число, полученное разбором строки в рантайме, - уже другим объектом, хотя значение равно. Строки: собранная из кусков строка равна исходной по значению, но это разные объекты.
// Отсюда правило без исключений: числа и строки сравниваем через равенство. Слово is оставляем для None и для случаев, где важна именно личность объекта. Ошибка на этом месте коварна тем, что на маленьких значениях она работает - маленькие числа интернируются интерпретатором, и сравнение через is «проходит» до тех пор, пока в проде не приедет число побольше.
- тождество
- один ли это объект в памяти; проверяется словом is
- интернирование
- интерпретатор переиспользует объекты для маленьких чисел и некоторых строк
Как отвечать: «Почему список в аргументе по умолчанию накапливает значения?»
Потому что значение по умолчанию вычисляется один раз - в момент создания функции, а не при каждом вызове. Если это изменяемый объект, все вызовы работают с одним и тем же списком. Я проверял: три одинаковых вызова без аргументов вернули один элемент, потом два, потом три. Лечится тем, что в значение по умолчанию ставят None, а внутри функции создают новый список, если ничего не передали. Это частный случай общего правила: переменная хранит ссылку на объект, а не сам объект. Отсюда же и список списков через умножение, где правка одной ячейки меняет все строки, потому что умножение повторяет ссылку, и разница между поверхностной и глубокой копией. Самый надёжный способ не наступать на это - не менять входные данные вовсе, а возвращать новый объект.
Ответ объясняет момент вычисления, даёт лечение и обобщает до модели ссылок с двумя родственными примерами. Совет не менять входные данные - зрелый вывод, а не заучивание частного случая.
На чём валятся
- −Ставят список или словарь в значение по умолчанию.
- −Кладут в значение по умолчанию вычисление текущего времени.
- −Делают список списков умножением и получают общие строки.
- −Сравнивают числа через is: на маленьких работает, на больших нет.
- −Считают поверхностную копию защитой от изменения вложенных данных.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 13, остальные разбираются в тренажёре.
a = int(input()); b = int(input()), ввели по 257. Что дастa is b?A)Не гарантировано True — крупные int не кэшируютсяB)True, ведь одинаковые числа — это один объект в PythonC)False, потому что is для чисел не работаетD)Ошибка: сравнивать числа через is синтаксически запрещенопоказать ответ и разбор
+A)Не гарантировано True — крупные int не кэшируются// разбор: CPython кэширует малые целые -5..256 как синглтоны, поэтому для них
isдаёт True. Для 257 и больше одинаковые значения — обычно разные объекты, иisвернёт False (хотя в одном литеральном блоке компилятор может их свернуть). Мораль: сравнивать числа по значению через ==, а не is.def add(x, items=[]): items.append(x); return items— вызвали дважды. Что не так?A)Список создаётся заново, но только если явно передать пустой списокB)Ошибка синтаксиса: изменяемый объект не поставить дефолтомC)Дефолт [] один на все вызовы — список копит значенияD)Ничего: Python создаёт новый список на каждый вызов функциипоказать ответ и разбор
+C)Дефолт [] один на все вызовы — список копит значения// разбор: Дефолтное значение вычисляется один раз — в момент определения функции, а не на каждый вызов. Изменяемый дефолт [] становится общим для всех вызовов без аргумента и накапливает мутации между ними. Правильно: items=None, а внутри
if items is None: items = [].- Скопировали список
b = a[:], но правка вложенного словаря в b видна и в a. Почему?A)Изменение словаря затрагивает оба, потому что словари неизменяемыB)Списки в Python не скопировать без сторонних библиотекC)Срез делает поверхностную копию — вложенные объекты общиеD)Срез вообще не копирует, b и a — это одно и то же имя спискапоказать ответ и разбор
+C)Срез делает поверхностную копию — вложенные объекты общие// разбор: a[:] (как и list(a), copy.copy) — поверхностная копия: новый внешний список, но его элементы — те же объекты, что и в оригинале. Вложенный словарь один на оба списка, поэтому правка видна и там, и там. Нужна независимость на всю глубину — copy.deepcopy.
- Чем отличаются
isи==в Python?A)is сравнивает значения, а == проверяет, что это буквально один и тот же объектB)is сравнивает идентичность (тот же объект), == — значенияC)Это синонимы: оба проверяют равенство значений, различаясь лишь стилем написанияD)is работает только с числами, а == — исключительно со строками и коллекциямипоказать ответ и разбор
+B)is сравнивает идентичность (тот же объект), == — значения// разбор: == вызывает __eq__ и сравнивает значения (два разных списка с одинаковым содержимым равны). is проверяет идентичность — что это буквально один и тот же объект в памяти (одинаковый id). Для None, True, False сравнивают через is (они синглтоны). Путаница возникает из-за кеширования мелких объектов, когда is случайно совпадает с ==; полагаться на это нельзя.
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.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.