Python. Начало

Интерпретатор, объекты в памяти, изменяемые и неизменяемые типы, сборка мусора, коллекции и стоимость операций над ними

Вячеслав Федоров

Лекция 4

Разработка и применение ПО

в физических исследованиях

>>>

Содержание

От исходного текста до стоимости операций

01

Язык и интерпретатор

02

Объекты в памяти

03

Изменяемость

04

Сборка мусора

05

Коллекции

06

Сложность операций

Цель лекции

Устройство объектов Python как основа правильного и быстрого кода

Когда обработка данных эксперимента занимает часы вместо минут, причина почти всегда состоит не в «медленном Python», а в неудачно выбранной структуре данных или лишнем копировании. Когда данные изменились «сами собой», причина — два имени, ссылающиеся на один объект.

Язык и интерпретатор

История, реализации, байт-код, модули, интерактивная оболочка, 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.

Язык

и его реализации

РеализацияНаписана наНазначение
CPythonCэталонная реализация: байт-код и виртуальная машина
PyPyRPythonJIT-компиляция, ускорение циклов на чистом Python
MicroPythonCмикроконтроллеры с сотнями килобайт памяти
GraalPyJavaвиртуальная машина GraalVM, доступ к библиотекам JVM
Jython, IronPythonJava, C#JVM и .NET; отстают от актуальных версий

Пояснение

Спецификация языка — The Python Language Reference. Кеш малых чисел, подсчёт ссылок и байт-код — детали реализации CPython, о которых идёт речь в этой лекции.

Для физика

MicroPython работает на платах лабораторной автоматики: датчики и приводы управляются тем же языком, что и обработка данных.

Путь

исходного текста

circle.py

исходный текст

токены

имена, числа, операторы

tokenize

AST

дерево разбора

ast

байт-код

объект кода

compile(), dis

ceval.c

цикл вычисления

виртуальная машина

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 и используется повторно, пока исходный файл не изменился; главный сценарий компилируется при каждом запуске.

Байт-код:

стековая машина

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.141593.14159
LOAD_FAST r3.14159, r
LOAD_CONST 23.14159, r, 2
BINARY_OP **3.14159, r²
BINARY_OP *3.14159·r²
RETURN_VALUEстек пуст, результат возвращён

Важно

Байт-код не входит в спецификацию языка: инструкции меняются от версии к версии, а файл .pyc привязан к версии интерпретатора.

Демонстрация:

байт-код

01

Версия интерпретатора

02

Модуль circle.py с функцией area

03

Импорт модуля и каталог __pycache__

04

Байт-код функции area

05

500 вызовов и специализированный байт-код

06

Байт-код присваивания 2 * 3.14159

Что наблюдается

После импорта появляется circle.cpython-312.pyc; после 500 вызовов умножение выполняет BINARY_OP_MULTIPLY_FLOAT, а 2 * 3.14159 вычислено ещё при компиляции.

root@lab — запись со стенда

Модуль

как объект

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) в каталоге сценария подменяет этот модуль при импорте.

Демонстрация:

модули

01

Имя и файл импортированного модуля

02

Атрибут area и вызов функции

03

Сценарий who.py с печатью __name__

04

Запуск файла и его импорт

05

random.py в текущем каталоге: подмена модуля стандартной библиотеки

06

Удаление файла: модуль стандартной библиотеки

Что наблюдается

Запущенный файл получает __name__ '__main__'; random.py в текущем каталоге закрывает модуль стандартной библиотеки.

root@lab — запись со стенда

Интерактивная

оболочка

чтение

вычисление

печать

>>> приглашение

Для физика

Блокнот Jupyter — та же интерактивная оболочка с сохранением кода, результатов и графиков.

СредствоНазначение
help(obj)справка по объекту
dir(obj)имена атрибутов объекта
type(obj), id(obj)тип и тождественность
_результат последнего выражения
python3 -i run.pyоболочка после выполнения сценария
python3 -qзапуск без приветствия

Дзен 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

Пояснение

Требования к оформлению кода подробно рассматриваются в главе книги «Требования к коду».

Объекты в памяти

