Кортежи и NamedTuple: иммутабельные структуры

tuple - неизменяемая упорядоченная последовательность. На первый взгляд - просто иммутабельный list, но есть нюансы. И есть полезная типизированная версия - NamedTuple с именованными полями. В этом уроке - когда использовать кортежи, в чём отличие от списка, и зачем нужны namedtuples.

Создание кортежа

# Литерал
point = (1, 2, 3)

# Без скобок (запятая - главная часть синтаксиса)
point = 1, 2, 3

# Пустой
empty = ()

# С одним элементом - ВАЖНО: запятая обязательна
single = (1,)           # tuple
not_tuple = (1)         # это просто int 1 в скобках!

# Из iterable
chars = tuple("hello")  # ('h', 'e', 'l', 'l', 'o')
nums = tuple([1, 2, 3]) # (1, 2, 3)
Скобки `()` нужны только для группировки или пустого кортежа. Сам кортеж определяется **запятыми**:
1, 2, 3       # тоже tuple - (1, 2, 3)
(1, 2, 3)     # tuple
(1)           # int!
(1,)          # tuple из одного элемента
1,            # тоже tuple - (1,)

Tuple vs List

Главные различия:

Свойствоtuplelist
Изменяемостьimmutablemutable
Литерал(1, 2) или 1, 2[1, 2]
Хешируемыйда (если элементы тоже)нет
Используется как ключ dictданет
Памятьчуть меньшечуть больше
Скорость доступачуть быстреечуть медленнее
Методыcount, indexмного (append, pop, sort...)

Когда какой:

  • list - набор похожих элементов, который меняется (массив, очередь, список пользователей)
  • tuple - фиксированная запись разнородных данных (координаты (x, y), цвет (r, g, b), возвращаемое из функции)

Эмпирическое правило: если хочется обращаться по индексу с разным семантическим значением (item[0] - имя, item[1] - возраст), это скорее всего должен быть namedtuple или dataclass. Про изменяемый list - в предыдущем уроке.

Операции с tuple

Те же что у list, но без модификации:

t = (1, 2, 3, 4, 5)

t[0]             # 1
t[-1]            # 5
t[1:3]           # (2, 3)
len(t)           # 5
2 in t           # True
t.count(2)       # 1
t.index(3)       # 2

# Конкатенация - возвращает новый tuple
t + (6, 7)       # (1, 2, 3, 4, 5, 6, 7)

# Повторение
(1, 2) * 3       # (1, 2, 1, 2, 1, 2)

# Распаковка
a, b, c, d, e = t
first, *rest = t   # first=1, rest=[2, 3, 4, 5] - rest всегда list!

*rest собирает остаток в list, не tuple - это особенность распаковки.

Иммутабельность - не глубокая

tuple immutable на верхнем уровне - нельзя переприсвоить элементы. Но если элемент сам mutable, его можно изменить:

t = ([1, 2], [3, 4])
t[0].append(99)
print(t)   # ([1, 2, 99], [3, 4])

t[0] = [5, 6]   # TypeError - переприсвоить нельзя

Это уже обсуждали в уроке про ссылки и изменчивость. Иммутабельность tuple - про структуру, не про содержимое ссылок.

Возврат нескольких значений из функции

Главное идиоматическое применение tuple:

def divmod(a, b):
    return a // b, a % b   # возвращает tuple (q, r)

q, r = divmod(7, 3)   # 2, 1

Скобки опциональны - запятая делает tuple. Распаковка на стороне вызова даёт удобный синтаксис.

NamedTuple - кортеж с именованными полями

Если у tuple элементы имеют семантику, имена улучшают читаемость:

# Безымянный tuple - что есть что?
user = ("Alice", 30, "alice@example.com")
print(user[0])   # имя? фамилия?

# С namedtuple - явно
from collections import namedtuple

User = namedtuple("User", ["name", "age", "email"])

user = User("Alice", 30, "alice@example.com")
print(user.name)    # Alice
print(user.age)     # 30
print(user.email)   # alice@example.com

# Можно по индексу и распаковывать
print(user[0])      # Alice
name, age, email = user

NamedTuple это полноценный tuple с дополнительным атрибутным доступом. Все методы tuple (count, index) работают.

typing.NamedTuple - современный синтаксис

С Python 3.5 есть class-based вариант с type hints:

from typing import NamedTuple

class User(NamedTuple):
    name: str
    age: int
    email: str

user = User(name="Alice", age=30, email="alice@example.com")
print(user.name, user.age)

Преимущества:

  • Type hints для каждого поля
  • Можно добавлять методы (читать только, поскольку tuple immutable)
  • Better support в IDE
  • Возможны default-значения
class User(NamedTuple):
    name: str
    age: int
    email: str = "unknown@example.com"   # default

    def display_name(self) -> str:
        return f"{self.name} ({self.email})"

user = User("Alice", 30)
print(user.display_name())   # Alice (unknown@example.com)

NamedTuple vs dataclass

NamedTuple и @dataclass похожи, но разные:

СвойствоNamedTuple@dataclass
Изменяемостьimmutablemutable (или frozen=True)
Базовый классtupleobject
Распаковка a, b = objработаетнет (без iter)
Памятькомпактнеебольше
Поддерживает наследованиесложнопроще
Методыможно добавлятьможно добавлять
Mutable defaultsнельзянельзя без field

Когда NamedTuple:

  • Данные не меняются (DTO, results)
  • Возвращаемое значение функции с несколькими полями
  • Hashable нужен (можно как ключ dict)
  • Распаковка удобна (x, y = point)

Когда @dataclass:

  • Объект меняется со временем
  • Сложная логика (методы, наследование)
  • Нужен большой публичный API

В новых проектах часто предпочитают @dataclass(frozen=True) - читается ближе к обычным классам.

Методы NamedTuple

User = namedtuple("User", ["name", "age", "email"])
user = User("Alice", 30, "a@b.c")

# Получить словарь
user._asdict()   # {'name': 'Alice', 'age': 30, 'email': 'a@b.c'}

# Создать новый с заменой поля
older = user._replace(age=31)
print(older)     # User(name='Alice', age=31, email='a@b.c')
print(user)      # User(name='Alice', age=30, email='a@b.c') - оригинал не изменился

# Список имён полей
User._fields     # ('name', 'age', 'email')

# Создать из iterable
data = ["Bob", 25, "b@b.c"]
user2 = User._make(data)

Методы начинаются с _ чтобы не конфликтовать с именами полей.

Подводные камни

1. Single-element tuple без запятой

single = (1)     # это int 1!
single = (1,)    # это tuple (1,)
single = 1,      # тоже tuple

Без запятой Python не понимает что это кортеж - просто скобки группировки.

2. Распаковка не в tuple

first, *rest = (1, 2, 3, 4)
print(type(rest))   # list - не tuple!

Star-распаковка всегда даёт list, независимо от типа исходного.

3. Сравнение tuple

Кортежи сравниваются лексикографически - поэлементно:

(1, 2) < (1, 3)      # True
(1, 2, 3) < (1, 2, 4) # True
(1, 2) < (1, 2, 0)   # True - короткий "меньше"

Часто используется для сортировки по нескольким ключам:

sorted(users, key=lambda u: (u.last_name, u.first_name))

Размер в памяти

import sys

sys.getsizeof((1, 2, 3))   # ~64 байта
sys.getsizeof([1, 2, 3])   # ~88 байт - чуть больше

Tuple компактнее, потому что не нужно резервировать место под будущие append. Для миллионов маленьких записей это может быть важно.

Hash и использование как ключ

# tuple из hashable hashable
key = (1, "abc")
data = {key: "value"}

# tuple с mutable - не hashable
bad_key = (1, [2, 3])
hash(bad_key)   # TypeError - unhashable type

Это позволяет использовать кортежи как ключи словаря для составных индексов:

inventory = {}
inventory[("warehouse-A", "SKU-001")] = 100
inventory[("warehouse-A", "SKU-002")] = 50
inventory[("warehouse-B", "SKU-001")] = 200

Сравнение с Go и PHP

Go не имеет встроенных кортежей. Похожее достигается через структуры:

type Point struct {
    X, Y int
}
p := Point{1, 2}

Множественные возвращаемые значения - синтаксис языка, не структура:

func divmod(a, b int) (int, int) {
    return a / b, a % b
}

PHP имеет ассоциативные массивы вместо специальных кортежей. С PHP 7.1 появилась распаковка через list().

Python tuple более компактный и идиоматичный для группировки разнородных данных.

Мини-задание

  1. Single-element tuple:
def is_tuple(obj):
    return isinstance(obj, tuple)

print(is_tuple((1)))    # False - это int
print(is_tuple((1,)))   # True - tuple
print(is_tuple(1,))     # True - тоже tuple (запятая делает дело)
  1. NamedTuple как DTO:
from typing import NamedTuple

class Coordinate(NamedTuple):
    lat: float
    lng: float
    elevation: float = 0.0

    def distance_to(self, other):
        # упрощённая евклидова
        return ((self.lat - other.lat) ** 2 +
                (self.lng - other.lng) ** 2 +
                (self.elevation - other.elevation) ** 2) ** 0.5

a = Coordinate(55.75, 37.62)         # Москва без elevation
b = Coordinate(51.50, -0.12, 100.0)  # Лондон с elevation

print(a.distance_to(b))
  1. Tuple как ключ:
# Курсы валют между парами
rates = {
    ("USD", "EUR"): 0.92,
    ("USD", "GBP"): 0.79,
    ("EUR", "USD"): 1.09,
}

def convert(amount, frm, to):
    if frm == to:
        return amount
    rate = rates.get((frm, to))
    if rate is None:
        raise ValueError(f"Нет курса {frm}->{to}")
    return amount * rate

print(convert(100, "USD", "EUR"))   # 92.0

Что дальше

Освоили tuple и NamedTuple. В следующем уроке - словари (dict): основная structure для key-value данных в Python.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.