Deep Engineering
Средний·Опубликовано·3.11 · 3.12 · 3.13 · 3.14·40 МИН

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

«Кортеж легче на 16 байт» верно, только если сравнивать его со списком, построенным литералом. Против выросшего через append та же разница — 72 байта, и getsizeof показывает её честно. А «кортеж быстрее» верно ровно для одной операции из пяти, и не потому, что он кортеж.

Полное техническое изложение

TL;DR

Список — изменяемая последовательность, кортеж — неизменяемая. Отсюда и всё остальное: список умеет расти и потому держит указатель на отдельный массив и запас под него, а кортеж хранит элементы прямо в себе. Поэтому кортеж легче — но насколько именно, зависит от того, с каким списком его сравнили. И поэтому же кортеж годится в ключи словаря, когда годится каждый его элемент.

Отсюда главное следствие: оба расхожих «и поэтому» — «легче» и «быстрее» — верны не вообще, а при одном сравнении из нескольких. sys.getsizeof показывает разницу в 16 байт при любой длине, но только против списка, построенного литералом: у выросшего через append есть запас, и на семнадцати элементах разница уже 72 байта — 248 против 176. А «кортеж быстрее» верно ровно для одной операции из пяти — создания из литерала, ×4,2 на 3.13.7 и ×4,7 на 3.14.7. На остальных четырёх разница есть, но знак у неё принадлежит сборке, а не типу: чтение по индексу на 3.13.7 у кортежа быстрее в 1,08 раза, а на 3.14.7 медленнее — ×0,92.

Дальше — числа, версии и границы. Те 16 байт — это ровно два поля: указатель на массив и allocated. Запас растёт не удвоением: list_resize считает его от длины — плюс восьмая часть и ещё шесть; отношение соседних запасов из-за шестёрки поначалу равно двум, а на длинных списках сходится к 1,125. tracemalloc на батче, за вычетом самого батча, даёт ровно то же, что getsizeof: 120,0 против 120 у кортежа, 183,9 против 184 у списка из включения; занижает getsizeof не массив, а элементы — и на списке свежих строк это видно: 653,6 против 184. Пятикратная разница на литерале — не про типы: константный кортеж во время исполнения не строится вовсе, он целиком лежит в co_consts, и стоит поставить внутрь переменную, как преимущество падает до ×1,3. Кортеж неизменяем неглубоко: t = ([],) — и t[0].append(1) работает. В 3.14 кортеж прибавил 8 байт — поле ob_hash, кеш хеша (gh-131525), и разрыв в getsizeof стал вдвое меньше: 8 байт вместо 16.

Порог входа
Перед уроком достаточно понимать
  • что список и кортеж — это последовательности и как их записывают;
  • что элемент достают по индексу, а всю последовательность обходят циклом;
  • что у словаря есть ключи и ключом годится не всякий объект.
Заранее знать не нужно
  • sys.getsizeof, tracemalloc, запас списка и list_resize;
  • байт-код, co_consts, поле ob_hash и то, что такое хеш.

Что здесь на самом деле спрашивают

контракт языкаГарантия языка: список изменяем, кортеж — нет. Всё остальное в уроке — про то, во что это обходится в конкретной сборке.

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

Оба следствия неточны, и неточны по-разному. «Легче» — верно, но величина зависит от того, какой список вы взяли для сравнения, и разброс там четырёхкратный. «Быстрее» — верно для одной операции из пяти, и по причине, которая к неизменяемости отношения не имеет.

Разбираем по одному — но сначала о том, из-за чего эти два типа вообще разные.

База: список меняется, кортеж — нет

Различие между ними начинается не с байтов, а с одного слова.

Список — изменяемая последовательность. В него можно добавить элемент, убрать, заменить один на другой — и объект при этом останется тем же самым.

Кортеж — неизменяемая. После создания состав его элементов менять нельзя: ни добавить, ни удалить, ни присвоить по индексу. t[0] = ... даёт TypeError: 'tuple' object does not support item assignment.

Всё, чего обычно ждут в ответе, следует из этого одного различия.

Первое: кортеж годится в ключи словаря, а список — нет. Ключ должен уметь не меняться, пока он лежит в словаре, — и потому обязан быть хешируемым. У кортежа это условие выполнимо: он хешируется, если хешируется каждый его элемент. У списка — не выполнимо никогда.

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

Третье: это же видно и в интерфейсе. Функция, вернувшая список, отдала наружу то, что вызывающий волен изменить, — и по этой причине list(l) обязан делать копию. В кортеже, который вернула функция, менять снаружи нечего, и копировать его незачем: tuple(t) is t — тот же объект.

