Изменяемый объект можно поменять на месте, не создавая новый: id() до и после операции совпадает. У неизменяемого любая «правка» на деле создаёт новый объект, а старый остаётся как был. Разница объясняет добрую половину багов уровня «я же ничего не менял»: от расползающегося списка-по-умолчанию до строки, которая не хочет обновляться. Код проверен на CPython 3.14.5.
Кто изменяемый, кто нет
Изменяемые: list, dict, set, bytearray. Неизменяемые: int, float, str, tuple, frozenset, bytes. Проверяется напрямую через id():
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 не реализуют __hash__. У кортежа реализует, потому что содержимое кортежа зафиксировано раз и навсегда:
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() снова. Совпал: объект изменился на месте. Изменился: операция вернула новый объект, а исходный остался как был.
Почему defaultdict и dataclass с полем-списком ведут себя иначе, чем обычный default-аргумент
defaultdict(list) создаёт новый список при каждом обращении к отсутствующему ключу: фабрика вызывается заново, а не один раз при определении. В dataclass то же самое решается через field(default_factory=list): фабрика тоже вызывается на каждый новый экземпляр, а не один раз при импорте класса, как в обычном def f(x=[]).
Можно ли сделать свой класс неизменяемым
Да, через __slots__ без сеттеров, frozen=True в @dataclass или переопределение __setattr__ с выбросом исключения. Питон не проверяет неизменяемость на уровне рантайма для произвольных классов, гарантию должен обеспечить сам класс.
Что учить дальше
Хешируемость и разделяемое состояние соседние темы с генераторами: генератор хранит своё состояние между вызовами next() примерно так же, как список в default-аргументе хранит своё между вызовами функции, только осознанно. В пути «Python для начинающих» на Koddo эта тема закрывается задачами на предсказание вывода: что покажет id(), упадёт ли +=.