C++ logo
blog

A static for loop in C++

You have if constexpr, but you want for constexpr
3 min read /

In C++, if constexpr lets you evaluate a condition at compile time, which can make for tidier (or messier) code than following more functional paradigms of template metaprogramming to achieve the same. An example of its use appears in C++: Forwarding references, overload resolution, and taking back control. Similarly, you may desire a for constexpr to evaluate a loop at compile time, for tidier (or messier) code, although no such construct exists in the language today.

This post demonstrates how to achieve something similar to the non-existent for constexpr, as well as considering some alternatives that are made available via the C++ language and standard library already.

  1. A static for loop
  2. Example: comparing tuples
  3. Alternatives
  4. Summary

A static for loop

We can create a static_for function template:

template<int I, int N, class F>
constexpr void static_for(F f) {
  if constexpr (I < N) {
    f.template operator()<I>();
    static_for<I + 1,N>(f);
  }
}

and use it:

static_for<0,3>([&]<int I>() {
  // body of loop, where I is loop index
});

The lambda function acts as the body of the loop, with the static argument I acting as the loop index. What actually happens is that static_for is a recursive function that is inlined at compile time to ultimately just call the lambda function three times, with I set to 0, 1 and 2, respectively. The if constexpr ensures termination of the recursion.

Example: comparing tuples

Consider a set of records stored as std::tuple objects, and comparing them for equality. There is a default comparison operator== that could be used for this purpose, but for illustration we can use static_for to implement our own comparison.

While this is mostly for illustration, it may be useful for edge cases. The motivation for this post emerged from a limitation in Nvidia’s CUDA compiler nvcc, which seems to have a few issues on the host-device dichotomy when it comes to the default operator==. This approach provided a workaround.

Complete code could look something like this:

#include <iostream>
#include <tuple>

template<int I, int N, class F>
constexpr void static_for(F f) {
  if constexpr (I < N) {
    f.template operator()<I>();
    static_for<I + 1,N>(f);
  }
}

using record = std::tuple<int,double,std::string>;

bool equal_static_for(const record& x, const record& y) {
  bool equal = true;
  static_for<0,3>([&]<int I>() {
    equal = equal && std::get<I>(x) == std::get<I>(y);
  });
  return equal;
}

int main() {
  record a{14, 94.8, "aaa"};
  record b{24, 83.3, "bbb"};
  record c{14, 94.8, "aaa"};
  
  std::cerr << equal_static_for(a, b) << ' ' << equal_static_for(a, c) << std::endl;
}

If we put this in a file test.cpp, we can compile with:

g++ -std=c++20 -o test test.cpp

and run with ./test to obtain:

0 1

There are limitations to such this approach. It is not, of course, an actual loop, and statements like continue and break cannot be used within the lambda function to skip to the next iteration or exit the loop prematurely. A return statement within the lambda function will return from the lambda function, not the enclosing function—although that offers an interesting prospect, where return in this context acts like continue in the context of an actual loop.

Alternatives

It is worth looking at some alternative implementations and considerations.

The first is to understand that simple loops with a static trip count are often unrolled by the compiler anyway, and this may be sufficient in many cases. The following loop, for example, will be unrolled when optimizations are enabled, because the trip count (10) is known at compile time:

for (int i = 0; i < 10; ++i) {
  //
}

However, unlike I in the example above, i is not constexpr in this loop; we could pass I as a template argument in e.g. std::get<I>(), but we cannot use i in the same way.

A second alternative is to use std::integer_sequence from the STL. One way to use it for an equality comparison is as follows:

bool equal_sequence(const record& x, const record& y) {
  return [&]<int... Is>(std::integer_sequence<int,Is...>) {
        return ((std::get<Is>(x) == std::get<Is>(y)) && ...);
      }(std::make_integer_sequence<int,3>());
}

This uses a fold expression for the && conjunction.

Personally, I find the std::integer_sequence approach less readable that the static_for approach above, but perhaps there are better ways to use it.

A third alternative is that std::tuple has a default operator== anyway, at least when its element types do, as in our example. This permits the usual operator== to work:

bool equal_default(const record& x, const record& y) {
  return x == y;
}

Of course, we may be interested in more elaborate computations than equality comparison, this was just an illustrative example.

Summary

While the C++ language does not provide a for constexpr statement for a static for loop, we can construct something similar, as demonstrated by static_for here.

Complete code for further play:

#include <iostream>
#include <tuple>

using record = std::tuple<int,double,std::string>;

template<int I, int N, class F>
constexpr void static_for(F f) {
  if constexpr (I < N) {
    f.template operator()<I>();
    static_for<I + 1,N>(f);
  }
}

bool equal_static_for(const record& x, const record& y) {
  bool equal = true;
  static_for<0,3>([&]<int I>() {
    equal = equal && std::get<I>(x) == std::get<I>(y);
  });
  return equal;
}

bool equal_sequence(const record& x, const record& y) {
  return [&]<int... Is>(std::integer_sequence<int,Is...>) {
        return ((std::get<Is>(x) == std::get<Is>(y)) && ...);
      }(std::make_integer_sequence<int,3>());
}

bool equal_default(const record& x, const record& y) {
  return x == y;
}

int main() {
  record a{14, 94.8, "aaa"};
  record b{24, 83.3, "bbb"};
  record c{14, 94.8, "aaa"};
  
  std::cerr << equal_static_for(a, b) << ' ' << equal_static_for(a, c) << std::endl;
  std::cerr << equal_sequence(a, b) << ' ' << equal_sequence(a, c) << std::endl;
  std::cerr << equal_default(a, b) << ' ' << equal_default(a, c) << std::endl;
}