Этого уже достаточно, чтобы ответить на базовый вопрос собеседования: что выбрать и почему. Всё дальнейшее — про приписку, которой этот ответ обычно заканчивают: «и поэтому кортеж легче и быстрее». Насколько легче, при каком сравнении — и в какой именно операции быстрее.

Механизм 1: ошибка, которая не падает

Как обычно, интересна не та ошибка, что роняет программу.

Правило про запятую в учебнике сформулировано прямо: a tuple with one item is constructed by following a value with a comma (it is not sufficient to enclose a single value in parentheses) (кортеж из одного элемента строится постановкой запятой после значения (заключить одно значение в скобки недостаточно)). В справочнике то же нормативно: it is actually the comma which makes a tuple, not the parentheses (кортеж на самом деле образует запятая, а не скобки).

Забыть запятую легко, а последствия зависят от того, что внутри:

PYTHON
ALLOWED = ("admin")      # ← запятой нет: это строка 'admin'
 
def allowed(role):
    return role in ALLOWED

Проверка «есть ли роль среди разрешённых» превратилась в проверку вхождения подстроки. Замер на шести именах — admin, adm, min, administrator, guest, a:

варианткого пропустило
("admin") — без запятойadmin, adm, min, a
("admin",) — с запятойadmin

Трое лишних из шести, ни одного исключения. Программа не упала, тесты на allowed("admin") прошли, а allowed("a") вернуло True.

Второй способ получить неверный ответ молча — умножение:

PYTHON
grid = [[]] * 3          # три ссылки на ОДИН список
for i, row in enumerate(grid):
    row.append(i)
grid                     # [[0, 1, 2], [0, 1, 2], [0, 1, 2]]
[sum(r) for r in grid]   # [3, 3, 3] — одна строка, посчитанная трижды

Правильная форма — включение, которое создаёт новый объект на каждом шаге:

PYTHON
grid = [[] for _ in range(3)]   # [[0], [1], [2]]

С кортежем ровно то же самое: ([],) * 3 даёт три ссылки на один список. Неизменяемость кортежа здесь ни при чём — и это подводит к следующему разделу.

Механизм 2: неизменяемость неглубокая

Кортеж хранит ссылки, и запрет касается замены ссылок, а не объектов, на которые они указывают:

PYTHON
t = ([], [])
t[0].append("а меня можно")   # работает
t[0] = ["а меня нельзя"]      # TypeError: 'tuple' object does not
                              # support item assignment

Отсюда и оговорка к тому, что в «Базе» сказано про ключи словаря: «кортеж можно класть в словарь» — не всегда.

PYTHON
hash((1, "a", (2, 3)))   # ок
hash((1, [2, 3]))        # TypeError: unhashable type: 'list'

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

Есть и обратная сторона той же медали, полезная на практике: раз кортеж неизменяем, копировать его незачем.

PYTHON
t = (1, 2, 3);  tuple(t) is t   # True  — тот же объект
l = [1, 2, 3];  list(l)  is l   # False — копия

list(l) обязан копировать: вернув тот же объект, конструктор отдал бы наружу изменяемую ссылку на чужие данные. tuple(t) копировать не обязан — менять там нечего.

Механизм 3: 16 байт — откуда они берутся

деталь реализации · CPython 3.13Раскладка объекта и запас списка — устройство CPython на 64 битах: числа менялись между версиями и ещё изменятся.

Теперь к той самой приписке — «и поэтому легче». Начинается она с числа, которое проверяется одной строкой:

PYTHON
import sys
sys.getsizeof([None] * 10)     # 136
sys.getsizeof((None,) * 10)    # 120

Разница — 16 байт. И она не меняется с длиной, если брать список, построенный литералом (эта оговорка окажется решающей, но пока о ней можно не думать):

длинасписоккортежразница
0564016
1644816
1013612016
10085684016
10008056804016

Постоянная разница означает постоянное поле, а не накладные расходы на элемент. Так и есть, и видно это в объявлениях структур.

У обоих объектов есть PyObject_VAR_HEAD — счётчик ссылок, указатель на тип и длина, всего 24 байта. Дальше они расходятся:

  • у кортежа массив элементов лежит прямо в объекте, сразу за заголовком;
  • у списка в объекте лежит только указатель на отдельный массив (8 байт) и поле allocated (ещё 8) — сколько места под этот массив выделено.

