章节目录

常量表达式、编译时常量和运行时常量

本节阅读量:

在上一课中,我们介绍了如何使用 const 关键字把变量声明为值不能更改的常变量。

在本课中,我们将关注常量的另一个属性:它到底是运行时常量,还是编译时常量。


as-if 规则

在 C++ 中,编译器有很大的空间来对程序进行优化。as-if 规则规定:只要修改不影响程序的“可观察行为”,编译器就可以随意改写程序以生成更优的代码。

编译器具体的优化方式由编译器自身决定。不过,我们可以做一些事情来帮助编译器更好地进行优化。


优化的机会

来看下面这个程序:

1
2
3
4
5
6
7
8
9
#include <iostream>

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

	return 0;
}

输出结果很简单:

1
7

然而,其中其实隐藏着一个有趣的优化机会。

如果这个程序完全按写出的样子编译(不做任何优化),编译器会生成一个可执行文件,它在运行时(即程序运行时)计算 3+4 的结果。如果程序执行了一百万次,3+4 就会被求值一百万次,得到一百万次结果 7。

但值得注意的是,3+4 的结果永远不会改变——它始终都是 7。因此,每次运行程序时都重新计算 3+4 是相当浪费的。


常量表达式

常量表达式(constant expression)是指可以由编译器在编译时求值的表达式。要成为常量表达式,表达式中用到的所有值都必须在编译时已知(并且所调用的运算符和函数都必须支持编译时求值)。

当编译器遇到常量表达式时,它可以在编译时对其求值,然后用计算结果替换该常量表达式。

在上面的程序中,表达式 “3 + 4” 就是一个常量表达式。因此,在编译该程序时,编译器可以将它替换为结果值 7。换句话说,基于 as-if 规则,编译器实际上等价于编译了下面这段代码:

1
2
3
4
5
6
7
8
9
#include <iostream>

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

	return 0;
}

这个程序的输出结果与之前的版本相同(都是 7),但生成的可执行文件不再需要在运行时花费 CPU 周期去计算 3+4!更棒的是,我们不需要做任何额外的事情就能启用这种行为(只要开启优化即可)。

需要注意的是,表达式 std::cout << x 并不是常量表达式,因为我们的程序不可能在编译时就把值输出到控制台。因此,该表达式始终会在运行时求值。必须在运行时求值的表达式,有时被称为运行时表达式。


另一个优化机会

上面的程序中还有另一处效率不高的地方:程序为 x 分配了一块内存,把值 7 存进去,然后在后续语句中再从这块内存中读取 x(7)来打印。由于 x 的值从未改变,所以这次内存访问其实是浪费的。

换句话说,基于 as-if 规则,编译器可以将上述程序优化为:

1
2
3
4
5
6
7
8
#include <iostream>

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

	return 0;
}

但要进行这样的优化,编译器必须确保 x 在定义与使用之间没有被修改过。由于 x 并不是常量,编译器必须自行分析才能判断是否可以这样优化。虽然现代编译器通常能够处理这种简单的情况,但并不是所有的编译器或所有的复杂情形都能完成此类优化。

与“常量表达式优化”(基本上是免费的)不同,这种优化可能无法自动进行。

不过,我们可以稍做一点工作来帮助编译器,让它更可能执行这类优化。


编译时常量

编译时常量是指其值为常量表达式的常量。字面量(如 1、2.3 和 “Hello, world!")是编译时常量的一种。

常变量可能是也可能不是编译时常量,这取决于它们的初始化方式。


编译时 const

如果常变量的初始值是一个常量表达式,那么该常变量就是编译时常量。

来看一个与上面类似的程序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include <iostream>

int main()
{
	const int x { 3 };  // x 是编译时常量
	const int y { 4 };  // y 是编译时常量

	const int z { x + y }; // x + y 是常量表达式, z 是编译时常量

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

	return 0;
}

由于 x 和 y 的初始值都是常量表达式,所以 x 和 y 都是编译时常量。这也意味着 x+y 同样是常量表达式。因此,在编译这个程序时,编译器可以计算出 x+y 的值,并用结果 7 替换这个常量表达式。

需要注意的是,编译时常量的初始化值可以是任何常量表达式。下面这几个变量都是编译时常量变量:

1
2
3
const int z { 3 };     // 3 是常量表达式, z 是编译时常量
const int a { 1 + 2 }; // 1 + 2 是常量表达式, a 是编译时常量
const int b { z * 2 }; // z * 2 是常量表达式, b 是编译时常量

具名常量常被用作编译时常变量:

1
const double gravity { 9.8 };

编译时常量的优化

编译时常量让编译器可以进行更多优化。在许多情况下,优化完成后,对编译时常变量的访问在程序中就不会再出现。例如,对编译时重力常数的每一次使用,编译器都可以直接用标识符 gravity 替换为 9.8,从而避免从内存中某个位置取值。

让我们回到前面的例子:

1
2
3
4
5
6
7
8
9
#include <iostream>

int main()
{
	int x { 7 }; // x 不是常量
	std::cout << x << '\n';

	return 0;
}

现在我们把 x 改成编译时常量:

1
2
3
4
5
6
7
8
9
#include <iostream>

int main()
{
	const int x { 7 }; // x 是编译时常量
	std::cout << x << '\n';

	return 0;
}

这样一来,编译器就知道 x 不会被修改,很可能会把程序优化为:

1
2
3
4
5
6
7
8
#include <iostream>

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

	return 0;
}

运行时常量

如果常变量的初始值不是常量表达式,那么它就是运行时常量。运行时常量是指其初始化值要在运行时才能确定的常量。

下面的示例展示了一个作为运行时常量的用例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>

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

    return y;  
}

int main()
{
    const int x { 3 };           // x 是编译时常量

    const int y { getNumber() }; // y 是运行时常量

    const int z { x + y };       // x + y 是运行时表达式, z 是运行时常量
    
    return 0;
}

尽管 y 有 const 限定符,但它的初始化值(getNumber() 的返回值)要到运行时才能得知。因此,y 是运行时常量,而不是编译时常量。由于 y 是运行时常量,所以 z 也必须在运行时计算,表达式 x+y 同样是一个运行时表达式。


5.0 常量(命名常量)

上一节

5.2 Constexpr

下一节