Тождественность, тип и значение, имена, кеши, числа, размеры объектов

02

Язык и интерпретатор

Объекты в памяти

Изменяемость

Сборка мусора

Коллекции

Сложность операций

Три свойства

объекта

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;

Пояснение

Объектами являются числа, строки, функции, классы и модули: у каждого есть заголовок со счётчиком ссылок и указателем на тип.

Имена

и ссылки

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.

Сравнение:

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.

Демонстрация:

имена и объекты

01

Запуск интерактивной оболочки

02

Второе имя для того же списка

03

Изменение списка через второе имя

04

Равный, но другой список

05

Тип объекта и тип типа

06

Два временных списка

Что наблюдается

id уникален только среди одновременно существующих объектов: два временных списка создаются по очереди и получают один адрес.

root@lab — запись со стенда

Кеш малых чисел

и интернирование

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 для чисел и строк зависит от версии и способа запуска, и программа на него не опирается.

Демонстрация:

кеш и интернирование

01

256: объект из кеша

02

257 в двух строках оболочки: два объекта

03

257 в одной строке: общая константа

04

is с литералом: предупреждение компилятора

05

Строка, собранная во время работы

06

sys.intern: один объект для равных строк

Что наблюдается

Одинаковые значения дают один объект или два в зависимости от способа компиляции; на == это не влияет.

root@lab — запись со стенда

Числа

и их представление

ТипПредставление
intпроизвольная точность: размер растёт вместе с числом
floatIEEE 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.

Демонстрация:

числа

01

Целое произвольной длины

02

Погрешность двоичного представления

03

Сравнение с допуском

04

Деление с округлением вниз и остаток

05

Округление к чётному

06

Переполнение и NaN

07

Точное значение 0.1 и дроби Fraction

Что наблюдается

0.1 хранится как ближайшая двоичная дробь; вещественные числа сравниваются с допуском.

root@lab — запись со стенда

Стоимость

объекта

Объектsys.getsizeof, байт
int 128
int 2**10040
float24
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 учитывает только сам объект, без объектов, на которые он ссылается.

Демонстрация:

размеры объектов

01

Целые разной длины и float

02

Строки ASCII и кириллицы, байты

03

Пустой список и список из тысячи элементов

04

Список из миллиона float

05

Полный объём: указатели и объекты

Что наблюдается

Тысяча элементов добавляет к пустому списку ровно 8000 байт указателей; миллион float вместе с объектами занимает около 32 МБ.

root@lab — запись со стенда

Изменяемость

Изменяемые и неизменяемые типы, хешируемость, копирование, строки и байты

03

Язык и интерпретатор

Объекты в памяти

Изменяемость

Сборка мусора

Коллекции

Сложность операций

Изменяемые

и неизменяемые

Неизменяемые

int

float

complex

bool

str

bytes

tuple

frozenset

range

None

«Изменение» создаёт новый объект, и имя связывается с ним.

Изменяемые

list

dict

set

bytearray

экземпляры классов

Объект меняется на месте, и изменение видно через все имена, связанные с ним.

Пояснение

Изменяемость — свойство объекта, а не имени: имя всегда можно связать с другим объектом.

Операция +=

для разных типов

Записьstr, tuple, intlist
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.

Кортеж

с изменяемым элементом

tuple t

две ссылки

list

[1, 2, 3]

int

3

t[0]

t[1]

Пояснение

Неизменны ссылки кортежа, а не объекты, на которые они указывают.

>>> 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]): список уже расширен, когда присваивание элементу кортежа вызывает ошибку.

Демонстрация:

изменяемость

01

Строка: новый объект после +=

02

Список: расширение на месте

03

Список: новый объект после xs = xs + [3]

04

Кортеж со списком: исключение и изменение одновременно

Что наблюдается

id строки после += другой, id списка прежний; t[0] += [3] вызывает исключение, но список внутри кортежа уже изменён.

root@lab — запись со стенда

Хешируемость

ключей

ТипХешируем
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 и хеши у них равны, поэтому в словаре это один ключ.

Копирование:

поверхностное и глубокое

