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

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

Уже довольно долго я размышляю над вопросом, что и когда с 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} {}
};

Недостатки

  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.

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

Комментарии (7)


  1. Chaos_Optima
    16.09.2021 17:32
    +4

    Маловато для статьи, на хабре уже была хорошая, подробная статья про корутины. А это выглядит как запись для блога. Где диаграмма передачи владения, подводные камни, как раз если бы вы написали про решение проблем которые вы обозначили (и от которых отмахнулись фразой, смотри в моей репе), было бы куда интереснее.


    1. Doktor3lo Автор
      17.09.2021 03:00

      То что мне показалось интересным и касалось непосредственно корутин - я описал в комментариях. Решение описанных проблем - это скорее умение работать с std::exception_ptr и std::variant (ну или более традиционно - через указатели). Если погружаться во все детали - многовато получится :)

      Статей про корутины действительно много, как и докладов на различных конференциях. Суть большинства из них тут. Я не ставил целью еще раз рассказать, как они работают, а хотел разобрать один вполне конкретный пример.


  1. 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;

    }


    Это конечно не труъ-генераторы, но симулировать их поведение в ранних стандартах можно через лямбды (строго говоря, через функторы; лямбы -- сахар над ними)


    1. Doktor3lo Автор
      17.09.2021 02:50

      Я это понимаю. Строго говоря и лямбды тоже сахар. Я без них больше 10 лет жил (писал на c++98), использовал классы и не обламывался.


  1. 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;
    }
    


    1. Doktor3lo Автор
      17.09.2021 03:43
      +1

      Написал длинный ответ и понял, что пишу не про то :) Вопрос отличный!

      Если кратко, в вашем примере я проблем не вижу. Всё должно корректно отработать. Могу предположить, что в вашем примере после завершения цикла программа зависнет на полсекунды в деструкторе future, который будет вызван методом destroy() из деструктора generator.

      Все локальные переменные корутины хранятся не в стеке, а в куче. Они будут освобождены вызовом m_coro.destroy() в порядке, обратном порядку создания. Тут действительно возможны подводные камни. Например, если вы используете конструкцию try { ... } catch для освобождения каких-то ресурсов. Если у вас везде RAII - проблем не будет.

      Чтобы проверить самого себя, можно задать себе вопрос: что будет, если в момент одной из итераций co_yield превратится в return (для корутины co_return)? Если это не приведет к катастрофе, то и корутина нормально отработает.


  1. GarretThief
    17.09.2021 11:29

    Забавно то, что в своё время на питоне был веб-фреймворк ( Tornado , вроде), который реализовывал корутины основываясь на генераторах. Тогда асинхрона в питоне не было, а генераторы были, вот и создал разраб асинхронные вызовы на основе yield.