ADR-034 — Политика применения дженериков в слое данных¶
| Поле | Значение |
|---|---|
| Статус | Принято |
| Версия | v.1 |
| Дата | 2026-07-29 |
| Владелец | Руслан Габитов |
| Уточняет | ADR-011 v.7 Process Data Flow |
Область. Документ решает, где в слое данных движка место дженерикам Go: параметризуется ли семейство интерфейсов
data.Value/data.Collection, где вместо этого живут типизированные поверхности и что закрывает последний нетипизированный шов — извлечение типизированного payload'а из «голого»data.Valueна стороне потребителя. Документ не пересматривает семантику data-flow (что вычисляется, когда данные пересекают узел — ADR-011 v.7) и runtime-плоскость данных (где данные живут и кто вправе их трогать — ADR-010 v.2). Это политика формы поверх обоих.
1. Контекст¶
1.1 Происхождение — дизайн до дженериков во времена языка с дженериками¶
Семейство Value спроектировано до появления в Go параметров типов
(до 1.18), поэтому его контракт — динамический интерфейс: Get(ctx) any,
Update(ctx, any) error, Lock/Unlock, Type() string, Clone() Value;
Collection, Record и map-способность расширяют его, разговаривая на
границе интерфейса на any. Теперь, когда дженерики — зрелая возможность
языка, регулярно возникает вопрос, не переработать ли семейство вокруг них —
и он будет возникать, пока на него не ответит decision record.
1.2 Как слой выглядит уже сейчас¶
Реализации уже дженериковые. Динамические конкреты — values.Variable[T],
values.Array[T], values.Map[T]; конструирование типобезопасно
(NewVariable[T], NewArray[T], NewMap[T]), и каждый конкрет несёт
типизированного близнеца с суффиксом T рядом с каждым any-методом
интерфейса: Get/GetT (плюс GetP), Update/UpdateT, GetAt/GetAtT,
Insert/InsertT, Entry/EntryT, SetEntry/SetEntryT и так далее.
Go-интероп также типизирован на своей границе: реестр адаптеров
(adapters.Register[T], adapters.Wrap) поднимает пользовательскую структуру
в навигируемое значение, разрешая тип один раз, при регистрации.
Один шов на практике остаётся нетипизированным: потребитель, держащий
«голый» data.Value — из Value() item-aware-элемента, чтения поля
Record.Field, payload'а external-задачи, path-обхода — извлекает payload
вручную:
amount, _ := d.Value().Get(ctx).(int)
Ровно эта форма повторяется по всем поставляемым примерам и в пользовательском коде задач. Дефект — отброшенный comma-ok: неудачное приведение молча даёт нулевое значение, процесс продолжает работать с неверными данными, а сбой всплывает далеко от причины — тот самый класс «тихого нуля», от которого существует политика обработки ошибок движка (ADR-022 v.1). Форма API сама провоцирует отбрасывание: двухстрочный comma-ok достаточно многословен, чтобы и примеры, и пользователи его сворачивали.
1.3 Почему решаем сейчас¶
Два драйвера. Во-первых, вопрос «не пора ли сделать Value дженериковым?»
заслуживает долговременного, находимого ответа, опирающегося на факты языка,
а не на привычку. Во-вторых, россыпь «тихих» приведений — живой класс
дефектов, который закрывается одним маленьким аддитивным помощником — но
помощник осмыслен только внутри явной политики того, где место
типизированным поверхностям.
2. Решение¶
2.1 Интерфейсы Value остаются динамическими — граница стирания типов неустранима¶
data.Value, data.Collection, data.Record и map-способность остаются
непараметризованными. Это не консерватизм; параметризованный Value[T] не
может выполнить работу — по трём складывающимся причинам:
- Движок гетерогенен и поздносвязан по своей предметной области.
В метамодели BPMN каждый item-aware-элемент несёт собственную ссылку на
ItemDefinition(см.docs/bpmn-spec/elements/data.md—itemSubjectRef, 0..1, на элемент), поэтому данные одного процесса гетерогенны по построению; а принятая моделью движка адресация разрешает данные по простому имени во время исполнения (ADR-010 v.2 §2.7; ADR-011 v.7 §2.6). Скоуп, таким образом, держитint-сумму рядом соstring-статусом рядом с записью; ассоциации, path-обходы и входы выражений обходят значения, типы которых неизвестны на этапе компиляции. В GoValue[int]иValue[string]— несвязанные типы: ковариантности и экзистенциальной квантификации нет, поэтому скоуп не может держать их в одной коллекции. Любое дженериковое семейство придётся стереть до непараметризованного интерфейса ровно на той границе, где работает движок; этот стёртый интерфейс — и есть сегодняшнийdata.Value. - Интерфейсы Go не могут объявлять дженериковые методы. Метод не может
вводить собственный параметр типа, поэтому
Get() Tвынуждает вынестиTна сам интерфейс — а это случай 1. - Типобезопасность приземлилась бы там, где она не нужна. Ядро движка
никогда не знает
T; его знают только два конца трубы — хост, создающий значение, и хост, потребляющий его. Параметризация середины протаскивает параметры типов через скоупы, плоскость данных, ассоциации и медиаторы ради нуля проверяемых гарантий в этих точках.
Это зеркалит устоявшееся разделение стандартной библиотеки: гетерогенные
контейнеры (context.Context, sync.Map) остаются динамическими; дженерики
обслуживают гомогенные контейнеры и края.
2.2 Дженерики живут на краях — конвенция «типизированных близнецов с суффиксом T»¶
Типизированным поверхностям место там, где вызывающий статически знает тип payload'а, и существующий паттерн настоящим закрепляется как конвенция для каждого текущего и будущего вида значений:
- Край конструирования — дженериковые конструкторы (
NewVariable[T],NewArray[T],NewMap[T]): тип payload'а фиксируется там, где значение рождается. - Край доступа хоста — у дженериковых конкретов каждый
any-метод интерфейса имеет близнеца с суффиксом T (GetT,UpdateT,GetAtT,EntryT, …), работающего вTконкрета без приведения в точке вызова. Новые методы дженериковых конкретов поставляются в обеих формах. (values.Recordсознательно вне конвенции: его ключи — схема, а поля — самиdata.Value; единогоTнет, чтение поля возвращает «голый»Value— ровно тот край извлечения, что ниже.) - Край интеропа — реестр адаптеров дженериковый при регистрации
(
Register[T]), разрешая каждый хост-тип один раз; движок потребляет получившееся значение через динамический интерфейс.
flowchart LR
subgraph typed["Типизированные края (код хоста, T известен)"]
C["конструирование<br/>NewVariable[T] · NewArray[T] · NewMap[T]"]
A["доступ хоста<br/>GetT · UpdateT · GetAtT · EntryT"]
R["интероп<br/>adapters.Register[T] / Wrap"]
X["извлечение<br/>data.As[T]"]
end
subgraph dynamic["Динамическое ядро (движок, T неизвестен)"]
S["скоупы · плоскость данных · ассоциации<br/>path-обходы · выражения"]
end
C --> S
R --> S
S --> A
S --> X
2.3 Край типизированного извлечения — data.As[T]¶
Единственный недостающий край закрывается одним дженериковым помощником в
pkg/model/data:
// As returns v's payload as T. It rejects a nil Value and reports a
// self-identifying error when the payload is not a T — naming both the
// held and the requested type — instead of the silent zero value a
// discarded type assertion produces.
func As[T any](ctx context.Context, v Value) (T, error)
Семантика:
- nil-guard — nil
Value— это ошибка вызывающего, отклоняемая явной ошибкой (никогда не возвратом нулевого значения), по общедвижковому правилу валидации публичных параметров. - Самоидентифицирующееся несовпадение — при неудачном приведении ошибка
называет функцию, удерживаемый динамический тип и запрошенный тип
(по форме
"As: value holds string, not int"), неся структурированные деталиerrs, чтобы observability могла её поднять. - Comma-ok становится неигнорируемым — сбой является
errorна обычном пути, а не булевым флагом, напрашивающимся на отбрасывание.
Край потребителя до и после:
// раньше — comma-ok напрашивается на отбрасывание; несовпадение — тихий ноль
amount, _ := d.Value().Get(ctx).(int)
// теперь — несовпадение является обычной самоидентифицирующейся ошибкой
amount, err := data.As[int](ctx, d.Value())
if err != nil {
return fmt.Errorf("reading order amount: %w", err)
}
As — благословлённая идиома извлечения везде, где код держит «голый»
data.Value; близнецы с суффиксом T остаются предпочтительными, когда
конкретный тип в руках. Поверхность сознательно минимальна: без паникующего
варианта MustAs, без позиционных коллекционных вариантов (AsAt[T] и
родни) — каждый ждёт конкретного драйвера, в духе фазовой дисциплины слоя.
2.4 Не-цели¶
- Дженериковое семейство интерфейсов
Value[T]/Collection[T](§2.1 исключает его). - Типизированная плоскость данных или API скоупов — ядро движка остаётся динамическим.
- Кодогенерированные пер-типовые аксессоры — реестр адаптеров уже покрывает пер-типовый интероп; генератор — машинерия, несоразмерная одному приведению.
- Ретрофит сигнатур существующих
any-методов — двойная поверхность и есть дизайн, а не миграционный долг.
3. Последствия¶
Положительные.
- Публичный API стабилен — всё решённое здесь аддитивно; никакой ломки для хостов.
- Одна благословлённая идиома извлечения заканчивает россыпь ручных
приведений; класс дефектов «тихого нуля» закрывается на каждом шве,
принявшем
As. - У повторяющегося вопроса про дженерики есть цитируемый ответ с причинами.
- Будущие виды значений наследуют готовую конвенцию (конструктор + близнецы +
совместимость с
As) вместо пере-решения формы.
Отрицательные / принятые.
- Динамическое ядро по-прежнему допускает несовпадения типов во время исполнения на шве — политика превращает их из дрейфующих нулевых значений в немедленные самоидентифицирующиеся ошибки, а не устраняет (устранение невозможно без отвергнутого сдвига границы стирания).
- Две параллельные поверхности (
Xxx/XxxT) — больше API для документирования; смягчение — единообразие конвенции. - Существующие примеры и гайды сохраняют форму ручного приведения до прикосновения; миграция оппортунистическая, не sweep.
4. Рассмотренные альтернативы¶
- Параметризовать семейство интерфейсов (
Value[T]). Отклонено по трём причинам §2.1: гетерогенные скоупы не могут хранить несвязанные инстанциации, интерфейсы не несут дженериковых методов, а стёртый интерфейс пришлось бы заново ввести ровно там, где сегодня стоитValue— переворот каждого шва данных ради нуля проверяемых гарантий там, где они важны. - Параллельное типизированное представление (
TypedValue[T]поверхValue). Второе семейство интерфейсов, которое хост может держать рядом с динамическим. Отклонено: удваивает публичную поверхность, движок по-прежнему потребляет стёртую форму, а близнецы с суффиксом T уже дают типизированный доступ везде, где известен конкрет, — обёртка добавляет слой, а не способность. - Кодогенерированные типизированные аксессоры на хост-тип. Отклонено: шов адаптеров времени регистрации уже решает пер-типовый интероп ограниченной одноразовой рефлексией; генерировать обёртки ради экономии одного приведения — провал теста соразмерности.
- Статус-кво — ручные приведения на краю потребителя. Отклонено по свидетельствам: поставляемые примеры систематически отбрасывают comma-ok, а собственная политика движка запрещает молча отброшенные сигналы сбоя. API, чей эргономичный путь — небезопасный путь, является дефектом дизайна, а не ошибкой пользователя.
5. Рекомендации enterprise-готовности¶
- Таксономия ошибок. Сбои
Asдолжны нести структурированные детали (запрошенный тип, удерживаемый тип и — где вызывающий его сообщает — имя данных или шов-источник), чтобы разбор инцидента отличал ошибку моделирования от бага кода хоста. - Линт-страж. После приземления
Asнаправлять контрибьюторов линт-правилом (например, паттерномforbidigo), помечающим приведения.Get(ctx).(/.Get(contextвне пакетов данных с указанием наdata.As[T]. - Документация. Страницы руководства разработчика о модели значений
должны представлять
Asкак каноническую идиому извлечения, а конвенцию T-близнецов — как ключ к чтению двойной поверхности API. - Заявление о совместимости. Всё здесь аддитивно; депрекаций не возникает, хосты на форме ручного приведения продолжают компилироваться.
6. Открытые вопросы¶
Нет.
7. Ссылки¶
- ADR-010 v.2 Process Data Model — runtime-плоскость данных, которую эта политика оформляет, но не пересматривает.
- ADR-011 v.7 Process Data Flow — семантика данных модельного слоя; §2.9 определяет семейство значений и динамические конкреты, которыми правит эта политика.
- ADR-022 v.1 Error Propagation and Logging Policy — правило «никаких тихих отбрасываний», которое край извлечения закрепляет.
- Спецификация языка Go — объявления методов не допускают параметров типов; интерфейсные типы не допускают ковариантности по аргументам типов.
История документа¶
| Версия | Дата | Автор | Изменения |
|---|---|---|---|
| v.1 | 2026-07-29 | Руслан Габитов | Принято (приземлено через сопутствующую SRD; make ci зелёный, diff-coverage 100% изменённых строк, помощник извлечения на 100% файлового покрытия). Исходное решение: семейство интерфейсов Value остаётся динамическим (граница стирания неустранима для поздносвязанных гетерогенных данных BPMN; интерфейсы Go не несут дженериковых методов); дженерики ограничены краями под именованной конвенцией типизированных близнецов с суффиксом T (дженериковые конструкторы, аксессоры XxxT, адаптеры времени регистрации); недостающий край потребителя закрыт data.As[T] — nil-guarded самоидентифицирующееся типизированное извлечение вместо склонного к отбрасыванию ручного приведения. Не-цели: Value[T], типизированная плоскость, кодоген-аксессоры, MustAs. |