How to make a C++20 coroutine alternative for this callback situation?

68 Views Asked by At

Assume that we have a well know callback solution that might look like this: (the code below is pseudocode in C++ syntax because the question is related to C++20 coroutines, necessary boilerplate code is intentionally omitted to make all this more readable)

class ICallback
{
public:
  virtual int callbackA(T param1, U param2, V param3)=0;
  virtual int callbackB(int a, double b)=0;
  virtual int callbackC(T1 param1, T2 param2)=0;
};

And we have some class that is processing some data and sometimes it calls any of the 3 callback methods:

class Processor
{
public:
  explicit Processor(ICallback* callback) : m_callback(callback){} // assume 'callback' cannot be nullptr

  int process(const uint8_t* data, int size)
  {
    int bytes_processed = 0;
    while(bytes_processed != size)
    {
      int bytes_consumed_in_this_iteration = doSomething(data + bytes_processed, size - bytes_processed);
      // Assume that doSomething() might call any callback method through m_callback!!
      bytes_processed += bytes_consumed_in_this_iteration;
    }
    return size; // return value is irrelevant
  }
private:
  int doSomething(const uint8_t* data, int size){ /* assume something useful is going on here */ }
  ICallback* m_callback;
};

Just assume that this code above is functional, so we receive calls 'callbackA()', 'callbackB()' and 'callbackC()'.

Now comes the funny part. Coroutines are at this moment a miracle given by gods. They can do everything and more, much more. Allegedly they can replace "callback hell" because they are perfect. So, I tried to do it (and failed). To be honest, coroutines are much bigger hell than all callbacks in the world.

I do understand the entire mambo-jumbo boilerplate code that is required behind things like T::promise_type and promise_type's methods like get_return_object(), return_value() or return_void(), initial_suspend(), final_suspend(), etc... so I won't contaminate with that boilerplate code here. The same applies for our custom awaiter object. Therefore, lets jump to the coroutine itself.

SomeType startCoroutine(/* don't really know what to put here*/)
{
  // First, I don't know how to feed a coroutine with data. What if I receive data over a socket?
  // I don't expect anyone to write the actual code, a comment and pseudocode with the most crucial parts should do
  
  // even if we ignore that hard part of delivering input data to the coroutine and simply assume that it's here even though we don't know how it arrived, what's next?

  byte data[100]; // assume it contains valid payload
  
  co_await std::make_pair(data, sizeof(data)); // calls promise_type::await_transform(std::pair<const uint8_t*, const uint32_t> value), it creates an instance of 'awaiter' and calls awaiter::await_ready() and then awaiter::await_resume() or awaiter::await_suspend(), depending on the awaiter::await_ready's() return value.

  // so, if we implement in our awaiter::await_resume() or in awaiter::await_suspend() something like Processor::process() from the beginning, but without callbacks, what should be done if after processing first 2 bytes we detect that now is the time to return control to the caller in the coroutine because we don't use callbacks any more? It can be done via await_resume(), but that returns the result, so we cannot go back and process the remaining 98 bytes of our data. If we remain in the suspended state, what then? We can resume, but resuming will call await_resume() again.
}
1

There are 1 best solutions below

0
Caleth On

First define some types holding the same data passed to the callbacks:

struct EventA { T param1; U param2; V param3; };
struct EventB { int a; double b };
struct EventC { T1 param1; T2 param2; };

using Event = std::variant<EventA, EventB, EventC>;

Now we change Processor::process into a free-function coroutine, perhaps with helper functions.

std::generator<Event> process(const uint8_t* data, int size)
{
  int bytes_processed = 0;
  while(bytes_processed != size)
  {
    auto [bytes_consumed_in_this_iteration, event] = doSomething(data + bytes_processed, size - bytes_processed);
    if (event)
      co_yield *event;
    bytes_processed += bytes_consumed_in_this_iteration;
  }
}

std::tuple<int, std::optional<Event>> doSomething(const uint8_t* data, int size) // stuff here, maybe creates an Event

Now this does require a type that's only in C++23, std::generator, but that is a widely applicable library type, you aren't fiddling around with defining a specific promise type yourself

I'm not sure this matches the call pattern that your doSomething has for the callbacks in ICallback, I'm assuming at most one callback per invocation of doSomething. If there are more, you'd need to return a range of Events, and then it'd be co_yield std::ranges::elements_of(events).