ЗаписьРезультат
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)

[·, ·]

[·, ·]

[·, ·]

[1, 2]

[1, 2]

Демонстрация:

копирование

01

Матрица умножением списка

02

Присваивание одной ячейке: изменение всех строк

03

Матрица через включение

04

Поверхностная и глубокая копии

05

Изменение вложенного списка оригинала

06

Тождественность вложенных списков

Что наблюдается

Поверхностная копия разделяет с оригиналом вложенные списки, глубокая — нет.

root@lab — запись со стенда

Изменяемое значение

по умолчанию

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__.

Пояснение

Функции подробно рассматриваются в следующей лекции; ловушка относится к изменяемости и часто встречается в коде обработки данных.

Демонстрация:

значение по умолчанию

01

Функция со списком по умолчанию

02

Два вызова: общий список

03

Общий список в __defaults__

04

Исправленная версия с None

Что наблюдается

Второй вызов возвращает [1, 2]: список создан однократно при выполнении def; с None каждый вызов получает новый список.

root@lab — запись со стенда

Строки

и байты

str

текст Unicode

bytes

числа 0–255

encode()

decode()

СимволыБайт в UTF-8
латиница ASCII, цифры1
кириллица, греческий2
иероглифы, ∮3
эмодзи4

Для физика

Файлы старых приборов и программ часто записаны в cp1251: кодировка указывается явно — open(path, encoding='cp1251'), а двоичные данные читаются в режиме 'rb'.

Пояснение

С Python 3.15 кодировкой по умолчанию на всех платформах является UTF-8 (PEP 686).

Демонстрация:

строки и байты

01

Кодирование строки в UTF-8

02

Длина в символах и в байтах; первый байт — число

03

Обратное декодирование

04

Байты cp1251, прочитанные как UTF-8

05

Правильная кодировка

Что наблюдается

Шесть символов кириллицы занимают 12 байт; байты cp1251 не являются корректным UTF-8, отсюда UnicodeDecodeError.

root@lab — запись со стенда

Сборка мусора

Подсчёт ссылок, циклические ссылки, поколения, слабые ссылки

04

Язык и интерпретатор

Объекты в памяти

Изменяемость

Сборка мусора

Коллекции

Сложность операций

Подсчёт

ссылок

x = []

ссылок: 1

y = x

ссылок: 2

del y

ссылок: 1

del x

0: объект освобождён

01

Ссылка появляется

имя, элемент контейнера, атрибут, аргумент вызова

02

Ссылка исчезает

del, новое значение имени, выход из функции, удаление из контейнера

03

Счётчик равен нулю

объект освобождается сразу, счётчики его элементов уменьшаются

Пояснение

С Python 3.12 у None, True, False и малых целых счётчик не меняется: это бессмертные объекты (PEP 683).

Демонстрация:

подсчёт ссылок

01

Счётчик нового списка

02

Второе имя: рост счётчика

03

del: уменьшение счётчика

04

Бессмертный None

05

Класс с методом __del__

06

Удаление последней ссылки

Что наблюдается

getrefcount показывает на единицу больше из-за ссылки в аргументе; сообщение печатается при удалении последнего имени, а не первого.

root@lab — запись со стенда

Циклические

ссылки

объект a

ссылок: 1

объект b

ссылок: 1

a.other

b.other

имён a и b уже нет: del a, b

Пояснение

Счётчики объектов цикла не обнуляются, хотя из программы до них уже не добраться.

Дерево, в котором узлы ссылаются на родителя

Двусвязный список

Объект, хранящий собственный связанный метод

Исключение в переменной: трассировка ссылается на кадры стека

Пояснение

Для таких случаев в Python 2.0 появился сборщик циклического мусора — модуль gc.

Поиск

недостижимых объектов

01

Копия счётчиков

для каждого отслеживаемого объекта gc_refs = ob_refcnt

02

Вычитание внутренних ссылок

обход ссылок между объектами поколения уменьшает gc_refs

03

Достижимые извне

объекты с gc_refs > 0 и всё, что достижимо из них, остаются

04

Недостижимые

