
На днях наткнулся на отличную статью Никиты Тимофеенко «Вам не нужны bool и Option» (далее [1]). Автор со стороны Rust-разработки поднимает фундаментально правильный вопрос: кодировать состояния системы разрозненными флагами вроде succeeded: bool и failed: bool — это плохой тон, ведущий к трудноуловимым багам. Система типов должна делать некорректные состояния запрещенными на этапе компиляции.
В C++ сообществе эта идея, разумеется, тоже не нова. Мы давно и успешно оборачиваем подобные вещи в std::variant, используя std::monostate как аналог отсутствия значения (можно задать псевдоним, например AbsentResult).
Но читая статью и комментарии к ней, я поймал себя на мысли: мы, разработчики, слишком сильно увлеклись мощью современных компиляторов. Как только стандарты C++ (да и Rust) дали нам инструменты для зубодробительных compile-time проверок любых сущностей, мы начали пихать эти проверки везде, где нужно и не нужно.
В этой статье я хочу показать, как правильное использование std::variant может... замаскировать серьезную ошибку проектирования архитектуры, и как увлечение построением идеальной системы типов отвлекает нас от фундаментальных вопросов проектирования систем. Мы разберем, как классические стейт-машины при поллинге (polling) заставляют нас писать тонны шаблонного кода ради проверок, которых в этой точке программы вообще не должно существовать.
От теории к практике C++: Строим безопасный мост
Вернемся к примеру из статьи [1]. Допустим, мы моделируем состояние запущенной задачи обработки файла (интересный пример из той статьи). В Rust это был бы красивый enum с данными внутри. В C++ мы просто будем использовать этот std::variant:
#include <variant>
#include <string>
using AbsentResult = std::monostate; // Состояние "еще выполняется / пусто"
struct ErrorState { std::string message; };
struct GoodResult { int data; };
using PollVariant = std::variant<AbsentResult, ErrorState, GoodResult>;Мало кто задумывается о том, что std::variant — это фактически enum, жестко привязанный к набору типов. Значение определенного типа всегда сопровождается предопределенным числовым индексом, по которому и производится проверка: «а какой тип сейчас хранит variant?». Это внутренний enum внутри std::variant, к которому разработчики C++ не догадались (или побоялись) дать прямой доступ по именам, хотя этот доступ все равно можно организовать штатными средствами языка.
Замечание: комитет стандартизации C++ на самом деле дал нам этот доступ, но в своем, «шаблонном» стиле — через
std::variant_alternativeи метод.index(). Но они сделали его числовым, а не именованным.
Если мы обрабатываем std::variant в функциональном стиле с помощью std::visit и паттерна overloaded, компилятор C++ полностью контролирует нас на этапе сборки. Но что, если нам по старинке нужна императивная стейт-машина (например, она уже «прибита гвоздями» в легаси-коде)? Что, если мы хотим использовать классический и понятный switch по индексу типа (my_variant.index()), но при этом не хотим закладываться на «магические числа» (case 0, case 1), которые сломаются при первом же изменении порядка типов в using PollVariant?
В идеальном мире мы бы хотели написать что-то вроде PollVariant::getTypeId<ErrorState>(). Но в стандарте этого нет. Разработчики часто решают это созданием параллельного enum, где вручную синхронизируют порядок:
// Хрупко! Если поменять местами типы в variant,
// этот enum станет бомбой замедленного действия
enum VariantType {
IndexAbsent = 0,
IndexError = 1,
IndexGood = 2
};В лучшем случае можно разместить этот enum в непосредственной близости с определением PollVariant. Но никакой реальной безопасности здесь, конечно, нет — это лишь иллюзия контроля. К счастью, C++ не был бы собой, если бы не позволял нам автоматизировать это на этапе компиляции.
Магия constexpr: заставляем компилятор считать за нас
Мы можем написать небольшую (по меркам C++ шаблоностроения) constexpr-функцию, которая пробежится по альтернативам нашего std::variant прямо во время сборки программы и вернет точный индекс нужного типа.
Вот как это, достаточно изящно, выглядит в C++20 с использованием fold expressions и шаблонных лямбд:
#include <iostream>
#include <type_traits>
// Универсальный хелпер для поиска индекса типа T внутри Variant
template <typename T, typename Variant>
constexpr size_t get_variant_index() {
size_t index = 0;
// Лямбда-поиск: разворачиваем последовательность индексов
bool found = []<size_t... Is>(std::index_sequence<Is...>, auto& idx) {
return ((std::is_same_v<T, std::variant_alternative_t<Is, Variant>> ? (idx = Is, true) : false) || ...);
}(std::make_index_sequence<std::variant_size_v<Variant>>{}, index);
if (!found) {
// Вызов метода, который завалит компиляцию, если тип не найден
throw "Type not found in variant";
}
return index;
}(Примечание для тех, кто работает на C++17: не расстраивайтесь, аналогичный трюк легко реализуется через классическую рекурсию шаблонов и if constexpr — компилятор сделает ровно то же самое).
А теперь смотрите, как красиво и стопроцентно безопасно мы объявляем наш enum:
enum VariantType {
IndexAbsent = get_variant_index<AbsentResult, PollVariant>(),
IndexError = get_variant_index<ErrorState, PollVariant>(),
IndexGood = get_variant_index<GoodResult, PollVariant>()
};Что мы получили?
Защита от изменений: если завтра наш коллега решит, что
GoodResultдолжен идти первым вstd::variant, нашenumперестроится автоматически при следующей сборке.switchне сломается.Защита от опечаток: если вы попытаетесь запросить
get_variant_index<СовершенноДругойТип, PollVariant>(), код просто не скомпилируется.
Теперь наш switch в стейт-машине выглядит кристально чисто:
void processResultWithSwitch(const PollVariant& res) {
switch (res.index()) {
case IndexAbsent:
std::cout << "Статус: В процессе...\n";
break;
case IndexError:
std::cout << "Статус: Ошибка -> " <<
std::get<ErrorState>(res).message << "\n";
break;
case IndexGood:
std::cout << "Статус: Успех -> " <<
std::get<GoodResult>(res).data << "\n";
break;
}
}Казалось бы, победа! Мы укротили систему типов C++, написали безопасный, расширяемый код, подружили std::variant со старым добрым switch и гордо закрыли задачу.
А теперь давайте сделаем шаг назад, снимем украшенный рунами плащ метапрограммиста и посмотрим на то, что мы только что написали, с точки зрения здравого смысла и архитектуры. И вот здесь начинается самое интересное…
Архитектурная слепота: Когда идеальные типы маскируют плохой дизайн
Давайте внимательно посмотрим на состояние IndexAbsent (оно же std::monostate или AbsentResult). Зачем оно вообще появилось в нашем std::variant?
Ответ стандартный: «Ну как же, у нас поллинг (polling)! Мы с определенной периодичностью опрашиваем какую-то подсистему, поток или внешний сервис. Пока данные генерируются, читаются или парсятся из файла, нам возвращают вариант, который хранит AbsentResult. Как только всё готово — там появится результат или ошибка».
И вот здесь кроется фундаментальная архитектурная ошибка, которую не способен отловить ни один компилятор в мире — ни C++, ни Rust, никто.
Мы объединили динамику процесса (фазу ожидания) и статику результата (данные) в один тип. А затем заставили нашу стейт-машину, поток или микросервис на каждом тике периода polling-таймера выполнять проверку:
[ Каждые 100 мс ]: «Так, что у нас там? Всё ещё AbsentResult? Ладно, ждем.
Что теперь? Опять AbsentResult? Ждем.
Что теперь? Опять AbsentResult? Ждем...»На этом этапе сам результат еще не существует, соответственно, проверять его тип не имеет смысла.
Ошибка состоит в том, что
AbsentResult— это вообще не состояние результата! Результата еще физически нет, и у него не может быть состояния. Это состояние генератора (мухи), за которым мы следим, пока он пытается этот результат (котлеты) создать!
Даже если мы по какой-то причине вынуждены работать через схему с опросом (polling), на этапе ожидания знание о типах результата (котлетах) будет только мешать и отвлекать нас от логики этой фазы. В нашей программе кроме результата (у которого есть состояния Error и Good) есть еще генератор (муха), который создает этот результат (и у него свои состояния: ready / pending). Состояния этих совершенно разных сущностей никак нельзя было записывать в один общий enum. Это грубая ошибка — но не на уровне типов, а на уровне идентификации сущностей, которые действуют в программе.
А мы при поллинге получили многократные, абсолютно бессмысленные проверки на каждом шаге цикла ожидания. Мы написали зубодробительный шаблонный хелпер get_variant_index только для того, чтобы безопасно обрабатывать в switch состояние, которого в этой точке логики вообще не должно существовать!
Давайте визуализируем, как этот академический оверхед выглядит на практике:
```text
[ Цикл поллинга / Поток данных ]
│
▼
┌──────────────────────────────────────────────┐
│ Стейт-машина Ожидания │
│ ──────────────────────────────────────────── │
│ Каждый тик проверяет PollVariant: │
│ - Это AbsentResult? -> (Ждем дальше...) │
│ - Это ErrorState? -> (Ой, обрабатываем!) │
│ - Это GoodResult? -> (Ура, обрабатываем!)│ ◄── МНОГОКРАТНЫЙ ЛИШНИЙ ОВЕРХЕД!
└──────────────────────────────────────────────┘ Фанаты контроля типов
счастливы, разработчик
пишет километры шаблонов.
```Разделяй и властвуй: Проектируем фазы, а не типы
Стейт-машина, которая занимается поллингом или ожиданием, должна быть унизительно простой. Её зона ответственности — отвечать на один-единственный бинарный вопрос: «Завершен ли процесс генерации?». Ей абсолютно плевать, каков результат этого завершения — успешный файл там или сетевая ошибка доступа.
Сложный, многовариантный тип вроде std::variant<ErrorState, GoodResult> должен рождаться на свет только в момент перехода в финальную фазу.
Если перепроектировать архитектуру правильно, разделив жизненный цикл на независимые фазы, картина кардинально меняется:
```text
[ Фаза 1: Ожидание / Поллинг ]
│
▼
┌──────────────────────────────────────────────┐
│ Простая Стейт-машина │
│ ──────────────────────────────────────────── │
│ Оперирует строго бинарным состоянием: │ ◄── Никаких лишних проверок
│ - Готово (Ready) / Не готово (Pending) │ на каждом шаге!
└──────────────────────────────────────────────┘
│
│ (Когда перешли в статус Ready)
▼
[ Фаза 2: Точка завершения (Single Shot) ]
│
▼
┌──────────────────────────────────────────────┐
│ Слой обработки результата │
│ ──────────────────────────────────────────── │
│ Один раз (!) распаковываем данные: │ ◄── std::variant / std::visit
│ - Либо Успех (GoodResult) │ работают только здесь.
│ - Либо Ошибка (ErrorState) │
└──────────────────────────────────────────────┘
```
Посмотрите, что произошло:
Из
std::variantполностью исчезstd::monostate. Он больше не нужен. Вариант стал «чистым» контейнером результата:std::variant<ErrorState, GoodResult>, и его логично переименовать во что-то вродеResultVariantи ничего не мешает нам добавить еще альтернативы и ветки их обработки после генерации.В цикле поллинга больше нет никаких
switch,overloadedлямбд и проверок типов. Там остался простойif (worker.is_ready()).Тяжелая артиллерия в виде
std::visitилиswitch(res.index())вызывается ровно один раз — в точке ленивой обработки результата, когда поллинг уже завершился.Невозможно перепутать сущности: Мы больше не можем перепутать состояния
is_readyиGoodResult, просто потому что это состояния разных сущностей на разных фазах выполнения программы. Даже если вы замените оба наtrue, они разнесены по времени и объектам.Исключены невозможные комбинации: А что случилось с гипотетическим багом, когда генератор еще
not_ready, а результат ужеGoodResult? Теперь мы физически не можем создать объект результата ни с каким состоянием, пока генератор не перешел в статусready. Мы отсекли это сочетание на уровне логики и последовательности исполнения кода.
Шаг к абсолютному дзену: Истинно реактивный подход
Разделение поллинга на бинарное ожидание генератора и последующую обработку чистого результата — это хороший шаг вперед. Но давайте признаем честно: сам по себе поллинг (polling) — это всё ещё костыль из прошлого века. Нам все еще нужно тратить ресурсы процессора, чтобы периодически просыпаться и дергать систему глупым вопросом: «Как там, готово?».
Так давайте сделаем следующий шаг и поднимемся на уровень архитектурного дзен-совершенства. Зададим себе вопрос: а зачем нам вообще следить за генератором? Почему бы не заставить сам генератор постучаться к нам, когда он закончит?
Вот оно — истинно реактивное программирование в своей первозданной сути.
Под реактивностью, здесь, я имею в виду не модные библиотеки с километрами функциональных операторов, а фундаментальный принцип: код не нагружает и не тормозит систему тоннами polling-запросов, а выдает реакцию строго в ответ на пришедшее событие (реагирует реактивно). Вызывающий поток либо мирно спит на средствах синхронизации (условных переменных, мьютексах, семафорах, epoll, фьючерсах, ...), либо занимается другими полезными задачами. Он полностью освобожден от бремени контроля! Бонусом идет то, что события обрабатываются мгновенно, тогда как в системе с поллингом мы всегда рискуем получить задержку реакции, равную периоду опроса.
Давайте посмотрим, как эта асинхронная идиллия выглядит на схеме взаимодействия потоков:

