Генераторы на корутинах C++

Kate

Administrator
Команда форума

Предисловие или крик души​

Данное предисловие имеет опосредованное отношение к теме статьи. Поэтому, если вы пришли чисто за примером - можете его пропустить.

Уже довольно долго я размышляю над вопросом, что и когда с 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 {
/* в clang 12 корутины до сих пор experimental */
using suspend_never = std::experimental::suspend_never;
using suspend_always = std::experimental::suspend_always;
using handle = std::experimental::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} {}
};

Недостатки​

  1. У нас получился класс, позволяющий написать любой генератор возвращающий size_t. Ужас! Но, его несложно переделать в шаблон генератора, возвращающего любой тип, для которого определен конструктор по умолчанию, копирующий конструктор и оператор копирования
  2. Генератор сразу же вычитывает одно значение из корутины. Хотя, было бы универсальнее, чтобы значение генерировалось только, когда оно действительно необходимо
  3. Наш генератор может быть нечаянно скопирован, что приведет к катастрофическим последствиям
  4. Генератор не возвращает исключения возникающие внутри корутины
Всё это не фатально и решается с использование 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.

На этом на сегодня всё.

 
Сверху