Магические методы Python: свой объект как список
Разбираем, какие dunder-методы делают ваш класс похожим на список: индексация, срезы, len, итерация и распаковка. Что реализовать, чтобы объект вёл себя предсказуемо, и где это ломается.

Вы написали класс, который хранит коллекцию — историю платежей, набор точек, буфер сообщений. Хочется писать obj[0], len(obj), for item in obj и obj[1:3], а не obj.get_item(0) и obj.count(). В Python это решается набором магических методов (их называют dunder — от double underscore). Реализуете нужные — и ваш объект начинает вести себя как встроенный список для всех, кто с ним работает. Проблема в том, что «похож на список» — это не один метод, а несколько независимых протоколов, и реализовав половину, вы получите объект, который итерируется, но падает на срезе.
Статья про Python 3.11+ (всё описанное работает начиная с 3.9, различия отмечу отдельно). Разбираем, какие протоколы за что отвечают, в каком порядке их добавлять и где именно объект перестаёт быть похожим на список, если сделать наивно.
Задача и почему очевидное решение неполное
Допустим, вы оборачиваете список платежей и хотите скрыть внутреннее хранилище. Наивный подход — унаследоваться от list. Это работает, но тянет весь интерфейс списка целиком: append, sort, __iadd__ и десятки методов, которые вы, возможно, не хотите отдавать наружу. Если объект должен быть неизменяемым или хранить данные не в списке (например, в базе или в numpy-массиве), наследование от list вредит.
Второй наивный подход — реализовать только __getitem__. Уже неплохо: Python по одному этому методу построит итерацию. Но len() упадёт, распаковка сработает криво, а срез вернёт не то, что вы ожидаете.
Правильный путь — реализовать ровно те протоколы, которые нужны, и понимать, что каждый делает.
Три независимых протокола
«Похож на список» распадается на три части, каждая со своим набором методов:
- Sequence — доступ по индексу и длина:
__getitem__,__len__. - Iterable — перебор в цикле:
__iter__(или один__getitem__как запасной вариант). - Container — проверка
x in obj:__contains__(или снова вывод через итерацию).
Ключевой момент: Python умеет выводить недостающие протоколы из имеющихся. Если есть __getitem__, принимающий целые индексы от нуля и бросающий IndexError за границей, интерпретатор построит итерацию и in сам. Но вывод медленнее и капризнее явной реализации.
Минимальный рабочий класс
Начнём с обёртки только для чтения. Реализуем __getitem__ с поддержкой срезов и __len__:
from collections.abc import Sequence
class PaymentLog:
"""Обёртка над списком платежей, доступ только на чтение."""
def __init__(self, payments):
# копируем, чтобы снаружи нельзя было мутировать внутренний список
self._items = list(payments)
def __getitem__(self, index):
# index может быть int или slice — list.__getitem__ обрабатывает оба
result = self._items[index]
if isinstance(index, slice):
# срез должен возвращать объект того же типа, а не голый list
return PaymentLog(result)
return result
def __len__(self):
return len(self._items)
def __repr__(self):
return f"PaymentLog({self._items!r})"
log = PaymentLog([100, 250, 75, 500])
print(log[0]) # 100
print(len(log)) # 4
print(log[1:3]) # PaymentLog([250, 75])
for amount in log: # работает без __iter__: Python выводит из __getitem__
print(amount)
print(500 in log) # True — тоже выведено
Тонкость, которую пропускают чаще всего: при срезе __getitem__ получает объект slice, а не int. Если вы не проверите тип и не завернёте результат обратно в свой класс, log[1:3] вернёт обычный list — и цепочка операций на нём поведёт себя не как ваш объект.
Явная итерация: когда __getitem__ мало
Вывод итерации из __getitem__ работает, только если индексы — целые числа от нуля подряд. Как только хранилище не поддерживает случайный доступ (генератор, поток, связанный список), нужен явный __iter__.
class MessageBuffer:
"""Буфер сообщений, который отдаёт данные потоком."""
def __init__(self):
self._messages = []
def add(self, text):
self._messages.append(text)
def __iter__(self):
# возвращаем новый итератор при каждом вызове —
# тогда объект можно перебирать в цикле повторно
return iter(self._messages)
def __len__(self):
return len(self._messages)
buffer = MessageBuffer()
buffer.add("привет")
buffer.add("как дела")
print(list(buffer)) # ['привет', 'как дела']
print(list(buffer)) # снова то же — объект переиспользуем
Если__iter__возвращаетselfи хранит позицию в атрибуте объекта, второй проход по циклу даст пустоту. Итерируемое и итератор — разные роли: объект-контейнер должен на каждый__iter__отдавать свежий итератор.
Сравнение подходов
| Подход | Что даёт | Чем платите |
|---|---|---|
Наследование от list | Весь интерфейс списка сразу, максимальная совместимость | Наружу торчат мутирующие методы; поведение сложно ограничить |
Только __getitem__ + __len__ | Индексация, срезы, итерация и in выводятся автоматически | Требует индексов от нуля; вывод in медленный — линейный перебор |
Явные __iter__ + __len__ | Работает с любым хранилищем, включая потоковое | Индексация obj[i] не появится без отдельного __getitem__ |
Наследование от collections.abc.Sequence | Реализуете __getitem__ и __len__ — получаете __contains__, index, count, __reversed__ бесплатно | Нельзя менять содержимое; для изменяемого нужен MutableSequence с бо́льшим числом методов |
Когда что выбирать
- Нужен полноценный неизменяемый список-подобный тип с
count,index,reversed— наследуйтесь отcollections.abc.Sequenceи реализуйте два метода. Это самый честный способ: ABC-класс сам проверит, что вы ничего не забыли. - Нужна изменяемость (
append,__setitem__) — беритеMutableSequence, придётся дописать__setitem__,__delitem__,insert. - Данные приходят потоком и индекс не имеет смысла — только
__iter__, не притворяйтесь последовательностью. - Нужна максимальная совместимость с кодом, который делает
isinstance(x, list)— придётся наследоваться отlist, обёртка через ABC такую проверку не пройдёт.
Sequence из abc: два метода вместо десяти
Абстрактный базовый класс collections.abc.Sequence достраивает интерфейс за вас. Вы пишете __getitem__ и __len__, а __contains__, __iter__, __reversed__, index() и count() приходят из миксина.
from collections.abc import Sequence
class Route(Sequence):
"""Маршрут как последовательность точек."""
def __init__(self, points):
self._points = list(points)
def __getitem__(self, index):
if isinstance(index, slice):
return Route(self._points[index])
return self._points[index]
def __len__(self):
return len(self._points)
route = Route([(0, 0), (1, 2), (3, 5)])
print(len(route)) # 3
print((1, 2) in route) # True — из миксина
print(route.index((3, 5))) # 2 — из миксина
print(list(reversed(route))) # [(3, 5), (1, 2), (0, 0)]
Проверка isinstance(route, Sequence) вернёт True. При этом isinstance(route, list) — False: ABC регистрирует тип как последовательность, но не делает его подклассом list.
Где подход ломается
- Отрицательные индексы и срезы при ручном
__getitem__. Если вы обрабатываете индекс сами, а не делегируете списку,obj[-1]иobj[::2]придётся поддержать вручную — иначе объект «похож на список» только на положительных индексах. - Срез возвращает не тот тип. Забыли проверку
isinstance(index, slice)— иobj[1:3]отдаёт голыйlist. Дальше по коду ломается всё, что ждало ваш класс. IndexErrorобязателен. Вывод итерации из__getitem__держится на том, что за границей вы бросаете именноIndexError. БроситеKeyErrorили вернётеNone— циклforлибо зациклится, либо упадёт с непонятной ошибкой.- Распаковка.
a, b, c = objработает через итерацию, но требует точного совпадения количества элементов. Это поведение вы не контролируете отдельно — оно вытекает из__iter__. - Хешируемость и сравнение. Списки нехешируемы и сравниваются поэлементно. Если хотите такое же поведение — не добавляйте
__hash__и реализуйте__eq__явно; по умолчанию ваш объект сравнивается по идентичности.
Что делать дальше
Если объект только читается — наследуйтесь от collections.abc.Sequence и реализуйте __getitem__ с обработкой срезов и __len__. Это даёт корректный список-подобный тип минимумом кода и защищает от забытого метода. Нужна изменяемость — переходите на MutableSequence и смотрите в документации, какие абстрактные методы он требует. Прежде чем отдавать класс в общий код, прогоните его через быстрый чек-лист: срез возвращает ваш тип, за границей летит IndexError, повторный for по объекту даёт те же данные, len() совпадает с числом элементов в итерации. Отдельная тема — реализация арифметики (__add__, __mul__) и __setitem__ для изменяемых коллекций; здесь мы её не разворачиваем.
Репетитор 1—11 класс и ВУЗ: Математика и физика методом наведения на мысль, помощь с ДЗ.
Спросить за 8 ₽Источники: Python Language Reference — Special method names, collections.abc — Abstract Base Classes for Containers
Частые вопросы
У меня Python 3.8, всё это заработает?
Почему цикл for работает, хотя я не написал __iter__?
Не сработало: срез возвращает обычный list вместо моего класса. В чём дело?
Чем наследование от Sequence лучше наследования от list?
Как сделать объект изменяемым, чтобы работали append и obj[i] = value?
Мой объект итерируется только один раз, второй for даёт пустоту. Почему?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.