引言

写C++的时候我们习惯把const当成"常量"来用,但是const只承诺对象的值初始化后不能更改,并不承诺值在编译期就已知:一个用运行时输入初始化的const变量,和一个编译期就能算出结果的const变量,在优化空间上完全是两回事。更麻烦的是,把本该编译期完成的计算留到运行时,编译器不会报任何错,性能就这样悄悄流失。C++用一组关键字来区分"值何时确定、求值何时发生",它们是const,constexpr,consteval,constinit。

正文

先把四个关键字的分工摆在一起:const管对象的不可变;constexpr表示可以进入常量表达式(constant expression,编译期即可求值的表达式);consteval把"可以"收紧成"必须",调用点强制编译期求值;constinit只要求初始化在编译期完成,之后变量照常可改。它们回答的都是同一个问题——求值发生在什么时候。

const与constexpr

const意味着对象的值在初始化后不能更改,初始化器可以在编译时或运行时已知,因此const对象可以在运行时求值。

const int x{10};
const int y{20};
const int z{x + y};   // z可能会在编译时求值,取决于编译器

z的初始化器x+y虽然全由常量构成,编译器也确实可以选择在编译时折叠它来优化性能,但这是编译器的恩赐,不是语言的保证。编译器判断一段表达式是否为常量表达式有时非常困难,我们可以用constexpr显式告诉编译器进行编译时求值:constexpr意味着对象可以在常量表达式中使用,初始化器的值必须在编译时已知。

constexpr int w{x + y};     // 初始化器必须是常量表达式,初始化在编译期完成
constexpr double d1{1.2};   // constexpr同样适用于非整型

const承诺的是不可变,constexpr承诺的是编译期已知。

任何其初始化器为常量表达式的常量变量都应声明为constexpr,任何其初始化器不是常量表达式的常量变量(运行时常量)都应声明为const。

constexpr为对象提供隐式const,写constexpr int时不需要再叠加const。

constexpr函数

constexpr关键字用于向编译器和其他开发人员表明函数可以在常量表达式中使用。constexpr函数可以在编译时求值,原理是编译器直接运行这个函数,所以函数内部可以有非const变量;它也可以在运行时求值,此时返回的是一个普通结果。

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

constexpr int d2{add(x, y)};   // 常量表达式中调用,编译期求值

对constexpr函数的调用想要在编译时求值,参数必须在编译时已知。另外,constexpr函数是隐式inline的,这使它们免受一次定义规则(one definition rule)的约束,定义可以直接放在头文件里。

编译器必须能够看到constexpr(或consteval)函数的完整定义,而不仅仅是前向声明。

如果一个constexpr函数的求值时机很重要,可以把它包进一个consteval函数里强制编译期求值:

consteval auto CONSTEVAL(auto value) { return value; }

constexpr int d5{CONSTEVAL(add(x, y))};   // 外层是consteval,add被迫编译期求值

consteval调用必须是常量表达式,连带着参数表达式也得在编译期求值,add(x,y)的求值时机就这样被锁死在编译期。

consteval与constinit

consteval声明表示函数必须在编译期调用求值,仅用于声明函数。它和constexpr函数的差别就一条:调用点不是常量表达式时,constexpr函数退回运行时求值,consteval函数直接编译失败。

consteval int sub(int a, int b) { return a - b; }

constexpr int d4{sub(y, x)};   // y,x编译期已知,OK

int a{2}, b{9};
// sub(a, b);   // 编译错误:a,b是运行时值,consteval调用必须编译期求值

constinit声明表示变量必须在编译期初始化,但不保证变量是常量不可修改,仅用于声明变量。

constinit int gx{add(10, 20)};   // 编译期完成初始化

gx += 1;   // 运行时照常可改

constinit的价值在静态存储期变量上:带动态初始化的全局变量,初始化顺序跨翻译单元不可控(静态初始化顺序惨案),而constinit强制初始化要么零初始化要么常量初始化,两者都在程序加载前完成,顺序问题不复存在。

constexpr if

C++17引入了constexpr if语句,它要求条件必须是常量表达式,条件将在编译时评估,被丢弃的分支不会被实例化,这在模板里是裁剪分支的利器。

constexpr double gravity{9.8};
if constexpr (gravity == 9.8)
    std::cout << "Gravity is normal.\n";
else
    std::cout << "We are not on Earth.\n";

当条件是常量表达式时,优先使用constexpr if语句而不是非constexpr if语句。

同一个constexpr函数在编译期与运行期还可以走不同路径:std::is_constant_evaluated()(头文件type_traits)在编译期求值语境中返回true,运行时返回false。

constexpr int compare(int x, int y)
{
    if (std::is_constant_evaluated())   // 普通if,不是if constexpr
        return (x > y ? x : y);
    else
        return (x < y ? x : y);
}

constexpr int c1{compare(2, 9)};   // 编译期路径:返回较大值9
int a{2}, b{9};
std::cout << compare(a, b);        // 运行时路径:返回较小值2

is_constant_evaluated()不能放进if constexpr的条件里,那样条件本身就在常量表达式语境中评估,恒为true;应该用普通if。

constexpr与类

要让类的对象成为constexpr对象,构造函数和需要参与常量求值的成员函数都得是constexpr的:声明constexpr构造函数以便能声明constexpr对象,声明constexpr成员函数以便能在常量表达式中评估。

class pair
{
    int m_x{};

public:
    constexpr pair(int x) : m_x{x} {}
    [[nodiscard]] constexpr int get() const { return m_x; }
};

constexpr pair p{1};
constexpr int d3{p.get()};   // 编译期完成构造与调用

小结

四个关键字回答的是同一个问题的四个侧面——求值发生在什么时候。const只承诺不可变,不管求值时机;constexpr保证能进入常量表达式,对象初始化和函数调用都是如此;consteval把"可以"收紧成"必须",参数不是常量表达式直接编译失败;constinit只锁定初始化发生在编译期,之后变量照常可改。它们的组合让编译期计算从编译器的自由裁量变成了程序员的显式契约。

一个最优的实践是,任何初始化器为常量表达式的常量变量都声明为constexpr,只有运行时才能确定初值的常量才用const;求值时机重要的函数调用用consteval显式约束,让错误在编译期暴露,而不是留到性能测试里去猜。

代码收录在Learncpp仓库