вызов __del__, разрыв ссылок, освобождение памяти

Пояснение

Отслеживаются только контейнеры: списки, кортежи, словари, множества, экземпляры классов, функции. Числа и строки не могут замкнуть цикл, и сборщик их не просматривает.

Пояснение

gc.collect() запускает сборку явно и возвращает число найденных недостижимых объектов.

Поколения

объектов

Поколение 0

новые контейнеры; сборка, когда создано на 700 больше, чем удалено

Поколение 1

пережившие одну сборку; каждая 10-я сборка поколения 0

Поколение 2

долгоживущие; каждая 10-я сборка поколения 1

выжившие

выжившие

Пояснение

Пороги возвращает gc.get_threshold(): в 3.12 это (700, 10, 10). Долгоживущий объект с большой вероятностью проживёт ещё, поэтому старшие поколения проверяются редко.

Для физика

При создании миллионов мелких контейнеров сборщик запускается часто; на время загрузки данных его отключают gc.disable() или переносят загруженное в постоянное поколение gc.freeze().

Демонстрация:

сборщик циклов

01

Пороги и счётчики поколений

02

Класс с методом __del__

03

Два объекта со взаимными ссылками

04

Удаление имён: сообщений нет

05

Явный запуск сборщика

Что наблюдается

После del объекты остаются в памяти; gc.collect находит цикл, вызывает __del__ у обоих объектов и возвращает 2.

root@lab — запись со стенда

Слабые ссылки

и освобождение ресурсов

СредствоНазначение
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__ не подходит для освобождения ресурсов: момент вызова не гарантирован, а при завершении интерпретатора вызов может не произойти.

Коллекции

Список, кортеж, множество, словарь, модуль collections, перебор и ветвления

05

Язык и интерпретатор

Объекты в памяти

Изменяемость

Сборка мусора

Коллекции

Сложность операций

Понятие

коллекции

Container: in

Iterable: for

Sized: len()

Collection

Sequence

Set

Mapping

СемействоТипы
Sequencelist, tuple, str, range
Setset, frozenset
Mappingdict

Пояснение

Интервал чисел отвечает на in, но не перебирается; генератор перебирается, но не знает длины. Ни то ни другое не коллекция.

Список:

динамический массив

PyListObject

ob_size = 3
allocated = 4

•

•

•

массив указателей ob_item

0.5

1.2

0.7

Пояснение

Ёмкость растёт с запасом около 1/8: 4, 8, 16, 24, 32, 40, 52, 64, 76, 92 — перевыделения всё реже.

ОперацияСтоимость
append, pop()O(1) амортизированно
lst[i], lenO(1)
insert(0, x), pop(0)O(n)
x in lst, remove, indexO(n)
sort, sortedO(n log n), устойчиво

Демонстрация:

рост списка

01

Пустой список и его размер

02

Добавление 80 элементов по одному

03

Печать при каждом перевыделении: длина и ёмкость

Что наблюдается

Ёмкость растёт ступенями 4, 8, 16, 24, 32, 40, 52, 64, 76, 92: на 80 добавлений приходится десять перевыделений.

root@lab — запись со стенда

Срезы

и сортировка

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 устойчивы: элементы с равными ключами сохраняют взаимный порядок, поэтому сортировка по нескольким ключам выполняется проходами от младшего ключа к старшему.

Кортеж

и распаковка

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()

Пояснение

Кортеж неизменяем и хешируем, если хешируемы элементы: пара координат или связка «прибор, канал» служит ключом словаря.

Множество:

хеш-таблица

'Ar'

hash

'He'

'Ar'

'Ne'

Пояснение

frozenset — неизменяемое и потому хешируемое множество. {} — пустой словарь; пустое множество создаётся вызовом set().

ОперацияСмысл и стоимость
x in s, add, removeO(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)любой итерируемый аргумент; операторы — только множества

Для физика

Отбор миллиона событий по набору разрешённых идентификаторов через множество занимает доли секунды там, где список требует минут.

Словарь:

компактная хеш-таблица

indices

·

1

·

0

2

·

·

·

entries: хеш, ключ, значение