8 + 8 = 16. Поле allocated — это и есть то, чего у кортежа нет и быть не может: кортежу незачем помнить запас, потому что он не растёт.

Механизм 4: 16 байт — только против одного списка из трёх

Теперь та самая оговорка. Разница в 16 байт получена на списке, построенном литералом, а у такого списка запаса нет. Возьмите другой список той же длины:

PYTHON
sys.getsizeof((None,) * 17)     # 176 — кортеж, ровно 17 слотов
sys.getsizeof([None] * 17)      # 192 — список повторением: тоже ровно 17
sys.getsizeof([0, 1, 2, ..., 16])  # 200 — список литералом: 18 слотов
sys.getsizeof(list(range(17)))  # 200 — конструктор: тоже 18
grown = []
for i in range(17): grown.append(i)
sys.getsizeof(grown)            # 248 — вырос через append: 24 слота

Одна длина, четыре списка, три разных числа — и запаса нет только у того, что получен повторением. Литерал, вопреки ожиданию, тоже перевыделяет: список из одних констант компилятор собирает не поэлементно, а как BUILD_LIST 0 плюс LIST_EXTEND из готового кортежа, а list_extend идёт тем же путём, что и append. Против последнего кортеж легче не на 16 байт, а на 72. «Кортеж легче на 16 байт» верно ровно для одного сравнения из трёх — и это, а не какое-то скрытое поле, главная неточность расхожего ответа.

Обратите внимание, что́ здесь происходит: getsizeof показывает запас честно. 248 = 56 + 24 × 8, где 24 — сколько слотов выделено. Массив, на который список ссылается, входит в это число целиком.

Тогда что getsizeof действительно не считает

Документация формулирует ограничение прямо: Only the memory consumption directly attributed to the object is accounted for, not the memory consumption of objects it refers to (Учитывается только память, непосредственно занятая объектом, а не память объектов, на которые он ссылается). Речь про элементы, а не про массив указателей.

Проверить это можно, сведя два инструмента. tracemalloc на батче из 10 000 объектов, за вычетом самого списка, который держит батч (он стоит 8,51 байта на объект):

что меримtracemallocgetsizeof
кортеж из 10120,0120
список через list(range(10))136,0136
список через включение183,9184

Они сходятся. Для этих объектов getsizeof не занижает ничего — и более строгий способ, мерить tracemalloc на батче объектов вместо sys.getsizeof поштучно, здесь ничего бы не изменил.

Почему сходятся — видно, если посмотреть на элементы. Это малые целые числа, то есть синглтоны: списку и кортежу принадлежат не они, а только указатели на них. Считать нечего.

Замените элементы на строки, которые создаются заново каждый раз, — и расхождение появляется сразу:

что меримtracemallocgetsizeof
список из 10 свежих строк653,6184

Вот здесь getsizeof и занижает — на 470 байт, то есть втрое. И занижает он именно то, о чём предупреждает документация: сами строки.

Практический вывод из этих двух таблиц один, и он не про списки: getsizeof надёжен ровно настолько, насколько элементы контейнера принадлежат кому-то ещё. Для контейнера общих объектов он точен; для контейнера собственных — показывает верхушку.

Возвращаемся к запасу — теперь понятно, что он не прячется, а просто зависит от способа построения.

Механизм 5: запас — цена того, что список умеет расти

Список, выросший через append, держит больше места, чем в нём элементов. Шаблон роста записан в комментарии над list_resize в Objects/listobject.c:

The growth pattern is: 0, 4, 8, 16, 24, 32, 40, 52, 64, 76, ...

Objects/listobject.c, комментарий над list_resize, тег v3.13.7

Проверено запуском — те же числа:

элементовgetsizeofвыделено под
0560
1884
51208
918416
1724824

Список из 17 элементов держит место под 24. Кортеж из 17 держит 17.

По какому правилу он растёт

Лестница выше — это вывод правила, а не само правило. Само правило записано в list_resize одной строкой:

C
new_allocated = ((size_t)newsize + (newsize >> 3) + 6) & ~(size_t)3;

То есть: новая длина плюс её восьмая часть плюс шесть, округлённое вниз до кратного четырём.

Здесь легко прочесть неверно, поэтому по шагам. Формула считает запас от длины, а не от прошлого запаса. Она отвечает на вопрос «в списке стало n элементов — сколько места под них выделить», и старое значение в неё не входит вовсе.

Формула проверяется на замере, а не принимается на слово, — первые двенадцать ступеней совпадают до единицы:

