В интернете невероятное количество статей о том "как написать свой генератор на С++20", но почти все они сводятся к новичковым хело вордам и почти ни одной статьи о том как написать хороший генератор. Что ж, это нужно исправлять!
что такое генератор
в С++20 добавили корутины(условно говоря функции, которые могут приостанавливать выполнение и продолжать), а генератор это одна из простейших корутин, он просто генерирует значения, причём всегда синхронно.
Пример:
generator<int> ints() {
// после каждого co_yield мы возвращаемся в функцию 'use'
co_yield 1;
co_yield 2;
co_yield 3;
}
void use() {
for(int i : ints()) foo(i);
}
Сначала определимся что такое хороший генератор, для меня это когда он:
эффективен, мы всё таки на С++ пишем
удобен в использовании(не провоцирует неявных ошибок, интерфейс понятен даже новичку)
В связи с этим возникает вопрос, зачем писать свой, ведь в С++23 уже добавили std::generator? Ну, во-первых С++23 в проде будет ещё не скоро, а во-вторых std::generator это, к сожалению, одна из худших вещей добавленных в стандартную библиотеку.
Вкратце опишу свои претензии к нему(кстати, его невозможно реализовать без компиляторной магии)
Интерфейс
template<typename Reference, typename Value = void, typename Alloc = void>
class std::generator;
Первый = void означает "по дефолту", второй = void означает type erasure! Это сбивает с толку даже матёрого С++ программиста и даже после чтения документации.
Сами авторы признают, что Alloc в этом интерфейсе лишний - логика генератора никак не зависит от того как его аллоцировали. Это должно достигаться специализацией coroutine_traits.
Ну это ещё полбеды, вот вы захотели генератор строк.
Пишем
std::generator<string> foo(); // уже ошиблись = )
Это уже будет неэффективно, потому что первый аргумент шаблона - ссылка. Правильно было бы написать generator<string_view>, но тогда на принимающей стороне нужно копировать. В итоге у вас выйдет что то типа generator<string&&, string> или что похуже и одному богу известно будет ли это эффективно.
std::generator буквально неюзабелен. Пройдусь банально по всем его методам:
begin - нельзя вызывать дважды, при этом нет способа проверить вызван ли он уже. Вызовете второй раз - undefined behavior. Хотя ожидаемое лично мной поведение - продолжение итерации с того места, где остановились в предыдущий раз
void foo(std::generator<int> x) {
for(int i : x) // возможно UB, причём проверить будет ли UB нельзя никак
do_smth(i);
}
подробный разбор недостатков std::generator
end - возвращает std::default_sentinel всегда, при этом метод не является статическим
деструктор - неявно noexcept, хотя .destroy на хендле который он вызывает может бросить исключение при разрушении локальных объектов на корутине. То есть помимо UB вам подсунули ещё и неявный std::terminate
мув конструктор - проблема в том, что он есть. А дефолтного конструктора нет! Это нонсенс, таким образом после мува конструктор является invalid объектом, который использовать нельзя. Никак. Вообще.
std::pmr::generator - проблема опять же в том, что это существует. Это алиас на генератор с std::pmr::polymorphic_alloc, хотя генератор не является контейнером и не использует этот аллокатор буквально никак, не вдаваясь в подробности - создание этого алиаса это банальный обман пользователя
Ух, что то я увлёкся...
Пишем наконец свой генератор
Итак, мы определились с тем что хотим видеть в генераторе и чего не хотим, для реализации нам понадобится написать
promise type нашей корутины с возможностью yield значение
сущность generator управляющую через промис корутиной
итератор, который будет ссылаться на генератор
Очевидно, что главное что надо сделать это yield и вы можете найти тысячи реализаций в интернете, которые выглядят как-то так:
struct promise {
YieldType value;
auto yield_value(YieldType x) {
value = std::move(x);
return std::suspend_always{};
}
};
И это .. неэффективно! Зачем нам хранить и создавать значение, если можно просто взять на него указатель?
struct promise {
YieldType* value;
auto yield_value(Yield&& x) {
value = std::addressof(x);
return std::suspend_always{};
}
};
Каждый уважающий себя программист на С++ сразу же, как ему покажется, увидит ошибку. Мы сохраняем указатель на "временный" объект, который разрушится как только вызов yield_value закончится
Но вы забыли, это же корутины! Давайте посмотрим на это из генератора
generator<int> foo() {
co_yield 5;
// мы дойдём до этой точки ТОЛЬКО после того как
// значение будет обработано вызывающей стороной!
}
Поэтому, если вспомнить правило "временные значения живут до конца выражения" и цитату стандарта "co_yield x" это yield-expression можно понять, что сохранять указатель это абсолютно безопасно и главное - эффективно.
Теперь насчёт других специальных методов промиса: мы должны решить как будет вести себя генератор на старте, генерировать первое значение сразу или подождать? Тут решение на самом д��ле неочевидно, но в итоге все реализации приходят к приостановке перед генерацией, чтобы в .begin сгенерировать первое значение и не пропустить ничего... А ещё чтобы генератор не стал за эту первую свою итерацию рекурсивным, сломав что-нибудь по дороге (но о рекурсивности - позже)
std::suspend_always initial_suspend() {
return {};
}
void return_void() {}
// пока приостанавливаемся в конце, чтобы не не уничтожить корутину раньше времени
// но это ещё не окончательно!
std::suspend_always final_suspend() const;
Почему то что мы делали до этого неэффективно из зачем нужен рекурсивный генератор
Для этого нужно немного подумать над тем, как наш генератор будут использовать. Представим себе такой код:
generator<int> g() {
for(int x : another_generator())
co_yield x;
}
Генератор выбрасывает значения другого генератора. Что при этом происходит?
пробуждается генератор 'g'
он будит генератор 'another_generator'
тот будит какой то ещё генератор и так по цепочке O(N) раз, в конце концов получается значение.
Каждое из пробуждений - достаточно дорогая операция, сравнимая с вызовом функции по указателю
А что если мы будем сразу из самого верхнего генератора в цепочке пробуждать самый нижний, а тот когда сгенерирует значение будет будить опять самый верхний? В этом и кроется суть рекурсивного генератора, он превращает O(N) вызовов для получения значения в гарантированное O(1) и это прекрасно.
Но для реализации подобного поведения нам придётся написать интрузивный список генераторов, а если захочется эффективности, то ещё ооочень много self reference типов промисов.
Но главное - что получается в конце. Нет буквально ни одного лишнего if относительно обычного генератора, весь оверхед - память, 1-2 указателя на 1 генератор. Фактически - оверхед смешной учитывая какие плюсы он даёт.
Идея будет простой: если генератор видит yield другого генератора, то он подключает его в цепочку, говорит ему куда генерировать значения и ... передаёт управление симметричным трансфером. Название сложное, а на деле это просто пробуждение другого генератора. Тот в свою очередь в final_suspend вернёт управление обратно
// Leaf == generator
template <typename Leaf>
struct attach_leaf {
Leaf leaf;
bool await_ready() const noexcept {
return leaf.empty();
}
std::coroutine_handle<> await_suspend(typename Leaf::handle_type owner) const noexcept {
// верим в лучшее, что никто не сделал истинно "рекурсивный"
// генератор и не yield'нул сам себя
assert(owner != leaf.top);
auto& leaf_p = leaf.top.promise();
auto& root_p = *owner.promise().root;
leaf_p.current_worker.promise().root = &root_p;
leaf_p._owner = owner;
root_p.current_worker = leaf_p.current_worker;
return leaf_p.current_worker; // собственно симметричный трансфер управления
}
void await_resume() {
}
};
Тут мы вводим несколько новых сущностей:
current_worker - генератор, который сейчас активен(самый нижний в цепочке)
root - самый высокий в цепочке генератор
owner - генератор, в котором yield'нули генератор
Самое интерерсное здесь, пожалуй, то как устроен промис
struct generator_promise {
generator_promise* root = this;
handle_type current_worker = self_handle();
union {
Yield** _current_result_ptr; // setted only in root
handle_type _owner; // setted only in leafs
};
};
То есть root по умолчанию это сам генератор, current_worker по умолчанию - тоже он же сам, current_result_ptr вовсе указывает на generator, то есть активный генератор прямо сразу туда складывает значения, минуя вообще всех
Интерфейс нашего генератора:
template <yieldable Y>
struct generator {
private:
Y* current_result = nullptr;
// ещё один хак, чтобы убрать лишние ифы. Можете посмотреть в исходниках
always_done_or<promise_type> top = always_done_coroutine();
public:
...
} // максимально просто и понятно
Итог
Мы получили генератор, который эффективен, рекурсивен, имеет дефолтный конструктор и вызов цикл for по дефолтно сконструированному генератору даст 0 значений(напомню std::generator даст UB), можно вызывать begin повторно и просто продолжать итерацию,
dd::generator<int> ints() {
for(int i = 0; i < 50; ++i)
co_yield i;
}
dd::generator<int> other_ints() {
for(int i = 0; i < 50; ++i)
co_yield i;
// elements_of всего лишь тег, рекурсивно yield'им 'ints'
co_yield dd::elements_of(ints());
}
int use_generators() {
auto g = other_ints();
int res = 0;
for(int v : gg) {
res += v;
if (v == 33) break;
}
// спокойно продолжаем итерацию
for (int v : gg)
res += v;
return res;
}
Ещё очень много тонкостей с обработкой исключений, кастомизацией аллокаций и мелочами для эффективности, всё это вы можете посмотреть в исходном коде: https://github.com/kelbon/kelcoro