
Предисловие или крик души
Данное предисловие имеет опосредованное отношение к теме статьи. Поэтому, если вы пришли чисто за примером - можете его пропустить.
Уже довольно долго я размышляю над вопросом, что и когда с C++ пошло не так. Почему выстрелили GoLang и Python? Обычные доводы, что дескать у C++ сложный синтаксис и легко выстрелить себе в ногу, объясняют это лишь отчасти. Поверьте, если лезть в дебри любого языка, наворотить нечитаемый код или выстрелить в ногу можно из чего угодно. Вот только тот же Go не стимулирует разработчика к таким изысканиям. Большинство прикладных задач решаются через ПРОСТЫЕ и ПОНЯТНЫЕ интерфейсы. Думаю те, кто хоть раз пробовал реализовать свой собственный поток (std::steam) на С++ поймут о чем я говорю. Так почему же нельзя в С++ сделать какой-нибудь stl lite - более высокоуровневый и простой интерфейс для тех, кто не хочет заморачиваться. Я понимаю, что сейчас есть conan и 100500+ библиотек в нем. Но, как среди этого зоопарка выбирать? Где гарантия, что выбранная мной библиотека не умрёт через год, и что в ней будут исправляться ошибки?
Поэтому, время от времени, когда выдается свободная минутка, я пробую реализовывать понравившиеся мне конструкции из других языков на С++. Доказывая себе и окружающим, что проблема не в языке. Например, меня есть собственные channel работающие почти как в GoLang. Но реализация получилась довольно сложная и не без косяков. Но если статья зайдет, доотлаживаю и напишу и про них.
Я уже пару лет как развлекаюсь написанием различных программ на C++ с использованием корутин. Но до сего момента это были асинхронные приложения. Я активно использовал co_await, но ни разу еще мне не понадобился co_yield. И вот, после трех дней вынужденного ничегонеделанья в больнице, я решил этот пробел восполнить и попробовать написать собственный генератор. А заодно и получше разобраться с promise_type и coroutine_handle
Намечаем цель
Вдохновлялся я генераторами Python. В данном случае я не надеялся получить полностью аналогичный синтаксис, да и не видел в этом смысла, но хотел добиться похожей лаконичности. Начал я, как водится, с конца. Хочу чтобы работал примерно следующий код:
generator generate(size_t start, size_t end) { for (auto i = start; i < end; ++i) { co_yield i; } } int main() { for (auto value: generate(0, 10)) { std::cout << value << std::endl; } return 0; }
Очевидно, что нам нужен некий объект generator с promise_type внутри.
Наработав некоторую практику использования, я решил, по возможности, отказаться от coroutine_traits. Код с ними выглядит конечно волшебно. И, если ваша цель впечатлить кого-то - это ваш путь. Наверное поэтому, такие примеры гуглятся в первую очередь. Но использование подобных неявных структур не способствует улучшению читаемости. При этом я ни в коем слуае не отрицаю, что в некоторых случаях они могут быть оправданы.
В первом приближении получился следующий код. Я добавил в него пояснения, зачем нужна та или иная строка, и почему она написана так, а не иначе. Сразу оговорюсь, это не окончательный, а самый первый и простой вариант. О его недостатках будет рассказано ниже
class generator { public: struct promise_type { using suspend_never = std::suspend_never; using suspend_always = std::suspend_always; using handle = std::coroutine_handle<promise_type>; size_t value; /* создание экземпляра класса generator * да, этим занимается promise! */ auto get_return_object() noexcept { return generator{handle::from_promise(*this)}; } /* suspend_never говорит C++ выполнить корутину * до первого вызова co_yield/co_return сразу в момент её создания */ suspend_never initial_suspend() noexcept { return {}; } /* suspend_always указывает С++ придержать разрушение * корутины в момент её завершения. Это необходимо, чтобы не * потерять возможность обращаться к promise и handle * после её завершения. В противном случае вы даже не сможете * проверить done() см. ниже */ suspend_always final_suspend() noexcept { return {}; } /* наши генераторы не будут ничего возвращать * через co_return, только через co_yield */ void return_void() noexcept {} /* обработка `co_yield value` внутри генератора */ suspend_always yield_value(size_t v) noexcept { value = v; return {}; } /* на первом этапе мы не обрабатываем исключения внутри генераторов*/ void unhandled_exception() { std::terminate(); } }; /* Поскольку finial_suspend придерживает уничтожение корутины * нам необходимо уничтожить её вручную */ ~generator() noexcept { m_coro.destroy(); } /* iterator и методы begin(), end() необходимы для компиляции цикла * for (auto value: generator(0, 10)), описание логики работы range * base for выходит за рамки данной статьи */ class iterator { public: bool operator != (iterator second) const { return m_self != second.m_self; } iterator & operator++() { /* воззобновить выполнение корутины - генератора */ m_self->m_coro.resume(); /* проверяем, завершилась ли корутина, если бы не final_suspend * возвращающий suspend_always - нас бы ждал облом */ if (m_self->m_coro.done()) { m_self = nullptr; } return *this; } size_t operator*() { /* достаем значение напрямую из promise */ return m_self->m_coro.promise().value; } private: iterator(generator *self): m_self{self} {} generator *m_self; friend class generator; }; /* первое значение корутины уже вычитано благодаря * inital_suspend, возвращающим suspend_never */ iterator begin() { return iterator{m_coro.done() ? nullptr : this}; } iterator end() { return iterator{nullptr}; } private: promise_type::handle m_coro; /* конструктор, который будет вызван из get_return_object */ explicit generator(promise_type::handle coro) noexcept: m_coro{coro} {} };
Недостатки
У нас получился класс, позволяющий написать любой генератор возвращающий size_t. Ужас! Но, его несложно переделать в шаблон ��енератора, возвращающего любой тип, для которого определен конструктор по умолчанию, копирующий конструктор и оператор копирования
Генератор сразу же вычитывает одно значение из корутины. Хотя, было бы универсальнее, чтобы значение генерировалось только, когда оно действительно необходимо
Наш генератор может быть нечаянно скопирован, что приведет к катастрофическим последствиям
Генератор не возвращает исключения возникающие внутри корутины
Всё это не фатально и решается с использование std::variant и std::exception_ptr. Я не стал вставлять в статью код, решающий все эти проблемы, его можно посмотреть в моем github. Кому лень, просто поверьте наслово, что у меня получился шаблон template <typename Value> class generator обладающий всеми этими свойствами.
Аппетит приходит во время еды
Потратив время на написание шаблона генератора, я приятно удивился той легкости, с которой можно реализовывать различные операции над ним. Первое что я попробовал, конечно же фильтр:
int main() { auto is_odd = [](auto v) { return v % 2 == 0; }; for (auto value: generate<int>(0,10) | is_odd) { std::cout << value << std::endl; } }
Реализация выглядит следующим образом. В этом примере я еще воспользовался концептами, но о них я рассказывать тоже не буду.
template <generator_type Generator, typename Predicate> auto operator | (Generator &&s, Predicate p) -> std::decay_t<Generator> { for (auto &value: s) { if (p(value)) { co_yield std::move(value); } } }
Примерно такая же тривиальная реализация получились для шаблона zip() - реализующего объединение результатов переданных в него генераторов в структуры std::pair или std::tuple (когда объединяются значения для трех и более генераторов), сложения однотипных генераторов при помощи перегрузки operator +, и шаблона для преобразования контейнера в генератор (может иметь смысл при использовании совместно с тем же zip). Примеры можно посмотреть на том же github.
На этом на сегодня всё.
