5-5 常量表达式
在第1.10节——表达式介绍中,我们介绍了表达式。默认情况下,表达式会在运行时进行求值。而在某些情况下,它们必须如此:
std::cin >> x;
std::cout << 5 << '\n';
由于输入和输出无法在编译时完成,上述表达式必须在运行时进行求值。
在之前的第5.4节——“假设规则与编译时优化”中,我们讨论了假设规则,以及编译器如何通过将工作从运行时转移到编译时来优化程序。根据假设规则,编译器可以选择在运行时或编译时求值某些表达式:
const double x { 1.2 };
const double y { 3.4 };
const double z { x + y }; // x + y may evaluate at runtime or compile-time
表达式 x + y 通常会在运行时求值,但由于 x 和 y 的值在编译时已知,编译器可能会选择在编译时进行求值,并用编译时计算出的值 4.6 初始化 z。
在其他少数情况下,C++ 语言要求表达式能在编译时求值。例如,constexpr 变量需要一个能在编译时求值的初始化器:
int main()
{
constexpr int x { expr }; // Because variable x is constexpr, expr must be evaluatable at compile-time
}
当需要常量表达式但未提供时,编译器将报错并终止编译。
我们将在下一课(5.6 -- constexpr 变量)讨论 constexpr 变量。
对于进阶读者
需要编译时可求值表达式的常见场景:
- constexpr 变量的初始化表达式(5.6节——constexpr 变量)。
- 非类型模板参数(11.9节——非类型模板参数)。
- std::array(17.1节——std::array介绍)或C风格数组(17.7节——C风格数组介绍)的定义长度。
本节将深入探索C++在编译时评估方面的更多特性,并解析C++如何将最后一种情况与前两种情况区分开来。
编译时编程的优势
尽管“如同”规则能显著提升性能,但它使我们不得不依赖编译器的复杂性来实际判断哪些内容可在编译时评估。这意味着当我们真正希望在编译时执行的代码段,其执行结果可能存在不确定性。同一代码在不同平台、不同编译器、不同编译选项下运行,或稍作修改后,都可能产生不同结果。由于“假设规则”的执行过程对开发者不可见,我们无法从编译器处获得关于哪些代码段被决定在编译时评估、以及评估原因的反馈。我们期望在编译时执行的代码可能因拼写错误或理解偏差而丧失资格,且我们永远无法知晓。
为改善这种状况,C++语言提供了明确指定编译时执行代码段的方法。利用语言特性实现编译时评估的行为称为编译时编程compile-time programming。
这些特性可在多个领域提升软件质量:
- 性能:编译时评估能缩小程序体积并提升运行速度。确保更多代码具备编译时评估能力,将带来更显著的性能收益。
- 通用性:此类代码可应用于需要编译时值的场景。依赖“假设规则”进行编译时评估的代码无法满足此需求(即使编译器选择在编译时评估该代码)——此设计旨在确保当前可编译的代码在未来编译器优化策略变更时仍能正常编译。
- 可预测性:当编译器判定代码无法在编译时执行时,可强制终止编译(而非默认在运行时评估)。这确保了我们真正需要在编译时执行的代码段必定能执行。
- 质量:编译器能在编译时可靠检测特定编程错误,并终止构建过程。这远比在运行时检测并优雅处理相同错误有效得多。
- 质量:或许最重要的是,编译时不允许出现未定义行为。若编译时出现导致未定义行为的操作,编译器应终止构建并要求修正。需注意这对编译器而言是难题,可能无法捕获所有情况。
综上所述,编译时评估使我们能够编写性能更优、质量更高(更安全且更少错误)的程序!因此尽管编译时评估为语言增添了额外复杂性,但其带来的收益极为可观。
以下C++特性是编译时编程的基础:
- 常量表达式变量(详见后续第5.6节——常量表达式变量)。
- 常量表达式函数(详见后续章节F.1——常量表达式函数)。
- 模板(详见章节11.6——函数模板)。
- static_assert(详见章节9.6——assert与static_assert)。
这些特性有一个共同点:它们都利用了常量表达式。
常量表达式
或许令人惊讶的是,C++标准几乎未提及“编译时”概念。相反,标准定义了“常量表达式”——即必须在编译时可求值的表达式,并规定了编译器处理此类表达式的规则。常量表达式构成了C++编译时求值的核心框架。
在第1.10节——表达式介绍中,我们定义表达式为“由字面量、变量、运算符和函数调用组成的非空序列”。常量表达式constant expression则是由字面量、常量变量、运算符和函数调用组成的非空序列,且所有部分都必须在编译时可求值。关键区别在于:常量表达式中每个组成部分都必须在编译时可求值。
核心要点
常量表达式中,每个组成部分都必须能在编译时求值。
非常量表达式通常称为非常量表达式,也可非正式地称为运行时表达式runtime expression(因这类表达式通常在运行时求值)。
可选阅读
C++20语言标准([expr.const]章节)规定:“常量表达式可在翻译阶段求值”。正如第2.10节-预处理器介绍所述,翻译是指构建程序的完整过程(包含预处理、编译和链接)。因此在编译型程序中,常量表达式可在编译过程中被求值;而在解释型程序中,翻译发生于运行时。
鉴于C++程序通常采用编译方式,我们将基于常量表达式可在编译时求值的假设展开讨论。
常量表达式可以包含什么?
作者注
从技术角度而言,常量表达式相当复杂。本节将深入探讨其可包含与不可包含的元素。您无需记住所有细节——当编译器检测到常量表达式缺失时,会明确指出错误,届时即可修正。
最常见的常量表达式包含以下内容:
- 字面量(如‘5’、‘1.2’)
- 多数运算符的常量表达式操作数(如3 + 4、2 * sizeof(int))
- 使用常量表达式初始化的const整型变量(如const int x { 5 };)。此为历史遗留例外——现代C++推荐使用constexpr变量。
- constexpr 变量(详见后续第 5.6 节——constexpr 变量)。
- 带常量表达式参数的 constexpr 函数调用(参见 F.1 节——constexpr 函数)。
对于进阶读者
常量表达式还可包含:
- 非类型模板参数(参见 11.9 节——非类型模板参数)。
- 枚举项(参见13.2节——无作用域枚举)。
- 类型特征(参见cppreference中关于类型特征的页面)。
- constexpr lambda表达式(参见20.6节——lambda表达式(匿名函数)介绍)。
提示
需注意,常量表达式中不可使用以下内容:
- 非const变量。
- 常量非整数变量,即使其初始化表达式为常量(例如 const double d { 1.2 };)。若需在常量表达式中使用此类变量,请将其定义为 constexpr 变量(参见第 5.6 课——constexpr 变量)。
- 非 constexpr 函数的返回值(即使返回表达式本身为常量)。
- 函数参数(即使该函数为 constexpr)。
- 操作数非常量表达式的运算符(例如当 x 或 y 非常量表达式时的 x + y,或因 std::cout 非常量表达式的 std::cout << “hello\n”)。
- new、delete、throw、typeid 运算符及逗号运算符 (,)。
包含上述任一元素的表达式均为运行时表达式。
相关内容
常量表达式的精确定义请参阅cppreference常量表达式页面。需注意常量表达式是通过排除法定义的,这意味着我们只能推断其本质。祝你好运!
术语规范
讨论常量表达式时,通常采用两种表述方式:
- “X 可用于常量表达式”常用于强调 X 的本质。例如:“5 可用于常量表达式”强调字面量 5 可用于常量表达式。
- “X是常量表达式”则强调包含X的完整表达式本身具有常量属性。例如“5是常量表达式”强调表达式5本身属于常量表达式。
后者在表述“字面量是常量表达式”时可能显得别扭(因字面量本质上是值),但其含义仅指包含字面量的表达式属于常量表达式。
顺带一提……
在定义常量表达式时,const 整数类型被保留下来,因为它们在语言内部已被视为常量表达式。
委员会曾讨论是否也应将具有常量表达式初始化的 const 非整数类型视为常量表达式(以保持与 const 整数类型的统一性)。最终决定不予采纳,以促进 constexpr 的更一致使用。
常量表达式与非常量表达式的示例
在下面的程序中,我们将考察若干表达式语句,并标明每个表达式属于常量表达式还是运行时表达式:
#include <iostream>
int getNumber()
{
std::cout << "Enter a number: ";
int y{};
std::cin >> y; // can only execute at runtime
return y; // this return expression is a runtime expression
}
// The return value of a non-constexpr function is a runtime expression
// even when the return expression is a constant expression
int five()
{
return 5; // this return expression is a constant expression
}
int main()
{
// Literals can be used in constant expressions
5; // constant expression
1.2; // constant expression
"Hello world!"; // constant expression
// Most operators that have constant expression operands can be used in constant expressions
5 + 6; // constant expression
1.2 * 3.4; // constant expression
8 - 5.6; // constant expression (even though operands have different types)
sizeof(int) + 1; // constant expression (sizeof can be determined at compile-time)
// The return values of non-constexpr functions can only be used in runtime expressions
getNumber(); // runtime expression
five(); // runtime expression (even though the return expression is a constant expression)
// Operators without constant expression operands can only be used in runtime expressions
std::cout << 5; // runtime expression (std::cout isn't a constant expression operand)
return 0;
}

