0%

Constant Expressions in C++

This is my learning notes from the course - Mastering C++ Standard Library Features.

Programming at Compile-Time

  • Compile-time computations with constexpr
  • Manipulation of variadic template type lists
  • Standard Library features focused on metaprogramming

Constant Expressions

  • What constant expression means?
  • constexpr in C++11/14/17
  • Practical use cases of constexpr functions

What Does Constant Expression Mean?

  • You might have noticed that values as non-type template parameters or array sizes do not accept run-time values
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    template <int I> struct foo { };

    int main()
    {
    int i;
    std::cin >> i;

    foo<i>{}; // <-- compile-time error

    return 0;
    }
  • This makes sense, as these values must be known at compile-time
  • The C++ Standard refers to expressions whose result can be computed at compile-time as “constant expressions
    1
    2
    3
    4
    5
    6
    7
    8
    template <int I> struct foo { };

    int main()
    {
    foo<5>{}; // <-- OK, `5` is a constant expression

    return 0;
    }

Constant Expressions - C++03

  • Before C++11, only a few language elements were valid constant expressions:
    • Expressions consisting of arithmetic literals and enumerators
    • Non-volatile const variables or static data members
    • Non-type template parameters
    • sizeof expressions
  • C++11 and C++14 greatly relaxed these limitations:
    https://en.cppreference.com/w/cpp/language/constant_expression

Constant Expressions - C++11

  • Since C++11, users can now write functions computing constant expressions thanks to the constexpr keyword

    1
    2
    3
    4
    5
    constexpr int five() { return 5; }

    std::array<int, five()> foo;
    // ^~~~~~
    // constant expression
  • In C++11, constexpr functions were limited to a single return statement - this limitation required developers to use recursion or unnatural code in order to produce useful results

    1
    2
    3
    4
    5
    6
    constexpr int fibonacci(int x)
    {
    return x == 0 ? 0
    : x == 1 ? 1
    : fibonacci(x - 1) + fibonacci(x - 2);
    }

  • constexpr functions can be executed both at run-time and compile-time
  • If all the arguments are known at compile-time, the function might be evaluated at compile-time
  • If any of the arguments is only known at run-time, the function cannot be evaluated at compile-time
  • If the function is invoked in a context where a constant expression is required, compile-time evaluation is mandatory

  • constexpr can also be applied to variables - it forces variables to be initialized with a constant expressions
    1
    2
    constexpr int n0 = 42;  // <-- OK
    constexpr int n1; // <-- ERROR
    1
    2
    int read_int_from_stdin();
    constexpr int n2 = read_int_from_stdin(); // <-- ERROR
    1
    2
    3
    constexpr int triplicate(int);
    constexpr int n3 = triplicate(5); // <-- OK
    static_assert(n3 == 15); // <-- OK

Constant Expressions - C++14

  • Since C++14, constexpr functions restrictions were greatly relaxed

  • Almost every language feature can be used inside the body of a constexpr function, except:

    • Dynamic allocation
    • Run-time based memory accesses and casts
    • Expressions that would lead to undefined behavior
    • Function calls to non-constexpr functions
    • (…)
  • An example of Fibonacci without recursion:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    constexpr int fibonacci(int x)
    {
    int a{0}, b{1};

    for(int i = 0; i < x; i++)
    {
    const int temp = b;
    b += a;
    a = temp;
    }

    return a;
    }

Constant Expressions - C++14

  • Since C++17, lambdas can be constexpr as well
    1
    []{ return 10; }  // implicitly `constexpr`
    1
    []() constexpr { return 10; }  // explicitly `constexpr`
    1
    2
    3
    4
    5
    []() constexpr  // compile-time error
    {
    int i;
    std::cin >> i;
    }

Compile-Time Branching

  • In C++17, we have compile-time branching: if constexpr(...)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    template <typename T>
    void foo(T x)
    {
    if constexpr(std::is_same_v<T, std::string>)
    {
    // ...
    }
    else
    {
    // ...
    }
    }