0: h('Ar'), 'Ar', 18

1: h('He'), 'He', 2

2: h('Ne'), 'Ne', 10

Пояснение

Порядок вставки гарантирован языком с версии 3.7.

ОперацияСтоимость
d[k], d[k] = v, del d[k], k in dO(1) в среднем
поиск по значениюO(n)
get, setdefault, pop(k, default)без исключения KeyError
popitem()последняя вставленная пара
d | e, d |= e (3.9)объединение

Пояснение

На словарях построены пространства имён модулей и атрибуты объектов: globals(), obj.__dict__.

Демонстрация:

хеши и порядок

01

Равные числа разных типов: равные хеши

02

Три записи — один ключ

03

Нехешируемый кортеж со списком

04

Хеш строки в разных запусках

05

PYTHONHASHSEED=0: воспроизводимый хеш и порядок

Что наблюдается

Хеш строк рандомизирован для защиты от подбора коллизий, поэтому порядок обхода множества строк меняется между запусками.

root@lab — запись со стенда

Модуль

collections

КлассНазначениеПрименение
namedtupleкортеж с именованными полямизапись измерения (t, x, y)
dequeдвусторонняя очередь, O(1) с обоих концовскользящее окно, maxlen
Counterподсчёт хешируемых объектовчастоты дискретных значений
defaultdictзначение по умолчанию для нового ключагруппировка событий по каналам
OrderedDictmove_to_end, popitem(last=False)кеш, вытесняющий давно не использованное
ChainMapпоиск по цепочке словарейнастройки: пользовательские поверх стандартных

Пояснение

Counter, defaultdict и OrderedDict — подклассы dict, deque поддерживает интерфейс последовательности, поэтому большая часть кода работает с ними без изменений; исключения есть: например, у deque нет срезов.

Демонстрация:

модуль collections

01

Counter: три самых частых символа

02

defaultdict: группировка энергий по каналам

03

deque с maxlen: скользящее окно

04

Среднее по окну

05

namedtuple: доступ по имени и по индексу

Что наблюдается

defaultdict создаёт список при первом обращении к каналу; deque с maxlen=3 хранит только три последних отсчёта.

root@lab — запись со стенда

Типичные

ловушки коллекций

ЗаписьРезультатПравильно
(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] в цикле по dRuntimeErrorfor k in list(d):
d[k] без ключаKeyErrord.get(k, default) или k in d

Пояснение

Методы, изменяющие список на месте (append, extend, insert, sort, reverse), возвращают None; sorted и срезы возвращают новые списки, reversed — итератор.

Демонстрация:

ловушки коллекций

01

Скобки, запятая и пустые фигурные скобки

02

+= со строкой

03

Результат sort

04

Общий список из dict.fromkeys

05

Удаление ключей при обходе словаря

06

Обход копии ключей

Что наблюдается

Строка добавляется посимвольно, sort возвращает None, fromkeys даёт общий список, а удаление при обходе словаря вызывает RuntimeError.

root@lab — запись со стенда

Перебор

коллекций

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
включенияновая коллекция одним выражением

Истинность

и ветвления

Ложные значения

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, и вычисляют правый операнд, только если без него не обойтись.

Выбор

коллекции

КоллекцияКогда применяется
listважен порядок, данные меняются, повторы допустимы
tupleданные фиксированы, нужен хешируемый объект
setуникальность, быстрая проверка вхождения
dictу данных есть естественный ключ
dequeдобавление и удаление с обоих концов
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.sort2·10⁷
O(n²)x in list внутри цикла по n10¹²

Пояснение

O(f(n)) описывает, как растёт время при росте n, без постоянных множителей и слагаемых низшего порядка.

Для физика

Оценка по замерам при n = 40 000 для миллиона чисел: удаление дубликатов с множеством — около 0,04 с, с проверкой по списку — около 20 минут.

Стоимость

операций

Операцияlistsetdictdeque
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), но в среднем на операцию приходится константа.

Демонстрация:

поиск и вставка

01

Поиск в списке из миллиона элементов

02

Тот же поиск в множестве

03

