Предисловие или крик души
Данное предисловие имеет опосредованное отношение к теме статьи. Поэтому, если вы пришли чисто за примером - можете его пропустить.
Уже довольно долго я размышляю над вопросом, что и когда с 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.
На этом на сегодня всё.
Комментарии (7)
tony-space
16.09.2021 22:57#include <iostream>
#include <optional>
auto generate(int from, int to)
{
return [=, cur = from]() mutable -> std::optional<int>
{
if(cur == to)
return std::nullopt;
return cur++;
};
}
int main()
{
auto gen = generate(0, 10);
while(auto val = gen())
{
std::cout << *val << std::endl;
}
return 0;
}
Это конечно не труъ-генераторы, но симулировать их поведение в ранних стандартах можно через лямбды (строго говоря, через функторы; лямбы -- сахар над ними)Doktor3lo Автор
17.09.2021 02:50Я это понимаю. Строго говоря и лямбды тоже сахар. Я без них больше 10 лет жил (писал на c++98), использовал классы и не обламывался.
kovserg
17.09.2021 02:08А не могли бы вы рассказать что будет если мы выйдем из цикла раньше чем генератор закончит: например по break. А сам генератор по мере выполнения будет открывать файлы и запускать потоки.
Типа такого?#include <thread> #include <future> #include <chrono> #include <fstream> #include <iostream> using namespace std; size_t fn(size_t i) { cout<<"processing "<<i<<"\n"; this_thread::sleep_for(chrono::milliseconds(500)); return i; } generator generate(size_t start, size_t end) { enum { ahead=4 }; ofstream log("log.txt"); future<size_t> cache[ahead]; size_t h=start, t=start; for(auto i=start; i<end; ++i) { if (i>=t) { h=t; t=h+ahead; if (t>end) t=end; for(auto j=0; j<t-h; ++j) cache[j]=async(fn,i+j); } size_t res=cache[i-h].get(); log<<"i="<<i<<" res="<<res<<endl; co_yield res; } } int main() { for(auto value: generate(0, 10)) { cout<<value<<endl; if (value==5) break; } return 0; }
Doktor3lo Автор
17.09.2021 03:43+1Написал длинный ответ и понял, что пишу не про то :) Вопрос отличный!
Если кратко, в вашем примере я проблем не вижу. Всё должно корректно отработать. Могу предположить, что в вашем примере после завершения цикла программа зависнет на полсекунды в деструкторе future, который будет вызван методом destroy() из деструктора generator.
Все локальные переменные корутины хранятся не в стеке, а в куче. Они будут освобождены вызовом m_coro.destroy() в порядке, обратном порядку создания. Тут действительно возможны подводные камни. Например, если вы используете конструкцию try { ... } catch для освобождения каких-то ресурсов. Если у вас везде RAII - проблем не будет.
Чтобы проверить самого себя, можно задать себе вопрос: что будет, если в момент одной из итераций co_yield превратится в return (для корутины co_return)? Если это не приведет к катастрофе, то и корутина нормально отработает.
GarretThief
17.09.2021 11:29Забавно то, что в своё время на питоне был веб-фреймворк (
Tornado
, вроде), который реализовывал корутины основываясь на генераторах. Тогда асинхрона в питоне не было, а генераторы были, вот и создал разраб асинхронные вызовы на основеyield
.
Chaos_Optima
Маловато для статьи, на хабре уже была хорошая, подробная статья про корутины. А это выглядит как запись для блога. Где диаграмма передачи владения, подводные камни, как раз если бы вы написали про решение проблем которые вы обозначили (и от которых отмахнулись фразой, смотри в моей репе), было бы куда интереснее.
Doktor3lo Автор
То что мне показалось интересным и касалось непосредственно корутин - я описал в комментариях. Решение описанных проблем - это скорее умение работать с std::exception_ptr и std::variant (ну или более традиционно - через указатели). Если погружаться во все детали - многовато получится :)
Статей про корутины действительно много, как и докладов на различных конференциях. Суть большинства из них тут. Я не ставил целью еще раз рассказать, как они работают, а хотел разобрать один вполне конкретный пример.