__len__, __getitem__, __setitem__ — делаем свою коллекцию
Списки, словари, строки — у них всех есть кое-что общее: к ним можно обращаться по индексу, узнавать их длину, проверять вхождение элемента. В этом уроке мы научим свои классы делать то же самое. После этого ваши объекты будут вести себя как настоящие встроенные коллекции Python.
🔍 Как Python на самом деле обрабатывает len() и квадратные скобки
Когда вы пишете len(my_list) — Python не колдует. Он просто вызывает метод my_list.__len__(). Когда вы пишете my_list[0] — Python вызывает my_list.__getitem__(0). Это та же самая идея, что мы видели с __str__ и __repr__: любая встроенная операция — это вызов специального метода.
📏 Длина
len(obj) → obj.__len__()
📖 Чтение
obj[key] → obj.__getitem__(key)
✏️ Запись
obj[key] = val → obj.__setitem__(key, val)
🗑️ Удаление
del obj[key] → obj.__delitem__(key)
🔎 Вхождение
x in obj → obj.__contains__(x)
Главная идея: если вы определите эти методы в своём классе — Python начнёт воспринимать ваш объект как коллекцию. Вы сможете писать len(obj), obj[0], for item in obj — и всё будет работать так же, как с обычным списком.
📏 __len__ — поддержка len()
Начнём с самого простого. Метод __len__ должен вернуть целое неотрицательное число — «количество элементов» в вашем объекте. Именно это число вернёт вызов len(obj).
class WordCollection:
def __init__(self):
self._words = []
def add(self, word):
self._words.append(word)
def __len__(self):
# len() будет возвращать количество слов в коллекции
return len(self._words)
wc = WordCollection()
wc.add("Python")
wc.add("ООП")
wc.add("класс")
print(len(wc)) # 3
print(len(wc)) # всё ещё 3 (не меняется само по себе)
wc.add("метод")
print(len(wc)) # 4
💡 Почему атрибут называется _words с подчёркиванием? Одно подчёркивание в начале — это соглашение: «этот атрибут внутренний, не трогай снаружи». Python не запрещает к нему обращаться, но программисты договорились: такие атрибуты — детали реализации класса. Мы храним данные в _words, а снаружи работаем через len(wc), wc.add() и т.д.
Важно: __len__ обязан вернуть целое число (int) и не может быть отрицательным. Python проверяет это и выбросит ValueError, если вернёте, например, -1.
📖✏️ __getitem__ и __setitem__ — квадратные скобки
Именно эти два метода позволяют писать obj[0] (чтение) и obj[0] = "новое значение" (запись). __getitem__ получает ключ/индекс и должен вернуть значение. __setitem__ получает ключ и новое значение и должен его сохранить.
class NumberBox:
"""Простая «коробка» для хранения числового ряда."""
def __init__(self, *values):
# Сохраняем переданные значения в список
self._data = list(values)
def __len__(self):
return len(self._data)
def __getitem__(self, index):
# obj[index] → возвращаем элемент по индексу
return self._data[index]
def __setitem__(self, index, value):
# obj[index] = value → меняем элемент по индексу
self._data[index] = value
box = NumberBox(10, 20, 30, 40, 50)
# Чтение по индексу — как у обычного списка:
print(box[0]) # 10
print(box[2]) # 30
print(box[-1]) # 50 ← отрицательный индекс тоже работает!
# Запись по индексу:
box[1] = 99
print(box[1]) # 99
# len():
print(len(box)) # 5
✅ Отрицательные индексы работают бесплатно, потому что мы просто передаём index в self._data[index], а встроенный список Python уже умеет обрабатывать -1, -2 и т.д. Вы получаете это бесплатно, если храните данные во внутреннем списке.
🗑️ __delitem__ — оператор del
Когда вы пишете del obj[key] — Python вызывает obj.__delitem__(key). Метод должен удалить элемент по указанному ключу или индексу. Если элемент не найден — принято бросать KeyError (для словарей) или IndexError (для последовательностей).
class NumberBox:
def __init__(self, *values):
self._data = list(values)
def __len__(self):
return len(self._data)
def __getitem__(self, index):
return self._data[index]
def __setitem__(self, index, value):
self._data[index] = value
def __delitem__(self, index):
# del obj[index] → удаляем элемент из внутреннего списка
del self._data[index]
box = NumberBox(10, 20, 30, 40, 50)
print(len(box)) # 5
del box[2] # удаляем элемент с индексом 2 (значение 30)
print(len(box)) # 4
print(box[0]) # 10
print(box[1]) # 20
print(box[2]) # 40 ← был 40, теперь стал на позиции 2
⚠️ Не забывайте про сдвиг индексов! После удаления элемента список «схлопывается»: всё, что было правее удалённого, сдвигается влево. Индекс 2 теперь указывает на то, что раньше было под индексом 3. Это стандартное поведение Python-списка.
🔎 __contains__ — оператор in
Оператор in проверяет, есть ли элемент в коллекции: 5 in box. Python вызывает для этого метод __contains__. Он должен вернуть True или False.
class NumberBox:
def __init__(self, *values):
self._data = list(values)
def __len__(self):
return len(self._data)
def __getitem__(self, index):
return self._data[index]
def __setitem__(self, index, value):
self._data[index] = value
def __delitem__(self, index):
del self._data[index]
def __contains__(self, item):
# x in obj → True если item есть в коллекции
return item in self._data
box = NumberBox(10, 20, 30, 40, 50)
print(10 in box) # True
print(99 in box) # False
print(30 in box) # True
# Оператор not in тоже работает:
print(99 not in box) # True
💡 Фоллбек без __contains__: если вы не определите __contains__, но определите __iter__ или __getitem__ — Python всё равно сможет выполнить in. Он просто обойдёт все элементы по одному и сравнит каждый. Это медленнее для больших коллекций, но работает автоматически. Если хотите быстрый поиск — определяйте __contains__ явно.
🔄 Магия: __getitem__ автоматически включает for-цикл
Вот неочевидная, но очень полезная особенность Python: если у вашего класса есть __getitem__, цикл for item in obj будет работать автоматически — даже без определения __iter__.
Как это происходит? Python начинает вызывать obj.__getitem__(0), потом obj.__getitem__(1), obj.__getitem__(2) — и так, пока не получит IndexError. Это сигнал «элементы закончились», и цикл останавливается.
class NumberBox:
def __init__(self, *values):
self._data = list(values)
def __len__(self):
return len(self._data)
def __getitem__(self, index):
# Python будет звать __getitem__(0), __getitem__(1), ...
# пока не получит IndexError — тогда цикл остановится
return self._data[index] # список сам бросит IndexError при выходе за границы
box = NumberBox(10, 20, 30, 40, 50)
# for-цикл работает без __iter__!
for item in box:
print(item)
# 10
# 20
# 30
# 40
# 50
# list() тоже работает:
print(list(box)) # [10, 20, 30, 40, 50]
# sum() тоже работает:
print(sum(box)) # 150
🧠 Это называется «протокол последовательности» (sequence protocol). Python не требует от вас реализовывать всё сразу. Достаточно __getitem__ — и вы уже получаете работающий for, in (медленный), list(), sum(), min(), max()... Добавляйте остальные методы только тогда, когда нужна особая логика или производительность.
✂️ Поддержка срезов (slice) в __getitem__
Вы наверняка писали что-то вроде my_list[1:4] — это срез (slice). Когда вы пишете obj[1:4], Python автоматически создаёт специальный объект slice(1, 4, None) и передаёт его в __getitem__ как параметр key. Никакого другого механизма нет!
Чтобы поддержать срезы, нужно проверить тип параметра: если это slice — обработать особым образом, если целое число (int) — вернуть один элемент.
class NumberBox:
def __init__(self, *values):
self._data = list(values)
def __len__(self):
return len(self._data)
def __getitem__(self, key):
if isinstance(key, slice):
# key — это объект slice, например slice(1, 4, None) для [1:4]
# Передаём его напрямую во внутренний список — он умеет с ним работать
sliced = self._data[key]
# Возвращаем новый NumberBox из среза, а не голый список
return NumberBox(*sliced)
else:
# Обычный целочисленный индекс
return self._data[key]
def __repr__(self):
return f"NumberBox{tuple(self._data)}"
box = NumberBox(10, 20, 30, 40, 50)
# Обычный доступ:
print(box[0]) # 10
print(box[-1]) # 50
# Срезы:
print(box[1:4]) # NumberBox(20, 30, 40)
print(box[::2]) # NumberBox(10, 30, 50) ← каждый второй
print(box[::-1]) # NumberBox(50, 40, 30, 20, 10) ← обратный порядок
📐 Что такое объект slice?
Когда Python видит obj[1:4:2], он создаёт slice(start=1, stop=4, step=2). У него три атрибута: start, stop, step. Все они могут быть None — например, obj[:4] создаёт slice(None, 4, None). Если вы хотите обработать их вручную, есть удобный метод: key.indices(len(self)) — он вернёт готовые целые числа с учётом длины коллекции.
🎵 Полный пример: класс Playlist
Теперь соберём всё вместе в один реальный пример. Создадим класс Playlist — плейлист с треками. Он будет поддерживать все операции коллекции: длину, индексацию, изменение, удаление, проверку вхождения и срезы.
class Track:
"""Один трек в плейлисте."""
def __init__(self, title, artist, duration_sec):
self.title = title
self.artist = artist
self.duration_sec = duration_sec
def __repr__(self):
minutes = self.duration_sec // 60
seconds = self.duration_sec % 60
return f"{self.artist} — {self.title} ({minutes}:{seconds:02d})"
class Playlist:
"""Плейлист — коллекция треков с полной поддержкой протокола последовательности."""
def __init__(self, name):
self.name = name
self._tracks = []
# --- Добавление трека (обычный метод) ---
def add_track(self, track):
self._tracks.append(track)
# --- len(playlist) ---
def __len__(self):
return len(self._tracks)
# --- playlist[index] и playlist[start:stop] ---
def __getitem__(self, key):
if isinstance(key, slice):
# При срезе возвращаем новый Playlist с отобранными треками
new_pl = Playlist(f"{self.name} [срез]")
new_pl._tracks = self._tracks[key]
return new_pl
return self._tracks[key]
# --- playlist[index] = track ---
def __setitem__(self, index, track):
self._tracks[index] = track
# --- del playlist[index] ---
def __delitem__(self, index):
del self._tracks[index]
# --- track in playlist ---
def __contains__(self, track):
return track in self._tracks
# --- Строковое представление ---
def __repr__(self):
return f"Playlist({self.name!r}, {len(self)} треков)"
def __str__(self):
if not self._tracks:
return f"Плейлист «{self.name}» пустой"
lines = [f"🎵 Плейлист «{self.name}» ({len(self)} треков):"]
for i, track in enumerate(self._tracks, 1):
lines.append(f" {i}. {track}")
total_sec = sum(t.duration_sec for t in self._tracks)
lines.append(f" Общее время: {total_sec // 60} мин {total_sec % 60} сек")
return "\n".join(lines)
# === Используем Playlist ===
# Создаём треки
t1 = Track("Bohemian Rhapsody", "Queen", 355)
t2 = Track("Stairway to Heaven", "Led Zeppelin", 482)
t3 = Track("Hotel California", "Eagles", 391)
t4 = Track("Smells Like Teen Spirit", "Nirvana", 301)
t5 = Track("Nothing Else Matters", "Metallica", 388)
# Создаём плейлист и добавляем треки
playlist = Playlist("Классика рока")
playlist.add_track(t1)
playlist.add_track(t2)
playlist.add_track(t3)
playlist.add_track(t4)
playlist.add_track(t5)
# len() работает:
print(len(playlist)) # 5
# Индексация работает:
print(playlist[0]) # Queen — Bohemian Rhapsody (5:55)
print(playlist[-1]) # Metallica — Nothing Else Matters (6:28)
# Срез работает:
top3 = playlist[0:3]
print(top3)
# 🎵 Плейлист «Классика рока [срез]» (3 треков):
# 1. Queen — Bohemian Rhapsody (5:55)
# 2. Led Zeppelin — Stairway to Heaven (8:02)
# 3. Eagles — Hotel California (6:31)
# Общее время: 20 мин 48 сек
# Замена трека:
new_track = Track("Back in Black", "AC/DC", 255)
playlist[1] = new_track
print(playlist[1]) # AC/DC — Back in Black (4:15)
# Проверка вхождения:
print(t1 in playlist) # True
print(new_track in playlist) # True
print(t2 in playlist) # False (мы его заменили!)
# for-цикл работает (благодаря __getitem__):
print("\nВсе треки:")
for track in playlist:
print(" -", track)
# Удаление:
del playlist[0]
print(f"\nПосле удаления первого трека: {len(playlist)} треков")
# Полный вывод:
print()
print(playlist)
🎯 Обратите внимание на метод __str__: в нём используется for i, track in enumerate(self._tracks, 1). Здесь enumerate создаёт пары (номер, трек), а цифра 1 говорит «начни нумерацию с 1, а не с 0» — это удобно для отображения пользователю. Цикл for здесь тоже работает, потому что мы итерируемся по self._tracks — обычному списку.
📊 Сводная таблица: какой метод за что отвечает
| Что пишет программист | Что вызывает Python | Что нужно вернуть |
|---|---|---|
len(obj) |
obj.__len__() |
целое неотрицательное int |
obj[key] |
obj.__getitem__(key) |
значение элемента |
obj[key] = value |
obj.__setitem__(key, value) |
None (ничего) |
del obj[key] |
obj.__delitem__(key) |
None (ничего) |
x in obj |
obj.__contains__(x) |
True или False |
obj[1:4] (срез) |
obj.__getitem__(slice(1, 4, None)) |
коллекцию элементов |
⚠️ Типичные ошибки и как их избежать
❌ Ошибка 1: __len__ возвращает отрицательное число
def __len__(self):
return -1 # ❌ ValueError: __len__() should return >= 0
def __len__(self):
return len(self._data) # ✅ всегда >= 0
Python явно запрещает отрицательную длину. Это логично: коллекция не может содержать минус-три элемента.
❌ Ошибка 2: не проверять isinstance(key, slice) в __getitem__
def __getitem__(self, key):
return self._data[key] # ✅ если _data — список, это сработает для срезов тоже
# Но если хотите вернуть свой тип, а не список:
def __getitem__(self, key):
if isinstance(key, slice):
return MyClass(*self._data[key]) # ✅ возвращаем MyClass
return self._data[key] # ✅ возвращаем элемент
Если хранить данные в списке и просто передавать key дальше — срезы заработают, но вернут голый список, а не ваш тип. Проверяйте isinstance(key, slice) если хотите правильный тип.
❌ Ошибка 3: for-цикл зависает, потому что __getitem__ никогда не бросает IndexError
def __getitem__(self, index):
if index >= len(self._data):
return None # ❌ Python не знает когда остановить цикл — он идёт вечно!
def __getitem__(self, index):
return self._data[index] # ✅ список сам бросит IndexError при выходе за границы
Python использует IndexError как сигнал «конец коллекции». Если вы ловите его и возвращаете что-то другое — цикл for никогда не остановится.
✅ Итоги урока
len(obj)вызывает__len__— должен вернуть целое неотрицательное число.obj[key]вызывает__getitem__— возвращает элемент по ключу или индексу.obj[key] = valueвызывает__setitem__— сохраняет новое значение.del obj[key]вызывает__delitem__— удаляет элемент.x in objвызывает__contains__— проверяет вхождение.- Срез
obj[1:4]передаёт в__getitem__объектslice(1, 4, None). Проверяйтеisinstance(key, slice)для обработки. - Если определить
__getitem__, циклforработает автоматически: Python вызывает__getitem__(0),__getitem__(1)... до первогоIndexError. - Нет
__contains__? Python автоматически использует__iter__или__getitem__для оператораin— но медленнее.
👉 В следующем уроке мы изучим арифметические dunder-методы: __add__, __mul__, __sub__ и другие. Научим объекты складываться, умножаться и сравниваться с помощью привычных математических операторов — как будто они числа!