Предисловие или крик души
Данное предисловие имеет опосредованное отношение к теме статьи. Поэтому, если вы пришли чисто за примером - можете его пропустить.
Уже довольно долго я размышляю над вопросом, что и когда с 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.
На этом на сегодня всё.