1
2
if constexpr(std::is_same_v<T, std::string>) { /* ... */ }
else { /* ... */ }
  • The condition must be a constant expression, otherwise will be compile-time error

  • Both branches (true and false) need to be parsable

  • Only the taken branch will be instantiated

  • In the non-instantiated branch, code invalid for the current T is allowed

  • Example of regular if-statement:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    template <typename T>
    int foo(const T& x)
    {
    if(std::is_same_v<T, std::string>)
    {
    return x.length();
    }
    else { return x; }
    }
    1
    2
    foo(std::string{"abcd"});  // Compile-time error
    foo(1234); // Compile-time error
  • Example of if constexpr:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    template <typename T>
    int foo(const T& x)
    {
    if constexpr(std::is_same_v<T, std::string>)
    {
    return x.length();
    }
    else { return x; }
    }
    1
    2
    foo(std::string{"abcd"});  // OK
    foo(1234); // OK

Constant Expressions - The Future

  • Proposals allowing dynamic memory allocation in constexpr functions are actively being discussed
  • A “constexpr allocator” was proposed to allow usage of STL containers in constexpr functions
  • constexpr functions might become as powerful as any other function

constexpr - Why?

  • constexpr functions allows us to produce constant expressions
    • If they’re invoked with run-time arguments, they will not produce constant expressions
  • Constant expressions can be used for compile-time data structures and algorithms
  • Performing computations at compile-time is safer and faster
    • Compile-time errors instead of run-time errors
    • Code won’t be generated or executed at run-time

Example: constexpr Factorial in C++14

1
2
3
4
5
6
7
8
9
10
constexpr int factorial(int n)
{
int result = 1;
for(int i = 1; i <= n; i++)
{
result *= i;
}

return result;
}

1
2
3
4
5
constexpr int f4 = factorial(4);
static_assert(f4 == 4 * 3 * 2 * 1);

static_assert(factorial(3) == 6);
static_assert(factorial(4) == 24);
  • Using a constexpr variable requires its initialization expression to be a constant expression
  • f4 is guaranteed to be evaluated at compile-time
  • We can therefore use static_assert

1
2
int f4 = factorial(4);
assert(f4 == 4 * 3 * 2 * 1);
  • f4 could be evaluated at compile-time, but it is not guaranteed
  • Note that a constexpr function can be evaluated at run-time

1
2
3
4
int input;
std::cin >> input;

int f_input = factorial(input);
  • f_input can only be evaluated at run-time
  • Note that a constexpr function can be evaluated at run-time

1
2
3
constexpr int widget_count = 6;

std::array<std::set<widget>, factorial(widget_count)> widget_permutations;
  • std::array requires a constant expression as its second template parameter
  • Since factorial is constexpr, we can use it instead of hard-coding the result
  • factorial can be (and should be) unit tested

Example: Validating Strings at Compile-Time

1
2
3
4
5
6
7
template <typename T>
constexpr bool is_valid_user_id(const T& id)
{
return std::size(id) >= 3
&& id[0] == 'U'
&& id[1] == '.';
}
  • std::size introduced in C++17 to homogeneously retrieve an array or a container’s size
  • Template - will work with both std::string and const char*, both run-time and compile-time

1
2
static_assert(is_valid_user_id("U.ABCD") == true);
static_assert(is_valid_user_id("ABCD") == false);
  • When a string known at compile-time is provided, is_valid_user_id will be evaluated as a constant expression

1
2
3
4
std::string id;
std::cin >> id;

assert(is_valid_user_id(id));
  • The exact same function will work with values only known at run-time

The Tunnel is Deep

  • Libraries such as Sprout or Yuki push the limits of constexpr by providing complicated algorithms and data structures
  • Ben Deane and Jason Turner’s “constexpr ALL the Things!“ talk shows how to implement a compile-time JSON parser

Summary

  • Learned that the constexpr functions can evaluate to constant expressions
  • Understood that constexpr functions might be evaluated at compile-time
  • Learned constexpr variables must be initialized with a constant expression
  • Studied that in C++14, constexpr functions do not have many limitations
  • Studied that in C++17, we have constexpr lambdas and compile-time branching with if constexpr(...)

Guidelines

  • Always define your compile-time constants as constexpr:
    1
    2
    3
    constexpr int frame_rate = 60;
    constexpr const char* debug_category = "app.rendering";
    constexpr float precision = calculate(42.f);
  • Even in a local context, constexpr variables can increase readability:
    1
    2
    3
    4
    5
    auto read_file(const char* path)
    {
    constexpr int chunk_size{1024};
    return read_chunks(chunk_size, path);
    }

  • Marked your functions as constexpr wherever possible
  • Considered if constexpr(...) when implementing static dispatch
  • Looked for opportunities to write and test constexpr functions instead of hardcoding pre-calculated computations - For example:
    • Constants
    • Array sizes
    • Loop conditions
    • Validation