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 theconstexpr
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 andinline
functions (in fact,constexpr
functions are implicitlyinline
). 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 toconstexpr
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
andstd::string
inconstexpr
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
- constexpr reference
- Features supported by various C++ compilers
- CppCon 2017: Ben Deane & Jason Turner “constexpr ALL the Things!” - a compile-time JSON parser
- Csv::Parser at GitHub