漫谈C++——从编译期常量到constexpr(三)

C++学习笔记

Posted by Felix Zhang on September 20, 2020

使用constexpr进行编译期运算

在上面两篇文章我分别介绍了什么是编译期常量以及C++03标准中的编译期运算。这篇文章我将围绕在constexpr这个关键字展开。

C++03中编译期运算的限制

我在上篇文章中所举的例子,要么非常简单,要么就是和模板元编程有关。非模板元编程的方法往往是一行就结束了,难以实现复杂的运算。此外我们还不能复用代码,无论在哪里我们都要再复制粘贴一遍,不符合我们设计代码的准则。模板元编程往往又非常的复杂,可读性也不高,虽然在C++14标准中我们有了可变参数模板,它能适当地改善模板元的可读性,但是这毕竟治标不治本。

如果你觉得上边的都不是问题,那么C++03版编译期运算最大的问题恐怕就在于,我们写出来的代码__只能够给编译期使用__。如果我们想实现一个函数,使得他在编译期和运行时都能够被使用,我们就必须复制一份代码,一份给编译期,一份适配run-time,这也无形中给代码维护和迭代带来没有必要的压力。

试一试constexpr

所以能不能写一种函数,它既能够在编译期运行也能够在运行期运行——上边的选择仅仅取决于当前的调用语境呢?在C++11中我们引入了constexpr关键字来很好地解决这个问题。

constexpr关键字出现在函数的声明中,保证函数返回一个编译期常量的__可能性__(但这个函数不是只能在编译期使用),即如果穿进去的参数是编译期常量,那么这个函数就能够也返回一个编译期常量。

有了constexpr,模板元编程版本的斐波那契函数的计算就可以被简化成:

constexpr unsigned fibonacci(unsigned i) {
	return (i <= 1u) ? i : (fibonacci(i - 1) + fibonacci(i - 2));
}

只是多了一个constexpr,这个函数就可以在编译期和运行期同时起作用。如果带有constexpr的函数的参数被编译器检测到为编译期常量,那么这个函数就可以自动地在编译期运行。请看下边的例子:

int main(int arg, char** argv) {
	char int_values[fibonacci(6)] = {};						//正确,数组大小在编译期被强制计算
	std::cout << sizeof(int_values) << std::endl; //正确,sizeof函数参数在编译期被计算
	
	std::cout << fibonacci(argc) << std::endl;  	//在运行时计算,因为argc只有在运行时才能确定
	std::cout << sizeof(std::array<char, fibonacci(argc)>) << std::endl;	//ERROR,模板参数要求在编译期确定fibonacci的值,但是argc是运行时参数。
}

最后一行编译时会报错,因为模板参数和sizeof()函数都要求在编译期确定fibonacci(argc)的值,但是argc只能在运行时确定。

constexptr 修饰的变量

声明时带有constexpr关键字的变量是常量表达式,因而可以被用做编译期计算。不像在C++03标准中,只有内置类型的字面量才能被视作编译期常量,这个标准在C++11和C++14中被放宽了许多。

由此衍生出了一个新概念’literal type’:声明时可以加constexpr修饰的类我们成为literal type。

Specifies that a type is a literal type. Literal types are the types of constexpr variables and they can be constructed, manipulated, and returned from constexpr functions. -cppreference.com

需要注意的是,所有拥有constexpr修饰的构造函数的类也都是literal type,因为拥有此类构造函数的类的对象可以被constexpr函数初始化。考虑下边的Point类,它就是一个literal type:

class Point {
	int x;
	int y;
public:
	constexpr Point(int ix, int iy) : x{ix}, y{iy} {}
	constexpr int getX() const { return x; }
	constexpr int getY() const { return y; }
};

我们可以使用constexpr构造函数来创造它的编译期常量对象,鉴于它也有xconstepxr类型的getter函数,我们也可以在编译期使用这些函数来获取它的成员值。

constexpr Point p{1, 2};			//OK, 因为有constexpr构造函数
constexpr int py = p.getY();	//OK, 因为y的getter是constexpr的。
double darry[py] {};

constexpr 修饰的函数

那么是不是所有的函数都可以被这么定义为编译期运算函数呢?其实不然。在C++11中,我们对constexpr函数的内容有非常严格的规定,在C++14中这些标准北方宽松了许多,但是保留的最严格的规定莫过于函数体内不能有try块,以及任何static和局部线程变量。并且,在函数中只能调用其他constexpr函数,该函数也不能有任何运行时才会有的行为,比如抛出异常、使用newdelete操作等等。所以在C++14中,如果把斐波那契函数写成下边这样,它的可读性会大大提升。:

constexpr unsigned fibonacci(unsigned i) {
	switch (i) {
		case 0: return 0;
		case 1: return 1;
		default: return fibonacci(i - 1) + fibonacci(i - 2);
	}
}

如果我们给一个函数加上一个constexpr关键字,不是说我们就把这个函数绑死在编译期上了——在文章一开始就说过,这个函数也应该能在运行期被复用。如果一次调用被认为是runtime的,那么这个函数返回的值也不再是编译期常量了——它就被当作一个正常的函数来对待。需要注意的是,在编译期调用constexpr函数,所有运行时所做的检查,在编译期均不会处理。最常见的问题就是int溢出的问题,此时我们还应该在代码中手动加上相应的检查:

constexpr unsigned fibonacci(unsigned i) {
	switch (i) {
    case 0: return 0;
    case 1: return 1;
    default: {
      auto f1 = fibonacci(i - 1);
      auto f1 = fibonacci(i - 2);
      //手动进行越界检查,如果compile-time发现越界,引导函数进入throw语句,throw语句是典型的run-time语句
      if(f1 > std::numeric_limits<unsigned>::max() - f2) {
        throw std::invalid_argument{"Overflow detected!"};
      }
    }
	}
}

上边的检查会始终起作用,如果我们在编译期传入一个过大的参数从而产生了整型溢出,那么函数语句就会走到抛出std::invalid_argument的分支语句中,又因为抛出异常这种行为是编译期所不允许的,所以编译时就会报错——这个函数调用并不是一个编译期运算表达式;如果没有上边的检查,那么编译器就会默许这种错误,导致错误更难发现。

结论

尽管编译期运算会延长我们的编译时间,但是我们有些时候会用它来加快程序的运行速度。但是在使用时我们仍应该抱着谨慎的态度。有些人说,反正constexpr函数在运行时和编译期都可以执行,那我们可不可以给每一个函数都加上constexpr呢?我对此观点持保留意见,因为它会让我们的代码中充斥着不必要的关键字,影响阅读不说,它到底给我们编译期带来的好处能不能把坏的影响抵消掉还是要好好权衡的。