常量表达式、编译时常量和运行时常量
本节阅读量:在上一课中,我们介绍了如何使用 const 关键字把变量声明为值不能更改的常变量。
在本课中,我们将关注常量的另一个属性:它到底是运行时常量,还是编译时常量。
as-if 规则
在 C++ 中,编译器有很大的空间来对程序进行优化。as-if 规则规定:只要修改不影响程序的“可观察行为”,编译器就可以随意改写程序以生成更优的代码。
编译器具体的优化方式由编译器自身决定。不过,我们可以做一些事情来帮助编译器更好地进行优化。
对于进阶读者
“as-if”规则有一个例外:即使复制构造函数具有可观察的行为,编译器也可以省略对它的不必要调用。
优化的机会
来看下面这个程序:
|
|
输出结果很简单:
|
|
然而,其中其实隐藏着一个有趣的优化机会。
如果这个程序完全按写出的样子编译(不做任何优化),编译器会生成一个可执行文件,它在运行时(即程序运行时)计算 3+4 的结果。如果程序执行了一百万次,3+4 就会被求值一百万次,得到一百万次结果 7。
但值得注意的是,3+4 的结果永远不会改变——它始终都是 7。因此,每次运行程序时都重新计算 3+4 是相当浪费的。
常量表达式
常量表达式(constant expression)是指可以由编译器在编译时求值的表达式。要成为常量表达式,表达式中用到的所有值都必须在编译时已知(并且所调用的运算符和函数都必须支持编译时求值)。
当编译器遇到常量表达式时,它可以在编译时对其求值,然后用计算结果替换该常量表达式。
在上面的程序中,表达式 “3 + 4” 就是一个常量表达式。因此,在编译该程序时,编译器可以将它替换为结果值 7。换句话说,基于 as-if 规则,编译器实际上等价于编译了下面这段代码:
|
|
这个程序的输出结果与之前的版本相同(都是 7),但生成的可执行文件不再需要在运行时花费 CPU 周期去计算 3+4!更棒的是,我们不需要做任何额外的事情就能启用这种行为(只要开启优化即可)。
需要注意的是,表达式 std::cout << x 并不是常量表达式,因为我们的程序不可能在编译时就把值输出到控制台。因此,该表达式始终会在运行时求值。必须在运行时求值的表达式,有时被称为运行时表达式。
关键点
在编译时计算常量表达式会让编译耗时变长(因为编译器需要做更多的工作),但这样的表达式只需要计算一次(而不是每次运行程序时都计算)。由此生成的可执行文件会运行得更快,并且占用的内存也更少。
C++ 在编译时进行计算的能力,是现代 C++ 中最重要、并且一直在快速发展的领域之一。
另一个优化机会
上面的程序中还有另一处效率不高的地方:程序为 x 分配了一块内存,把值 7 存进去,然后在后续语句中再从这块内存中读取 x(7)来打印。由于 x 的值从未改变,所以这次内存访问其实是浪费的。
换句话说,基于 as-if 规则,编译器可以将上述程序优化为:
|
|
但要进行这样的优化,编译器必须确保 x 在定义与使用之间没有被修改过。由于 x 并不是常量,编译器必须自行分析才能判断是否可以这样优化。虽然现代编译器通常能够处理这种简单的情况,但并不是所有的编译器或所有的复杂情形都能完成此类优化。
与“常量表达式优化”(基本上是免费的)不同,这种优化可能无法自动进行。
不过,我们可以稍做一点工作来帮助编译器,让它更可能执行这类优化。
编译时常量
编译时常量是指其值为常量表达式的常量。字面量(如 1、2.3 和 “Hello, world!")是编译时常量的一种。
常变量可能是也可能不是编译时常量,这取决于它们的初始化方式。
编译时 const
如果常变量的初始值是一个常量表达式,那么该常变量就是编译时常量。
来看一个与上面类似的程序:
|
|
由于 x 和 y 的初始值都是常量表达式,所以 x 和 y 都是编译时常量。这也意味着 x+y 同样是常量表达式。因此,在编译这个程序时,编译器可以计算出 x+y 的值,并用结果 7 替换这个常量表达式。
需要注意的是,编译时常量的初始化值可以是任何常量表达式。下面这几个变量都是编译时常量变量:
|
|
具名常量常被用作编译时常变量:
|
|
编译时常量的优化
编译时常量让编译器可以进行更多优化。在许多情况下,优化完成后,对编译时常变量的访问在程序中就不会再出现。例如,对编译时重力常数的每一次使用,编译器都可以直接用标识符 gravity 替换为 9.8,从而避免从内存中某个位置取值。
让我们回到前面的例子:
|
|
现在我们把 x 改成编译时常量:
|
|
这样一来,编译器就知道 x 不会被修改,很可能会把程序优化为:
|
|
关键点
使用编译时常量有助于编译器识别哪些地方可以进行优化。
运行时常量
如果常变量的初始值不是常量表达式,那么它就是运行时常量。运行时常量是指其初始化值要在运行时才能确定的常量。
下面的示例展示了一个作为运行时常量的用例:
|
|
尽管 y 有 const 限定符,但它的初始化值(getNumber() 的返回值)要到运行时才能得知。因此,y 是运行时常量,而不是编译时常量。由于 y 是运行时常量,所以 z 也必须在运行时计算,表达式 x+y 同样是一个运行时表达式。
关键点
编译时常量可以用于常量表达式,并且允许编译器进行更好的优化。而运行时常量只能用于非常量表达式,它的主要用途是保证对象的值不会被修改。