$ sudo teach IT
МОДУЛЬ 7 · УРОК 3

__len__, __getitem__, __setitem__ — делаем свою коллекцию

Списки, словари, строки — у них всех есть кое-что общее: к ним можно обращаться по индексу, узнавать их длину, проверять вхождение элемента. В этом уроке мы научим свои классы делать то же самое. После этого ваши объекты будут вести себя как настоящие встроенные коллекции Python.

⏱ ~40 минут 🎓 Для новичков 🐍 Python 3

🔍 Как 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__ и другие. Научим объекты складываться, умножаться и сравниваться с помощью привычных математических операторов — как будто они числа!