длиназамерформула
144
91616
172424
415252
657676
109128128

Почему «восьмая часть», а по таблице удвоение

В формуле два слагаемых, и на разной длине правит разное.

На коротком списке правит + 6. Для длины 1 восьмая часть — это ноль, и весь запас даёт шестёрка: 1 + 0 + 6 = 7, вниз до четырёх. Поэтому первые ступени и выглядят удвоением: 4 → 8 → 16.

На длинном правит len / 8. Шестёрка на фоне тысяч уже ничего не решает, и отношение соседних запасов сходится к 1,125. Это видно только на замере:

длиназапаск прошлому запасу
9162,000
17241,500
41521,300
1291481,156
6737601,131
11 97713 4801,126
49 36555 5401,125
289 053325 1881,125

Вот что такое 1,125: предел, к которому отношение сходится на длинных списках, а не множитель на каждом шаге. Расхожее «список удваивается» верно ровно для первых двух ступеней и неверно дальше.

Чем это отличается от удвоения на практике

Удвоение — это vector в C++ и срез в Go до 256 элементов. Разница видна на счёте, если дорастить список до трёхсот тысяч элементов:

CPythonудвоением
перевыделений по дороге7618
итоговый запас325 188524 288
лишнего сверх длины8,4 %74,8 %

Вот и весь размен. Вчетверо больше перевыделений — ради того, чтобы готовый список не держал лишнего почти на три четверти своей длины. Для языка, где списки живут долго и их много, это разумная сторона размена; для языка, где важнее скорость роста, — другая.

Мотив в том же комментарии назван прямо, и это не экономия памяти: enough to give linear-time amortized behavior over a long sequence of appends() in the presence of a poorly-performing system realloc() (достаточно, чтобы дать амортизированно линейное время на длинной череде append() даже при неудачной системной реализации realloc()). Запас существует для того, чтобы тысяча append подряд стоила линейно, а не квадратично.

Два случая, где запас ведёт себя иначе

Большой скачок за один раз запаса не получает. В list_resize рядом с формулой стоит отдельная ветка: Do not overallocate if the new size is closer to overallocated size than to the old size (Не выделять с запасом, если новый размер ближе к размеру с запасом, чем к старому). Разница видна на одной тысяче элементов:

PYTHON
a = []; a.extend(range(1000))          # выделено под 1000
b = []
for i in range(1000): b.append(i)      # выделено под 1100

Ровно тысяча против тысячи ста. Это и есть измеренная цена совета «стройте одним выражением, а не циклом append».

Обратно память возвращается не сразу. Условие в начале list_resize пропускает realloc вовсе, пока длина держится в верхней половине запаса:

C
if (allocated >= newsize && newsize >= (allocated >> 1))

Поэтому список из тысячи элементов после четырёхсот pop всё ещё держит место под тысячу. Сжатие срабатывает на длине 499 — первой, что оказалась меньше половины, — и даёт ровно то, что предсказывает формула: 499 + 62 + 6 = 567, округлённое вниз до 564.

что сделалидлинавыделено под
list(range(1000))1 0001 000
400 раз pop()6001 000
ещё 200 раз pop()400564

Практически это значит, что список, который однажды вырос до миллиона и потом опустел наполовину, память не отдаст.

Так объясняются три разных числа из раздела выше: 192, 200 и 248 при одной и той же длине. Повторение ([None] * 17) знает длину точно и запаса не берёт; литерал и list(range(17)) тоже знают её, но идут через list_extend и округляют до 18; выросший через append прошёл всю лестницу и остановился на 24.

Практическое следствие узкое, но полезное: если длина известна заранее, стройте одним выражением, а не циклом append. Не ради 56 байт на список, а ради того, что тогда размер предсказуем — пусть и с точностью до одного лишнего слота.

Механизм 6: «кортеж быстрее» — одна операция из пяти

наблюдение замераbench/list-vs-tuple/cost.py, CPython 3.13.7. Абсолютные наносекунды зависят от машины; содержательна разница между операциями.

Теперь второе следствие расхожего ответа. Замер (3.13.7, лучшее из семи прогонов по 2 000 000 повторов):

операциясписоккортежкортеж быстрее в
создать из литерала43,0 нс10,3 нс×4,16
создать из переменных39,6 нс30,8 нс×1,29
прочитать по индексу16,7 нс15,5 нс×1,08
распаковать x, y, z19,5 нс19,6 нс×0,99
обойти 1000 элементов8,6 мкс8,4 мкс×1,03

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

