C++ 中的常量表达式和编译器优化

本文有助于理解哪些是编译期执行,哪些是运行期执行,也有助于理解模板

as-if规则

在C++中,编译器被赋予了很大的空间去优化程序。as-if规则说的就是:对编译器来说,只要不改变程序的可观测的行为,编译器可以对程序作任何旨在产生更优化代码的任何改动。

对这个规则有一个例外:不必要的对拷贝构造函数的调用可被优化掉,即便这些拷贝构造函数能够产生可被观测的行为。

虽然编译器如何对一个程序进行优化取决于编译器本身,但是我们可以作一些工作来使得编译器优化的更好。

优化时机

考虑下述代码

#include <iostream>

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

	return 0;
}

我们很容易知道,输出为7.

但是,背后有一个很有趣的优化方案。

如果这个程序就像所写的代码那样被编译(没有任何优化),编译器就会产生计算3+4的二进制代码,用于在运行期使用。要是这些代码执行一百万次,3+4就会被计算一百万次,7这个结果就会被产生一百万次。由于3+4的计算结果7永远不会改变,一遍又一遍的重复计算就非常浪费时间和资源。

编译期表达式求值

现代C++编译器能够在编译期对一些表达式进行求值。在这种情形下,编译器就用求值后的值来代替表达式。例如,编译器可以对上面的代码进行如此优化:

#include <iostream>

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

	return 0;
}

这段代码与上述代码一样输出7的结果,但是优化后的执行代码不再需要在运行时浪费CPU周期来计算3+4。而且,我们不需要作任何额外的事情来获得这项好处(当然你的编译器优化开关需要打开)。

好处:

编译期求值允许编译器在编译期做一些工作,否则的话这些工作就要在运行期完成。这些优化可能会使我们的编译过程更长,但是由于表达式只需在编译期求值一次,免除了在运行期的计算,这会让我们的执行代码运行更快和使用更少的内存。

常量表达式

有一种总是在编译期就可以被求值的表达式叫做“常量表达式”。常量表达式的准确定义非常复杂,我们这里采用一种简化的方法:常量表达式就是这么一种表达式:它仅仅包含编译期常量和支持编译期求值的操作符或者函数。下面的表述中,常量表达式和运行期常量是一个意思:

编译期常量就是这么一个常量:其值在编译期就已经知道。包括:

  • 字面量(比如'5','1.2')
  • 有Constexpr修饰的变量
  • 带有一个常量表达式初始化的const型的整型变量(比如: const int x{5}),在现代C++中,更倾向于使用constexpr
  • 非类型的模板形参(整形值作为模板形参)
  • 枚举

其他的不是编译期常量的常量变量也叫运行期常量,运行期常量不能用在常量表达式中。

一个非整型的常量变量(Const non-integral variables)总是运行期常量(即便他们有一个常量表达式初始化)。如果你需要这样的变量成为编译期常量,把他们定义为constexpr变量。

最重用的支持编译期求值的操作符和函数包括:

  • 使用编译期常量作操作数的算术运算符(比如1+2)
  • constexpt和consteval函数。

下面的例子中,我们识别一些常量表达式和非常量表达式,我们也识别哪些变量是非常量,运行时常量,或者编译期常量。

#include <iostream>

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

    return y;
}

int main()
{
    // 非常量变量:
    int a { 5 };                 // 5 是常量表达式
    double b { 1.2 + 3.4 };      // 1.2 + 3.4 是常量表达式

    // 带有一个常量初始化的常整型变量是编译期常量
    const int c { 5 };           // 5 是常量表达式
    const int d { c };           // c 是常量表达式
    const long e { c + 2 };      // c + 2 是常量表达式

    // 其他的常型变量是运行时常量:
    const int f { a };           // a 不是常量表达式
    const int g { a + 1 };       // a + 1 不是常量表达式
    const long h { a + c };      // a + c 不是常量表达式
    const int i { getNumber() }; // getNumber() 不是常量表达式

    const double j { b };        // b 不是常量表达式
    const double k { 1.2 };      // 1.2 是常量表达式

    return 0;
}

不是常量表达式的表达式又叫运行期表达式。例如:std::cout << x << '\n' 是一个运行时表达式,这是因为x不是一个编译期常量,operator<<也不支持编译期求值(因为输出操作在编译期无法执行)。

我们为什么关注常量表达式

