2025, Oct 07 19:16

Как в Python dataclasses вычислять __len__ и __iter__ по полям таймеров без «магических чисел»

Как в Python dataclasses программно считать __len__ и __iter__ без магических чисел: интроспекция через dataclasses.fields и default_factory для таймеров.

Считать элементы в dataclass через len не стоит опираться на «магические числа». Если экземпляр класса содержит набор объектов Timer и вы уже предоставляете их через итерацию, логичный шаг — вычислять размер программно. Ниже — лаконичный подход, который держит __len__ в согласии с __iter__, не создавая долгов по сопровождению.

Проблема

У вас есть dataclass, который агрегирует несколько экземпляров Timer. Разные подклассы могут объявлять разные наборы таймеров, и вы хотите, чтобы len(instance) отражал их фактическое количество. Изначальный подход — захардкоженное «магическое» число — хрупок и легко забывается при изменении полей.

from dataclasses import dataclass
@dataclass
class CoreTimers:
    def tick(self):
        pass
    def __iter__(self):
        return iter([])
@dataclass
class DeviceTimers(CoreTimers):
    cycleTimer        = tmr.Timer(0)
    vProtectTimer     = tmr.Timer(0)
    aRestTimer        = tmr.Timer(0)
    aMuteTimer        = tmr.Timer(0)
    vRestTimer        = tmr.Timer(0)
    vMuteTimer        = tmr.Timer(0)
    def tick(self):
        self.cycleTimer.tic()
        self.vProtectTimer.tic()
        self.aRestTimer.tic()
        self.aMuteTimer.tic()
        self.vRestTimer.tic()
        self.vMuteTimer.tic()
    def __len__(self):
        return 6
    def __iter__(self):
        return iter([
            self.cycleTimer,
            self.vProtectTimer,
            self.aRestTimer,
            self.aMuteTimer,
            self.vRestTimer,
            self.vMuteTimer,
        ])

Что на самом деле происходит

Dataclasses — это обычные классы Python с декоратором, который генерирует __init__ и другие удобства. Следовательно, __iter__ и __len__ работают как в любом классе. Проблема выше в том, что количество таймеров зашито литералом в __len__, и оно быстро «уплывает», когда поля добавляют или удаляют. Есть и другая тонкость: создание объектов Timer как атрибутов класса приводит к тому, что все экземпляры dataclass разделяют одни и те же Timer. Если нужно, чтобы у каждого экземпляра были свои независимые таймеры, потребуется фабрика на уровне экземпляра.

Поскольку dataclasses раскрывают объявленные поля через dataclasses.fields, можно проинспектировать экземпляр, найти все атрибуты, соответствующие таймерам, и вывести и итерацию, и длину из одного источника истины. Простое соглашение об именах, например суффикс Timer, делает выборку наглядной и предсказуемой.

Решение

Перенесите логику в базовый класс. Используйте dataclasses.fields для интроспекции, фильтруйте по суффиксу Timer и реализуйте __iter__, __len__ и массовый tick, опираясь на этот единый источник полей. Чтобы экземпляры были независимыми, применяйте default_factory — так каждый экземпляр dataclass получит свои собственные объекты Timer.

from dataclasses import dataclass, fields, field
@dataclass
class CoreTimers:
    @property
    def _timer_names(self):
        for f in fields(self):
            if f.name.endswith("Timer"):
                yield f.name
    def __iter__(self):
        return (getattr(self, name) for name in self._timer_names)
    def __len__(self):
        return len(list(self._timer_names))
    def tick(self):
        for name in self._timer_names:
            getattr(self, name).tic()
@dataclass
class DeviceTimers(CoreTimers):
    cycleTimer: tmr.Timer      = field(default_factory=lambda: tmr.Timer(0))
    vProtectTimer: tmr.Timer   = field(default_factory=lambda: tmr.Timer(0))
    aRestTimer: tmr.Timer      = field(default_factory=lambda: tmr.Timer(0))
    aMuteTimer: tmr.Timer      = field(default_factory=lambda: tmr.Timer(0))
    vRestTimer: tmr.Timer      = field(default_factory=lambda: tmr.Timer(0))
    vMuteTimer: tmr.Timer      = field(default_factory=lambda: tmr.Timer(0))

Такой подход выводит и итерацию, и подсчет из одного определения, поэтому len(instance) и list(instance) всегда совпадают — независимо от того, сколько полей Timer объявляет подкласс. Если в реальном проекте таймеры не разделяют общее правило именования, адаптируйте фильтр в _timer_names под ваш критерий.

Почему это важно

Опора на dataclasses.fields избавляет классы, ведущие себя как коллекции, от догадок и захардкоженных значений. Это помогает избежать тонких багов из‑за разделяемого состояния, гарантируя, что каждый экземпляр dataclass получает собственные объекты Timer. Логика сосредоточена в одном месте: при изменении набора таймеров поведение __iter__, __len__ и групповых операций остается корректным без правок, раскиданных по классу.

Главная мысль

Пусть класс описывает себя сам. В dataclasses интроспекция встроена, значит, len можно вычислять программно по объявленным полям вместо жестко заданных чисел. Держитесь простого соглашения об именах вроде суффикса Timer, привяжите __iter__ и __len__ к единому источнику и создавайте таймеры через default_factory, чтобы экземпляры оставались изолированными. Этого достаточно, чтобы коллекция таймеров была предсказуемой, удобной в поддержке и свободной от «магических чисел».

Статья основана на вопросе с StackOverflow от JKomp и ответе jsbueno.