Изменяемый объект можно поменять на месте, не создавая новый: id() до и после операции совпадает. Значение неизменяемого объекта поменять нельзя: для другого значения нужен другой объект. Но операция, которая ничего не меняет, может вернуть тот же объект, поэтому одного сравнения id() недостаточно для определения изменяемости типа. Разница объясняет добрую половину багов уровня «я же ничего не менял»: от расползающегося списка-по-умолчанию до строки, которая не хочет обновляться. Код проверен на CPython 3.14.5.
Изменение объекта и смена ссылки — разные операции
append меняет список с тем же id. Метод строки возвращает новый объект, поэтому после upper() у имени s другой id.Кто изменяемый, кто нет
Изменяемость и преобразование типов — разные вещи: первая про то, можно ли поменять объект, вторая про то, как получить из него другой. Изменяемые: list, dict, set, bytearray. Неизменяемые: int, float, str, tuple, frozenset, bytes. В этом примере id() подтверждает, что append изменил существующий список:
lst = [1, 2, 3]
before_id = id(lst)
lst.append(4)
print(id(lst) == before_id, lst)
True [1, 2, 3, 4]
Адрес объекта не изменился. append дописал элемент в существующую структуру данных. Со строкой иначе:
s = "python"
old = s
s = s.upper()
print(s is old, s)
False PYTHON
Другой объект. s.upper() не тронул исходную строку, она вообще не может быть тронута, а построил новую и вернул её. Имя s теперь указывает на другой объект, старый (если на него больше никто не ссылается) утилизирует сборщик мусора. Любой метод строки, который выглядит как изменение (.replace(), .strip(), конкатенация +), на самом деле фабрика новых строк:
t = (1, 2, 3)
try:
t[0] = 99
except TypeError as e:
print(f"TypeError: {e}")
TypeError: 'tuple' object does not support item assignment
Кортеж прямо отказывается: у него нет методов для изменения содержимого, потому что содержимого менять не для чего. Таков контракт типа.
Для байтов та же граница проходит по наличию буквы «a» в имени: bytearray изменяемый, bytes нет.
ba = bytearray(b"abc")
ba[0] = ord("x")
print(ba)
bs = b"abc"
try:
bs[0] = ord("x")
except TypeError as e:
print(f"TypeError: {e}")
bytearray(b'xbc')
TypeError: 'bytes' object does not support item assignment
Присваивание — это не копия
a = b не копирует объект, оно даёт второе имя тому же объекту. Для изменяемых типов это ловушка:
a = [1, 2, 3]
b = a
b.append(4)
print(a, b, a is b)
[1, 2, 3, 4] [1, 2, 3, 4] True
Список один, имён два. Правка через b видна через a, потому что менять больше нечего: это буквально один и тот же объект в памяти. a is b подтверждает: не «равны по значению», а один и тот же объект.
Копия по срезу или .copy() решает это только на один уровень вглубь:
import copy
matrix = [[1, 2], [3, 4]]
shallow = matrix.copy()
shallow[0].append(99)
print(matrix)
print(shallow)
[[1, 2, 99], [3, 4]]
[[1, 2, 99], [3, 4]]
matrix.copy() создал новый внешний список, но элементы внутри те же самые вложенные списки, скопированные по ссылке. Правка shallow[0] бьёт по общему объекту, и обе таблицы это видят. Для вложенных структур нужен copy.deepcopy, который рекурсивно копирует всё до конца:
matrix2 = [[1, 2], [3, 4]]
deep = copy.deepcopy(matrix2)
deep[0].append(99)
print(matrix2)
print(deep)
[[1, 2], [3, 4]]
[[1, 2, 99], [3, 4]]
Для плоского списка без вложенности срез flat[:] или .copy() достаточен, там нечему течь на второй уровень:
flat = [1, 2, 3]
flat_copy = flat[:]
flat_copy.append(4)
print(flat, flat_copy)
[1, 2, 3] [1, 2, 3, 4]
Изменяемый default-аргумент
Классика, которую спрашивают почти на каждом собеседовании. Список по умолчанию вычисляется один раз, при определении функции, а не при каждом вызове, и живёт как один и тот же объект между вызовами:
def add_item(item, cart=[]):
cart.append(item)
return cart
print(add_item("яблоко"))
print(add_item("хлеб"))
print(add_item("сыр"))
['яблоко']
['яблоко', 'хлеб']
['яблоко', 'хлеб', 'сыр']
Каждый вызов без явного cart получает тот же список, что и предыдущий. Проверяется прямо: сохранить объект add_item.__defaults__[0] до вызова, сравнить is после.
first = add_item.__defaults__[0]
add_item("х")
print(add_item.__defaults__[0] is first)
True
Никакого пересоздания. def выполняется один раз при импорте модуля, и выражение cart=[] в этот момент вычисляется ровно однажды, так же, как декоратор срабатывает при определении функции, а не при вызове. Фикс стандартный: default None, список создаётся заново внутри тела.
def add_item_fixed(item, cart=None):
if cart is None:
cart = []
cart.append(item)
return cart
print(add_item_fixed("яблоко"))
print(add_item_fixed("хлеб"))
['яблоко']
['хлеб']
Каждый вызов без cart теперь получает свежий список.
Тот же изъян живёт и в теле класса: список, записанный рядом с методами, создаётся один раз при описании и оказывается общим для всех экземпляров — разобрано в статье про классы.
Кортеж со списком внутри: парадокс +=
Кортеж неизменяем, но ничто не мешает одному из его элементов быть изменяемым списком. Неизменяемость кортежа означает только одно: нельзя переприсвоить t[0]. Что происходит внутри объекта по адресу t[0], забота уже не кортежа, а списка.
t = ([1, 2], "x")
t[0].append(3)
print(t)
([1, 2, 3], 'x')
Список внутри кортежа изменился, сам кортеж нет: он всё так же указывает на тот же список, что и до вызова. Дальше начинается парадокс, который ломает интуицию сильнее всего:
try:
t[0] += [4]
except TypeError as e:
print(f"TypeError: {e}")
print(t)
TypeError: 'tuple' object does not support item assignment
([1, 2, 3, 4], 'x')
Ошибка есть, но список всё равно вырос. Причина в том, как Python разворачивает +=: сначала вызывается t[0].__iadd__([4]), который у списка меняет его на месте и возвращает тот же объект. Список уже увеличился. Только после этого интерпретатор пытается выполнить t[0] = <результат>, чтобы завершить присваивание, а вот это кортеж и запрещает. Изменение состоялось, присваивание, которое должно было его «оформить», упало. Отсюда практическое правило: список внутри кортежа мутируй методами (.append, .extend), не через +=. Результат один и тот же, но без спектакля с исключением.
Хешируемость: почему список нельзя в ключи словаря
d = {}
try:
d[[1, 2]] = "координата"
except TypeError as e:
print(f"TypeError: {e}")
TypeError: cannot use 'list' as a dict key (unhashable type: 'list')
Ключ словаря должен быть хешируемым: его хеш остаётся постоянным в течение жизни объекта, а равные объекты имеют одинаковый хеш. У встроенных изменяемых контейнеров list, dict, set хеширование отключено. Кортеж хешируется, если хешируемы все его элементы:
coords = {(55, 37): "Москва", (59, 30): "Питер"}
print(coords[(55, 37)])
print(hash((1, 2)))
Москва
-3550055125485641917
Но кортеж со списком внутри снова ловит ту же ошибку, ведь хеш кортежа считается через хеши его элементов, а у списка хеша нет:
try:
hash(([1, 2], 3))
except TypeError as e:
print(f"TypeError: {e}")
TypeError: unhashable type: 'list'
frozenset, неизменяемая версия множества, хешируем и годится в ключи и в элементы другого множества:
print(hash(frozenset({1, 2, 3})))
-272375401224217160
Хешируемость не равна неизменяемости. Например, экземпляры обычного пользовательского класса по умолчанию сравниваются и хешируются по идентичности, даже если их атрибуты меняются. Если же равенство и хеш зависят от значений полей, эти значения должны оставаться стабильными при использовании объекта как ключа.
Кэш малых int и интернирование строк: деталь реализации CPython
a = 100
b = 100
print(a is b)
True
Похоже на закон природы, но это деталь CPython: интерпретатор заранее создаёт объекты для целых чисел от −5 до 256 и переиспользует их, чтобы не аллоцировать заново на каждой мелкой операции. Проверить границу честно, без литералов, которые компилятор может схлопнуть в один объект ещё на этапе компиляции, можно через значение, вычисленное в рантайме:
import random
a = random.randint(200, 200)
b = random.randint(200, 200)
print(a, a is b)
c = random.randint(300, 300)
d = random.randint(300, 300)
print(c, c is d)
200 True
300 False
200 внутри кэша, оба имени указывают на один объект. 300 снаружи, два разных объекта с одинаковым значением. Полагаться на это в коде нельзя: is для чисел вне диапазона False, для чисел внутри деталь реализации, которая один раз уже менялась между версиями CPython. Строки ведут себя похоже, но по другой причине: интернированию подвергаются короткие строки, похожие на идентификаторы, собранные во время компиляции, а не построенные в рантайме:
s3 = "hel" + "lo_" + str(random.randint(1, 1))
s4 = "hel" + "lo_" + str(random.randint(1, 1))
print(s3, s4, s3 is s4, s3 == s4)
hello_1 hello_1 False True
Значения равны, объекты разные: строка собрана в рантайме через конкатенацию и str(), интернирование её не коснулось. == сравнивает значение и в обоих случаях отработал верно. is сравнивает адрес объекта, и полагаться на него для чисел и строк нельзя в принципе, совпадение адресов зависит от диапазона значения, длины строки и версии интерпретатора.
Одно устойчивое исключение: None. Он существует в интерпретаторе в единственном экземпляре, второго None создать нельзя, поэтому is None не оптимизация, а единственно корректная проверка:
n = None
print(n is None, n == None)
class Weird:
def __eq__(self, other):
return True
w = Weird()
print(w == None, w is None)
True True
True False
Weird переопределил __eq__ так, что он врёт про равенство с чем угодно, включая None. == в такой ситуации возвращает то, что написал автор класса, а is None нет, потому что сравнивает адрес объекта, а не результат чужого метода. Отсюда правило PEP 8: сравнение с None всегда is, никогда ==.
Похожая природа проблемы с общим изменяемым состоянием, только про гонку между потоками, а не про int и строки, разобрана в статье про GIL: проверка «есть ли уже в кэше» и запись в кэш разъезжаются по времени точно так же, как здесь разъезжаются создание объекта и его переиспользование.
На чём ловят на собеседовании
«Кортеж полностью неизменяем». Неизменяем сам кортеж как контейнер: нельзя поменять, что лежит на позиции 0. Что происходит с содержимым позиции 0, если это список, вопрос уже не к кортежу. Пример с t[0].append(3) выше.
«t[0] += [4] для списка внутри кортежа безопасна, раз кортеж неизменяем». Наоборот, именно там и падает TypeError, хотя список меняется. Механизм разобран в разделе про парадокс +=.
«is быстрее ==, поэтому им можно сравнивать числа и строки». Быстрее, но не о том же самом. is сравнивает адрес объекта, == значение. Для None разницы нет, потому что None один; для чисел вне диапазона −5…256 и для строк, собранных в рантайме, is даёт False там, где == даёт True.
«Список можно использовать как ключ словаря, если его не менять после». Нет: у списка __hash__ не реализован в принципе, вне зависимости от намерений автора. Словарь не проверяет обещания, он проверяет наличие метода.
Частые вопросы
Можно ли определить изменяемость типа через id()
Нет. id() позволяет сравнить идентичность объектов, но не определяет, какие изменения допускает тип. Например, n = 1; n += 0 может сохранить тот же объект, хотя int неизменяем. А items = items + [2] создаёт новый список, хотя list изменяем. Проверяйте контракт типа и конкретной операции.
Почему defaultdict и dataclass с полем-списком ведут себя иначе, чем обычный default-аргумент
defaultdict(list) создаёт новый список при каждом обращении к отсутствующему ключу: фабрика вызывается заново, а не один раз при определении. В dataclass то же самое решается через field(default_factory=list): фабрика тоже вызывается на каждый новый экземпляр, а не один раз при импорте класса, как в обычном def f(x=[]).
Можно ли сделать свой класс неизменяемым
Для запрета обычного присваивания атрибутам подходит frozen=True в @dataclass. Это не глубокая заморозка: вложенный список всё ещё можно изменить. __slots__ ограничивает набор атрибутов, но не запрещает присваивать им новые значения. Неизменяемость всего состояния зависит также от типов и поведения вложенных объектов.
Что учить дальше
Хешируемость и разделяемое состояние соседние темы с генераторами: генератор хранит своё состояние между вызовами next() примерно так же, как список в default-аргументе хранит своё между вызовами функции, только осознанно. В пути «Python для начинающих» на Koddo эта тема закрывается задачами на предсказание вывода: что покажет id(), упадёт ли +=.