Вступление: Загадка () против []

Положа руку на сердце, давайте признаемся: когда вы только начинали учить Python, вам наверняка на первом же занятии вам сказали: «Списки (list) — изменяемые, а кортежи (tuple) — нет. Запомнили? Молодцы».

И большинство из нас кивнуло и пошло дальше. Казалось бы, всё просто: если данные могут меняться — берём квадратные скобки [], если это константа — круглые (). Задача решена.

А что, если я скажу, что на этом простом правиле заканчивается Python для новичков и начинается Python для профессионалов?

Потому что за этой банальной разницей скрывается целый мир оптимизаций, архитектурных решений и подводных камней. Вы когда-нибудь задумывались, почему кортежи на самом деле быстрее списков? Не на уровне теории, а на уровне байтов и выделения памяти? Или почему Python позволяет использовать кортеж как ключ словаря, а при попытке сделать то же самое со списком просто взрывается с ошибкой TypeError?

2. Что такое неизменяемость (Immutability)? Объясняем на пальцах

Давайте на секунду забудем про код и представим две вещи: надпись на доске маркером и гравировку на камне. Надпись на доске можно стереть, исправить, дописать что-то новое. Это — изменяемый (mutable) объект. Гравировку же просто так не поменяешь. Чтобы что-то изменить, придётся брать новый камень и делать новую гравировку. Это — неизменяемый (immutable) объект.

В Python всё точно так же.

Список (list): Надпись на доске

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

Давайте проверим это с помощью встроенной функции id(), которая показывает уникальный идентификатор объекта в памяти (можно думать о нём как об адресе).

# Создаем наш "маркерный" список
my_list = [1, 2, 'привет']
print(f"Адрес списка в памяти ДО изменения: {id(my_list)}")

# Давайте что-нибудь в нём изменим
my_list[2] = 'пока'
my_list.append(4)
print(f"Наш список теперь: {my_list}")

# А теперь фокус: проверяем адрес еще раз
print(f"Адрес списка в памяти ПОСЛЕ изменения: {id(my_list)}")

Вывод:

Адрес списка в памяти ДО изменения: 4351048256
Наш список теперь: [1, 2, 'пока', 4]
Адрес списка в памяти ПОСЛЕ изменения: 4351048256

Видите? Адрес не изменился! Мы "стерли" и "дописали" значения прямо в том же самом объекте. Python не создавал новый список.

Кортеж (tuple): Гравировка на камне

А теперь посмотрим на кортеж. Он неизменяемый. Любая попытка поменять его "на месте" обречена на провал.

my_tuple = (1, 2, 'привет')

# Пытаемся провернуть тот же трюк, что и со списком...
# my_tuple[2] = 'пока'
# ...и Python тут же нас останавливает!
# TypeError: 'tuple' object does not support item assignment

Он буквально кричит: "Эй, этот объект высечен в камне, его нельзя менять!".

«Но постойте, — скажете вы, — я же могу сделать вот так!»

my_tuple = (1, 2, 'привет')
print(f"Адрес кортежа ДО 'изменения': {id(my_tuple)}")

# Добавляем новый элемент
my_tuple = my_tuple + (4,) # Обратите внимание на синтаксис
print(f"Наш кортеж теперь: {my_tuple}")

print(f"Адрес кортежа ПОСЛЕ 'изменения': {id(my_tuple)}")

Вывод:

Адрес кортежа ДО 'изменения': 4351332928
Наш кортеж теперь: (1, 2, 'привет', 4)
Адрес кортежа ПОСЛЕ 'изменения': 4351336064

А вот и ключевой момент! Адрес изменился. Мы не поменяли старый кортеж. Мы взяли новый "камень" и создали на нём совершенно новую "гравировку", которая включает в себя и старые, и новые данные. Старый объект (1, 2, 'привет') остался нетронутым (и вскоре будет удален сборщиком мусора).

Итог:

  • Изменяемость (Mutability) — это возможность поменять содержимое объекта, не меняя его адрес в памяти. list, dict, set — изменяемые.

  • Неизменяемость (Immutability) — это гарантия, что внутреннее состояние объекта не изменится после его создания. Любая "модификация" на самом деле создает новый объект. tuple, str, int, frozenset — неизменяемые.

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

3. Главный вопрос: Почему кортежи быстрее? Заглянем под капот CPython

Итак, мы выяснили, что кортежи неизменяемы. Но почему это делает их быстрее? Ответ кроется не на поверхности, а в деталях реализации Python. Есть две ключевые причины: то, как выделяется память, и хитрые оптимизации на уровне компиляции.