Про каждую кратность прогон сам говорит, различима ли она прибором, — и для этого повторяет весь замер пятью отдельными процессами. Отдельными намеренно: внутри одного процесса пять повторов держатся своего значения, а между процессами строка «создать из переменных» уезжает с 1,29 на 1,79 — разброс живёт между запусками, а не внутри них, и замер, повторённый внутри процесса, показал бы ложно узкий размах.

  Различима ли каждая кратность прибором (размах по 5 процессам):
    создать литерал           4.08 ..  4.24   размах 0.16   различима да
    создать из переменной     1.29 ..  1.29   размах 0.01   различима да
    прочитать по индексу      1.08 ..  1.11   размах 0.02   различима да
    распаковать               0.99 ..  1.00   размах 0.01   различима нет
    обойти 1000 элементов     1.03 ..  1.03   размах 0.00   различима да

Две средние строки — чтение и распаковка, то, ради чего контейнер обычно и заводят. Распаковка разницы не даёт вовсе: размах лежит по обе стороны от единицы. А чтение по индексу — даёт: 1,08, устойчиво во всех пяти процессах.

И вот тут начинается самое интересное, потому что на 3.14.7 та же строка выходит ×0,92 — быстрее оказывается список, и тоже устойчиво, размах 0,03. Та же перемена знака на распаковке (×0,96) и на обходе (×0,83). Каждое из двух чисел верно внутри своего прогона; вместе они говорят одно: на четырёх строках из пяти знак принадлежит сборке, а не типу. Переносить с собой стоит не кратность, а первую строку — ту единственную, у которой есть объяснение в байт-коде, а не в замере.

А вот первая строка требует объяснения, потому что пятикратная разница на пустом месте не берётся.

Механизм 7: почему литерал — объяснение в байт-коде

Разница на первой строке видна дизассемблером и никакого секундомера не требует (дизассемблер 3.13.7):

x = (1, 2, 3)   ->  LOAD_CONST  STORE_NAME
                    co_consts: ((1, 2, 3), None)

x = [1, 2, 3]   ->  BUILD_LIST  LOAD_CONST  LIST_EXTEND  STORE_NAME
                    co_consts: ((1, 2, 3), None)

Константный кортеж во время исполнения не строится вовсе. Он собран на этапе компиляции и целиком лежит в co_consts; на месте выражения стоит одна инструкция — взять готовое. Списку так нельзя: он изменяем, и вернуть один и тот же объект на каждом проходе цикла означало бы отдать наружу общее состояние. Поэтому список собирается заново каждый раз — и, что любопытно, из той же самой константы: LIST_EXTEND разворачивает в него тот же кортеж (1, 2, 3), который лежит в co_consts у обоих.

Отсюда и граница действия этого преимущества. Стоит внутри появиться хоть одному неконстантному выражению:

x = (a, 2, 3)   ->  LOAD_NAME  LOAD_CONST  LOAD_CONST  BUILD_TUPLE  STORE_NAME
x = [a, 2, 3]   ->  LOAD_NAME  LOAD_CONST  LOAD_CONST  BUILD_LIST   STORE_NAME

Одна и та же форма, разная последняя инструкция — и разница падает впятеро с лишним до полутора раз.

На 3.14 те же строки выглядят чуть иначе: мелкие целые константы грузятся отдельной инструкцией LOAD_SMALL_INT. На вывод это не влияет — константный кортеж по-прежнему целиком лежит в co_consts, а список по-прежнему собирается.

Практический вывод: «кортеж быстрее» — это про константы, а не про типы. Если ваш кортеж собирается из переменных, вы получаете полтора раза на создании объекта, которое в горячем цикле обычно не главное.

Что с этим делать

Не выбирайте кортеж ради скорости. На чтении и распаковке разницы нет, на создании из переменных — полтора раза. Выигрыш в пять раз бывает только у константного литерала, и он не про типы, а про co_consts.

Выбирайте кортеж ради смысла. Учебник формулирует это как разницу намерений: кортеж usually contain a heterogeneous sequence of elements (обычно содержат разнородную последовательность элементов), список — their elements are usually homogeneous (их элементы обычно однородны). Кортеж говорит читателю кода «это одна запись фиксированной формы», список — «это сколько угодно однородных штук».

Ставьте запятую и проверяйте типом. ("admin") — строка. Если значение приходит извне или собирается условно, assert isinstance(x, tuple) дешевле разбирательства с тем, почему пользователь a оказался администратором.