Посмотрите, как красиво всё встало на свои места:
Идеальное разделение сущностей: Вызывающий поток вообще ничего не знает про промежуточные состояния генератора. Для него фазы «генератор думает» просто не существует в коде — она инкапсулирована на уровне ОС или асинхронного фреймворка.
Чистый контракт данных:
std::variant<ErrorState, GoodResult>используется исключительно как безопасный конверт для передачи финального сообщения между потоками. В нем нет никакогоstd::monostate, потому что пустой конверт отправлять незачем.Нулевой оверхед: Обработчик просыпается ровно один раз. Мы вызываем
std::visitилиswitchровно один раз. Никаких холостых прокруток цикла, никаких проверок на каждом тике таймера.
В реактивной схеме система типов C++ наконец-то занимается своим прямым делом — безопасно описывает результат, а не пытается починить плохой дизайн управления потоками данных.
Синдром «Космического корабля»
Почему мы так часто наступаем на эти грабли? Мне кажется наличие невероятно мощных, сложных и увлекательных средств контроля типов, которые предлагают современные языки программирования, вызывает у разработчика непреодолимый зуд: надо обязательно использовать всё это богатство!
Возникает когнитивное искажение: «Я же не зря купил космический корабль, надо лететь из Москвы в Питер через Южный полюс(заодно пингвинов посмотрю)! Раз компилятор позволяет нам написать compile-time верификацию любой сложности, значит, код написан правильно».
Но это ловушка. Безопасность типов (Type Safety) не равна архитектурной корректности. Запихивая логику ожидания внутрь типа данных результата, мы маскируем плохой дизайн. Мы лечим симптомы вместо болезни: строим сложные шаблонные леса там, где нужно было просто провести четкую границу между фазами выполнения программы.
Заключение: Вместо вывода
И в Rust, и в C++ безопасные составные типы (enum с данными и std::variant) — это великолепный инструмент, который делает код читаемым и надежным [1]. Ошибочные состояния действительно нужно делать невыразимыми, ломающими компиляцию.
Но прежде чем бросаться кодить очередной зубодробительный шаблон или вложенный std::visit, сделайте шаг назад и задайте себе три вопроса:
Должно ли это состояние вообще существовать в этой подсистеме?
Нельзя ли сократить количество состояний, если разделить логику по фазам исполнения?
А принадлежат ли эти состояния одной единственной сущности или мы собрали состояния мух и котлет в одном месте?
Эти вопросы могут оказаться гораздо важнее вопроса «Как нам запретить состояния, которых не должно существовать?». Возможно, вместо того чтобы доказывать компилятору свою правоту с помощью метапрограммирования, стоит просто перепроектировать архитектуру так, чтобы лишних состояний не возникало логически-физически.
А как вы боретесь с ложными состояниями в своих проектах? Не пробовали анализировать разделение функциональности между действующими сущностями программы? Как всегда, с благодарностью приму любые комментарии, критику и исправления.

Комментарии 0