Причина №1: Оптимизация выделения памяти (Статика против Динамики)

Представьте, что вы собираете вещи.

  • Список (list) — это как эластичный рюкзак. Вы можете начать с пары вещей, но вы знаете, что, возможно, захотите доложить что-то еще. Поэтому вы берете рюкзак с запасом места. Python поступает так же: при создании списка он выделяет память не только под текущие элементы, но и резервирует дополнительное пространство. Это гениальный ход, который делает операции append() невероятно быстрыми — не нужно каждый раз просить у системы новую память. Но у этого есть цена: небольшой оверхед по памяти и более сложная логика управления ею.

  • Кортеж (tuple) — это как запаянный на заводе контейнер с фиксированным числом ячеек. Его размер известен в момент создания и никогда не изменится. Поэтому Python выделяет ровно столько памяти, сколько нужно для хранения всех его элементов, ни байтом больше. Процесс выделения памяти максимально прост и эффективен.

Итог: Кортежи более компактны и требуют меньше "умственной" работы от Python по управлению памятью, что делает их создание более быстрым и легковесным процессом.

Причина №2: Магия компиляции (Constant Folding)

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

Если компилятор видит кортеж, состоящий из констант (например, (1, 2, 'hello')), он расценивает его как единую, неизменяемую константу. Он создает этот объект один раз во время компиляции и просто сохраняет его. Каждый раз, когда ваш код доходит до этой строки, Python не создает новый кортеж, а просто берет ссылку на уже готовый, предзаготовленный объект.

Со списками такой трюк не пройдет. Поскольку список изменяем, Python не может быть уверен, что вы не захотите его поменять. Поэтому он обязан честно создавать новый список [1, 2, 'hello'] при каждом выполнении этой строки кода.

Доказательство: timeit и dis спешат на помощь!

Слова — это хорошо, но давайте посмотрим на цифры. С помощью модуля timeit измерим скорость создания миллиона списков и кортежей.

import timeit

# Замеряем время на создание 10 миллионов списков
list_time = timeit.timeit(stmt="[1, 2, 3, 4, 5]", number=10_000_000)
# Замеряем время на создание 10 миллионов кортежей
tuple_time = timeit.timeit(stmt="(1, 2, 3, 4, 5)", number=10_000_000)

print(f"Время создания списков:  {list_time:.3f} сек")
print(f"Время создания кортежей: {tuple_time:.3f} сек")

Типичный результат:

Время создания списков:  0.581 сек
Время создания кортежей: 0.089 сек

Разница в 5-6 раз! Это и есть та самая магия оптимизации в действии.

А чтобы увидеть её своими глазами, заглянем в байт-код с помощью модуля dis (дизассемблер).

import dis

def create_list():
  my_list = [1, 2, 3]

def create_tuple():
  my_tuple = (1, 2, 3)

print("--- Байт-код для списка ---")
dis.dis(create_list)

print("\n--- Байт-код для кортежа ---")
dis.dis(create_tuple)

Результат (упрощенно):

--- Байт-код для списка ---
  2           0 LOAD_CONST               1 (1)
              2 LOAD_CONST               2 (2)
              4 LOAD_CONST               3 (3)
              6 BUILD_LIST               3  # <--- Строим список КАЖДЫЙ раз
              8 STORE_FAST               0 (my_list)
             10 LOAD_CONST               0 (None)
             12 RETURN_VALUE

--- Байт-код для кортежа ---
  5           0 LOAD_CONST               1 ((1, 2, 3)) # <--- Загружаем ГОТОВЫЙ кортеж
              2 STORE_FAST               0 (my_tuple)
              4 LOAD_CONST               0 (None)
              6 RETURN_VALUE

Посмотрите! В случае со списком есть инструкция BUILD_LIST, которая собирает его из отдельных констант (1, 2, 3) прямо во время выполнения. А в случае с кортежем используется всего одна инструкция LOAD_CONST, которая загружает уже готовый, заранее созданный объект (1, 2, 3).

Вот вам и разгадка. Кортежи быстрее не потому, что "так написано в книжке", а из-за фундаментальных оптимизаций в самом сердце Python.

4. Практика: Когда использовать кортежи? 5 железобетонных правил

Итак, мы вооружились знаниями о неизменяемости и скорости. Теперь давайте превратим их в простой и понятный набор правил, который поможет вам в 99% случаев делать правильный выбор.

Правило №1: Для "атомарных" данных (целостность)

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

Подумайте о координатах на карте. Точка (x, y) — это единое целое. Вам никогда не захочется изменить только x, оставив y прежним. Изменение координаты — это создание новой точки.