Не считайте разницу в памяти одним сравнением. 16 байт (8 на 3.14) получаются только против списка, построенного литералом; против выросшего через append та же разница — 72 байта. И помните границу самого getsizeof: массив списка он считает целиком, а элементы — нет. Для контейнера общих объектов он точен, для контейнера собственных показывает верхушку.

Помните, что неизменяемость неглубокая. Кортеж с изменяемым элементом внутри не защищает ни от изменения, ни от TypeError при попытке сделать его ключом.

Глубже: что изменилось в 3.14

За четыре версии раскладка менялась ровно один раз, и знать об этом стоит: от версии зависит то самое число, которое вы увидите у себя в консоли.

PYTHON
sys.getsizeof(())    # 40 на 3.11, 3.12, 3.13
                     # 48 на 3.14

Прибавка постоянная — 8 байт при любой длине, — то есть в объект добавили одно поле. Это ob_hash, кеш хеша:

C
// Include/cpython/tupleobject.h, тег v3.14.0
typedef struct {
    PyObject_VAR_HEAD
    Py_hash_t ob_hash;      /* Cached hash. Initially set to -1 */
    PyObject *ob_item[1];
} PyTupleObject;

Разрыв между списком и кортежем стал вдвое меньше: 8 байт вместо 16.

Здесь есть соблазн, и его стоит назвать: увидеть, что на 3.14 повторный hash идёт быстрее, и написать «хеш стал вчетверо быстрее». Сравнивать время двух версий нельзя ни с числом, ни без него — сборки, на которых сняты эти числа, различаются не только версией языка, и такое сравнение ничего не доказывает.

Доказать наличие кеша можно внутри одного запуска, и это законно на любой версии: если кеша нет, повторный хеш того же объекта стоит столько же, сколько первый; если есть — заметно дешевле.

версияgetsizeof(())повторный хеш дешевле первого в
3.11.15400,9 раза
3.12.3400,9 раза
3.13.7400,9 раза
3.14.7484,5 раза

Три версии подряд говорят «кеша нет», четвёртая — «есть», и ровно там же кортеж прибавил 8 байт. Каждая строка — сравнение двух чисел одного прогона; между строками сравнивать нечего, и не нужно.

Заявленный эффект в самой задаче назван честно и узко: the mdp benchmark increased by 86% (тест mdp ускорился на 86 %), и тут же — no measurable improvement on any other benchmark, but it also seems to have no downside, including for memory usage (ни на одном другом тесте заметного улучшения нет, но и вреда, включая расход памяти, тоже не видно). Последнее относится к max_rss на наборе тестов, а не к размеру одного объекта: каждый кортеж стал на 8 байт больше, и getsizeof это показывает. Оба утверждения верны одновременно, и это ровно тот случай, ради которого стоит различать «память процесса» и «размер объекта».

История версий

