Python. Начало
Интерпретатор, объекты в памяти, изменяемые и неизменяемые типы, сборка мусора, коллекции и стоимость операций над ними
Вячеслав Федоров
Лекция 4
Разработка и применение ПО
в физических исследованиях
Изложение. Четвёртая лекция открывает часть курса, посвящённую языку Python. Рассматривается то, что скрывается за именем переменной: как интерпретатор исполняет программу, как объекты размещаются в памяти, чем изменяемые объекты отличаются от неизменяемых, в какой момент память освобождается, как устроены встроенные коллекции и во что обходятся операции над ними. Надпись на титуле — приглашение интерактивной оболочки Python, в которой выполняется большая часть демонстраций.
Демонстрации. Слайды с розовым выделением слова «Демонстрация» отмечают места, где изложение прерывается для работы в интерактивной оболочке или терминале виртуальной машины lab. На каждом таком слайде проигрывается запись тех же команд, поэтому показ воспроизводим и без живого терминала.
Содержание
От исходного текста до стоимости операций
Изложение. Лекция состоит из шести частей. Первая посвящена языку и интерпретатору: истории, реализациям и пути, который проходит исходный текст до исполнения. Вторая рассматривает объекты в памяти: тождественность, тип и значение, имена и ссылки, кеши и стоимость объекта. Третья — изменяемые и неизменяемые типы и следствия этого деления: хешируемость, копирование, значения по умолчанию, строки и байты. Четвёртая — освобождение памяти: подсчёт ссылок и сборщик циклического мусора. Пятая — встроенные коллекции и модуль collections, а также перебор коллекций и ветвления. Шестая переводит сказанное в оценки стоимости операций и завершается домашним заданием.
Цель лекции
Устройство объектов Python как основа правильного и быстрого кода
Когда обработка данных эксперимента занимает часы вместо минут, причина почти всегда состоит не в «медленном Python», а в неудачно выбранной структуре данных или лишнем копировании. Когда данные изменились «сами собой», причина — два имени, ссылающиеся на один объект.
Изложение. Устройство объектов и коллекций необходимо знать в четырёх ситуациях. Во-первых, программа изменяет данные, которые не должна была изменять: функция обработки получила список и отсортировала его на месте, и исходный порядок измерений утрачен. Во-вторых, программа расходует больше памяти, чем ожидалось: каждое число в Python является объектом с заголовком, и список из миллиона чисел занимает вчетверо больше памяти, чем те же числа в массиве. В-третьих, программа работает медленно: проверка вхождения в список внутри цикла превращает линейный алгоритм в квадратичный. В-четвёртых, результат сравнения зависит от способа запуска: is используется там, где требуется ==.
Для физиков: обработка экспериментальных данных почти целиком состоит из операций над коллекциями — отбор событий, группировка по каналам, поиск совпадений, — поэтому выбор коллекции определяет, уложится обработка в минуты или займёт часы.
Язык и интерпретатор
История, реализации, байт-код, модули, интерактивная оболочка, PEP 8
01
Язык и интерпретатор
Объекты в памяти
Изменяемость
Сборка мусора
Коллекции
Сложность операций
Изложение. Первая часть посвящена языку как таковому: откуда он появился, чем язык отличается от его реализаций и что происходит с исходным текстом программы до того, как она начнёт выполняться.
История
языка
1989
Сотрудник CWI (Амстердам) начинает Python — преемника учебного языка ABC
1991
Версия 0.9.0: первая публикация исходного кода
2000
Python 2.0: включения списков, сборщик циклического мусора
2008
Python 3.0: str — текст Unicode, print — функция, без обратной совместимости
2018
Гвидо ван Россум покидает пост BDFL; с 2019 г. — руководящий совет
2020
Конец поддержки Python 2.7
2025
Python 3.14: официальная поддержка сборки без GIL
Пояснение
С версии 3.9 новая версия выходит ежегодно в октябре и поддерживается около пяти лет. Код курса — Python 3.10 и новее, на стенде — 3.12.
Изложение. Гвидо ван Россум, сотрудник Национального исследовательского института математики и информатики Нидерландов (CWI) в Амстердаме, начал работу над Python в декабре 1989 года как проект на рождественские каникулы, пока институт был закрыт. Предшественником послужил созданный в том же институте язык ABC, предназначенный для обучения: от него Python унаследовал выделение блоков отступами и немногочисленность конструкций. Название дано в честь комедийного шоу «Летающий цирк Монти Пайтона». Исходный код версии 0.9.0 опубликован в феврале 1991 года.
Python 2.0 (2000) добавил включения списков и сборщик циклического мусора, рассматриваемый в четвёртой части лекции. Python 3.0 (2008) нарушил обратную совместимость: строки стали последовательностями символов Unicode, print — функцией, а деление / целых чисел даёт вещественный результат. Переход занял более десяти лет, поддержка Python 2.7 прекращена 1 января 2020 года.
В 2018 году ван Россум сложил с себя полномочия «великодушного пожизненного диктатора» (BDFL) после споров вокруг оператора := (PEP 572). С 2019 года решения о развитии языка принимает руководящий совет из пяти человек, избираемый разработчиками ядра (PEP 13). С версии 3.9 действует ежегодный цикл выпусков (PEP 602): новая версия выходит в октябре и около пяти лет получает сначала исправления ошибок, затем только исправления безопасности. В Python 3.13 (2024) появилась экспериментальная сборка без глобальной блокировки интерпретатора (GIL, PEP 703), в 3.14 (2025) она получила официальную поддержку (PEP 779), оставаясь необязательной.
Язык
и его реализации
Реализация Написана на Назначение
CPython C эталонная реализация: байт-код и виртуальная машина
PyPy RPython JIT-компиляция, ускорение циклов на чистом Python
MicroPython C микроконтроллеры с сотнями килобайт памяти
GraalPy Java виртуальная машина GraalVM, доступ к библиотекам JVM
Jython, IronPython Java, C# JVM и .NET; отстают от актуальных версий
Пояснение
Спецификация языка — The Python Language Reference. Кеш малых чисел, подсчёт ссылок и байт-код — детали реализации CPython, о которых идёт речь в этой лекции.
Для физика
MicroPython работает на платах лабораторной автоматики: датчики и приводы управляются тем же языком, что и обработка данных.
Изложение. Python — язык, описанный спецификацией (The Python Language Reference), и у него несколько реализаций. CPython, написанный на C, является эталонной и самой распространённой реализацией: именно он устанавливается с python.org и из пакетов дистрибутивов. PyPy написан на RPython и компилирует часто выполняемые участки программы в машинный код во время работы (JIT), поэтому циклы на чистом Python выполняются в нём в несколько раз быстрее; совместимость с расширениями на C при этом неполная. MicroPython — компактная реализация для микроконтроллеров. GraalPy работает на виртуальной машине GraalVM и позволяет вызывать библиотеки JVM. Jython и IronPython, реализации для JVM и .NET, отстают от актуальных версий языка.
Всё, что далее говорится о кеше малых чисел, подсчёте ссылок, байт-коде и сборщике мусора, относится к CPython. Спецификация языка их не требует и упоминает только как детали реализации CPython, а другие реализации устроены иначе: в PyPy, например, подсчёта ссылок нет, и объект освобождается не сразу после удаления последней ссылки.
Для физиков: Cython и Numba, ускоряющие численные расчёты, рассматриваются в части книги «Ускорение расчётов».
Путь
исходного текста
токены
имена, числа, операторы
tokenize
ast
compile(), dis
виртуальная машина
x = 2 * 3.14159
NAME x
OP =
NUMBER 2
OP *
NUMBER 3.14159
AST
Assign(Name('x'), BinOp(Constant(2), Mult(), Constant(3.14159)))
байт-код
LOAD_CONST 6.28318
STORE_NAME x
свёртка констант
Пояснение
Для импортируемых модулей байт-код сохраняется в __pycache__/circle.cpython-312.pyc и используется повторно, пока исходный файл не изменился; главный сценарий компилируется при каждом запуске.
Изложение. CPython не исполняет исходный текст напрямую. Сначала лексический анализатор разбивает текст на токены: имена, числа, строки, операторы, отступы. Синтаксический анализатор (с версии 3.9 — PEG-анализатор, PEP 617) строит из токенов абстрактное синтаксическое дерево (AST). Компилятор обходит дерево, попутно вычисляя выражения из одних констант, и порождает байт-код — последовательность инструкций виртуальной стековой машины, упакованную вместе с таблицами констант и имён в объект кода. Этот объект исполняет цикл вычисления интерпретатора (Python/ceval.c), по одной инструкции за шаг.
Каждый этап доступен из самого Python: модули tokenize и ast, встроенная функция compile и модуль dis. Для импортируемых модулей байт-код сохраняется в каталоге __pycache__ в файле с тегом версии интерпретатора (circle.cpython-312.pyc) и используется повторно, пока исходный файл не изменился. Главный сценарий компилируется при каждом запуске заново; на время запуска это почти не влияет, поскольку компиляция занимает миллисекунды.
Байт-код:
стековая машина
def area(r): return 3.14159 * r ** 2
1 0 RESUME 0 2 2 LOAD_CONST 1 (3.14159) 4 LOAD_FAST 0 (r) 6 LOAD_CONST 2 (2) 8 BINARY_OP 8 (**) 12 BINARY_OP 5 (*) 16 RETURN_VALUE
Инструкция Стек после неё
LOAD_CONST 3.14159 3.14159
LOAD_FAST r 3.14159, r
LOAD_CONST 2 3.14159, r, 2
BINARY_OP ** 3.14159, r²
BINARY_OP * 3.14159·r²
RETURN_VALUE стек пуст, результат возвращён
Важно
Байт-код не входит в спецификацию языка: инструкции меняются от версии к версии, а файл .pyc привязан к версии интерпретатора.
Изложение. Виртуальная машина CPython является стековой: инструкции берут операнды с вершины стека значений и кладут туда результат. Функция area компилируется в семь инструкций; первая, RESUME, стек не меняет, поэтому в таблице их шесть. LOAD_CONST кладёт на стек константу из таблицы констант объекта кода, LOAD_FAST — значение локальной переменной r. BINARY_OP снимает два верхних значения и кладёт результат операции, указанной аргументом: сначала возведение в степень, затем умножение. RETURN_VALUE снимает результат и возвращает его вызывающему коду.
В выводе dis первый столбец — номер строки исходного текста, второй — смещение инструкции в байтах. Инструкция занимает два байта, а за BINARY_OP следует место под кеш, поэтому смещения идут 8, 12, 16. RESUME в начале функции служит точкой, в которой интерпретатор проверяет запросы трассировки и прерывания.
С версии 3.11 интерпретатор адаптивный (PEP 659): во время работы он заменяет общие инструкции специализированными, например BINARY_OP для двух вещественных чисел — инструкцией умножения вещественных, и вызов dis.dis(area, adaptive=True) показывает эту замену. Суперинструкции, объединяющие две загрузки, формируются уже при создании объекта кода, а специализация умножения наступает после двух вызовов; в демонстрации 500 вызовов взяты с запасом. Байт-код не входит в спецификацию языка: набор инструкций меняется от версии к версии (RESUME и BINARY_OP появились в 3.11), поэтому файл .pyc привязан к версии интерпретатора, а вывод dis в статьях о других версиях выглядит иначе.
Демонстрация:
байт-код
02
Модуль circle.py с функцией area
03
Импорт модуля и каталог __pycache__
05
500 вызовов и специализированный байт-код
06
Байт-код присваивания 2 * 3.14159
Что наблюдается
После импорта появляется circle.cpython-312.pyc; после 500 вызовов умножение выполняет BINARY_OP_MULTIPLY_FLOAT, а 2 * 3.14159 вычислено ещё при компиляции.
root@lab — запись со стенда
Демонстрация. python3 --version показывает версию 3.12.3. printf записывает в файл circle.py функцию area. Импорт модуля из командной строки создаёт каталог __pycache__ с файлом circle.cpython-312.pyc: тег версии в имени позволяет разным версиям интерпретатора хранить свой байт-код рядом.
В оболочке dis.dis(circle.area) печатает байт-код функции: RESUME, загрузку константы 3.14159, загрузку аргумента r, загрузку константы 2, два BINARY_OP (возведение в степень и умножение) и RETURN_VALUE. После 500 вызовов area(1.0) вывод с adaptive=True показывает работу адаптивного интерпретатора: умножение выполняет специализированная инструкция BINARY_OP_MULTIPLY_FLOAT. Пары загрузок объединены в суперинструкции LOAD_CONST__LOAD_FAST и LOAD_FAST__LOAD_CONST ещё при создании объекта кода, до первого вызова. Возведение в степень осталось общим BINARY_OP: специализации для него нет. Последняя команда дизассемблирует присваивание x = 2 * 3.14159: произведение вычислено при компиляции (свёртка констант), в байт-коде осталась одна константа 6.28318.
Результат цикла присваивается имени y: оболочка печатает значение каждого выражения, в том числе внутри цикла, и без присваивания вывела бы 500 строк.
Модуль
как объект
01
Файл — модуль
import circle один раз выполняет circle.py и создаёт объект модуля; повторный import берёт его из sys.modules
02
Пространство имён
имена модуля — его атрибуты: circle.area, dir(circle)
03
__name__
у импортированного модуля — его имя, у запущенного файла — '__main__'
04
Поиск по sys.path
после встроенных модулей первым просматривается каталог сценария
# run.py import circle def main(): print(circle.area(2.0)) if __name__ == "__main__": main()
Важно
Файл с именем модуля стандартной библиотеки или установленного пакета (random.py, statistics.py, numpy.py) в каталоге сценария подменяет этот модуль при импорте.
Изложение. Каждый файл .py является модулем. Инструкция import circle находит файл circle.py, компилирует его (или берёт байт-код из __pycache__), создаёт объект модуля, заносит его в sys.modules и выполняет файл сверху вниз; все имена, определённые в файле, становятся атрибутами этого объекта: circle.area, circle.__name__, circle.__file__. Выполняется модуль один раз: повторный import находит объект в словаре sys.modules и возвращает его без повторного выполнения. Модуль — такой же объект, как число или список: type(circle) — класс module.
Атрибут __name__ у импортированного модуля равен его имени, а у файла, запущенного как сценарий, — строке '__main__'. На этом основана идиома if __name__ == "__main__": код под условием выполняется при запуске файла и не выполняется при его импорте, поэтому один файл служит и сценарием, и библиотекой функций.
Модули ищутся по списку каталогов sys.path, и первым в нём стоит каталог запускаемого сценария. Поэтому файл random.py, statistics.py или numpy.py, созданный рядом со сценарием, подменяет одноимённый модуль стандартной библиотеки или установленного пакета, и импорт приводит к непонятным ошибкам. Не подменяются только модули, встроенные в интерпретатор (sys, а в сборках Ubuntu и math), и модули, загруженные при его запуске. Имя файла выбирается так, чтобы не совпадать с именами модулей; оно также должно быть допустимым идентификатором: файл 1lab.py нельзя импортировать инструкцией import.
Демонстрация:
модули
01
Имя и файл импортированного модуля
02
Атрибут area и вызов функции
03
Сценарий who.py с печатью __name__
04
Запуск файла и его импорт
05
random.py в текущем каталоге: подмена модуля стандартной библиотеки
06
Удаление файла: модуль стандартной библиотеки
Что наблюдается
Запущенный файл получает __name__ '__main__'; random.py в текущем каталоге закрывает модуль стандартной библиотеки.
root@lab — запись со стенда
Демонстрация. Модуль circle из предыдущей демонстрации импортируется, и печатаются его имя circle и путь к файлу. dir(circle) заканчивается именем area, а вызов circle.area(1.0) возвращает 3.14159. Сценарий who.py из одной строки print(__name__) при запуске печатает __main__, а при импорте — who. Далее в текущем каталоге создаётся файл random.py с одной строкой x = 1. Импорт random находит его раньше модуля стандартной библиотеки, и вызов random.randint завершается AttributeError: module 'random' has no attribute 'randint'. После удаления файла random.__file__ указывает на /usr/lib/python3.12/random.py.
В Python 3.13 и новее сообщение об ошибке дополнено подсказкой о том, что локальный файл закрывает модуль стандартной библиотеки.
Интерактивная
оболочка
Для физика
Блокнот Jupyter — та же интерактивная оболочка с сохранением кода, результатов и графиков.
Средство Назначение
help(obj) справка по объекту
dir(obj) имена атрибутов объекта
type(obj), id(obj) тип и тождественность
_ результат последнего выражения
python3 -i run.py оболочка после выполнения сценария
python3 -q запуск без приветствия
Изложение. Интерактивная оболочка (REPL, read–eval–print loop) читает строку, выполняет её и печатает значение выражения, после чего снова выводит приглашение >>>. Строка с многоточием ... означает, что оболочка ждёт продолжения блока; пустая строка блок завершает. Для исследования объектов служат help, dir, type и id. Значение последнего выражения сохраняется в переменной _: это ещё одна ссылка на объект, которая учитывается в демонстрациях с подсчётом ссылок. python3 -i run.py выполняет сценарий и оставляет оболочку открытой со всеми его переменными, что удобно для разбора ошибки.
В Python 3.13 оболочка заменена новой: многострочное редактирование, цветные сообщения об ошибках, вставка блоков кода; в 3.14 добавлена подсветка синтаксиса. В Ubuntu 24.04 — Python 3.12 с прежней оболочкой. IPython и Jupyter добавляют расширенное автодополнение, магические команды (%timeit, %debug) и сохранение сеанса.
Для физиков: блокнот Jupyter — основной инструмент первичной обработки данных: код, результаты и графики хранятся вместе, а ячейки можно перезапускать по одной.
Дзен Python
и PEP 8
>>> import this The Zen of Python, by Tim Peters Beautiful is better than ugly. Explicit is better than implicit. Simple is better than complex. Readability counts. Errors should never pass silently. There should be one-- and preferably only one --obvious way to do it.
PEP 8 Соглашение
Отступ 4 пробела
Длина строки до 79 символов
Функции, переменные snake_case
Классы CapWords
Константы UPPER_CASE
Проверка, форматирование ruff, black
Пояснение
Требования к оформлению кода подробно рассматриваются в главе книги «Требования к коду».
Изложение. PEP 20 «Дзен Python» — девятнадцать афоризмов Тима Питерса о принципах дизайна языка; их выводит команда import this. На слайде приведены наиболее цитируемые: явное лучше неявного, простое лучше сложного, удобочитаемость важна, ошибки не должны замалчиваться, и для каждой задачи должен быть один очевидный способ решения. Эти принципы объясняют многие решения, рассматриваемые далее: например, отсутствие неявного преобразования строки в число.
PEP 8 — соглашение об оформлении кода: отступ в четыре пробела, строки до 79 символов, snake_case для функций и переменных, CapWords для классов, UPPER_CASE для констант. Соблюдение соглашения проверяется автоматически (ruff, flake8), а оформление приводится к нему форматировщиками (black, ruff format). Отступы в Python — часть синтаксиса: блок без отступа после двоеточия вызывает IndentationError, одну из самых частых ошибок начинающих. Форматировщики black и ruff format по умолчанию ограничивают строку 88 символами; для 79 задаётся line-length = 79.
Для физиков: код расчёта читают чаще, чем пишут: научный руководитель, соавторы, сам автор через год. Единое оформление снимает вопросы, не относящиеся к физике задачи.
Объекты в памяти
Тождественность, тип и значение, имена, кеши, числа, размеры объектов
02
Язык и интерпретатор
Объекты в памяти
Изменяемость
Сборка мусора
Коллекции
Сложность операций
Изложение. Вторая часть посвящена тому, что скрывается за именем переменной: что такое объект, чем имя отличается от объекта, чем is отличается от ==, какие объекты интерпретатор использует повторно и сколько памяти занимает каждый объект.
Три свойства
объекта
01
Тождественность
id(obj): в CPython — адрес в памяти, неизменен за время жизни объекта
02
Тип
type(obj): определяет допустимые операции и тоже неизменен
03
Значение
данные объекта; у изменяемых объектов меняется на месте
// Include/object.h, упрощённо typedef struct { Py_ssize_t ob_refcnt; // число ссылок PyTypeObject *ob_type; // тип } PyObject; // Include/cpython/floatobject.h typedef struct { PyObject ob_base; // заголовок double ob_fval; // значение } PyFloatObject;
Пояснение
Объектами являются числа, строки, функции, классы и модули: у каждого есть заголовок со счётчиком ссылок и указателем на тип.
Изложение. В Python всё является объектом: число, строка, список, функция, класс, модуль. У каждого объекта три свойства. Тождественность возвращает функция id: в CPython это адрес объекта в памяти, и он не меняется, пока объект существует. Тип возвращает функция type; он определяет, какие операции с объектом допустимы, и тоже не меняется. Значение — данные, хранящиеся в объекте; у изменяемых объектов оно меняется на месте, у неизменяемых — никогда.
В исходном коде CPython любой объект начинается с заголовка PyObject: счётчик ссылок ob_refcnt и указатель на объект типа ob_type. Объект float добавляет к заголовку одно поле double. Поэтому вещественное число, занимающее в C 8 байт, в Python занимает 24: 8 байт счётчика, 8 байт указателя на тип и 8 байт значения.
Для физиков: заголовок объясняет, почему численные расчёты ведут в массивах NumPy, где числа лежат подряд без заголовков, а не в списках Python.
Имена
и ссылки
a = [1, 2, 3] b = a # второе имя b.append(4) print(a) # [1, 2, 3, 4]
Пояснение
Присваивание связывает имя с объектом и ничего не копирует.
пространство имён
a
b
list
ссылок: 2 [1, 2, 3, 4]
Пояснение
Динамическая типизация: тип хранится в объекте, и имя можно связать с объектом любого типа.
Пояснение
Строгая типизация: '1' + 1 вызывает TypeError; неявно преобразуются только числа: 1 + 2.0 = 3.0.
Изложение. Переменная в Python не содержит объект, а является именем, связанным с объектом. Имена модуля хранятся в словаре, который возвращает globals(); локальные имена функции — в массиве кадра стека. Присваивание b = a связывает с тем же объектом второе имя и ничего не копирует, поэтому изменение списка через b видно и через a: объект один, имён два, и счётчик ссылок объекта равен двум.
Тип принадлежит объекту, а не имени: после x = 1 и x = 'один' имя x связано сначала с числом, затем со строкой. Это динамическая типизация. При этом типизация строгая: строка и число не складываются, '1' + 1 вызывает TypeError, и неявные преобразования допускаются только между числовыми типами (bool → int → float → complex).
Для физиков: функция, получившая список измерений, получает ссылку на тот же объект, что и вызывающий код; копия создаётся только явно.
Сравнение:
is и ==
Выражение Вопрос Механизм
a is b один ли это объект сравнение id; не переопределяется
a == b равны ли значения вызов a.__eq__(b); задаётся типом
x = [1, 2] y = [1, 2] x == y # True x is y # False if result is None: ...
Важно
is применяется только к объектам-одиночкам: None, True, False. Значения сравниваются через ==.
Пояснение
Сравнение с литералом через is (x is 257) с версии 3.8 вызывает предупреждение SyntaxWarning.
Изложение. Оператор is проверяет, является ли это одним и тем же объектом, то есть сравнивает тождественность; переопределить его нельзя. Оператор == сравнивает значения и вызывает метод __eq__ левого операнда, поэтому смысл равенства задаёт тип: списки равны поэлементно, числа разных типов — по величине (1 == 1.0). Два списка с одинаковым содержимым равны, но являются разными объектами.
Правило простое: is применяется только к объектам, существующим в единственном экземпляре, — None, True, False и специальные маркеры; во всех остальных случаях используется ==. Проверка if result is None предпочтительнее if result == None: она быстрее и не зависит от того, как тип определил __eq__. Сравнение с числовым или строковым литералом через is компилятор с версии 3.8 отмечает предупреждением SyntaxWarning, поскольку результат зависит от кешей интерпретатора.
Демонстрация:
имена и объекты
01
Запуск интерактивной оболочки
02
Второе имя для того же списка
03
Изменение списка через второе имя
04
Равный, но другой список
Что наблюдается
id уникален только среди одновременно существующих объектов: два временных списка создаются по очереди и получают один адрес.
root@lab — запись со стенда
Демонстрация. В оболочке список [1, 2] связывается с именем a, затем с тем же объектом связывается имя b. После b.append(3) кортеж (a, a is b, id(a) == id(b)) показывает [1, 2, 3], True и True: объект один. Список c = [1, 2, 3] равен a, но является другим объектом: (True, False). type(a) возвращает класс list, а type(type(a)) — класс type: классы тоже являются объектами, и их тип — type.
Последняя строка сравнивает два временных списка: [] is [] даёт False, поскольку оба списка существуют одновременно, а id([]) == id([]) даёт True: первый список уничтожается сразу после вызова id, и второй занимает освободившееся место. Уникальность id гарантируется только среди объектов, существующих одновременно.
Кеш малых чисел
и интернирование
01
Малые целые
объекты от −5 до 256 (CPython 3.12–3.14) создаются при запуске и используются повторно
02
Константы блока
одинаковые константы одного блока компиляции хранятся однократно: файл целиком, строка оболочки
03
Интернирование строк
имена и строки, похожие на идентификаторы, хранятся в одном экземпляре; sys.intern — явно
a = 256; b = 256 # a is b → True c = 257 # в оболочке, строка 1 d = 257 # строка 2 c is d # False
Важно
Всё перечисленное — детали реализации CPython: результат is для чисел и строк зависит от версии и способа запуска, и программа на него не опирается.
Изложение. Интерпретатор экономит память и время, используя некоторые объекты повторно. Целые числа от −5 до 256 создаются при запуске, и всякое вычисление, дающее такое число, возвращает готовый объект; в Python 3.12 эти объекты к тому же бессмертны, о чём пойдёт речь в четвёртой части. Одинаковые константы одного блока компиляции хранятся в таблице констант однократно. Файл компилируется целиком, поэтому две константы 257 в одном модуле — один объект, и c is d в сценарии даёт True; в интерактивной оболочке каждая строка компилируется отдельно, и те же присваивания в двух строках дают два объекта. Строки, похожие на идентификаторы, и все имена в коде интернируются: хранятся в единственном экземпляре, что ускоряет поиск атрибутов в словарях. Функция sys.intern интернирует строку явно.
Тождественность таких объектов — деталь реализации: она зависит от версии интерпретатора (в 3.15 диапазон кеша малых чисел расширен), от способа запуска (файл целиком или отдельные строки оболочки) и от того, получено значение при компиляции или вычислено во время работы. Программа, сравнивающая числа или строки через is, работает при отладке и ломается в другом окружении.
Демонстрация:
кеш и интернирование
02
257 в двух строках оболочки: два объекта
03
257 в одной строке: общая константа
04
is с литералом: предупреждение компилятора
05
Строка, собранная во время работы
06
sys.intern: один объект для равных строк
Что наблюдается
Одинаковые значения дают один объект или два в зависимости от способа компиляции; на == это не влияет.
root@lab — запись со стенда
Демонстрация. Присваивания a = 256 и b = 256 в двух строках дают один объект из кеша малых чисел: a is b — True. Для 257 в двух строках оболочки c is d — False: каждая строка компилируется отдельно, и объектов два. Те же присваивания в одной строке дают True: строка компилируется как один блок, и константа 257 хранится в нём однократно. Выражение c is 257 компилятор отмечает предупреждением SyntaxWarning с подсказкой использовать ==.
Строка 'физика', записанная литералом, и строка, собранная функцией join из двух частей, равны, но являются разными объектами: (True, False). Вызовы sys.intern(s) и sys.intern(t) возвращают один и тот же объект из таблицы интернированных строк: True. Сами имена s и t при этом не перепривязываются; чтобы пользоваться интернированной строкой, результат присваивают имени: t = sys.intern(t).
Числа
и их представление
Тип Представление
int произвольная точность: размер растёт вместе с числом
float IEEE 754, 64 бита: 53 бита мантиссы, 15–17 значащих цифр
complex пара float; мнимая единица — 1j
bool подкласс int: sum(v > threshold for v in data) считает события
Fraction, Decimal точные дроби и десятичная арифметика (модули fractions, decimal)
0.1 + 0.2 # 0.30000000000000004 0.1 + 0.2 == 0.3 # False math.isclose(0.1 + 0.2, 0.3) # True -7 // 2, -7 % 2 # (-4, 1) round(2.5) # 2
Для физика
Вещественные числа сравниваются с допуском: math.isclose(a, b), numpy.isclose для массивов; при сравнении с нулём задаётся abs_tol.
Изложение. Целые числа в Python имеют произвольную точность: переполнения нет, а объект растёт вместе с числом, храня его цифрами по 30 бит. float — двоичное число двойной точности по IEEE 754: 53 бита мантиссы дают 15–17 значащих десятичных цифр, диапазон — до 1,8·10^308; за его пределами умножение и сложение дают inf, а возведение в степень, функции модуля math и перевод большого int во float вызывают OverflowError. complex хранит пару float, мнимая единица записывается как 1j. bool является подклассом int, поэтому True + True равно 2, а выражение sum(v > threshold for v in data) подсчитывает значения выше порога. Для точной арифметики служат модули fractions (рациональные дроби) и decimal (десятичные числа с заданной точностью).
Десятичная дробь 0.1 не представима в двоичной системе точно и хранится как ближайшая двоичная, поэтому 0.1 + 0.2 отличается от 0.3 в последнем знаке. Оператор / для целых операндов всегда даёт float, // делит с округлением вниз, а остаток % имеет знак делителя: −7 // 2 = −4, −7 % 2 = 1. Функция round округляет половины к чётному (банковское округление): round(0.5) = 0, round(2.5) = 2.
Для физиков: вещественные результаты сравниваются только с допуском — math.isclose(a, b, rel_tol=1e-9) или numpy.isclose для массивов. Сравнение через == даёт ложные расхождения при проверке расчёта. У math.isclose по умолчанию abs_tol=0, поэтому сравнение с нулём всегда ложно, и допуск задаётся явно; у numpy.isclose по умолчанию atol=1e-8, и малые величины в единицах СИ (заряды, массы частиц) оказываются «близкими» друг к другу — для них atol уменьшают или обнуляют.
Демонстрация:
числа
01
Целое произвольной длины
02
Погрешность двоичного представления
04
Деление с округлением вниз и остаток
07
Точное значение 0.1 и дроби Fraction
Что наблюдается
0.1 хранится как ближайшая двоичная дробь; вещественные числа сравниваются с допуском.
root@lab — запись со стенда
Демонстрация. 2 ** 100 вычисляется точно: переполнения целых нет. 0.1 + 0.2 даёт 0.30000000000000004, и сравнение с 0.3 возвращает False, а math.isclose — True. Для отрицательного делимого −7 // 2 равно −4, остаток −7 % 2 равен 1, divmod возвращает оба числа сразу. round(0.5), round(1.5) и round(2.5) дают 0, 2 и 2: половины округляются к чётному. 1e308 * 10 переполняется в inf, а NaN не равен даже самому себе. (0.1).as_integer_ratio() показывает точное значение, хранящееся в памяти: 3602879701896397 / 36028797018963968, то есть 3602879701896397 / 2^55. Дроби модуля fractions складываются точно: Fraction(1, 10) + Fraction(2, 10) == Fraction(3, 10) даёт True.
Стоимость
объекта
Объект sys.getsizeof, байт
int 1 28
int 2**100 40
float 24
str 'a' 42
str 'я' 60
bytes b'a' 34
list [] 56 + 8 на ячейку
tuple () 40 + 8 на элемент
dict {} 64
set() 216
Для физика
Список из миллиона float занимает около 32 МБ: 8 МБ указателей и 24 МБ самих объектов. Массив NumPy тех же чисел — 8 МБ.
Пояснение
sys.getsizeof учитывает только сам объект, без объектов, на которые он ссылается.
Изложение. Функция sys.getsizeof возвращает размер объекта в байтах; значения на слайде получены на 64-битном CPython 3.12. Минимальное целое занимает 28 байт, число 2**100 — 40: цифры по 30 бит хранятся в массиве переменной длины. float занимает 24 байта. Строка из одного символа ASCII — 42 байта, из одного символа кириллицы — 60: у строки не из ASCII заголовок длиннее на 16 байт, а символ и завершающий ноль занимают по 2 байта; ширина символа (1, 2 или 4 байта, PEP 393) выбирается по самому «широкому» символу строки. Пустой список занимает 56 байт и ещё 8 байт на каждую ячейку массива указателей: список хранит указатели, а не сами объекты, и ячеек может быть больше, чем элементов, — запас для добавления рассматривается в пятой части. Кортеж занимает 40 байт и 8 на элемент. Пустое множество сразу выделяет таблицу на восемь ячеек и занимает 216 байт.
sys.getsizeof учитывает только сам объект: размер списка не включает размер его элементов. Полный объём структуры считается обходом или измеряется модулем tracemalloc.
Для физиков: миллион измерений в списке float занимает около 32 МБ, из них 24 МБ — заголовки и значения отдельных объектов. Массив NumPy хранит те же числа подряд в 8 МБ и обрабатывает их без интерпретатора, поэтому численные данные держат в массивах.
Демонстрация:
размеры объектов
01
Целые разной длины и float
02
Строки ASCII и кириллицы, байты
03
Пустой список и список из тысячи элементов
04
Список из миллиона float
05
Полный объём: указатели и объекты
Что наблюдается
Тысяча элементов добавляет к пустому списку ровно 8000 байт указателей; миллион float вместе с объектами занимает около 32 МБ.
root@lab — запись со стенда
Демонстрация. sys.getsizeof для 1, 2 ** 100 и 1.0 даёт 28, 40 и 24 байта. Строки 'a' и 'я' занимают 42 и 60 байт, b'a' — 34. Пустой список занимает 56 байт, список из тысячи нулей — 8056: к заголовку добавлены 8000 байт указателей, причём все указатели ведут на один объект 0.0. Список xs из миллиона различных float строится включением; сумма размера самого списка и размеров всех его элементов даёт 32 448 728 байт — около 32 МБ, из которых 24 МБ приходятся на объекты float.
Изменяемость
Изменяемые и неизменяемые типы, хешируемость, копирование, строки и байты
03
Язык и интерпретатор
Объекты в памяти
Изменяемость
Сборка мусора
Коллекции
Сложность операций
Изложение. Третья часть посвящена основному делению объектов Python — на изменяемые и неизменяемые — и его следствиям: поведению +=, хешируемости, копированию, значениям по умолчанию, а также различию строк и байтов.
Изменяемые
и неизменяемые
Неизменяемые
int
float
complex
bool
str
bytes
tuple
frozenset
range
None
«Изменение» создаёт новый объект, и имя связывается с ним.
Изменяемые
list
dict
set
bytearray
экземпляры классов
Объект меняется на месте, и изменение видно через все имена, связанные с ним.
Пояснение
Изменяемость — свойство объекта, а не имени: имя всегда можно связать с другим объектом.
Изложение. Неизменяемыми являются числа всех типов, строки, байты, кортежи, frozenset, range и None. Однажды созданный неизменяемый объект хранит своё значение до конца жизни; всякая «модификация» создаёт новый объект, и имя связывается с ним. Изменяемые объекты — списки, словари, множества, bytearray и по умолчанию экземпляры собственных классов — меняются на месте, и изменение видно через все имена, ссылающиеся на объект.
Изменяемость относится к объекту, а не к имени: имя, связанное с неизменяемой строкой, можно связать с другой строкой, а константы в смысле языка C в Python нет. Соглашение UPPER_CASE лишь сообщает читателю, что имя менять не предполагается.
Операция +=
для разных типов
Запись str, tuple, int list
x += y новый объект, имя связывается с ним тот же объект расширяется на месте
x = x + y новый объект новый объект
f(x) меняет x невозможно изменение видно вызывающему коду
def normalize(v): total = sum(v) for i in range(len(v)): v[i] /= total # список вызывающего data = [2.0, 6.0] normalize(data) data # [0.25, 0.75]
Для физика
Функция обработки, изменяющая переданный список на месте, изменяет исходные измерения. Если они ещё нужны, функция возвращает новый список.
Пояснение
x += y выполняется как x = x.__iadd__(y), если метод определён, иначе как x = x + y.
Изложение. Составное присваивание x += y ведёт себя по-разному в зависимости от изменяемости. Интерпретатор сначала ищет у объекта метод __iadd__ (сложение на месте). У списка он есть: список расширяется, и имя остаётся связанным с тем же объектом. У строки, кортежа и числа такого метода нет, поэтому выполняется x = x + y: создаётся новый объект, и имя связывается с ним. Запись x = x + y создаёт новый объект для любого типа.
Отсюда следует поведение функций. Функция получает ссылку на объект вызывающего кода. Неизменяемый объект функция изменить не может: присваивание параметру лишь связывает локальное имя с новым объектом. Изменяемый объект функция меняет на месте, и вызывающий код видит изменения.
Для физиков: функция normalize на слайде нормирует список на месте, и после вызова исходных значений в data больше нет. Если они понадобятся, функция возвращает новый список: return [x / total for x in v].
Кортеж
с изменяемым элементом
Пояснение
Неизменны ссылки кортежа, а не объекты, на которые они указывают.
>>> t = ([1, 2], 3) >>> t[0] += [3] Traceback (most recent call last): ... TypeError: 'tuple' object does not support item assignment >>> t ([1, 2, 3], 3)
Важно
t[0] += [3] выполняется как t[0] = t[0].__iadd__([3]): список уже расширен, когда присваивание элементу кортежа вызывает ошибку.
Изложение. Кортеж хранит ссылки, и неизменны именно они: подставить в ячейку кортежа другой объект нельзя. Сам объект, на который указывает ссылка, о запрете ничего не знает, поэтому список внутри кортежа можно изменить: t[0].append(3) выполняется без ошибок.
Показательный случай — t[0] += [3]. Составное присваивание элементу раскладывается на три шага: чтение t[0], вызов __iadd__ у полученного списка и запись результата обратно в t[0]. Второй шаг расширяет список на месте, третий пытается присвоить элементу кортежа и вызывает TypeError. В результате возникает исключение, и одновременно список внутри кортежа оказывается изменён. Этот случай разобран в официальном FAQ по Python.
Кортеж со списком внутри не хешируем: хеш кортежа вычисляется из хешей элементов, а у списка хеша нет. Такой кортеж нельзя поместить в множество или использовать как ключ словаря.
Демонстрация:
изменяемость
01
Строка: новый объект после +=
02
Список: расширение на месте
03
Список: новый объект после xs = xs + [3]
04
Кортеж со списком: исключение и изменение одновременно
Что наблюдается
id строки после += другой, id списка прежний; t[0] += [3] вызывает исключение, но список внутри кортежа уже изменён.
root@lab — запись со стенда
Демонстрация. Для строки s = 'abc' запоминается id, после s += 'd' проверка id(s) == i даёт False: имя связано с новой строкой 'abcd'. Для списка xs = [1] после xs += [2] та же проверка даёт True: список расширен на месте. Запись xs = xs + [3] создаёт новый список, и id меняется. Для кортежа t = ([1, 2], 3) инструкция t[0] += [3] вызывает TypeError: 'tuple' object does not support item assignment, и тем не менее t после этого равен ([1, 2, 3], 3).
Хешируемость
ключей
Тип Хешируем
int, float, complex, bool да
str, bytes да
tuple из хешируемых да
frozenset, None да
list, dict, set, bytearray нет
tuple со списком нет
01
Хеш неизменен
hash(x) не меняется, пока объект существует
02
Равные — с равными хешами
из a == b следует hash(a) == hash(b)
03
Ключи и элементы
ключ словаря и элемент множества обязаны быть хешируемыми
Пояснение
1 == 1.0 == True и хеши у них равны, поэтому в словаре это один ключ.
Изложение. Словари и множества находят элементы по хешу — целому числу, вычисляемому из значения функцией hash. Отсюда два требования. Хеш объекта не должен меняться, пока объект находится в словаре, иначе объект окажется в «чужой» ячейке и не будет найден; поэтому хешируемы неизменяемые объекты, а изменяемые встроенные контейнеры объявляют хеш отсутствующим. Равные объекты обязаны иметь равные хеши, иначе поиск равного ключа будет выполняться не в той ячейке.
Кортеж хешируем, если хешируемы все его элементы: его хеш вычисляется из хешей элементов. Экземпляры собственных классов по умолчанию хешируемы по тождественности, пока класс не определяет __eq__: определение __eq__ без __hash__ делает экземпляры нехешируемыми.
Поскольку 1 == 1.0 == True, их хеши тоже равны, и в словаре это один ключ: словарь {1: 'int', 1.0: 'float', True: 'bool'} содержит одну пару с ключом 1 и значением 'bool'. Демонстрация — в пятой части, вместе с устройством словаря.
Копирование:
поверхностное и глубокое
Запись Результат
b = a второе имя, копии нет
a.copy(), a[:], list(a), copy.copy(a) поверхностная: новый контейнер, те же элементы
copy.deepcopy(a) глубокая: рекурсивная копия с учётом циклов
Важно
[[0] * 3] * 3 повторяет ссылку на один внутренний список; матрица создаётся включением [[0] * 3 for _ in range(3)].
a
b = a.copy()
c = deepcopy(a)
Изложение. Присваивание копии не создаёт. Поверхностная копия — метод copy, срез a[:], конструктор list(a) или функция copy.copy — создаёт новый внешний контейнер, заполненный теми же ссылками. Для списка чисел этого достаточно, но вложенные списки у копии и оригинала общие: изменение вложенного списка, например a[0].append(99), видно в копии, а присваивание a[0] = [...] меняет только оригинал. Глубокая копия copy.deepcopy рекурсивно копирует все вложенные объекты; словарь memo внутри неё запоминает уже скопированные объекты, поэтому циклические структуры копируются корректно.
На диаграмме оба списка — оригинал a и поверхностная копия b — ссылаются на один внутренний список [1, 2], а глубокая копия c получила собственный.
Умножение списка на число повторяет ссылки. [[0] * 3] * 3 создаёт внешний список из трёх ссылок на один и тот же внутренний список, и присваивание m[0][0] = 1 меняет «все строки» сразу. Матрица создаётся включением: тело включения выполняется на каждой итерации заново и создаёт новый внутренний список.
Демонстрация:
копирование
01
Матрица умножением списка
02
Присваивание одной ячейке: изменение всех строк
03
Матрица через включение
04
Поверхностная и глубокая копии
05
Изменение вложенного списка оригинала
06
Тождественность вложенных списков
Что наблюдается
Поверхностная копия разделяет с оригиналом вложенные списки, глубокая — нет.
root@lab — запись со стенда
Демонстрация. m = [[0] * 3] * 3 и присваивание m[0][0] = 1 дают [[1, 0, 0], [1, 0, 0], [1, 0, 0]]: все три строки — один и тот же список. Та же матрица, построенная включением, после такого же присваивания равна [[1, 0, 0], [0, 0, 0], [0, 0, 0]]. Далее для a = [[1, 2], [3]] создаются поверхностная копия b и глубокая копия c. После a[0].append(99) копия b показывает [[1, 2, 99], [3]], а c остаётся [[1, 2], [3]]. Последняя проверка подтверждает причину: b — другой объект, но b[0] is a[0] даёт True, а c[0] is a[0] — False.
Изменяемое значение
по умолчанию
def append_to(x, target=[]): # ошибка target.append(x) return target append_to(1) # [1] append_to(2) # [1, 2]
def append_to(x, target=None): if target is None: target = [] target.append(x) return target
Важно
Значение по умолчанию вычисляется один раз, при выполнении def, и хранится в append_to.__defaults__.
Пояснение
Функции подробно рассматриваются в следующей лекции; ловушка относится к изменяемости и часто встречается в коде обработки данных.
Изложение. Значение параметра по умолчанию вычисляется один раз — когда интерпретатор выполняет инструкцию def — и хранится в атрибуте __defaults__ функции. Если это значение изменяемое, все вызовы без аргумента работают с одним и тем же объектом: список по умолчанию накапливает всё, что в него добавляли при предыдущих вызовах.
Решение — указать по умолчанию None и создавать новый список внутри функции, при каждом вызове заново. Проверка выполняется через is None, а не через not target: пустой список, переданный явно, тоже ложен, и функция подменила бы его своим.
Та же ловушка возникает со словарём по умолчанию (cache={}) и с объектами, созданными в заголовке функции: вызов datetime.now() в значении по умолчанию выполнится один раз при определении функции.
Демонстрация:
значение по умолчанию
01
Функция со списком по умолчанию
02
Два вызова: общий список
03
Общий список в __defaults__
04
Исправленная версия с None
Что наблюдается
Второй вызов возвращает [1, 2]: список создан однократно при выполнении def; с None каждый вызов получает новый список.
root@lab — запись со стенда
Демонстрация. Функция append_to определяется с параметром target=[]. Вызов append_to(1) возвращает [1], вызов append_to(2) — [1, 2]: второй вызов получил тот же список. Атрибут append_to.__defaults__ показывает кортеж ([1, 2],) — это и есть общий список. После переопределения функции с target=None два вызова в одной строке дают ([1], [2]).
Строки
и байты
Символы Байт в UTF-8
латиница ASCII, цифры 1
кириллица, греческий 2
иероглифы, ∮ 3
эмодзи 4
Для физика
Файлы старых приборов и программ часто записаны в cp1251: кодировка указывается явно — open(path, encoding='cp1251'), а двоичные данные читаются в режиме 'rb'.
Пояснение
С Python 3.15 кодировкой по умолчанию на всех платформах является UTF-8 (PEP 686).
Изложение. str хранит текст — последовательность символов Unicode; длина строки измеряется в символах. bytes хранит сырые данные — последовательность чисел от 0 до 255: содержимое файла, пакета из сети, отсчёты прибора; индексирование bytes возвращает число. Оба типа неизменяемы, изменяемый вариант байтов — bytearray.
Автоматического перехода между ними нет: требуется кодировка, указывающая, какими байтами записан каждый символ. encode переводит текст в байты, decode — байты в текст. В UTF-8 латиница занимает один байт, кириллица два, иероглифы и многие математические символы три, эмодзи четыре. Почти все UnicodeDecodeError возникают, когда байты читаются не в той кодировке, в которой были записаны.
До версии 3.15 кодировка по умолчанию у open зависит от локали, и на Windows это cp1251 или cp1252; с версии 3.15 по умолчанию используется UTF-8 на всех платформах (PEP 686). Кодировку в open поэтому указывают явно.
Для физиков: протоколы и файлы старого лабораторного оборудования нередко записаны в cp1251 или содержат двоичные данные. Двоичный файл открывается в режиме 'rb', и разбор его записей выполняет модуль struct или numpy.frombuffer.
Демонстрация:
строки и байты
01
Кодирование строки в UTF-8
02
Длина в символах и в байтах; первый байт — число
04
Байты cp1251, прочитанные как UTF-8
Что наблюдается
Шесть символов кириллицы занимают 12 байт; байты cp1251 не являются корректным UTF-8, отсюда UnicodeDecodeError.
root@lab — запись со стенда
Демонстрация. Строка 'привет' кодируется в UTF-8, и объект bytes печатается шестнадцатеричными кодами: по два байта на букву, начиная с \xd0\xbf. len(text), len(data) и data[0] дают 6, 12 и 208: индексирование байтов возвращает число. decode('utf-8') восстанавливает строку. Та же строка, закодированная в cp1251, при декодировании как UTF-8 вызывает UnicodeDecodeError: байт 0xef (буква «п» в cp1251) в UTF-8 начинает трёхбайтовую последовательность, но следующий байт не является байтом продолжения. decode('cp1251') возвращает исходную строку.
Сборка мусора
Подсчёт ссылок, циклические ссылки, поколения, слабые ссылки
04
Язык и интерпретатор
Объекты в памяти
Изменяемость
Сборка мусора
Коллекции
Сложность операций
Изложение. Четвёртая часть отвечает на вопрос, когда объект освобождается. Память в Python освобождается автоматически двумя механизмами: подсчётом ссылок, который работает всегда и сразу, и сборщиком циклического мусора, который периодически ищет группы объектов, ссылающихся друг на друга.
Подсчёт
ссылок
01
Ссылка появляется
имя, элемент контейнера, атрибут, аргумент вызова
02
Ссылка исчезает
del, новое значение имени, выход из функции, удаление из контейнера
03
Счётчик равен нулю
объект освобождается сразу, счётчики его элементов уменьшаются
Пояснение
С Python 3.12 у None, True, False и малых целых счётчик не меняется: это бессмертные объекты (PEP 683).
Изложение. Основной механизм освобождения памяти — подсчёт ссылок. Счётчик ob_refcnt в заголовке объекта увеличивается, когда на объект появляется новая ссылка: имя в пространстве имён, элемент списка или словаря, атрибут другого объекта, аргумент вызова на стеке. Счётчик уменьшается, когда ссылка исчезает: при del, при связывании имени с другим объектом, при выходе из функции, при удалении элемента из контейнера. Как только счётчик достигает нуля, объект уничтожается немедленно, в том же месте программы; при этом уменьшаются счётчики всех объектов, на которые он ссылался, и освобождение может распространиться каскадом.
Механизм быстр и предсказуем: файл, открытый без with, закрывается в CPython сразу после удаления последней ссылки на объект файла. Опираться на это не следует: в других реализациях, например PyPy, момент освобождения не определён.
С версии 3.12 некоторые объекты бессмертны (PEP 683): None, True, False, малые целые и другие встроенные объекты имеют фиксированное значение счётчика, которое не меняется при появлении и удалении ссылок. Это избавляет процессор от записи в заголовки самых популярных объектов и позволяет разделять их между процессами и подынтерпретаторами без копирования страниц памяти.
Демонстрация:
подсчёт ссылок
02
Второе имя: рост счётчика
03
del: уменьшение счётчика
05
Класс с методом __del__
06
Удаление последней ссылки
Что наблюдается
getrefcount показывает на единицу больше из-за ссылки в аргументе; сообщение печатается при удалении последнего имени, а не первого.
root@lab — запись со стенда
Демонстрация. Для нового списка x функция sys.getrefcount возвращает 2: одна ссылка — имя x, вторая — аргумент самого вызова. После y = x счётчик равен 3, после del y снова 2. Для None возвращается 4294967295: счётчик бессмертного объекта зафиксирован и не меняется. Далее определяется класс Probe, метод __del__ которого печатает сообщение при уничтожении объекта. Объект получает два имени, p и q. del p ничего не печатает — ссылка q ещё существует; del q удаляет последнюю ссылку, и сообщение «объект удалён» печатается сразу.
В интерактивной оболочке значение последнего выражения сохраняется в переменной _; эта дополнительная ссылка откладывает уничтожение объекта, поэтому в демонстрации объекты не выводятся отдельным выражением без присваивания.
Циклические
ссылки
a.other
b.other
имён a и b уже нет: del a, b
Пояснение
Счётчики объектов цикла не обнуляются, хотя из программы до них уже не добраться.
Дерево, в котором узлы ссылаются на родителя
Двусвязный список
Объект, хранящий собственный связанный метод
Исключение в переменной: трассировка ссылается на кадры стека
Пояснение
Для таких случаев в Python 2.0 появился сборщик циклического мусора — модуль gc.
Изложение. Слабое место подсчёта ссылок — циклы. Если объекты ссылаются друг на друга, их счётчики не обнулятся и после удаления всех имён: каждый объект удерживается ссылкой из другого, хотя из программы до них уже не добраться. Такая группа объектов занимает память до конца работы процесса, если её не найдёт отдельный механизм.
Циклические ссылки возникают часто: дерево, в котором узлы хранят ссылку на родителя; двусвязный список; объект, сохранивший в атрибуте собственный связанный метод (связанный метод ссылается на объект); исключение, сохранённое в переменной, — его трассировка ссылается на кадры стека, а кадры — на локальные переменные, среди которых и само исключение. Поэтому Python удаляет имя из блока except при выходе из него.
Для поиска таких групп в Python 2.0 (2000) добавлен сборщик циклического мусора, доступный как модуль gc.
Поиск
недостижимых объектов
01
Копия счётчиков
для каждого отслеживаемого объекта gc_refs = ob_refcnt
02
Вычитание внутренних ссылок
обход ссылок между объектами поколения уменьшает gc_refs
03
Достижимые извне
объекты с gc_refs > 0 и всё, что достижимо из них, остаются
04
Недостижимые
вызов __del__, разрыв ссылок, освобождение памяти
Пояснение
Отслеживаются только контейнеры: списки, кортежи, словари, множества, экземпляры классов, функции. Числа и строки не могут замкнуть цикл, и сборщик их не просматривает.
Пояснение
gc.collect() запускает сборку явно и возвращает число найденных недостижимых объектов.
Изложение. Сборщик рассматривает только объекты, способные ссылаться на другие, — контейнеры: списки, словари, множества, экземпляры классов, функции, кадры. Числа и строки ни на что не ссылаются, цикла замкнуть не могут и сборщиком не отслеживаются; кортежи и словари, содержащие только такие объекты, сборщик по ходу работы тоже перестаёт отслеживать.
Алгоритм не ищет циклы напрямую, а определяет, какие объекты достижимы извне. Для каждого объекта рассматриваемого поколения счётчик ссылок копируется в поле gc_refs. Затем обходятся все ссылки между объектами поколения, и каждая уменьшает gc_refs того объекта, на который указывает. После этого у объекта, на который ссылаются только объекты того же поколения, gc_refs равно нулю, а gc_refs > 0 означает ссылку извне — от имени, со стека, из объекта другого поколения. Такие объекты и всё, что достижимо из них, остаются. Остальные недостижимы: у них вызываются финализаторы __del__ (с версии 3.4 это возможно и для объектов в цикле, PEP 442), затем ссылки внутри цикла разрываются, счётчики падают до нуля, и память освобождается обычным путём.
Поколения
объектов
Поколение 0
новые контейнеры; сборка, когда создано на 700 больше, чем удалено
Поколение 1
пережившие одну сборку; каждая 10-я сборка поколения 0
Поколение 2
долгоживущие; каждая 10-я сборка поколения 1
выжившие
выжившие
Пояснение
Пороги возвращает gc.get_threshold(): в 3.12 это (700, 10, 10). Долгоживущий объект с большой вероятностью проживёт ещё, поэтому старшие поколения проверяются редко.
Для физика
При создании миллионов мелких контейнеров сборщик запускается часто; на время загрузки данных его отключают gc.disable() или переносят загруженное в постоянное поколение gc.freeze().
Изложение. Проверять все объекты при каждой сборке дорого, поэтому сборщик делит отслеживаемые объекты на три поколения. Новые контейнеры попадают в поколение 0. Сборка поколения 0 запускается, когда число созданных контейнеров превышает число удалённых на пороговое значение — 700. Объекты, пережившие сборку, переходят в поколение 1, которое проверяется при каждой десятой сборке поколения 0, пережившие и её — в поколение 2, которое проверяется при каждой десятой сборке поколения 1 и, кроме того, только если с прошлой полной сборки накопилось достаточно новых долгоживущих объектов. Основание — наблюдение, что большинство объектов живёт недолго, а долгоживущий объект с большой вероятностью проживёт ещё.
Сборщик настраивается функциями модуля gc: get_threshold и set_threshold, disable и enable, collect, freeze. В Python 3.14.0–3.14.4 сборщик был инкрементальным с двумя поколениями; из-за роста потребления памяти в рабочих системах начиная с 3.14.5 возвращён поколенческий сборщик версии 3.13 (What's New in Python 3.14). Пороги и число поколений поэтому проверяются по документации модуля gc своей версии.
Для физиков: программа, создающая миллионы мелких контейнеров (частицы как объекты, словари событий), запускает сборщик часто, и каждая полная сборка обходит все накопленные объекты. На время загрузки больших структур сборщик отключают gc.disable() с обязательным включением после, либо после загрузки переносят объекты в постоянное поколение, которое сборщик не просматривает, вызовом gc.freeze().
Демонстрация:
сборщик циклов
01
Пороги и счётчики поколений
02
Класс с методом __del__
03
Два объекта со взаимными ссылками
04
Удаление имён: сообщений нет
Что наблюдается
После del объекты остаются в памяти; gc.collect находит цикл, вызывает __del__ у обоих объектов и возвращает 2.
root@lab — запись со стенда
Демонстрация. gc.get_threshold() возвращает пороги (700, 10, 10). gc.get_count() возвращает три счётчика: на сколько созданных контейнеров больше, чем удалённых, с последней сборки поколения 0, и сколько сборок младшего поколения прошло с последней сборки следующего; в записи — (563, 2, 1). Класс Probe снова печатает сообщение из __del__. Объекты a и b ссылаются друг на друга через атрибут other. После del a, b сообщений нет: у каждого объекта осталась ссылка от другого. gc.collect() находит недостижимую группу, вызывает __del__ у обоих объектов — печатаются два сообщения — и возвращает 2, число найденных недостижимых объектов.
Слабые ссылки
и освобождение ресурсов
Средство Назначение
weakref.ref(obj) ссылка без увеличения счётчика; после удаления объекта возвращает None
WeakValueDictionary кеш, не удерживающий объекты в памяти
with open(...) as f: файл закрывается при выходе из блока, а не при сборке мусора
import weakref class Node: def __init__(self, parent=None): self.children = [] self.parent = None if parent is not None: self.parent = weakref.ref(parent)
Важно
__del__ не подходит для освобождения ресурсов: момент вызова не гарантирован, а при завершении интерпретатора вызов может не произойти.
Изложение. Слабая ссылка weakref.ref указывает на объект, не увеличивая его счётчик: пока объект существует, вызов ссылки возвращает его, после уничтожения — None. Слабые ссылки разрывают циклы там, где обратная ссылка не должна удерживать объект: узел дерева хранит сильные ссылки на детей и слабую — на родителя, и дерево освобождается подсчётом ссылок без участия сборщика. WeakValueDictionary строит кеш, который не мешает освобождению объектов: запись исчезает вместе с объектом. Значениями такого словаря могут быть экземпляры собственных классов и массивы NumPy; списки, словари, кортежи, числа и строки слабых ссылок не допускают.
Ресурсы — файлы, соединения, блокировки — освобождаются явно, конструкцией with, а не в __del__: момент вызова финализатора зависит от реализации и от того, попал ли объект в цикл, а при завершении интерпретатора вызов не гарантирован. Менеджеры контекста рассматриваются в последующих лекциях.
Коллекции
Список, кортеж, множество, словарь, модуль collections, перебор и ветвления
05
Язык и интерпретатор
Объекты в памяти
Изменяемость
Сборка мусора
Коллекции
Сложность операций
Изложение. Пятая часть посвящена встроенным коллекциям: их общему определению, внутреннему устройству списка, кортежа, множества и словаря, специализированным коллекциям модуля collections, а также конструкциям, которыми коллекции перебираются и проверяются.
Понятие
коллекции
Семейство Типы
Sequence list, tuple, str, range
Set set, frozenset
Mapping dict
Пояснение
Интервал чисел отвечает на in, но не перебирается; генератор перебирается, но не знает длины. Ни то ни другое не коллекция.
Изложение. Стандартная библиотека называет коллекцией объект с тремя независимыми свойствами: он является контейнером (отвечает на оператор in), итерируемым (перебирается в цикле for) и имеет длину (отвечает на len). Эти свойства описаны абстрактными классами модуля collections.abc — Container, Iterable и Sized, а их объединение — классом Collection. Принадлежность проверяется через isinstance(x, Collection).
Ниже располагаются три семейства. Последовательности (Sequence) упорядочены и доступны по индексу: списки, кортежи, строки, range. Множества (Set) хранят уникальные элементы без порядка. Отображения (Mapping) хранят пары ключ — значение; это словари.
Свойства независимы: числовой интервал отвечает на вопрос, лежит ли число внутри, но перебрать все его точки нельзя; генератор перебирается, но не знает заранее, сколько элементов выдаст. Собственный класс, отвечающий на in, рассматривается в главе книги «Коллекции».
Список:
динамический массив
PyListObject
ob_size = 3 allocated = 4
массив указателей ob_item
Пояснение
Ёмкость растёт с запасом около 1/8: 4, 8, 16, 24, 32, 40, 52, 64, 76, 92 — перевыделения всё реже.
Операция Стоимость
append, pop() O(1) амортизированно
lst[i], len O(1)
insert(0, x), pop(0) O(n)
x in lst, remove, index O(n)
sort, sorted O(n log n), устойчиво
Изложение. Список в CPython — динамический массив указателей на объекты. В структуре PyListObject хранятся текущая длина ob_size, ёмкость allocated и указатель на массив ob_item. Память выделяется с запасом: при нехватке места новая ёмкость в CPython 3.12 вычисляется как n + n/8 + 6, округлённое вниз до кратного четырём, поэтому ёмкости идут 4, 8, 16, 24, 32, 40, 52, 64, 76, 92. При перевыделении (реаллокации) все указатели копируются в новый блок, что стоит O(n), но перевыделения происходят всё реже, и в среднем append стоит константу — амортизированное O(1).
Доступ по индексу и len стоят O(1). Вставка и удаление в начале сдвигают все последующие указатели и стоят O(n). Проверка in, remove и index просматривают список с начала — O(n). Сортировка (алгоритм Timsort, с 3.11 с правилом слияния Powersort) стоит O(n log n) и устойчива: элементы с равными ключами сохраняют взаимный порядок. extend выгоднее серии append: размер добавляемых данных учитывается сразу.
Элементы списка сами в массиве не лежат: массив хранит указатели, а объекты размещены отдельно, поэтому список может содержать объекты любых типов вперемешку.
Демонстрация:
рост списка
01
Пустой список и его размер
02
Добавление 80 элементов по одному
03
Печать при каждом перевыделении: длина и ёмкость
Что наблюдается
Ёмкость растёт ступенями 4, 8, 16, 24, 32, 40, 52, 64, 76, 92: на 80 добавлений приходится десять перевыделений.
root@lab — запись со стенда
Демонстрация. Для пустого списка запоминается размер 56 байт. В цикле добавляется 80 элементов; после каждого append размер списка сравнивается с предыдущим, и при изменении печатаются длина и ёмкость, вычисленная как (размер − 56) / 8. Печатается десять строк: при длине 1 ёмкость становится 4, при 5 — 8, при 9 — 16, затем 24, 32, 40, 52, 64, 76 и 92. Остальные семьдесят добавлений записывают указатель в свободную ячейку без перевыделения.
Срезы
и сортировка
xs = [0, 1, 2, 3, 4, 5] xs[1:4] # [1, 2, 3]: новый список xs[::2] # [0, 2, 4] xs[::-1] # [5, 4, 3, 2, 1, 0] xs[-2:] # [4, 5] ys = xs[:] # поверхностная копия
Для физика
Срез массива NumPy — представление, а не копия: после b = a[1:3]; b[0] = 99 меняется и массив a.
from operator import attrgetter, itemgetter by_energy = attrgetter('energy') events.sort(key=by_energy) # на месте top = sorted(events, key=by_energy, reverse=True) rows.sort(key=itemgetter(1)) # вторичный ключ rows.sort(key=itemgetter(0)) # первичный
Пояснение
sort и sorted устойчивы: элементы с равными ключами сохраняют взаимный порядок, поэтому сортировка по нескольким ключам выполняется проходами от младшего ключа к старшему.
Изложение. Срез xs[start:stop:step] выбирает элементы от start включительно до stop исключительно с шагом step; отрицательные индексы отсчитываются с конца, пропущенные границы означают начало и конец, отрицательный шаг обходит последовательность в обратном порядке. Срез списка создаёт новый список — поверхностную копию выбранной части, поэтому xs[:] — ещё один способ скопировать список. У неизменяемых последовательностей CPython может вернуть тот же объект: для кортежа t[:] is t даёт True, и это незаметно, поскольку объект всё равно не изменится. Присваивание срезу xs[1:3] = ... заменяет часть списка на месте, и длина правой части может быть любой, если шаг равен единице.
Метод sort сортирует список на месте и возвращает None, функция sorted возвращает новый список из любого итерируемого объекта. Параметр key задаёт функцию, вычисляющую ключ сортировки, reverse — обратный порядок; удобные ключи дают attrgetter и itemgetter модуля operator. Сортировка устойчива: элементы с равными ключами сохраняют взаимный порядок. Поэтому таблица сортируется по нескольким столбцам проходами от младшего ключа к старшему, в том числе с разным направлением для разных столбцов; кортеж ключей key=lambda r: (r[0], -r[1]) годится, только если второй ключ числовой.
Для физиков: срез массива NumPy — не копия, а представление тех же данных. Изменение среза меняет исходный массив; копия создаётся явно методом copy. Это главное различие между срезами списков и массивов, и оно рассматривается в главе о NumPy.
Кортеж
и распаковка
point = 1.5, -2.0 # кортеж: запятая single = (42,) # (42) — число first, second, *rest = range(10) x, _, z = (1, 2, 3) a, b = b, a # обмен (1, 2) < (1, 3) # True def bounds(data): return min(data), max(data)
from collections import namedtuple Point = namedtuple('Point', 'x y') p = Point(1.5, y=-2.0) p.x, p[1] # 1.5, -2.0 p._asdict()
Пояснение
Кортеж неизменяем и хешируем, если хешируемы элементы: пара координат или связка «прибор, канал» служит ключом словаря.
Изложение. Кортеж — неизменяемая последовательность; ссылки на элементы хранятся прямо в объекте кортежа, а не в отдельном массиве, как у списка, а пустой кортеж существует в единственном экземпляре. Кортеж создаёт запятая, а скобки лишь группируют: point = 1.5, -2.0 — кортеж, (42) — число, а кортеж из одного элемента записывается (42,). Неизменяемость даёт хешируемость: пара координат, связка «прибор, канал» или дата из трёх чисел становятся ключом словаря. Кортежи сравниваются лексикографически — по первому различающемуся элементу, поэтому список записей (время, канал, значение) сортируется без указания ключа; если в первой различающейся позиции оказываются несравнимые значения, например None и число, возникает TypeError.
Распаковка разбирает последовательность на имена одним присваиванием. Звёздочка собирает остаток в список, подчёркивание по соглашению помечает ненужное значение. Обмен a, b = b, a создаёт кортеж справа и распаковывает его слева. Функция, возвращающая несколько значений, фактически возвращает один кортеж.
namedtuple — фабрика классов, создающая подтип кортежа с именованными полями: объект остаётся кортежем со всеми его свойствами, но к полям можно обращаться по имени. Служебные методы (_asdict, _replace) начинаются с подчёркивания, чтобы не совпасть с именами полей. Современные альтернативы — typing.NamedTuple с аннотациями типов и dataclasses для изменяемых записей — рассматриваются в лекции о классах.
Множество:
хеш-таблица
Пояснение
frozenset — неизменяемое и потому хешируемое множество. {} — пустой словарь; пустое множество создаётся вызовом set().
Операция Смысл и стоимость
x in s, add, remove O(1) в среднем
s | t объединение, O(len s + len t)
s & t пересечение, O(min(len s, len t))
s - t, s ^ t разность O(len s), симметричная O(len s + len t)
s.union(it), s.update(it) любой итерируемый аргумент; операторы — только множества
Для физика
Отбор миллиона событий по набору разрешённых идентификаторов через множество занимает доли секунды там, где список требует минут.
Изложение. Множество реализовано хеш-таблицей с открытой адресацией. По хешу элемента вычисляется номер ячейки; если ячейка занята другим элементом, проверяются следующие по определённому правилу, пока не найдётся нужный элемент или пустая ячейка. Таблица множества заполняется не более чем на три пятых (у словаря — на две трети), после чего увеличивается. Поэтому проверка вхождения, добавление и удаление стоят в среднем O(1) независимо от размера множества; при массовых совпадениях хешей они вырождаются в O(n). Хеш-таблицы подробно разобраны в главе «Хеш-функции».
Операции над двумя множествами имеют свою стоимость: объединение проходит оба множества, а пересечение перебирает меньшее и проверяет каждый элемент в большем, поэтому пересечение множества из тысячи элементов с множеством из двух миллионов выполняется за десятки микросекунд.
Множество применяется ради уникальности (list(set(data)) удаляет дубликаты, но теряет порядок) и ради быстрой проверки принадлежности. Методы union, intersection, update принимают любой итерируемый объект и сразу несколько аргументов, а операторы |, & и - — только множества. Литерала пустого множества нет: {} создаёт пустой словарь, пустое множество создаётся вызовом set(). frozenset — неизменяемый вариант, пригодный как ключ словаря и элемент другого множества.
Для физиков: отбор событий по набору разрешённых идентификаторов через множество выполняется за доли секунды; тот же отбор со списком идентификаторов требует минут.
Словарь:
компактная хеш-таблица
indices
entries: хеш, ключ, значение
Пояснение
Порядок вставки гарантирован языком с версии 3.7.
Операция Стоимость
d[k], d[k] = v, del d[k], k in d O(1) в среднем
поиск по значению O(n)
get, setdefault, pop(k, default) без исключения KeyError
popitem() последняя вставленная пара
d | e, d |= e (3.9) объединение
Пояснение
На словарях построены пространства имён модулей и атрибуты объектов: globals(), obj.__dict__.
Изложение. Словарь с версии 3.6 устроен как компактная хеш-таблица из двух массивов. Разреженный массив indices хранит небольшие целые — номера записей, а плотный массив entries хранит сами записи (хеш, ключ, значение) в порядке вставки; если все ключи — строки, хеш в записи с 3.11 не хранится, поскольку он уже сохранён в объекте строки. Поиск вычисляет по хешу позицию в indices и по номеру находит запись. Такое устройство занимает меньше памяти, чем прежняя таблица с записями в разреженном массиве, и сохраняет порядок вставки: в 3.6 это было деталью реализации CPython, с 3.7 — гарантия языка.
Операции по ключу — чтение, запись, удаление, проверка in — стоят O(1) в среднем. Поиск по значению требует просмотра всех записей и стоит O(n); если он нужен часто, заводят второй словарь с обратным отображением. get возвращает значение по умолчанию вместо исключения KeyError, setdefault за один поиск возвращает значение или вставляет новое, popitem удаляет последнюю вставленную пару, а у пустого словаря вызывает KeyError; pop(k) без значения по умолчанию при отсутствии ключа тоже вызывает KeyError. С версии 3.9 словари объединяются операторами | и |=.
Словарь — самая нагруженная структура языка: на нём построены пространства имён модулей, атрибуты объектов (obj.__dict__) и передача именованных аргументов.
Демонстрация:
хеши и порядок
01
Равные числа разных типов: равные хеши
03
Нехешируемый кортеж со списком
04
Хеш строки в разных запусках
05
PYTHONHASHSEED=0: воспроизводимый хеш и порядок
Что наблюдается
Хеш строк рандомизирован для защиты от подбора коллизий, поэтому порядок обхода множества строк меняется между запусками.
root@lab — запись со стенда
Демонстрация. Первая команда подтверждает, что hash(1), hash(1.0) и hash(True) равны. Словарь {1: 'int', 1.0: 'float', True: 'bool'} печатается как {1: 'bool'}: ключ остаётся первым вставленным, а значение — последним. hash(([1], 2)) вызывает TypeError: unhashable type: 'list'. Три запуска интерпретатора печатают три разных хеша строки 'abc': хеш строк и байтов рандомизируется при каждом запуске. Порядок элементов множества {'a', 'b', 'c'} в первом запуске отличается от двух других; у трёх элементов порядки могут совпасть и случайно. После export PYTHONHASHSEED=0 хеш и порядок в трёх запусках совпадают.
Рандомизация (с версии 3.3) защищает веб-сервисы от атаки, при которой злоумышленник подбирает ключи с одинаковым хешем и вырождает операции словаря в O(n). Порядок словаря от неё не зависит: он определяется порядком вставки.
Модуль
collections
Класс Назначение Применение
namedtuple кортеж с именованными полями запись измерения (t, x, y)
deque двусторонняя очередь, O(1) с обоих концов скользящее окно, maxlen
Counter подсчёт хешируемых объектов частоты дискретных значений
defaultdict значение по умолчанию для нового ключа группировка событий по каналам
OrderedDict move_to_end, popitem(last=False) кеш, вытесняющий давно не использованное
ChainMap поиск по цепочке словарей настройки: пользовательские поверх стандартных
Пояснение
Counter, defaultdict и OrderedDict — подклассы dict, deque поддерживает интерфейс последовательности, поэтому большая часть кода работает с ними без изменений; исключения есть: например, у deque нет срезов.
Изложение. Модуль collections содержит надстройки над встроенными коллекциями для частых сценариев. namedtuple создаёт кортежи с именованными полями. deque — двусторонняя очередь, устроенная как двусвязный список блоков по 64 элемента: добавление и удаление с обоих концов стоят O(1), а параметр maxlen превращает её в кольцевой буфер, из которого при переполнении вытесняются старые элементы. Counter — подкласс словаря для подсчёта хешируемых объектов с методом most_common и арифметикой счётчиков. defaultdict вызывает фабрику при обращении d[k] к отсутствующему ключу (get и in ключ не создают), избавляя от проверок перед вставкой. OrderedDict после 3.7 нужен ради методов move_to_end и popitem(last=False) и сравнения с учётом порядка; на нём строится кеш, вытесняющий давно не использованные записи. ChainMap объединяет несколько словарей для поиска, не копируя их: пользовательские настройки поверх стандартных.
Для физиков: deque с maxlen даёт скользящее среднее по последним N отсчётам, Counter — частоты дискретных значений (номеров каналов, кодов событий), defaultdict(list) — группировку событий по каналам в один проход.
Демонстрация:
модуль collections
01
Counter: три самых частых символа
02
defaultdict: группировка энергий по каналам
03
deque с maxlen: скользящее окно
05
namedtuple: доступ по имени и по индексу
Что наблюдается
defaultdict создаёт список при первом обращении к каналу; deque с maxlen=3 хранит только три последних отсчёта.
root@lab — запись со стенда
Демонстрация. Counter('abracadabra').most_common(3) возвращает [('a', 5), ('b', 2), ('r', 2)]. defaultdict(list) группирует пары (канал, энергия): для канала 1 собираются 0.5 и 0.7, для канала 2 — 1.2, и проверка наличия ключа перед append не нужна. deque(maxlen=3) после добавления чисел от 1 до 5 содержит только 3, 4, 5, и их среднее равно 4.0 — так вычисляется скользящее среднее. Point = namedtuple('Point', 'x y') создаёт тип записи; поле доступно и по имени p.x, и по индексу p[1], а _asdict() возвращает словарь.
Типичные
ловушки коллекций
Запись Результат Правильно
(42) число 42 (42,) — кортеж из одного элемента
{} пустой словарь set() — пустое множество
xs += 'abc' ['a', 'b', 'c']: += принимает любой итерируемый xs.append('abc')
xs = xs.sort() None: изменяющий метод ничего не возвращает xs.sort() или ys = sorted(xs)
dict.fromkeys(keys, []) один список на все ключи {k: [] for k in keys}
del d[k] в цикле по d RuntimeError for k in list(d):
d[k] без ключа KeyError d.get(k, default) или k in d
Пояснение
Методы, изменяющие список на месте (append, extend, insert, sort, reverse), возвращают None; sorted и срезы возвращают новые списки, reversed — итератор.
Изложение. Таблица собирает ошибки, которые чаще всего встречаются при работе со встроенными коллекциями. Кортеж создаётся запятой, а не скобками: (42) — число 42 в скобках, кортеж из одного элемента записывается (42,). Фигурные скобки без элементов создают словарь, пустое множество создаётся только вызовом set(). Составное присваивание += у списка работает как extend и принимает любой итерируемый объект, поэтому строка добавляется посимвольно; для добавления строки целиком нужен append. Методы, изменяющие объект на месте (sort, reverse, append, extend), возвращают None, и присваивание xs = xs.sort() теряет список.
dict.fromkeys(keys, value) связывает все ключи с одним и тем же объектом value; если это список, он окажется общим для всех ключей, как строки матрицы [[0] * 3] * 3. Отдельный список для каждого ключа создаёт включение. Добавлять и удалять ключи словаря во время обхода нельзя: интерпретатор обнаруживает изменение размера и вызывает RuntimeError; обходится копия ключей list(d). Обращение по отсутствующему ключу вызывает KeyError; get возвращает значение по умолчанию, а проверка наличия выполняется оператором in.
Демонстрация:
ловушки коллекций
01
Скобки, запятая и пустые фигурные скобки
04
Общий список из dict.fromkeys
05
Удаление ключей при обходе словаря
Что наблюдается
Строка добавляется посимвольно, sort возвращает None, fromkeys даёт общий список, а удаление при обходе словаря вызывает RuntimeError.
root@lab — запись со стенда
Демонстрация. Выражение (42), (42,), type({}) показывает число 42, кортеж (42,) и класс dict. После xs += 'abc' список равен ['a', 'b', 'c']. print([3, 1, 2].sort()) печатает None: список отсортирован на месте, а метод ничего не вернул. dict.fromkeys('ab', []) создаёт словарь, в котором оба ключа ссылаются на один список, и после d['a'].append(1) словарь равен {'a': [1], 'b': [1]}. Цикл for k in d: del d[k] завершается RuntimeError: dictionary changed size during iteration, а тот же цикл по list(d) удаляет все ключи, и словарь становится пустым.
Перебор
коллекций
for i, (t, v) in enumerate(zip(times, volts)): if v > threshold: print(f'превышение: отсчёт {i}, t = {t}') break else: print('порог не превышен')
with open('run.dat', encoding='utf-8') as f: for line in f: # построчно t, v = map(float, line.split())
squares = [x ** 2 for x in data if x > 0] index = {name: i for i, name in enumerate(names)}
Средство Назначение
range(a, b, s) ленивая последовательность чисел
enumerate пары индекс, элемент
zip(strict=True) параллельный перебор, проверка длин
for ... else ветка, если не было break
включения новая коллекция одним выражением
Изложение. Цикл for в Python перебирает элементы, а не индексы. Если нужен номер, его даёт enumerate, выдающий пары индекс — элемент; несколько последовательностей перебираются параллельно через zip, а параметр strict=True (с версии 3.10) вызывает ошибку при разной длине вместо молчаливого обрезания по кратчайшей. range(a, b, s) — ленивая последовательность: она не хранит числа, занимает постоянную память и отвечает на in за O(1).
Ветка else у цикла выполняется, если цикл завершился без break; это готовый каркас поиска: в теле — поиск с выходом по break, в else — ветка «не найдено». while применяется там, где число итераций заранее неизвестно.
Включения (comprehensions) строят список, множество или словарь одним выражением с фильтром. Они короче и быстрее цикла с append: тело включения выполняется без поиска метода append на каждой итерации.
Файл тоже перебирается циклом for: построчное чтение не загружает файл в память целиком, что важно для многогигабайтных записей эксперимента. Файл открывается в блоке with, который закрывает его при выходе, а кодировка указывается явно.
Для физиков: первый пример на слайде ищет первый отсчёт, превысивший порог, в паре массивов времени и напряжения; параметр strict=True проверяет длины только при полном обходе, а при выходе по break проверки нет, поэтому длины при необходимости сравнивают до цикла.
Истинность
и ветвления
Ложные значения
False
None
0
0.0
0j
''
b''
[]
()
{}
set()
range(0)
if data: # вместо len(data) > 0 name = user_name or 'аноним' if xs and xs[0] > 0: # xs[0] только для непустого if 0 < x < 1: # цепочка сравнений kind = 'чёт' if n % 2 == 0 else 'нечет'
match event: case ('hit', x, y): hits.append((x, y)) case ('miss',): misses += 1 case _: raise ValueError(event)
Пояснение
and и or возвращают один из операндов, а не True или False, и вычисляют правый операнд, только если без него не обойтись.
Изложение. В условии не обязательно записывать сравнение: любой объект истинен или ложен. Ложны False, None, нули всех числовых типов и пустые коллекции; всё остальное истинно. Поэтому if data: читается как «если данные есть» и заменяет len(data) > 0. Собственный класс задаёт истинность методами __bool__ или __len__.
and и or ленивы: правый операнд вычисляется, только если без него результат не определён, и возвращается один из операндов, а не True или False. Отсюда идиомы name = user_name or 'аноним' и проверка xs and xs[0] > 0, не вызывающая ошибки на пустом списке. Сравнения записываются цепочкой: 0 < x < 1 означает 0 < x and x < 1 с однократным вычислением x. Условное выражение a if условие else b (PEP 308) выбирает одно из двух значений в одной строке.
Конструкция match (с версии 3.10, PEP 634) сопоставляет значение с образцами: литералами, последовательностями с захватом элементов в имена, словарями, экземплярами классов; case _ перехватывает всё остальное. match не заменяет цепочку if для сложных условий, но удобен при разборе структурированных данных — записей событий, команд, сообщений протокола.
Для физиков: массив NumPy истинностного значения не имеет — при длине больше единицы if data: вызывает ValueError; пустоту массива проверяют через data.size, а условия над элементами — через any() и all().
Выбор
коллекции
Коллекция Когда применяется
list важен порядок, данные меняются, повторы допустимы
tuple данные фиксированы, нужен хешируемый объект
set уникальность, быстрая проверка вхождения
dict у данных есть естественный ключ
deque добавление и удаление с обоих концов
Counter, defaultdict подсчёт и группировка без проверок
Пояснение
Выбор делается в начале, когда переписывание ещё обходится дёшево.
Изложение. Итог части сведён в таблицу. Список — выбор по умолчанию, когда важен порядок и данные меняются. Кортеж — когда данные фиксированы или нужен хешируемый объект. Множество — ради уникальности и быстрой проверки вхождения. Словарь — когда у данных есть естественный ключ: имя канала, номер события, дата измерения. Дек — для работы с обоими концами. Counter и defaultdict — когда код обрастает проверками наличия ключа. Выбор делается в начале работы, когда переписывание ещё обходится дёшево; следующая часть переводит эти рекомендации в числа.
Сложность операций
O-нотация, стоимость операций, скрытые циклы, измерение
06
Язык и интерпретатор
Объекты в памяти
Изменяемость
Сборка мусора
Коллекции
Сложность операций
Изложение. Шестая часть переводит устройство коллекций в оценки: как время операции растёт с размером данных, во что обходится каждая операция, где в коде скрыты лишние проходы по данным и как сложность измерить.
O-нотация
и порядок роста
Класс Примеры Операций при n = 10⁶
O(1) lst[i], x in set, d[k] 1
O(log n) bisect в отсортированном списке 20
O(n) x in list, sum, min, копия 10⁶
O(n log n) sorted, list.sort 2·10⁷
O(n²) x in list внутри цикла по n 10¹²
Пояснение
O(f(n)) описывает, как растёт время при росте n, без постоянных множителей и слагаемых низшего порядка.
Для физика
Оценка по замерам при n = 40 000 для миллиона чисел: удаление дубликатов с множеством — около 0,04 с, с проверкой по списку — около 20 минут.
Изложение. Запись O(f(n)) описывает, как растёт время работы при росте размера входных данных n, отбрасывая постоянные множители и слагаемые низшего порядка. Иерархию необходимо помнить: O(1) < O(log n) < O(n) < O(n log n) < O(n²). Константное время не зависит от размера: доступ по индексу, поиск в множестве и словаре. Логарифмическое — бинарный поиск в отсортированном списке (модуль bisect): двадцать шагов на миллион элементов. Линейное — один проход по данным: поиск в списке, sum, min, копирование. O(n log n) — сортировка. Квадратичное — цикл в цикле, в том числе скрытый: проверка вхождения в список внутри цикла по тем же данным.
Строгое определение O-нотации и оценка пространственной сложности приведены в главе книги «Введение в алгоритмы».
Для физиков: при миллионе событий линейный проход занимает доли секунды, а квадратичный — десятки минут. Оценка по замерам демонстрации этой части: функция удаления дубликатов с проверкой по списку обрабатывает 40 000 чисел за 1,87 с; при росте n в 25 раз, до миллиона, время вырастет в 625 раз — около 20 минут, тогда как версия с множеством справится примерно за 0,04 с.
Стоимость
операций
Операция list set dict deque
x in c (у dict — ключ) O(n) O(1) O(1) O(n)
добавление в конец O(1)* O(1)* O(1)* O(1)
добавление в начало O(n) — — O(1)
удаление: remove(x), del d[k] O(n) O(1) O(1) O(n)
доступ по индексу или ключу O(1) — O(1) O(1) с концов
поиск по значению O(n) — O(n) O(n)
сортировка O(n log n) — — —
копирование O(n) O(n) O(n) O(n)
Пояснение
* — амортизированная оценка: редкое перевыделение памяти стоит O(n), но в среднем на операцию приходится константа.
Изложение. Таблица сводит стоимость основных операций для списка, множества, словаря и дека. Главное различие — проверка вхождения: линейная у списка и дека, константная в среднем у множества и словаря. Добавление в конец стоит амортизированную константу у списка и хеш-таблиц: изредка происходит перевыделение с копированием, но в среднем операция дешёвая. Добавление в начало списка сдвигает все элементы; дек делает это за O(1). Доступ по индексу у списка — O(1), у дека — O(1) только у концов и O(n) в середине; у словаря O(1) стоит доступ по ключу, а поиск по значению — O(n), у множества доступа по ключу нет вовсе. Сортировка определена для списка; множество и словарь сортируются через sorted, создающий новый список.
Полная таблица для встроенных типов приведена на странице TimeComplexity вики Python.
Демонстрация:
поиск и вставка
01
Поиск в списке из миллиона элементов
02
Тот же поиск в множестве
03
Вставка в начало списка и дека при росте n
Что наблюдается
Поиск в множестве быстрее в сотни тысяч раз; время вставки в начало списка удваивается вместе с n, у дека не меняется.
root@lab — запись со стенда
Демонстрация. python3 -m timeit выполняет выражение многократно и печатает лучшее среднее время из пяти серий; подготовка в -s в измерение не входит. Поиск последнего элемента в списке из миллиона чисел занимает около 4 мс, в множестве — около 10 нс: разница в сотни тысяч раз, и она растёт вместе с n. Сценарий insert.py измеряет пару «вставка в начало и удаление из начала» для списка и дека при n от 10 000 до 160 000: время списка при каждом удвоении n удваивается (2,8; 5,1; 10,6; 22,1; 44,7 мкс, столбец «отношение» около 2), время дека остаётся около 12 нс. Сценарий повторяет измерение из главы книги «Сложность операций с коллекциями» (абсолютные значения зависят от машины, отношение около 2 то же); вставленный элемент сразу удаляется, чтобы список не рос за время замера.
Скрытые
циклы
def slow(data): # O(n²) result = [] for x in data: if x not in result: # проход по списку result.append(x) return result def fast(data): # O(n) seen, result = set(), [] for x in data: if x not in seen: # O(1) seen.add(x) result.append(x) return result
x in list, list.remove, list.index в цикле
insert(0, x) и pop(0) у списка в цикле
s += '...' для строк в цикле на уровне модуля
sum(списки, []) — повторное копирование
min(data) или sorted(data) внутри цикла
Изложение. Вложенные циклы видны сразу, а однострочные вызовы скрывают проход по данным. Функции slow и fast удаляют дубликаты с сохранением порядка и различаются проверкой вхождения: slow проверяет x not in result по списку, fast — по множеству seen. За проверкой по списку стоит ещё один проход, поэтому slow квадратична, а fast линейна.
Типичные источники скрытой квадратичности перечислены справа: проверка вхождения, remove и index в цикле; вставка и удаление в начале списка; накопление строки через += на уровне модуля (каждое += копирует всё накопленное; в функции CPython дописывает строку на месте, но языком это не гарантируется, и строки собирают через join); sum(списки, []), копирующий накопленный список на каждом шаге; поиск минимума или сортировка внутри цикла по тем же данным.
Правило: сложность оценивается не по числу строк, а по стоимости каждой операции, умноженной на число её выполнений; стоимости последовательных участков складываются.
Демонстрация:
удаление дубликатов
01
Функция slow: проверка вхождения по списку
02
Удвоение размера данных: 10 000, 20 000, 40 000
Что наблюдается
При удвоении n время slow растёт примерно вчетверо (O(n²)), время fast — вдвое (O(n)).
root@lab — запись со стенда
Демонстрация. grep показывает функцию slow из сценария dedup.py; рядом в нём определена fast с множеством seen. Сценарий генерирует n случайных чисел из диапазона [0, n) и измеряет обе функции, выбирая лучшее из трёх выполнений (timeit.repeat). Для n = 10 000, 20 000 и 40 000 время slow составляет около 115, 460 и 1870 мс — при каждом удвоении n оно растёт вчетверо, что и означает квадратичную сложность. Время fast — 0,28, 0,57 и 1,55 мс: удвоение n примерно удваивает время.
Измерение
сложности
01
Удвоение n
отношение времён t(2n) / t(n): 1 — O(1), 2 — O(n), 4 — O(n²)
02
Повторение операции
timeit многократно выполняет код и выбирает лучшую серию
03
Подготовка отдельно
создание данных выносится в setup и в замер не входит
04
Реалистичные данные
на наборе из пяти различных значений квадратичный код не виден
Важно
[1, 2, 2, 3, 4, 4, 5] * 1000 содержит семь тысяч элементов, но пять различных значений: список result в slow не превысит пяти элементов, и квадратичность не проявится.
Пояснение
Профилирование и измерение времени подробно рассматриваются в части книги «Ускорение расчётов».
Изложение. Сложность можно измерить, выполняя операцию на входах разного размера и прослеживая, как растёт время. Удобнее всего удваивать n: отношение времён t(2n)/t(n) около 1 означает константу, около 2 — линейный рост, около 4 — квадратичный, чуть больше 2 — O(n log n).
Одну быструю операцию измерить нельзя: разрешение таймера и шум того же порядка, что и сама операция. Поэтому код повторяется много раз, что и делает timeit; из нескольких серий выбирается лучшая, поскольку остальные искажены посторонней нагрузкой. Подготовка данных выносится в setup, иначе в замер попадёт создание списка. Если операция меняет данные, её действие отменяется в том же замере, как в сценарии insert.py. timeit оборачивает измеряемый код в функцию, а в функции CPython дописывает строку на месте, поэтому квадратичный рост s += '...' в таком замере не проявляется: правило о join проверяется сценарием, выполняемым на уровне модуля.
Проверка ведётся на данных, приближенных к реальным. Если вместо случайных чисел подставить [1, 2, 2, 3, 4, 4, 5] * 1000 — семь тысяч элементов, как в замере из главы «Сложность операций с коллекциями», — разница между slow и fast сократится до полутора раз: различных значений всего пять, список result не превысит пяти элементов, и квадратичность не проявится. На неудачно подобранном наборе квадратичный код не вызывает подозрений, а проблема проявляется на реальных данных.
Правила
выбора
01
Иерархия роста
O(1) < O(log n) < O(n) < O(n log n) < O(n²)
02
Скрытые вложенные циклы
x in list внутри цикла даёт O(n²)
03
Структура под операцию
множества и словари — для поиска, списки — для перебора, дек — для обоих концов
04
Поиск в цикле
коллекция один раз до цикла превращается в множество или словарь
Пояснение
Преждевременная оптимизация вредна, но выбор структуры данных к ней не относится: от него зависит, справится ли программа с реальным объёмом данных.
Изложение. Правила части сводятся к четырём. Иерархию роста необходимо помнить. Вложенные циклы дают O(n²), и внутренний цикл часто скрыт за вызовом вроде x in list. Структура подбирается под операцию: множества и словари — для поиска, списки — для последовательного перебора, дек — для работы с обоими концами. Если по коллекции многократно выполняется поиск, её один раз до цикла превращают в множество или словарь: построение множества имеет тот же порядок O(m), что и один линейный поиск, хотя и с большей константой, и окупается после нескольких поисков: n проверок стоят O(n + m) вместо O(n·m).
Подбор констант в коде, выполняемом раз в сутки, является напрасной тратой времени — это преждевременная оптимизация. Выбор структуры данных к ней не относится: от него зависит, справится ли программа с реальным объёмом данных.
Итоги
лекции
01
Переменная — имя, связанное с объектом; присваивание ничего не копирует.
02
is сравнивает тождественность и применяется к объектам-одиночкам: None, True, False; значения сравниваются через ==.
03
Неизменяемые объекты «меняются» созданием новых; изменяемые меняются на месте, и это видно через все имена.
04
Объект освобождается, когда счётчик ссылок обнуляется; циклы находит сборщик gc.
05
Коллекция выбирается под операцию: множество и словарь — для поиска, список — для перебора.
06
Сложность измеряется удвоением n на данных, похожих на реальные.
Изложение. Главная мысль лекции: переменная в Python — имя, связанное с объектом, а не ячейка со значением. Из неё следуют общий список после двух присваиваний, накопительный аргумент по умолчанию, поведение += для разных типов и необходимость явного копирования. is проверяет тождественность и применяется только к объектам-одиночкам; кеши малых чисел и строк — деталь реализации. Память освобождается подсчётом ссылок сразу после удаления последней ссылки, а циклы находит поколенческий сборщик. Коллекция выбирается под операцию, и стоимость операций проверяется измерением с удвоением размера данных.
Следующая лекция посвящена функциям: области видимости, замыканиям, аргументам и декораторам.
Домашнее задание
Решение задач на Python и анализ сложности
Две любые задачи лёгкого уровня с сайта CodeRun (coderun.yandex.ru)
02
Исходный код решения на Python
03
Пояснение: подробное описание хода решения
04
Обоснование выбора алгоритма и структур данных
05
Временная и пространственная сложность в O-нотации
Цель
Не только рабочий код, но и понимание его эффективности: почему выбран список, множество или словарь и как время решения растёт с размером входа
Рекомендуется подтвердить оценку замером с удвоением размера входных данных
Репозиторий: github.com/phys-dev/python-algo-task · главы «Введение в алгоритмы» и «Сложность операций с коллекциями»
Домашнее задание. Требуется решить две любые задачи лёгкого уровня с сайта CodeRun (coderun.yandex.ru). По каждой задаче сдаются ссылка на задачу, исходный код решения на Python, подробное описание хода решения, обоснование выбора алгоритма и структур данных (почему список, множество или словарь) и анализ сложности: временная сложность в O-нотации и пространственная сложность. Цель задания — научиться не только писать рабочий код, но и понимать его эффективность.
Решение сдаётся форком репозитория phys-dev/python-algo-task. Для выполнения достаточно материала этой лекции и глав книги «Введение в алгоритмы» и «Сложность операций с коллекциями». Оценку сложности рекомендуется подтвердить замером: время решения на входе удвоенного размера.
Типичные
ошибки
01
Проверка вхождения в список в цикле
скрытый O(n²): множество или словарь строится один раз до цикла
02
Сложность без обоснования
указывается, какая операция и сколько раз определяет оценку
03
Забытая память
пространственная сложность учитывает вспомогательные структуры
04
Сортировка «бесплатно»
sorted стоит O(n log n) и входит в оценку
05
Строка через += в цикле
части собираются в список и объединяются join
06
Замер на пяти значениях
проверка на данных размера и вида, как в условии задачи
Изложение. На слайде приведены ошибки, наиболее часто допускаемые в задании. Проверка вхождения в список внутри цикла превращает линейное решение в квадратичное; множество или словарь строится один раз до цикла. Оценка сложности без обоснования не принимается: указывается, какая операция и сколько раз определяет оценку. Пространственная сложность учитывает вспомогательные структуры — множество seen в решении с удалением дубликатов занимает O(n). Сортировка не бесплатна: sorted стоит O(n log n), и эта стоимость входит в итоговую оценку. Строка, собираемая через += в цикле, может дать квадратичное время; части собираются в список и объединяются join. Замер на наборе из нескольких различных значений не выявит квадратичности; проверка ведётся на данных того размера и вида, что указаны в условии задачи.
Источники
8
Ramalho L. Fluent Python. – 2nd ed. – O'Reilly, 2022
Изложение. Основой лекции служат главы книги курса «Объекты и память», «Коллекции» и «Сложность операций с коллекциями»; первая часть опирается на документацию CPython (модуль dis, справочник языка), а порядок изложения и часть примеров следуют лекциям курса «Python» Computer Science Center. Для самостоятельного изучения модели данных рекомендуется раздел Data model справочника языка и статья Н. Батчелдера об именах и значениях, для коллекций и модели данных в целом — книга Л. Рамальо «Fluent Python», для стоимости операций — страница TimeComplexity вики Python.
Спасибо за внимание
Вопросы
phys-dev.github.io/soft-dev-book
Изложение. Надпись на последнем слайде — del, инструкция, удаляющая имя: объект при этом уничтожается, только если это была последняя ссылка на него. Лекция завершается ответами на вопросы по её содержанию и по домашнему заданию.