Вставка в начало списка и дека при росте n

Что наблюдается

Поиск в множестве быстрее в сотни тысяч раз; время вставки в начало списка удваивается вместе с n, у дека не меняется.

root@lab — запись со стенда

Скрытые

циклы

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) внутри цикла

Демонстрация:

удаление дубликатов

01

Функция slow: проверка вхождения по списку

02

Удвоение размера данных: 10 000, 20 000, 40 000

Что наблюдается

При удвоении n время slow растёт примерно вчетверо (O(n²)), время fast — вдвое (O(n)).

root@lab — запись со стенда

Измерение

сложности

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 не превысит пяти элементов, и квадратичность не проявится.

Пояснение

Профилирование и измерение времени подробно рассматриваются в части книги «Ускорение расчётов».

Правила

выбора

01

Иерархия роста

O(1) < O(log n) < O(n) < O(n log n) < O(n²)

02

Скрытые вложенные циклы

x in list внутри цикла даёт O(n²)

03

Структура под операцию

множества и словари — для поиска, списки — для перебора, дек — для обоих концов

04

Поиск в цикле

коллекция один раз до цикла превращается в множество или словарь

Пояснение

Преждевременная оптимизация вредна, но выбор структуры данных к ней не относится: от него зависит, справится ли программа с реальным объёмом данных.

Итоги

лекции

01

Переменная — имя, связанное с объектом; присваивание ничего не копирует.

02

is сравнивает тождественность и применяется к объектам-одиночкам: None, True, False; значения сравниваются через ==.

03

Неизменяемые объекты «меняются» созданием новых; изменяемые меняются на месте, и это видно через все имена.

04

Объект освобождается, когда счётчик ссылок обнуляется; циклы находит сборщик gc.

05

Коллекция выбирается под операцию: множество и словарь — для поиска, список — для перебора.

06

Сложность измеряется удвоением n на данных, похожих на реальные.

Домашнее задание

Решение задач на Python и анализ сложности

Две любые задачи лёгкого уровня с сайта CodeRun (coderun.yandex.ru)

01

Ссылка на задачу

02

Исходный код решения на Python

03

Пояснение: подробное описание хода решения

04

Обоснование выбора алгоритма и структур данных

05

Временная и пространственная сложность в O-нотации

Цель

Не только рабочий код, но и понимание его эффективности: почему выбран список, множество или словарь и как время решения растёт с размером входа

Рекомендуется подтвердить оценку замером с удвоением размера входных данных

Репозиторий: github.com/phys-dev/python-algo-task · главы «Введение в алгоритмы» и «Сложность операций с коллекциями»

Типичные

ошибки

01

Проверка вхождения в список в цикле

скрытый O(n²): множество или словарь строится один раз до цикла

02

Сложность без обоснования

указывается, какая операция и сколько раз определяет оценку

03

Забытая память

пространственная сложность учитывает вспомогательные структуры

04

Сортировка «бесплатно»

sorted стоит O(n log n) и входит в оценку

05

Строка через += в цикле

части собираются в список и объединяются join

06

Замер на пяти значениях

проверка на данных размера и вида, как в условии задачи

Источники

1

Книга курса: главы «Объекты и память», «Коллекции», «Сложность операций с коллекциями»

phys-dev.github.io/soft-dev-book

2

Лебедев С. Python. Начало. – Computer Science Center

youtube.com/watch?v=G5QGI-vcmOA

3

Лебедев С. Встроенные коллекции и модуль collections. – Computer Science Center

youtube.com/watch?v=1yCWXWJoO-Y

4

The Python Language Reference: Data model

docs.python.org/3/reference/datamodel.html

5

Модули dis и gc — документация Python

docs.python.org/3/library/dis.html, …/gc.html

6

TimeComplexity — вики Python

wiki.python.org/moin/TimeComplexity

7

Batchelder N. Facts and Myths about Python Names and Values

nedbatchelder.com/text/names.html

8

Ramalho L. Fluent Python. – 2nd ed. – O'Reilly, 2022

Спасибо за внимание

Вопросы

phys-dev.github.io/soft-dev-book

del