ВерсияИзменениеЧто это значит для кода
3.11Раскладка обоих типов такая же, как в 3.12 и 3.13: пустой кортеж — 40 байт, пустой список — 56. Форма байт-кода константного литерала тоже совпадает: LOAD_CONST у кортежа против BUILD_LIST и LIST_EXTEND у списка. Проверено запуском на всех четырёх версиях.
3.14У кортежа появляется поле ob_hash — кеш хеша (gh-131525, PR #131529). Каждый кортеж прибавляет 8 байт, разрыв со списком в getsizeof становится вдвое меньше. Повторный хеш того же кортежа впервые оказывается дешевле первого — проверено внутри одного запуска на всех четырёх версиях. Отдельно: мелкие целые константы грузятся инструкцией LOAD_SMALL_INT, из-за чего дизассемблер тех же строк выглядит иначе, чем на 3.13.

Как отвечать на собеседовании

Короткий ответ: список — изменяемая последовательность, кортеж — неизменяемая, и выбирают между ними по этому, а не по байтам. Кортеж годится в ключи словаря, когда годится каждый его элемент, и говорит читателю «это одна запись фиксированной формы»; список говорит «это сколько угодно однородных штук». Разница в памяти есть, но она не то, ради чего тип меняют.

Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.

Если интервьюер копает глубже

Про память: список хранит указатель на отдельный массив плюс запас, кортеж — элементы в себе, отсюда и разница в getsizeof. Но она равна 16 байтам только при одном сравнении: кортеж против списка, построенного литералом. У выросшего через append есть запас, и на семнадцати элементах разница уже 72 байта.

Что отличает хороший ответ: не соглашаться с «кортеж быстрее» вообще, а назвать, где именно. Из пяти операций преимущество есть у одной — создания из литерала, — и не потому, что это кортеж: константный кортеж во время исполнения не строится вовсе, он лежит в co_consts. Поставьте внутрь переменную, и преимущество падает до полутора раз. На чтении по индексу и на распаковке разницы нет вовсе. И отдельно: кортеж неизменяем неглубоко — t = ([],), и t[0].append(1) работает.

Дальше спросят

Спросят дальше

Кортеж неизменяемый — значит, его всегда можно взять ключом словаря?

Короткий ответ

Нет: неизменяемость неглубокая. Кортеж держит ссылки, и если внутри лежит список, кортеж перестаёт быть хешируемым — а лежащий внутри список по-прежнему можно менять.

Спросят дальше

Раз кортеж экономнее, стоит ли менять списки на кортежи ради памяти?

Короткий ответ

Экономия в шестнадцать байт получается только против одного списка из трёх — того, что построен так же, как кортеж. У списка есть запас на рост, и он и есть цена того, что список умеет расти; менять структуру данных ради этой разницы значит платить изменяемостью за величину, которую в вашем профиле, скорее всего, не видно.

Частые заблуждения

Утверждение

кортеж легче списка, потому что неизменяемый

На самом деле

Легче — да, но величина зависит от того, какой список взяли. Против списка, построенного литералом, разница ровно 16 байт при любой длине — это два поля заголовка: указатель на массив и allocated. Против выросшего через append — уже 72 байта при длине 17 (248 против 176). Запас getsizeof показывает честно; неточен не он, а сравнение, в котором взяли самый компактный список из трёх возможных.

Утверждение

кортеж быстрее списка

На самом деле

На одной операции из пяти. Замер на 3.13.7: создание из литерала — ×4,16, из переменных — ×1,29, чтение по индексу — ×1,08, распаковка — ×0,99, обход тысячи элементов — ×1,03. Четырёхкратная разница в первой строке не про типы: константный кортеж во время исполнения не строится вовсе, он лежит в co_consts. Поставьте внутрь переменную — останется 1,3 раза. А из четырёх остальных строк три на 3.14.7 меняют знак на противоположный: там быстрее список.

Утверждение

кортеж обходится быстрее, потому что проще устроен

На самом деле

Направление зависит от сборки. На 3.13.7 обход тысячи элементов у кортежа быстрее в 1,03 раза; на 3.14.7 — наоборот, быстрее список, ×0,83. Оба числа измерены внутри своего прогона, оба устойчивы по пяти процессам и оба верны. Из этого следует не «в 3.14 стало хуже» (так сравнивать нельзя), а то, что направление на обходе — не свойство языка.

Утверждение

кортеж неизменяемый, значит его содержимое не поменяется

На самом деле

Кортеж хранит ссылки, и запрет касается их замены. t = ([], []), затем t[0].append(1) — работает; t[0] = [1] даёт TypeError: 'tuple' object does not support item assignment. Разница между «нельзя заменить ссылку» и «нельзя изменить объект» здесь решающая.

Утверждение

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

На самом деле

Кортеж хешируется, только если хешируется каждый его элемент. hash((1, [2, 3])) даёт TypeError: unhashable type: 'list'. Причём узнаете вы об этом в момент вставки в словарь, а не в момент создания кортежа, — то есть далеко от того места, где список туда попал.

Утверждение

скобки делают кортеж

На самом деле

Делает запятая. Справочник формулирует это нормативно: it is actually the comma which makes a tuple, not the parentheses (кортеж на самом деле образует запятая, а не скобки). (1) — целое число, (1,) и 1, — кортежи. Отсюда ошибка, которая не падает: ALLOWED = ("admin") превращает проверку роли в проверку вхождения подстроки, и "a" in ALLOWED даёт True.

Утверждение

[[]] * 3 создаёт три пустых списка

На самом деле

Создаёт три ссылки на один список: grid[0] is grid[1] даёт True. Дописав по элементу в каждую «строку», получите [[0, 1, 2], [0, 1, 2], [0, 1, 2]] и суммы [3, 3, 3] вместо [0, 1, 2]. Правильная форма — включение: [[] for _ in range(3)]. С кортежем то же самое: ([],) * 3.

Практика

Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.

Практика · что напечатает

Два списка из семнадцати одинаковых чисел: один написан литералом, другой собран через append. Что напечатает этот код?
import sys

literal = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
grown = []
for n in range(17):
  grown.append(n)

print(literal == grown)
print(sys.getsizeof(literal) == sys.getsizeof(grown))
print(sys.getsizeof(literal), sys.getsizeof(grown))

Практика · оцените

Создание из литерала: [1, 2, 3] против (1, 2, 3). Во сколько раз кортеж быстрее?
раза

Проверка знаний

Вопрос 1 из 5

sys.getsizeof дал разницу между списком и кортежем в 16 байт. При каком условии он даст другую?

Источники и что читать дальше

6 ИСТОЧНИКОВ

  1. Учебник — кортежи и последовательностиОфициальная документация. Отсюда правило про запятую, на котором держится раздел про ошибку, которая не падает: «a tuple with one item is constructed by following a value with a comma (it is not sufficient to enclose a single value in parentheses)» (кортеж из одного элемента строится постановкой запятой после значения (заключить одно значение в скобки недостаточно)). Там же — про то, чем кортеж и список отличаются по замыслу: «Tuples are immutable, and usually contain a heterogeneous sequence of elements» (Кортежи неизменяемы и обычно содержат разнородную последовательность элементов), а у списка «their elements are usually homogeneous and are accessed by iterating over the list» (их элементы обычно однородны, и доступ к ним получают обходом списка). Это про намерение автора кода, а не про то, что запрещает интерпретатор.https://docs.python.org/3.14/tutorial/datastructures.html
  2. Стандартные типы — последовательностиОфициальная документация. Нормативная формулировка того же правила: «it is actually the comma which makes a tuple, not the parentheses» (кортеж на самом деле образует запятая, а не скобки), и оговорка про исключения: «The parentheses are optional, except in the empty tuple case, or when they are needed to avoid syntactic ambiguity» (Скобки необязательны — кроме случая пустого кортежа и случаев, когда они нужны, чтобы избежать синтаксической неоднозначности).https://docs.python.org/3.14/library/stdtypes.html#tuples
  3. Objects/listobject.c — list_resize и запасИсходный код CPython. Комментарий над list_resize на теге v3.13.7, из которого взят шаблон роста: «The growth pattern is: 0, 4, 8, 16, 24, 32, 40, 52, 64, 76, ...» (Шаг роста таков: 0, 4, 8, 16, 24, 32, 40, 52, 64, 76, …). Там же назван мотив — не экономия, а «linear-time amortized behavior over a long sequence of appends() in the presence of a poorly-performing system realloc()» (амортизированно линейное время на длинной череде append() даже при неудачной системной реализации realloc()). Проверено запуском: getsizeof списка после append даёт ровно 4, 8, 16, 24. Тег CPython 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Objects/listobject.c
  4. Include/cpython/tupleobject.h — PyTupleObject на теге v3.14.0Исходный код CPython. Структура, из которой следует и разница в 16 байт, и её изменение в 3.14: PyObject_VAR_HEAD, затем `Py_hash_t ob_hash` с комментарием «Cached hash. Initially set to -1.» (Кешированный хеш. Изначально равен −1.), затем ob_item[]. Поля ob_hash в 3.13 нет — отсюда 40 байт у пустого кортежа против 48. Тег CPython 3.14.0.https://github.com/python/cpython/blob/v3.14.0/Include/cpython/tupleobject.h
  5. CPython gh-131525 — Caching the tuple hash calculation speeds up some code significantlyИсточник. Задача mdboom от 20 марта 2025, из которой выросло поле ob_hash (PR #131529, влит 27 марта 2025). Заявленный эффект узкий и назван честно: «the mdp benchmark increased by 86%» (тест mdp ускорился на 86 %), при этом «no measurable improvement on any other benchmark, but it also seems to have no downside, including for memory usage» (ни на одном другом тесте заметного улучшения нет, но и вреда, включая расход памяти, тоже не видно). Последнее относится к max_rss на наборе тестов, а не к размеру одного объекта: каждый кортеж стал на 8 байт больше, и это видно в getsizeof.https://github.com/python/cpython/issues/131525
  6. sys.getsizeof — что именно он считаетОфициальная документация. «Only the memory consumption directly attributed to the object is accounted for, not the memory consumption of objects it refers to» (Учитывается только память, непосредственно занятая объектом, а не память объектов, на которые он ссылается). Речь про ЭЛЕМЕНТЫ, а не про массив указателей: массив списка вместе с запасом getsizeof считает целиком, и это проверено сходимостью с tracemalloc в bench/list-vs-tuple/layout.py.https://docs.python.org/3.14/library/sys.html#sys.getsizeof