Используйте кортеж, когда данные — это структура:

  • Координаты: point = (10, 20)

  • Цвет RGB: color = (255, 0, 128)

  • Запись из базы данных: user_record = ('Alice', 30, 'alice@example.com')

Используя кортеж, вы не только защищаете данные от случайного изменения, но и даете четкий сигнал другим программистам (и себе в будущем): «Эту структуру не нужно дергать по частям. Она — одно целое».

Правило №2: В качестве ключей для словарей

Это даже не правило, а закон Python. Ключом в словаре может быть только хешируемый объект. Если говорить просто, то у объекта должен быть постоянный хеш (числовой идентификатор), который не меняется в течение его жизни.

  • У кортежей он есть, потому что они неизменяемы.

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

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

Работает (и это очень полезно):

# Словарь, где ключ — это координаты региона
region_capitals = {
    (40.7128, -74.0060): "New York",
    (34.0522, -118.2437): "Los Angeles"
}

Не работает (и никогда не будет):

# Попытка использовать список как ключ
# region_capitals = {[40.7128, -74.0060]: "New York"}
# TypeError: unhashable type: 'list'

Правило №3: При возврате нескольких значений из функции

Это самый элегантный и "питонический" (Pythonic) способ вернуть из функции несколько результатов. Python делает это настолько изящно, что вы могли даже не замечать, что используете кортежи.

def get_user_stats(user_id):
    # ... какая-то сложная логика ...
    username = "admin"
    login_count = 152
    last_login_ip = "192.168.1.1"
    
    # Мы неявно создаем и возвращаем кортеж!
    return username, login_count, last_login_ip

# А здесь происходит магия распаковки кортежа
name, logins, ip = get_user_stats(1)

print(f"Пользователь {name} заходил {logins} раз с IP {ip}")

Код return username, login_count, last_login_ip на самом деле является коротким синтаксисом для return (username, login_count, last_login_ip). Это читаемо, эффективно и не требует создания громоздких словарей или списков только для того, чтобы передать данные на уровень выше.

Правило №4: Для микрооптимизаций в горячих точках

Осторожно! Это правило для продвинутых пользователей. Не занимайтесь преждевременной оптимизацией.

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

Когда это может иметь смысл:

# Представим, что это ОЧЕНЬ большой набор констант
# и он используется в главном цикле вашей программы
ALLOWED_ITEM_CODES = (101, 203, 404, 550, 812, ...) 

def process_items(items):
    for item in items:
        if item.code in ALLOWED_ITEM_CODES: # Эта проверка будет чуть быстрее
            # ... делаем что-то важное ...

Помните: сначала пишите читаемый код. Если он работает медленно, используйте профилировщик, чтобы найти узкое место. И только если профилировщик укажет на этот цикл, задумайтесь о замене списка на кортеж.

Правило №5: Когда сам язык подает вам пример

Обратите внимание, как устроен сам Python. Когда он собирает произвольное количество позиционных аргументов в функции, во что он их упаковывает? Правильно, в кортеж!

def my_func(*args):
    print(type(args))

my_func(1, 'hello', True)
# <class 'tuple'>

Язык сам использует tuple для передачи набора данных, который зафиксирован в момент вызова функции. Это лучший намек на то, для чего эта структура данных была задумана.

5. Расширяем горизонты: frozenset и другие неизменяемые типы

Думаете, история о неизменяемости заканчивается на кортежах? Как бы не так! Идея иммутабельности настолько важна в Python, что она встроена в целый ряд других типов данных, которыми вы, возможно, уже пользуетесь каждый день.

Знакомьтесь, frozenset — замороженный брат-близнец set

Вы наверняка знаете и любите set (множество) за его способность хранить только уникальные элементы и молниеносно проверять принадлежность. Но у set есть та же "проблема", что и у списка — он изменяемый.

my_set = {1, 2, 3}
my_set.add(4)      # Работает
my_set.remove(1)   # Работает

А что, если нам нужно множество, которое нельзя изменить? Например, чтобы использовать его как ключ в словаре?

Для этого и существует frozenset. Это, по сути, set, который окунули в жидкий азот. Он обладает всеми преимуществами множества (уникальность, быстрые проверки), но при этом он полностью неизменяем.

Смотрите сами:

# Создаем "замороженное множество"
frozen_tags = frozenset(['python', 'best_practices', 'performance'])

# Попытка изменить его приведет к ошибке
# frozen_tags.add('new_tag')
# AttributeError: 'frozenset' object has no attribute 'add'

