Объекты и память
В настоящей главе рассматривается то, что скрывается за именем переменной: чем изменяемые объекты отличаются от неизменяемых, почему это различие проявляется в неожиданных местах, а также как устроены условные конструкции и циклы.
Слайды к главе. Материал главы изложен также во второй, третьей и четвёртой частях лекции «Python. Начало» (объекты в памяти, изменяемость, сборка мусора) с демонстрациями в интерактивной оболочке; слайды лекции доступны на сайте книги и в PDF.
Объекты в памяти
В Python объектом является всё: и число, и строка, и функция, и сам класс. Любой объект обладает тремя свойствами. Тип определяет набор действий, разрешённых с объектом (int, str, list). Значение представляет собой данные, хранящиеся внутри объекта (42, "hello"). Идентификатор является числом, которое не меняется на всё время жизни объекта и уникально среди объектов, существующих одновременно: после уничтожения объекта его идентификатор может достаться новому. В CPython это адрес объекта в памяти, получаемый функцией id().
s = "hello"
print(id(s)) # Выведет что-то вроде 4390213040
Ссылки, а не копии
Переменная в Python не «содержит» объект, а ссылается на него, поэтому присваивание одной переменной другой создаёт второе имя для того же объекта, а данные остаются на месте. Если объект изменён через одно имя, изменение будет видно и через второе.
a = [1, 2, 3]
b = a
b.append(4)
print(a) # [1, 2, 3, 4] — изменился и a!
Сравнение is и ==
Оператор is проверяет, является ли это одним объектом, а == сравнивает значения. Два списка могут быть равны поэлементно и при этом оставаться разными объектами, расположенными в памяти по разным адресам.
x = [1, 2]
y = [1, 2]
print(x == y) # True
print(x is y) # False
Кеширование целых чисел
При запуске интерпретатор заранее создаёт объекты для небольших целых чисел (в CPython 3.10–3.14 это диапазон от −5 до 256, в 3.15 кеш расширен), и все переменные с такими значениями ссылаются на один и тот же заранее созданный объект.
a = 256
b = 256
print(a is b) # True — это один и тот же закешированный объект
Для 257 результат зависит от того, каким образом запускается код.
c = 257
d = 257
print(c is d)
При запуске этого фрагмента в виде файла будет получено True, тогда как при вводе тех же трёх строк в интерактивной оболочке результатом окажется False.
Причина заключается не в кеше чисел, а в работе компилятора. Python компилирует файл целиком и объединяет одинаковые константы всех его функций, поэтому c и d получают ссылку на одну и ту же константу. В интерактивной оболочке каждая строка компилируется отдельно, общей таблицы нет, и создаются два разных объекта.
Тождественность объектов относится к деталям реализации, и опираться на неё нельзя. Она зависит от версии интерпретатора, от способа запуска (файл целиком или отдельные строки интерактивной оболочки) и от того, получено значение при компиляции или вычислено во время работы программы.
💡 Золотое правило: для сравнения значений необходимо использовать
==, аisприменять только для проверки наNone,TrueиFalse.
Изменяемые и неизменяемые объекты
Это основное деление объектов Python.
Неизменяемые (immutable)
К неизменяемым относятся int, float, str, tuple, bytes и bool. Однажды созданный объект хранит своё значение навсегда. Любая «модификация» создаёт новый объект, на который переставляется старое имя, в чём проще всего убедиться по идентификатору, меняющемуся после каждого +=.
s = "hello"
print(id(s))
s += "!"
print(id(s)) # ID изменился — это новый объект!
Изменяемые (mutable)
list, dict и set, напротив, изменяются на месте. Список после append остаётся тем же самым объектом, в котором лишь стало на один элемент больше, и все имена, ссылавшиеся на него, увидят добавленный элемент.
lst = [1, 2]
print(id(lst))
lst.append(3)
print(id(lst)) # ID остался прежним
Кортежи с изменяемыми элементами
Неизменяемость кортежа распространяется только на него самого: кортеж хранит ссылки, и неизменны именно они, поэтому подставить в ячейку другой объект нельзя. Сам же объект, на который указывает ссылка, о запрете ничего не знает.
t = ([1, 2], [3, 4])
t[0].append(3)
print(t) # ([1, 2, 3], [3, 4]) — это допустимо!
Такой кортеж нельзя поместить в множество или использовать как ключ словаря, поскольку хеш кортежа складывается из хешей его элементов, а у списка хеша нет. Попытка завершится исключением TypeError: unhashable type: 'list' при вставке.
Строки и байты
Ниже рассматриваются два неизменяемых типа, которые начинающие разработчики часто путают.
Строки (str)
str хранит текст, то есть последовательность символов Unicode. Длина строки измеряется в символах, а не в байтах, и один символ может быть латинской буквой, кириллической, иероглифом или значком эмодзи. Способ размещения этих символов в памяти определяется интерпретатором.
Байты (bytes)
bytes хранит сырые данные, то есть последовательность чисел от 0 до 255: то, что находится в файле, приходит по сети или считывается с прибора. Понятие «символ» здесь отсутствует, есть только числа. Тип также является неизменяемым; его изменяемый вариант называется bytearray.
Автоматического перехода между ними нет, поскольку требуется кодировка, указывающая, каким набором байтов записан каждый символ. encode преобразует текст в байты, decode — байты в текст.
text = "привет"
encoded = text.encode('utf-8') # str -> bytes
decoded = encoded.decode('utf-8') # bytes -> str
Кириллица в UTF-8 занимает по два байта на символ, поэтому len(text) даст 6, а len(encoded) вернёт 12. Почти все UnicodeDecodeError возникают именно отсюда: байты читаются не в той кодировке, в которой были сохранены.
Условные конструкции
Базовый синтаксис
Ветвление в Python обходится без круглых и фигурных скобок: достаточно условия, двоеточия и блока, записанного с отступом.
temperature = 15
if temperature > 20:
print("Наденьте футболку")
else:
print("Лучше взять куртку")
Элегантные проверки
В условии не обязательно записывать сравнение, поскольку любой объект сам по себе истинен или ложен: пустая строка, пустой список, ноль и None ложны, всё остальное истинно. Поэтому if name: читается как «если имя есть» и заменяет if name != "":
name = input("Введите имя: ")
if name: # False, если строка пустая
print(f"Привет, {name}!")
else:
print("Привет, незнакомец!")
Современный match (Python 3.10+)
Когда одну переменную необходимо сравнить с десятком значений, цепочка из elif становится нечитаемой, и для этого случая предусмотрен match, аналог switch из других языков. Ветви перебираются сверху вниз, вертикальная черта объединяет несколько вариантов в один случай, а case _ перехватывает всё, что не было перехвачено раньше.
http_status = 404
match http_status:
case 200 | 201:
print("Успех")
case 401 | 403 | 404:
print("Ошибка клиента")
case 500 | 503:
print("Ошибка сервера")
case _:
print("Неизвестный статус")
💡 Совет:
matchследует применять при сравнении одной переменной с несколькими значениями. Для сложных условий предпочтительнееif/elif/else.
Циклы
while
while повторяет тело, пока условие истинно, и требуется там, где число итераций заранее неизвестно, например пока значение не выйдет за заданную границу.
i = 1
while i < 1000:
print(i)
i *= 2
for и range
for в Python перебирает не индексы, а элементы; если необходим счётчик, его предоставляет range. С одним аргументом он отсчитывает от нуля, с тремя задаёт начало, границу и шаг. Правая граница в диапазон не входит.
for i in range(5): # 0, 1, 2, 3, 4
print(i)
for i in range(1, 10, 2): # 1, 3, 5, 7, 9
print(i)
enumerate для получения индекса
Когда вместе с элементом требуется и его номер, заводить отдельный счётчик не нужно: enumerate выдаёт пары «индекс, элемент», распаковываемые в заголовке цикла.
for idx, char in enumerate("abc"):
print(f"Индекс: {idx}, Символ: {char}")
Ветка else в циклах
У циклов в Python есть ветка else, почти не встречающаяся в других языках. Она выполняется, если цикл завершился естественно, то есть перебрал всё до конца и ни разу не встретил break. Это готовый каркас для поиска: в теле цикла выполняется поиск с выходом по break, а в else записывается ветка «не найдено».
for i in range(5):
if i == 10:
break
else:
print("Цикл завершился без break")
Производительность при работе со строками
Каждое += для строки создаёт новый объект и копирует в него всё накопленное ранее. На сотой итерации копируется сто символов, на стотысячной — сто тысяч, и в сумме получается квадратичное время вместо линейного.
result = ""
for _ in range(100000):
result += "a" # Создаёт новый объект на каждой итерации!
Следует сделать оговорку: если на строку не ссылается никто, кроме локальной переменной, интерпретатор дописывает её на месте, и в теле функции этот же цикл выполняется за линейное время, тогда как на уровне модуля, где на строку ссылается ещё и словарь глобальных имён, время растёт квадратично. Оптимизация опирается на счётчик ссылок, исчезает при любом лишнем имени и языком не гарантируется. Это необходимо учитывать при измерении через timeit: он оборачивает код в функцию, и квадратичный рост там не проявится.
Правильнее накапливать части в списке и объединить их один раз. Список изменяем, append ничего не копирует, а join заранее вычисляет итоговую длину, выделяет память один раз и переносит туда все части.
parts = []
for _ in range(100000):
parts.append("a")
result = "".join(parts) # Быстрое объединение
💡 Совет: для частых операций конкатенации рекомендуется использовать
list+join()для строк илиbytearrayдля байтов.
Сборка мусора
Память под объекты в Python освобождается автоматически, и обеспечивается это двумя механизмами.
Основной — подсчёт ссылок: у каждого объекта есть счётчик, показывающий, сколько имён и контейнеров на него ссылаются. Как только счётчик падает до нуля, объект уничтожается немедленно. Это быстро и предсказуемо, и в большинстве программ ничего сверх этого не требуется.
Слабым местом подсчёта ссылок являются циклические ссылки. Если объекты ссылаются друг на друга, их счётчики никогда не обнулятся, даже когда до них уже нельзя добраться из программы.
a = []
b = [a]
a.append(b) # Теперь a и b ссылаются друг на друга
del a, b # Объекты недостижимы, но ссылки остались
Для таких случаев предусмотрен второй механизм, поколенческий сборщик мусора. Он запускается периодически, находит группы объектов, ссылающихся друг на друга и недостижимых извне, и удаляет их целиком. Поколенческим он называется потому, что молодые объекты проверяются часто, а пережившие несколько проверок — редко: долгоживущий объект с большой вероятностью проживёт ещё.
Неочевидные особенности Python
1. Изменяемые аргументы по умолчанию
Наиболее известная ловушка языка. Значение по умолчанию, записанное в заголовке, вычисляется один раз, когда интерпретатор доходит до строки def, после чего список сохраняется между вызовами и накапливает всё помещённое в него.
def append_to(element, target=[]): # так делать нельзя!
target.append(element)
return target
print(append_to(1)) # [1]
print(append_to(2)) # [1, 2] — тот же список!
Решение: по умолчанию указывается None, а пустой список создаётся внутри функции, заново при каждом вызове.
def append_to(element, target=None):
if target is None:
target = []
target.append(element)
return target
2. Ленивые логические операторы
and и or вычисляют правую часть только тогда, когда без неё нельзя обойтись. Если левый операнд and ложен, результат уже известен, и правая часть не вычисляется. Благодаря этому выражение if xs and xs[0] > 0 не приведёт к ошибке на пустом списке.
def expensive_call():
print("Вызов выполнен!")
return True
# expensive_call() не будет вызвана, т.к. первое условие False
if False and expensive_call():
pass
3. Атрибуты функций
Функция также является объектом, и ей можно присвоить произвольный атрибут. На этом основаны декораторы, подсчитывающие вызовы или кеширующие результат на самой функции.
def my_func():
pass
my_func.custom_attr = 42
print(my_func.custom_attr) # 42
Заключение
Переменная представляет собой имя, привязанное к объекту, а не контейнер со значением. Отсюда следуют и общий список после двух присваиваний, и накопительный аргумент по умолчанию, и квадратичное время сборки строки через +=.
Таким образом, можно сформулировать четыре правила:
isприменяется только дляNone,TrueиFalse; во всех остальных случаях используется==.- Строки собираются через
join, а не через+=в цикле. - Значение по умолчанию не должно быть изменяемым объектом, вместо него указывается
None. - Прежде чем передать объект в стороннюю функцию, необходимо определить, изменяем он или нет.
Полезные материалы: