Writing a Compile-Time CSV Parser in C++, Part 1: Constexpr Overview

Introduction

In part 1 of the article I discuss the constexpr feature as it's specified in C++17 and C++20.

In part 2 I describe the steps I took to write a library for compile-time (and runtime) parsing of CSV files called Csv::Parser:

What Do I Mean by Compile-Time?

One of the major strengths of C++, like other compiled-to-native-code languages, is speed. C++ achieves it by compiling the source code to fast, optimized, CPU-native machine code.

While the first ISO standard of C++ (C++98) is a fast language, many developers think that there is more room for efficiency. Move operations, "as if" rule for optimizing out memory allocations, and guaranteed copy elision introduced in later C++ standards are some of the new features which improve the performance of existing code "for free".

However, the quickest calculation is the one that does not happen at all, or, more precisely, happens in advance during compilation.

Templates always provided the standard C++ with some rudimentary capability of compile-time calculation. However, C++11 introduced a whole new feature called constexpr which made compile-time calculation easier, but still tricky and difficult to reason about due to its limitations. C++17 removed most of these limitations and now a lot of ordinary source code can be easily evaluated during compilation, freeing up valuable computational resources on the user's machine.

What constexpr Does

For sake of simplicity, I will mainly focus on constexpr as it's defined in C++17. Pre-C++17 constexpr is quite limited and not many of C++20 constexpr features have been fully implemented as of GCC 11 / Clang 11.

Marking a function or a variable constexpr declares that it is possible to evaluate the value of the function or variable at compile time. Such values can then be used where only compile-time constant expressions are allowed. Marking a variable declaration constexpr also implies const. (Note: constexpr methods used to be const by default, but this was removed in C++14).

The syntax for making a function or a variable constexpr is:

constexpr int func()
{
    // ...
}

// must be initialized by a constant expression
constexpr int variable = 1;

Compile-Time or Runtime?

It is important to understand that simply marking a function constexpr does not mean it will be evaluated during compilation. Whether such functions are evaluated at compile-time or runtime depends on a few things:

  • If the result of the function must be available at compile-time as a constant expression, the function is evaluated at compile-time. For example, assigning the function result to a constexpr variable or using it as a non-type template argument forces compile-time evaluation. Of course, for this to work, all the arguments of the constexpr function must be constant expressions as well. Note that if such evaluation is impossible (because, for example, a function argument value is unavailable at compile-time), you will get a compilation error.

  • If the function depends on runtime values, it will be evaluated at runtime.

  • If the function depends on compile-time values, but its result is not required to be a constant expression, it is up to the compiler to decide whether it will run the evaluation at compile-time or not. Current compilers fall back to runtime evaluation in this case.

C++20 Standard Library provides a function std::is_constant_evaluated to help you find out whether the evaluation is happening at compile-time or runtime.

A Note on constexpr Lambdas

Unlike regular functions, lambda functions are automatically constexpr if the compiler determines that they can be. It may still be useful to mark them explicitly with constexpr keyword to force them to adhere to constexpr rules:

auto lambda = [](int argument) constexpr
{
    // only the operations valid in constexpr context are allowed here
};

Forcing Compile-Time Evaluation

The only way to guarantee that a constexpr function will be evaluated at compile-time is to use its result as a constant expression. There are a few ways to do this in C++:

  • Assign it to a constexpr variable:

    constexpr auto result = func();
    
  • Provided its result can be used as a non-type template argument, you can do the following: ```C++ template constexpr void forceCompileTime() { }

forceCompileTime();

Or something like:
```C++
template <int N>
struct ForceCompileTime {
    static const int value = N;
};

auto value = ForceCompileTime<func()>::value;
  • If you just want to call a few functions at compile-time, you could use constexpr lambda:
    [[maybe_unused]] constexpr auto result = [&]() constexpr {
      func1();
      func2();
      return true;  // or perhaps return func3();
    }();
    

Constexpr Functions - What Can I Write Inside Them?

constexpr functions are limited in what they can do. Here are a few of these limitations (as of C++17 and C++20):

  • A constexpr function cannot call a non-constexpr function. This means that the C standard library is off limits.

  • The definition of a constexpr function must be available when calling it. This is similar to function templates and inline functions (in fact, constexpr functions are implicitly inline). In practice, this means that the definitions should be placed in header files.

  • All local variables, parameters, and the return value must be of a Literal Type

  • A constexpr function cannot be a coroutine.

  • A constexpr function can be virtual, but only since C++20.

  • goto is not allowed.

  • Inside a constexpr function, declaration of a variable without initialization is allowed, but only since C++20. The same rule applies to constexpr variables.

  • Since C++20, try blocks are allowed, but actually throwing an exception at compile-time is also prohibited, causing a compilation error.

  • C++20 allows some use of std::vector and std::string in constexpr contexts, but neither GCC 11 nor Clang 11 implement it (yet).

Testing and Error Reporting

One useful side effect of evaluating constexpr functions at compile-time is that we can also do some testing during compilation without actually running any unit tests. By catching the errors early on at compile-time, this approach increases the reliability and correctness of the code without being dependent on runtime tests.

For example, we can use static_assert in a test to verify correctness of a function at compile-time:

constexpr int add(int a, int b)
{
    return a + b;
}

static_assert(add(1, 2) == 3);  // OK

// The following line fails to compile: 
static_assert(add(1, 2) == 4);

We can also conditionally throw an exception to trigger a compilation error. For example, to verify that a precondition is met we can write:

constexpr int compute(int a, int b)
{
    if (a > b) {
        throw std::logic_error("Precondition failed");
    }
    return a + b;
}

[[maybe_unused]] constexpr int result1 = compute(1, 5);  // OK
[[maybe_unused]] constexpr int result2 = compute(50, 2);  // Fails to compile

Note that we cannot use static_assert in compute() because the parameters may or may not be constant expressions - compute() can be used in runtime context as well.

It is important to note that a compiler is not required to issue an error if a constexpr function contains a code path that can only be evaluated at runtime, but is not triggered with particular set of compile-time arguments. The conditional throwing of exception in the example above relies on this behavior.

What's Next

In part 2 of the article I will talk about a way to approach writing a compile-time CSV parser, presenting a Csv::Parser library implemented using constexpr.

Useful Links