C++ Coroutine TS — It's About Inversion of Control

One motivating example where lazy sequences enable you to separate your concerns and maximize code reuse — without increasing complexity.

·4 min read

Many people look at the Coroutines TS and wonder: what is all the fuss about?

This article gives you one motivating example where lazy sequences enable you to separate your concerns and maximize code reuse without increasing complexity.

Example: Approximating the Golden Ratio

Every Math student knows how to approximate the golden ratio via the Fibonacci series:

fib(n+1) / fib(n) -> φ = 1.6180…

Or using C++:

double golden(double epsilon = 0.00001) {
  int a = 0;
  int b = 1;
  double ratio = 0;
 
  while (1) {
    tie(a, b) = tuple{b, a+b};
    auto delta = b / (double)a - ratio;
    ratio = b / (double)a;
    if (abs(delta) < epsilon)
      return ratio;
  }
}
 
int main() {
  cout << golden() << std::endl;
}

This is straightforward, but what if we have additional use-cases for golden?

  • Maybe we want to limit the maximum number of iterations
  • Or maybe we want to print after every iteration
  • Or maybe we want to return a tuple of { fib(n+1), fib(n) }
  • Or maybe there's a use-case we haven't thought of yet

In order to satisfy all of these, we would need to modify the algorithm and provide customization points. We could add configuration options or use generic functions:

template<class TestF, class Map>
auto golden(TestF test, Map f) {
  int a = 0;
  int b = 1;
  double ratio = 1;
 
  while (1) {
    tie(a, b) = tuple{b, a+b};
    auto delta = b/(double)a - ratio;
    ratio = b/(double)a;
    if (test(delta, ratio, a, b))
      return f(abs(delta), ratio, a, b);
  }
}

We can see the complexity increased as we generalized it. An alternative would be writing a variant for each specific use-case — readable but difficult to maintain.

How can we achieve reusability and simplicity?

This issue is caused by the fact that the algorithm controls both the iteration and the representation of the sequence. These concerns should be separated.

Inversion of Control via Coroutines TS

The Coroutines TS enables you to define lazy sequences. A lazy sequence is just an ordered set of values where each value is only computed as it is requested.

You might think of a lazy sequence as a container with two properties:

  • bool hasNext() — is there another value in this sequence?
  • T takeNext() — return the next value in the sequence and advance by 1.

This decouples computation from representation while maintaining full control over the sequence.

The following examples use the Conduit library, which provides various primitives for creating lazy sequences built on the Coroutine TS.

We can define a lazy sequence for Fibonacci:

auto fib() -> seq<int> {
  int a = 0;
  int b = 1;
 
  while (1) {
    co_yield a;
    tie(a, b) = tuple{b, a+b};
  }
}

Since the golden ratios are a transformation of the Fibonacci numbers, we can compute them using a scan:

auto fibRatios = [] {
  return fib() >> scan(1, [](auto a, auto b){
    return b / (double)a;
  });
};

Here we use the sequence to print the first 10 Fibonacci-ratios:

int main() {
  int i = 0;
  for (auto x : fibRatios()) {
    cout << x << endl;
    if (++i == 10) break;
  }
}

Now we can control the iteration ourselves — adding a delta check and an iteration limit without touching the algorithm:

int main() {
  int n = 100;
  auto prevRatio = 0.0;
  for (auto ratio : fibRatios()) {
    cout << ratio << endl;
    auto delta = ratio - prevRatio;
    if (n == 0 || delta < 0.0001) break;
    prevRatio = ratio;
    n--;
  }
}

The beauty is that we take just one definition of the sequence and use it in multiple places. Unlike the templated solution, the code still maps closely to the mathematical definition.

Conclusion

The Coroutines TS allows us to give control back to the consumer of an algorithm. This frees the implementer from providing customization hooks to the end-user to modify the behaviour of the algorithm.

Related Articles