2025, Nov 01 04:46

Как сделать поле обязательным в экземпляре dataclass, но опциональным в __init__

Разбираем, как в Python dataclass оставить параметр опциональным только в __init__, избегая Optional в поле и предупреждений тайпчекера: пишем свой __init__.

Сделать поле необязательным лишь на этапе создания, но обязательным в уже созданном объекте — распространённая задача при моделировании простых объектов-значений. В обычных классах Python это делается элементарно, однако с @dataclass легко нарваться на предупреждения тайпчекера, если не учесть нюансы. Ниже — краткое объяснение проблемы и аккуратное решение.

Базовый вариант без @dataclass

В лобовом варианте класс подставляет вторую координату равной первой, если её не передали:

class Coord:
    def __init__(self, a: int, b: int | None = None):
        self.a = a
        self.b = b if b is not None else a

Так во всех созданных экземплярах тип b во время выполнения всегда int: None заменяется на значение в конструкторе.

Попытка с @dataclass, которая вызывает предупреждения

На первый взгляд переписать это через @dataclass несложно:

from dataclasses import dataclass

@dataclass
class Coord:
    a: int
    b: int | None

    def __post_init__(self):
        if self.b is None:
            self.b = self.a

В рантайме всё работает, но b теперь аннотировано как опциональное. Статический анализ отмечает безобидный код вроде Coord(1, 2).b + 0 как потенциальную работу с None и выдаёт ложные предупреждения. Обходной путь — разделить «публичное» поле и параметр, используемый только в конструкторе, на разные атрибуты, но это выглядит излишне многословно.

Почему так происходит

В @dataclass нет встроенного способа сказать: «это поле опционально только для __init__, а в экземпляре — обязательное». __post_init__ позволяет подправить значения после работы сгенерированного конструктора, но не меняет объявленный тип поля, поэтому инструменты всё равно считают его опциональным.

Практическое решение

Самый простой и предсказуемый выход — оставить @dataclass, но определить свой __init__. Если вы его пишете, декоратор не генерирует конструктор, при этом все остальные удобства dataclasses сохраняются.

from dataclasses import dataclass

@dataclass
class Coord:
    a: int
    b: int

    def __init__(self, a: int, b: int | None = None):
        self.a = a
        self.b = self.a if b is None else b

Так сохраняется нужная логика создания объекта, на экземпляре не появляется опциональный тип, а преимущества @dataclass остаются: автоматически генерируются __eq__, __repr__, при необходимости — расширенные сравнения и хеширование, а также (опционально) слоты. Никаких специальных флагов передавать не нужно — достаточно определить __init__.

Почему это стоит помнить

Легко подумать, что __post_init__ покрывает все потребности по настройке конструктора, но он рассчитан на небольшие правки, когда сгенерированный __init__ в целом подходит. Если же требуется иная сигнатура или другая семантика конструктора, проще и яснее написать __init__ вручную, сохранив остальную механику dataclass нетронутой.

Я почему‑то не подумал просто написать собственный init. Это полностью рабочее решение: как вы и отметили, оно сохраняет преимущества по сравнению с «сырим» классом — сравнения и строковое представление.

Итог

Если поле должно быть обязательным в экземпляре, но допустимо опускаться при создании, не пытайтесь протолкнуть это через __post_init__. Определите __init__ внутри @dataclass и позвольте декоратору сделать остальное. В итоге вы получаете точные типы, отсутствие ложных срабатываний из‑за опциональных аннотаций и все эргономичные плюсы, которые дают dataclasses.

Материал основан на вопросе на StackOverflow от Dominik Kaszewski и ответе от ShadowRanger.