在下面的代码片段中,我们定义了一组变量,并标明它们是否可用于常量表达式:
// Const integral variables with a constant expression initializer can be used in constant expressions:
const int a { 5 }; // a is usable in constant expressions
const int b { a }; // b is usable in constant expressions (a is a constant expression per the prior statement)
const long c { a + 2 }; // c is usable in constant expressions (operator+ has constant expression operands)
// Other variables cannot be used in constant expressions (even when they have a constant expression initializer):
int d { 5 }; // d is not usable in constant expressions (d is non-const)
const int e { d }; // e is not usable in constant expressions (initializer is not a constant expression)
const double f { 1.2 }; // f is not usable in constant expressions (not a const integral variable)
当常量表达式在编译时被求值时
由于常量表达式始终能够在编译时被求值,您可能认为常量表达式总会在编译时被求值。但反直觉的是,事实并非如此。
编译器仅在需要常量表达式的上下文中才必须在编译时求值常量表达式。
术语说明
必须在编译时求值的表达式在技术上称为显式常量求值表达式manifestly constant-evaluated expression。您可能仅在技术文档中遇到该术语。
在不需要常量表达式的上下文中,编译器可选择在编译时或运行时求值常量表达式。
const int x { 3 + 4 }; // constant expression 3 + 4 must be evaluated at compile-time
int y { 3 + 4 }; // constant expression 3 + 4 may be evaluated at compile-time or runtime
变量 x 的类型为 const int,且初始化表达式为常量表达式,因此 x 可用于常量表达式中。其初始化表达式必须在编译时求值(否则 x 的值在编译时无法确定,则 x 无法用于常量表达式)。另一方面,变量 y 为非 const 类型,因此 y 不能用于常量表达式。尽管其初始化表达式本身是常量表达式,但编译器可选择在编译时或运行时进行求值。
即使没有强制要求,现代编译器在启用优化时通常也会在编译时求解常量表达式。
关键洞察
编译器仅需在需要常量表达式的上下文中于编译时评估常量表达式。在其他情况下,它可能评估也可能不评估。
提示
表达式在编译时被完全评估的可能性可归类如下:
- 绝不:编译器无法在编译时确定所有值的非常量表达式。
- 可能:编译器能在编译时确定所有值的非常量表达式(按“假设规则”优化)。
- 可能:用于非常量表达式上下文的常量表达式。
- 必然:用于常量表达式上下文的常量表达式。
对于进阶读者
那么为何C++不强制要求所有常量表达式在编译时求值?至少存在两个合理原因:
- 编译时求值会增加调试难度。若代码中存在编译时计算的错误,我们可用的诊断工具极为有限。允许非必需常量表达式在运行时求值(通常在关闭优化时),便于对代码进行运行时调试。能够逐步执行并检查程序运行状态,可显著降低调试难度。
- 为编译器提供灵活的优化空间(或受编译器选项影响)。例如,编译器可能提供选项将所有非必需常量表达式的求值推迟至运行时,从而为开发者缩短编译时间。
为什么编译时表达式必须是常量(可选)
你可能在想,为什么编译时表达式只能包含常量对象(以及能在编译时求值为常量的运算符和函数)。
考虑以下程序:
#include <iostream>
int main()
{
int x { 5 };
// x is known to the compiler at this point
std::cin >> x; // read in value of x from user
// x is no longer known to the compiler
return 0;
}
首先,x 被初始化为值 5。此时编译器已知 x 的值。但随后 x 被赋予用户提供的值。编译器无法在编译时预知用户将提供何种值,因此此后编译器便无法确定 x 的值。因此表达式 x 并非总能在编译时求值,这违反了此类表达式必须始终支持编译时求值的要求。
由于常量值不可变,初始化表达式可在编译时求值的常量变量,其值在编译时始终已知。这使得系统保持简单。
虽然语言设计者本可将编译时表达式定义为所有值均在编译时已知的表达式(而非必须始终能在编译时求值的表达式),但这将显著增加编译器的复杂度(因为编译器需负责判断每个变量何时可能被赋予编译时未知的值)。添加单行代码(如 std::cin >> x)可能导致程序其他部分失效(若 x 在任何需要编译时已知值的上下文中被使用)。
测验时间
问题 #1
针对每条语句,请判断:
- 初始化表达式是否为常量表达式或非常量表达式。
- 变量是否为常量表达式或非常量表达式。
a)
char a { 'q' };
显示答案
'q' 是常量表达式,因为它是字面量。
a 是非常量表达式,因为它被定义为非常量。
b)
const int b { 0 };
显示答案
0 是常量表达式,因为它是字面量。
b 是常量表达式,因为它是常量整数类型,且初始化器为常量表达式。
c)
const double c { 5.0 };
显示答案
'5.0' 是常量表达式,因为它是字面量。
c 是非常量表达式,因为它被定义为 const 类型但不属于整数类型。
根据编译时常量的定义,只有初始化表达式为常量且类型为 const 整数的变量才是编译时常量。c 是 double 类型,不属于整数类型,因此不符合该定义。
d)
const int d { a * 2 }; // a defined as char a { 'q' };
显示答案
a 不是常量表达式,因此 a * 2 也是非常量表达式。
d 是非常量表达式,因为其初始化表达式不是常量表达式。
e)
int e { c + 1.0 }; // c defined as const double c { 5.0 };
显示答案
c 是非常量表达式,因此 c + 1.0 也是非常量表达式。
e 是非常量表达式,因为它未被定义为 const,且其初始化器不包含常量表达式。
f)
const int f { d * 2 }; // d defined as const int d { 0 };
显示答案
d 和 2 都是常量表达式,因此 d * 2 也是常量表达式。
f 是常量表达式,因为它是 const 整数类型,且初始化器为常量表达式。
g)
const int g { getNumber() }; // getNumber returns an int by value
显示答案
getNumber() 返回一个非常量值,因此它是一个非常量表达式。
g 是一个非常量表达式,因为其初始化器是一个非常量表达式。
h)
额外加分题:
const int h{};
显示答案
{} 调用值初始化。此处没有显式初始化器。
h 是常量表达式,因为它是 const 整数类型,且初始化器为常量表达式(值初始化将 h 初始化为 0,而 0 是常量表达式)。

浙公网安备 33010602011771号