Списки vs кортежи в Python: когда что использовать

Полгода назад я хранил координаты городов в списках: moscow = [55.75, 37.62]. Всё работало, пока коллега случайно не написал moscow[0] = 0 в середине скрипта. Программа отработала без ошибок, но вывела карту с Москвой на экваторе. Один кортеж moscow = (55.75, 37.62) вместо списка предотвратил бы эту ошибку: попытка присваивания вызвала бы TypeError. Списки и кортежи это две последовательности в Python, которые хранят упорядоченные коллекции элементов. Главное отличие: списки изменяемы (mutable), кортежи нет (immutable).
Эта статья разбирает разницу между списками и кортежами по пяти параметрам: изменяемость, производительность, память, хешируемость и области применения.
Синтаксис и создание
Списки
Списки создаются квадратными скобками или конструктором list().
fruits = ["яблоко", "груша", "слива"]
numbers = list(range(5)) # [0, 1, 2, 3, 4]
empty = []
mixed = ["text", 42, 3.14, True]
Кортежи
Кортежи создаются круглыми скобками или конструктором tuple(). Скобки можно опускать, если контекст однозначен.
point = (55.75, 37.62)
colors = "red", "green", "blue" # скобки необязательны
single = (42,) # запятая обязательна для одного элемента
empty = ()
from_string = tuple("lupins") # ('l', 'u', 'p', 'i', 'n', 's')
Одноэлементный кортеж требует запятую после значения. Без неё Python трактует (42) как число в скобках, а не кортеж.
not_a_tuple = 42
print(type(not_a_tuple)) # <class 'int'>
a_tuple = (42,)
print(type(a_tuple)) # <class 'tuple'>
Изменяемость: главное отличие
Списки изменяемы. Элементы можно добавлять, удалять и заменять после создания.
fruits = ["яблоко", "груша", "слива"]
fruits[0] = "банан" # замена элемента
fruits.append("киви") # добавление
fruits.remove("груша") # удаление
print(fruits) # ['банан', 'слива', 'киви']
Кортежи неизменяемы. Попытка изменить элемент вызывает TypeError.
point = (55.75, 37.62)
try:
point[0] = 0
except TypeError as e:
print(e)
# 'tuple' object does not support item assignment
Ловушка: изменяемые объекты внутри кортежа
Кортеж хранит ссылки на объекты. Если ссылка указывает на изменяемый объект (например, список), сам объект можно менять. Кортеж при этом не меняется: он продолжает ссылаться на тот же список.
config = ("production", ["server1", "server2"])
config[1].append("server3") # это работает!
print(config) # ('production', ['server1', 'server2', 'server3'])
try:
config[1] = ["other"] # а это нет
except TypeError:
print("Нельзя заменить ссылку")
Можно изменить содержимое списка внутри кортежа, но нельзя заменить сам список на другой объект.
Доступные методы
Списки имеют 11 методов для изменения данных: append, extend, insert, remove, pop, clear, sort, reverse, copy и другие. Кортежи имеют только 2 метода: count и index.
# Список
nums = [3, 1, 4, 1, 5]
nums.sort()
nums.append(9)
print(nums) # [1, 1, 3, 4, 5, 9]
# Кортеж
t = (3, 1, 4, 1, 5)
print(t.count(1)) # 2
print(t.index(4)) # 2
Общие операции, работающие с обоими типами:
data = (10, 20, 30, 40, 50) # или список
print(len(data)) # 5
print(data[2]) # 30
print(data[1:4]) # (20, 30, 40)
print(30 in data) # True
print(min(data)) # 10
print(max(data)) # 50
print(sum(data)) # 150
Индексирование, срезы, in, len, min, max, sum, итерация через for работают одинаково для обоих типов.
Производительность
Скорость создания
Создание кортежа быстрее создания списка, потому что Python выделяет память за одну операцию и не создаёт механизм для будущих изменений.
import timeit
print(timeit.timeit("(1, 2, 3, 4, 5)", number=10_000_000))
# ~0.08 сек
print(timeit.timeit("[1, 2, 3, 4, 5]", number=10_000_000))
# ~0.45 сек
Кортеж создаётся примерно в 5 раз быстрее списка. Python кэширует небольшие кортежи и переиспользует их, тогда как для каждого списка выделяется новая память.
Скорость итерации
При проходе через for разница минимальна. На тысяче элементов и кортеж, и список перебираются примерно за одинаковое время. Выбирать структуру данных ради скорости итерации не стоит.
Расход памяти
Кортежи занимают меньше памяти, чем списки с теми же данными. Список хранит дополнительный буфер для будущих элементов (over-allocation).
import sys
lst = [1, 2, 3, 4, 5]
tpl = (1, 2, 3, 4, 5)
print(sys.getsizeof(lst)) # 104 байта (CPython 3.12)
print(sys.getsizeof(tpl)) # 80 байт
Для пяти элементов список занимает примерно на 30% больше памяти, чем кортеж. Для единичных структур разница незаметна. Для миллиона записей экономия при использовании кортежей может составить сотни мегабайт.
Хешируемость и словарные ключи
Кортежи (содержащие только неизменяемые элементы) хешируемы. Списки нет. Это значит, что кортежи можно использовать как ключи словаря и элементы множеств.
# Кортеж как ключ словаря
locations = {}
locations[(55.75, 37.62)] = "Москва"
locations[(59.93, 30.32)] = "Петербург"
print(locations[(55.75, 37.62)]) # Москва
# Список как ключ — ошибка
try:
d = {[1, 2]: "value"}
except TypeError as e:
print(e)
# unhashable type: 'list'
Кортеж с вложенным списком тоже нехешируем.
try:
d = {(1, [2, 3]): "value"}
except TypeError as e:
print(e)
# unhashable type: 'list'
Кортежи в множествах
points = {(0, 0), (1, 1), (0, 0), (2, 2)}
print(points) # {(0, 0), (1, 1), (2, 2)} — дубликат удалён
Составные ключи словаря
directory = {}
directory[("Иванов", "Пётр")] = "+7-999-111-2233"
directory[("Иванов", "Анна")] = "+7-999-444-5566"
for (last, first), phone in directory.items():
print(f"{first} {last}: {phone}")
Кортежи как составные ключи позволяют индексировать данные по нескольким полям без создания отдельного класса.
Распаковка кортежей
Python позволяет распаковать кортеж в отдельные переменные за одну операцию.
# Простая распаковка
point = (55.75, 37.62)
lat, lon = point
print(lat) # 55.75
# Обмен значений
a, b = 1, 2
a, b = b, a
print(a, b) # 2 1
# Распаковка в цикле
pairs = [("alice", 90), ("bob", 85), ("carol", 95)]
for name, score in pairs:
print(f"{name}: {score}")
Распаковка работает и со списками, но идиоматичнее использовать кортежи, когда элементы имеют разные роли (имя и оценка, широта и долгота).
Распаковка с items()
d = {"a": 10, "b": 1, "c": 22}
for key, val in d.items():
print(val, key)
Метод items() возвращает пары (ключ, значение) как кортежи. Цикл for распаковывает каждую пару в две переменные.
Паттерн DSU: сортировка с кортежами
DSU (Decorate-Sort-Undecorate) это приём, в котором данные оборачиваются в кортежи для сортировки по нужному полю.
txt = "but soft what light in yonder window breaks"
words = txt.split()
# Decorate: создать список кортежей (длина, слово)
decorated = []
for word in words:
decorated.append((len(word), word))
# Sort: Python сортирует кортежи поэлементно
decorated.sort(reverse=True)
# Undecorate: извлечь слова
result = [word for length, word in decorated]
print(result)
# ['yonder', 'window', 'breaks', 'light', 'what', 'soft', 'but', 'in']
Кортежи сравниваются поэлементно: сначала первый элемент, при равенстве второй.
print((0, 1, 2) < (0, 3, 4)) # True (сравнение по второму элементу)
print((0, 1, 2000000) < (0, 3, 4)) # True (2-й элемент решает)
namedtuple: кортеж с именами полей
Обычный кортеж хранит данные по позициям: point[0] это широта, point[1] долгота. namedtuple добавляет имена полям без потери свойств кортежа.
from collections import namedtuple
Point = namedtuple("Point", ["lat", "lon"])
moscow = Point(55.75, 37.62)
print(moscow.lat) # 55.75
print(moscow[0]) # 55.75 (работает и по индексу)
print(moscow) # Point(lat=55.75, lon=37.62)
namedtuple остаётся кортежем: неизменяемый, хешируемый, можно использовать как ключ словаря.
Когда использовать список, а когда кортеж
Практическое правило: если коллекция будет расти, сжиматься или перемешиваться, берите список. Если данные фиксированы и описывают одну запись (координаты, RGB, строку CSV), берите кортеж.
Конвертация между типами
# Список в кортеж
fruits = ["яблоко", "груша", "слива"]
frozen = tuple(fruits)
print(frozen) # ('яблоко', 'груша', 'слива')
# Кортеж в список
point = (55.75, 37.62)
editable = list(point)
editable.append(200)
print(editable) # [55.75, 37.62, 200]
Конвертация создаёт новый объект. Исходная коллекция не меняется.
Неочевидные детали
Первый факт: пустой кортеж () это синглтон в CPython. Все пустые кортежи ссылаются на один объект в памяти.
a = ()
b = ()
print(a is b) # True
Второй факт: оператор += для кортежей создаёт новый объект, а для списков изменяет существующий.
t = (1, 2)
print(id(t))
t += (3,)
print(id(t)) # другой id
lst = [1, 2]
print(id(lst))
lst += [3]
print(id(lst)) # тот же id
Третий факт: sorted() принимает кортеж, но всегда возвращает список. Для получения отсортированного кортежа: tuple(sorted(t)).
Четвёртый факт: умножение кортежа на число дублирует ссылки, а не объекты.
t = ([1, 2],) * 3
t[0].append(3)
print(t) # ([1, 2, 3], [1, 2, 3], [1, 2, 3])
Все три списка это один объект. Изменение одного затрагивает все.
FAQ
Можно ли сортировать список кортежей?
Да. Python сравнивает кортежи поэлементно, поэтому sort() работает без дополнительных настроек.
Что быстрее: x in list или x in tuple?
Для поиска оба делают линейный проход. Разница минимальна. Если нужен быстрый поиск, используйте set.
Как вернуть несколько значений из функции?
Запись return a, b эквивалентна return (a, b).
def min_max(data):
return min(data), max(data)
lo, hi = min_max([3, 41, 12, 9, 74])
print(lo, hi) # 3 74
Зачем нужен namedtuple, если есть dataclass?
namedtuple легковеснее и неизменяем по умолчанию. dataclass (Python 3.7+) гибче: поддерживает значения по умолчанию, методы, наследование. Для простых неизменяемых записей хватает namedtuple. Для сложных объектов с логикой лучше dataclass(frozen=True).
Мой совет: по умолчанию берите список. Переключайтесь на кортеж в трёх случаях: данные не должны меняться, нужен ключ словаря, или функция возвращает несколько значений. Если начали использовать кортеж и через месяц потребовался append, конвертируйте в список одной строкой.