至少有三个原因使得常量表达式非常有用:

  • 常量表达式总是可以在编译期被求值,也意味着他们可以在编译期被优化。这会产生更快和更小的代码。
  • 对运行期表达式,在编译期期间,只能知道它的类型。对常量表达式,表达式的类型和值在编译期期间都能确定。这会允许我们在编译期对其值进行合法性检查。如果这个值不满足我们的需求,我们可以输出编译错误,这就允许我们立即识别和更正发现的问题。这样的结果就是代码更安全,更容易测试,更不容易误用。
  • 有些C++特性需要常量表达式(下面描述)

常量表达式通常需要满足以下条件:

  • constexpr变量需要一个初始化
  • 非类型模板行参(值行参模板)
  • 有确定长度的std::array

常量表达式包括constexpr变量和constexpr函数。

常量表达式在什么时刻被求值?

编译器仅在编译期上下文要求对一个常量表达式进行求值的时候才求值(比如编译期常量初始化),在上下文不需要常量表达式的地方,编译器可能在编译期或者运行时进行求值。

const int x { 3 + 4 }; // 常量表达式 3 + 4 必须在编译期进行求值
int y { 3 + 4 };       // 常量表达式3 + 4 可能在编译期也可能在运行期进行求值

这是因为变量x的类型为const int,并且有一个常量表达式初始化,所以它是一个编译期常量。它的初始化必须在编译期进行求值(除非x的值在编译期不知道,或者x不是一个编译期常量)

对于变量y,它不需要一个常量表达式初始化(没有const),所以编译器可以在编译期或者运行期对其进行求值。

即便不需要这么做,现代编译器通常对常量表达式在编译期进行求值,这是因为这么做更容易优化,效率更高。

子常量表达式的部分优化
#include <iostream>

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

	return 0;
}

全表达式std::cout << 3 + 4 << '\n';是一个运行时表达式,这是因为它的输出只能在运行时执行。但是我们注意到全部表达式里面包含一个子表达式3+4.

编译器一直以来就可以对一个常量子表达式进行优化,即便这个全表达式是一个运行期表达式。于是这个全表达式优化以后的结果就是:

#include <iostream>

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

	return 0;
}
非常量表达式的优化

编译器甚至可以对一些非常量表达式或者子表达式进行优化,让我们看前面的例子:

#include <iostream>

int main()
{
	int x { 7 };            // x is 非常量表达式
	std::cout << x << '\n'; // x 一个非常量子表达式

	return 0;
}

当x被初始化的时候,值7就被存储在为x分配的内存中。于是下一行代码就会直接到内存中获取7这个值。

即便x是一个非常量表达式,聪明的编译器能够意识到x在这段代码中永远被求值为7,因此在as-if规则下,可以优化这段代码为:

#include <iostream>

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

	return 0;
}

由于x在程序中不再被使用到,因此编译器甚至进一步将程序优化为:

#include <iostream>

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

	return 0;
}

在这个版本中,变量x已经彻底被移除掉了(因为它不再被使用,因此不再需要了)。当一个变量被从一个程序中移除,我们称这个变量被优化掉或者优化删除。

但是,由于x不是一个常量,这样的优化需要编译器能识别出x的值在整个程序中确实没有变化。编译器是否能识别出这一点,归结到这个程序的复杂度或者编译器优化功能的复杂度。

常变量很容易被优化

看下面的例子

#include <iostream>

int main()
{
	const int x { 7 }; // x is 常变量
	std::cout << x << '\n';

	return 0;
}

在这个版本中,编译器不再需要推断x在整个程序中不会变化,因为x是一个const变量,这就保证了这个变量在初始化之后,就不再变了。这就使得编译器很容易得出结论这个变量可以被很安全地从代码中移除。

能被编译器优化的各种变量的排序如下:

  • 编译期常变量(总可以被优化)
  • 运行期常变量
  • 非常变量(Non-const variables)(简单情况下会被优化)

把一个变量常量化有助于编译器进行编译优化

编译期常变量可被用在常量表达式中,比运行期表达式更大可能在编译期被求值。

编译优化使得程序调试更难

当编译器对程序进行优化时,变量,表达式,语句和函数调用可能被重新安排顺序,改变,用值替代,甚至彻底移除,这些变化会影响调试。

在编译期,我们很难直观或者有工具帮助我们理解编译器作了什么。如果一个变量或者表达式被一个值取代了,假设那个值是一个错误的值,我们怎么调试?

为了减少这些问题,我们一般在调试的时候关闭编译优化,于是编译后的代码和我们的原始代码就非常接近。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值