2025, Sep 22 17:03

Зависимые значения по умолчанию в dataclass: рабочие приёмы для конфигов на Python

Почему dataclass не поддерживает зависимые значения по умолчанию и что делать: __post_init__ и classmethod. Практичные решения для конфигов в Python.

Dataclasses против зависимых значений по умолчанию в Python: как сохранить конфиги удобными для правок и не утонуть в обвязке

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

Минимальный пример, который выглядит правильно, но не работает

Предположим, нам нужен класс, где значение по умолчанию одного поля — функция от другого. Первая попытка обычно выглядит так:

from enum import Enum
import typing as tp
import dataclasses as dc
import random

class Phase(Enum):
  ENTER = "incoming"
  EXIT = "outgoing"
  NAP = "sleeping"

def picker_from_enum[U: Enum](K: type[U]) -> tp.Callable[[], U]:
  """
  Возвращает вызываемый объект, который выдаёт случайный элемент перечисления K.
  """
  return lambda: random.choice(list(K))

mean_lag = {
  Phase.ENTER: 10,
  Phase.EXIT: 20,
  Phase.NAP: 100
}

def delay_sampler_from_phase(p: Phase) -> tp.Callable[[], int]:
  """
  Возвращает вызываемый объект, который выдаёт случайную задержку, распределённую вокруг mean_lag[p].
  """
  return lambda: int(random.gauss(mean_lag[p]))

@dc.dataclass
class RandomQuery:
  mode: Phase = dc.field(default_factory=picker_from_enum(Phase))
  lag: int = dc.field(default_factory=delay_sampler_from_phase(mode))

if __name__ == "__main__":
  print(RandomQuery())

Это не работает, потому что выражение, переданное в default_factory для lag, пытается обратиться к mode на этапе определения класса. Экземпляра ещё нет — значит, и читать mode неоткуда. Похожие попытки с self.mode или cls.mode не помогут, а замена фабрики на прямое значение по умолчанию не решит проблему с моментом вычисления.

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

Dataclass отлично подходят, когда вызывающий код передаёт все публичные поля напрямую. В этом обычном сценарии вызов вроде RandomQuery(mode=Phase.ENTER, lag=5) вообще не трогает никакие default_factory. Важнее другое: объявления полей dataclass вычисляются при создании класса, поэтому всё, что передаётся в default_factory=..., должно быть готовым к использованию безаргументным вызываемым объектом, а не вычислением, которое ссылается на другое поле будущего экземпляра.

По этой причине, если одно поле зависит от другого, идиоматично считать его уже после создания объекта. Для этого у dataclass есть __post_init__.

Рабочий приём с dataclass

Можно сохранить dataclass и вычислять зависимые значения в одном месте сразу после конструктора:

import dataclasses as dc

@dc.dataclass
class RandomQuery:
  mode: Phase = dc.field(default_factory=picker_from_enum(Phase))
  lag: int = dc.field(init=False)

  def __post_init__(self):
    self.lag = delay_sampler_from_phase(self.mode)()

Так логика в рантайме остаётся корректной, а зависимость — явной. Но если зависимых полей много, блок post-init быстро разрастается. В этом и боль: при нескольких группах связанных свойств класс сложнее просматривать и легче ошибиться тем, кто просто хочет подправить входные значения.

Когда проще обычный класс

Если типичный сценарий — создавать объекты без параметров и поручать классу самому заполнять значения, обычный класс требует меньше церемоний и держит всю инициализацию в одном очевидном месте:

# не dataclass
class RandomQuery:
  def __init__(self):
    self.mode = random.choice(list(Phase.__members__.values()))
    self.lag = int(random.gauss(mean_lag[self.mode]))

Такой дизайн прозрачен для пользователей среднего уровня: все движущиеся части живут в __init__, и нет разрыва между объявлениями полей и длинным post-init.

Dataclass плюс вторичный конструктор

Если основной поток работы — передавать все поля явно, но хочется и удобный «рандомизированный» способ создавать экземпляры, чисто выглядит classmethod как вторичный конструктор:

from dataclasses import dataclass
from typing import Self

@dataclass
class RandomQuery:
  mode: Phase
  lag: int

  @classmethod
  def random(cls) -> Self:
    mode = random.choice(list(Phase.__members__.values()))
    lag = int(random.gauss(mean_lag[mode]))
    return cls(mode=mode, lag=lag)

Так сохраняется семантика dataclass для «обычного пути», а вдобавок появляется однострочник для создания рандомизированных объектов. Небольшая подсказка: вместо list(Phase.__members__.values()) можно также использовать list(Phase).

Иногда встречается и другой вектор: если нужно генерировать случайные значения обобщённо для нескольких dataclass, dataclasses.fields() позволяет интроспектировать объявленные поля. Это может пригодиться для самодельного фаззера или генератора, хотя нередко это избыточно, если вы не строите вокруг этого отдельный инструмент.

Почему это важно для редактируемого конфиг-слоя DSL

Смысл всей конструкции — дать пользователям среднего уровня возможность расширять или настраивать поверхность конфигурации, не трогая ядро генератора. Если класс, который они редактируют, компактный и предсказуемый, сценарии добавляются уверенно. Если он расползается по множеству init=False и длинному post-init с повторяющимися строками, мелкие ошибки проскакивают легко. Выбор между простым инициализатором класса, dataclass с post-init или dataclass с отдельным «рандом»-конструктором — это не вопрос стиля, а соответствия тому, как класс используется большую часть времени.

Практические советы и выводы

Если вы хотите зависимые значения по умолчанию прямо внутри объявления одного dataclass, Python не даст сослаться из default_factory одного поля на другое. Ближайшая идиоматичная альтернатива — присваивать зависимые значения в __post_init__. Если типичный путь вызова — без аргументов, а всё рандомизируется или выводится, обычный класс собирает всю инициализацию в одном месте и легче читается и поддерживается. Если же в обычном пути значения передаются явно, но нужен удобный рандомизированный билдер, сделайте classmethod-конструктор, который возвращает экземпляр с вычисленными полями.

В системах с множеством однотипных полей, число которых растёт, подумайте, помогает ли вам плоская пронумерованная форма. Группировка родственных значений и вынос генерации в вспомогательные функции часто уменьшают повторение и делают намерения понятнее следующему редактору файла.

Коротко: выберите схему конструирования, которая соответствует тому, как ваши объекты обычно создаются; держите зависимые вычисления в одном чётко определённом месте; отдавайте предпочтение структурам, которые пользователи среднего уровня смогут быстро просканировать и изменить, не выискивая логику по всему классу.

Статья основана на вопросе на StackOverflow от globglogabgalab и ответе David Maze.