В чём же его фишка? Точно та же, что и у кортежа! Поскольку frozenset хешируемый, его можно использовать как ключ словаря. Это открывает интересные возможности, например, для кэширования результатов на основе набора параметров.

# Кэш для результатов обработки наборов тегов
cache = {
    frozenset(['python', 'django']): 'Result A',
    frozenset(['python', 'performance']): 'Result B'
}

Неожиданный поворот: вы используете неизменяемые типы постоянно

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

  • Строки (str): Вы когда-нибудь пробовали изменить один символ в строке?

    my_string = "Hello"
    # my_string[0] = "J" 
    # TypeError: 'str' object does not support item assignment
    

    Любая операция над строкой, будь то replace(), upper() или конкатенация, не меняет исходную строку, а создает новую. Строки — классический пример иммутабельности.

  • Числа (int, float) и булевы значения (bool): Это может показаться странным, но числа тоже неизменяемы. Когда вы делаете x = 5, а затем x = x + 1, вы не меняете объект 5. Вы создаете новый объект 6 и заставляете переменную x указывать на него. Объекты 5 и 6 так и остаются в памяти как незыблемые константы.

Вывод:

Неизменяемость — это не какая-то экзотическая особенность кортежей. Это фундаментальный принцип в дизайне Python, который обеспечивает предсказуемость, безопасность и открывает двери для различных оптимизаций. Понимая этот принцип, вы начинаете видеть язык в совершенно новом свете.

Финальный экзамен: 5 задач для закрепления

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

Задача 1: Коварная функция

Что выведет этот код и, самое главное, почему?

def add_item(item, basket=[]):
    basket.append(item)
    return basket

print(add_item('яблоко'))
print(add_item('банан'))
print(add_item('апельсин'))
Задача 2: Ключ на старт!

Представьте, что у вас есть словарь my_dict = {}. Какие из следующих операций завершатся успешно, а какие вызовут ошибку? Объясните свой ответ.

  1. my_dict[(1, 2, 3)] = "успех"

  2. my_dict[[1, 2, 3]] = "ошибка"

  3. my_dict[frozenset([1, 2, 3])] = "успех"

  4. my_dict[{1, 2, 3}] = "ошибка"

Задача 3: Неизменяемый, но с сюрпризом

Что произойдет после выполнения этого кода? Будет ли ошибка? Если нет, то что будет в my_tuple?

my_tuple = (1, 2, ['a', 'b'])
my_tuple[2].append('c')

print(my_tuple)
Задача 4: Выбор архитектора

Вы пишете программу для управления светофором. У вас есть набор постоянных цветовых кодов RGB, которые никогда не меняются: КРАСНЫЙ = (255, 0, 0), ЖЕЛТЫЙ = (255, 255, 0), ЗЕЛЕНЫЙ = (0, 255, 0).

В каком виде вы будете хранить эти цвета в основной конфигурации программы: в виде списка [(255,0,0), ...] или кортежа ((255,0,0), ...)? Назовите минимум две причины для своего выбора.

Задача 5: Мастер распаковки

У вас есть функция, которая возвращает историю оценок ученика. Первые две оценки — самые важные (за контрольные), последняя — за поведение, а между ними могут быть оценки за домашние работы, количество которых неизвестно.

def get_grades():
    return (10, 9, 7, 8, 8, 10) # 10, 9 - контрольные; 7,8,8 - домашки; 10 - поведение

Напишите одну строку кода, которая распакует результат функции get_grades() в четыре переменные: control_work1, control_work2, homeworks (должен быть списком) и behavior_grade.

Анонс новых статей, полезные материалы, а так же если в процессе решения возникнут сложности, обсудить их или задать вопрос по статье можно в моём Telegram-сообществе.

Уверен, у вас все получится. Вперед, к практике!

Комментарии (1)


  1. Gadd
    12.11.2025 09:11

    Благодаря динамической природе Python, в нём не видна суть кортежа - благодаря этому он воспринимается как "просто неизменяемый список". Однако суть его совсем в другом, и это видно только в статических языках, например в Rust.
    Я воспринимаю эту разницу так: список - это изменяемая последовательность однородных данных, кортеж - это неизменяемый набор разнородных данных. Он потому неизменяемый, так как данные разнородны и в статических языках мы не можем заменить тип элемента кортежа. В Python эта разница размывается, так как в списке могут быть ссылки на объекты разных типов.
    В этой статье этот момент упоминался:

    Используйте кортеж, когда данные — это структура

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

    ИМХО. Критика приветствуется.