5.2 — Constant expressions, compile-time const, and runtime const

In prior lesson 5.1 -- Constant variables (named constants), we covered how the const keyword could be used to make a variable a constant variable whose value cannot be changed.

In this lesson, we’ll take a look at another property of constants: whether they are runtime constants or compile-time constants.

The as-if rule

In C++, compilers are given a lot of leeway to optimize programs. The as-if rule says that the compiler can modify a program however it likes in order to produce more optimized code, so long as those modifications do not affect a program’s “observable behavior”.

For advanced readers

There is one exception to the as-if rule: unnecessary calls to a copy constructor can be elided (omitted) even if those copy constructors have observable behavior. We cover this topic in lesson 14.15 -- Class initialization and copy elision.

Exactly how a compile optimizes is up to the compiler itself. However, there are things we can do to help the compiler optimize better.

An optimization opportunity

Consider the following short program:

#include <iostream>

int main()
{
	int x { 3 + 4 };
	std::cout << x << '\n';

	return 0;
}

The output is straightforward:

7

However, there’s an interesting optimization possibility hidden within.

If this program were compiled exactly as it was written (with no optimizations), the compiler would generate an executable that calculates the result of 3 + 4 at runtime (when the program is run). If the program were executed a million times, 3 + 4 would be evaluated a million times, and the resulting value of 7 produced a million times.

But note that the result of 3 + 4 never changes -- it is always 7. So re-evaluating 3 + 4 every time the program is run is wasteful.

Constant expressions

A constant expression is an expression that can be evaluated by the compiler at compile-time. To be a constant expression, all the values in the expression must be known at compile-time (and all the operators and functions called must support compile-time evaluation).

When the compiler encounters a constant expression, it may evaluate the expression at compile-time, and then replace the constant expression with the result of the evaluation.

In the above program, the expression 3 + 4 is a constant expression. So when this program is compiled, the compiler may evaluate constant expression 3 + 4 and then replace the constant expression 3 + 4 with the resulting value 7. In other words, under the as-if rule, the compiler may actually compile this:

#include <iostream>

int main()
{
	int x { 7 };
	std::cout << x << '\n';

	return 0;
}

This program produces the same output (7) as the prior version, but the resulting executable no longer needs to spend CPU cycles calculating 3 + 4 at runtime! Even better, we don’t need to do anything to enable this behavior (besides have optimizations turned on).

Note that the expression std::cout << x is not a constant expression, because our program can’t output values to the console at compile-time. So this expression will always evaluate at runtime. An expression that must be evaluated at runtime is sometimes called a runtime expression.

Key insight

Evaluating constant expressions at compile-time makes our compilation take longer (because the compiler has to do more work), but such expressions only need to be evaluated once (rather than every time the program is run). The resulting executables are faster and use less memory.

The ability for C++ to perform compile-time evaluation is one of the most important and evolving areas of modern C++.

Another optimization opportunity

There is another inefficiency in the program above: The program allocates memory for x, stores the value 7 in that memory, and then in the subsequent statement goes back to memory to get the value of x (7) to be printed. Since the value of x never changes, this memory access is wasteful.

In other words, under the as-if rule, the compiler could optimize the above program into the following:

#include <iostream>

int main()
{
	std::cout << 7 << '\n';

	return 0;
}

But in order to make this optimization, the compiler has to be sure that x isn’t changed between when it is defined and when it is used. Because x is non-constant, the compiler will have to do its own analysis to determine whether this is the case. While a sophisticated modern optimizing compiler would be able to do this (at least in this simple case), less sophisticated compilers or more complicated cases will impede this optimization.

Unlike the constant expression optimization (which we essentially got for free), this optimization we may or may not get for free. However, for a trivial amount of work, we can help the compiler out so that it is much more likely to perform this optimization.

Compile-time constants

A compile-time constant is a constant whose value is a constant expression. Literals (e.g. ‘1’, ‘2.3’, and “Hello, world!”) are one type of compile-time constant.

Const variables may or may not be compile-time constants (depending on how they are initialized).

Compile-time const

A const variable is a compile-time constant if its initializer is a constant expression.

Consider a program similar to the above that uses const variables:

#include <iostream>

int main()
{
	const int x { 3 };  // x is a compile-time const
	const int y { 4 };  // y is a compile-time const

	const int z { x + y }; // x + y is a constant expression, so z is compile-time const

	std::cout << z << '\n'; 

	return 0;
}

Because the initialization values of x and y are constant expressions, x and y are compile-time constants. This means x + y is also constant expression. So when the compiler compiles this program, it can evaluate x + y for their values, and replace the constant expression with the resulting literal 7.

Note that the initializer of a compile-time const can be any constant expression. All of the following will be compile-time const variables:

const int z { 3 };     // 3 is a constant expression, so z is compile-time const
const int a { 1 + 2 }; // 1 + 2 is a constant expression, so a is compile-time const
const int b { z * 2 }; // z * 2 is a constant expression, so b is compile-time const

Compile-time const variables are often used as named constants:

const double gravity { 9.8 };

Optimizing compile-time constants away

Compile-time constants enable the compiler to perform optimizations that aren’t available with non-compile-time constants. In many cases, compile-time constants can be optimized out of the program entirely. For example, whenever compile-time constant gravity is used, the compiler can simply replace the identifier gravity with the literal double 9.8, which avoids having to fetch the value from somewhere in memory.

Let’s go back to a prior example, where we left off with this program:

#include <iostream>

int main()
{
	int x { 7 }; // x is non-constant
	std::cout << x << '\n';

	return 0;
}

Now let’s make x a compile-time constant:

#include <iostream>

int main()
{
	const int x { 7 }; // x is now a compile-time constant
	std::cout << x << '\n';

	return 0;
}

If we do that, then the compiler knows x won’t change, and will more than likely optimize the program into this:

#include <iostream>

int main()
{
	std::cout << 7 << '\n';

	return 0;
}

In cases where a compile-time constant variable cannot be optimized out (or when optimizations are turned off), the variable will still be created (and initialized) at runtime.

Key insight

Making variables compile-time constants helps the compiler determine what can be optimized.

Runtime const

A const variable is a runtime constant if its initializer is a non-constant expression. Runtime constants are constants whose initialization values can’t be determined until runtime.

The following example illustrates the use of a constant that is a runtime constant:

#include <iostream>

int getNumber()
{
    std::cout << "Enter a number: ";
    int y{};
    std::cin >> y;

    return y;  
}

int main()
{
    const int x { 3 };           // x is a compile time constant

    const int y { getNumber() }; // y is a runtime constant

    const int z { x + y };       // x + y is a runtime expression, so z is a runtime const
    
    return 0;
}

Even though y has a const qualifier, the initialization value (the return value of getNumber()) isn’t known until runtime. Thus, y is a runtime constant, not a compile-time constant. And because y is a runtime constant, evaluation of y must be done at runtime, so the expression x + y is also a runtime expression.

Key insight

Compile-time constants can be used in constant expressions and allow for better optimization.
Runtime constants can only be used in non-constant expressions. Their primary use is to ensure an object’s value is not modified.

guest
Your email address will not be displayed
Find a mistake? Leave a comment above!
Correction-related comments will be deleted after processing to help reduce clutter. Thanks for helping to make the site better for everyone!
Avatars from https://gravatar.com/ are connected to your provided email address.
Notify me about replies:  
128 Comments
Newest
Oldest Most Voted
Inline Feedbacks
View all comments