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

C++学习笔记

Posted by Felix Zhang on September 15, 2020

C++11标准前的编译期运算

在第一篇文章中,我把主要精力放在了什么是编译期常量,以及编译期常量有什么作用上。在这一篇文章中,我将更详细地介绍编译期常量是如何产生的。之所以要把编译期常量了解的这么透彻,是因为他是编译期运算的基础。在这篇文章中还会讲解我们在C++11 标准前都可以用做哪些编译期运算(compile-time calculations),通过了解这些比较原始的方法,我们能够更好地理解C++11标准为编译期运算方面所做的工作。

编译期常量都从哪里来?

在我们的经验中,大部分编译期常量的来源还是字面常量(literals)以及枚举量(enumerations)。比如上一篇文章我写的someStruct<42ul, 'e', GREEN> theStruct;someStruct的三个模板参数都是常量——分别是整形字面量、char型字面量和枚举常量。

比较典型的编译期常量的来源就是内置的sizeof操作符。编译器必须在编译期就知道一个变量占据了多少内存,所以它的值也可以被用作编译期常量。

1
2
3
4
5
6
7
8
9
 class SomeClass {
   //...
 };
 int const count = 10;  //作为数组的size,编译期常量
 SomeClass theMovie[count] = { /* ... */}; //常量表达式,在编译期计算
 int const otherConst = 26; //只是常量,但不是编译期常量
 
 int i = 419;
 unsigned char buffer[sizeof(i)] = {};   //常量表达式,在编译期计算

另一个经常出现编译期常量最常出现的地方就是静态类成员变量(static class member variables),而枚举常量常常作为它的替换也出现在类中。

1
2
3
4
5
6
 struct SomeStruct{
   static unsigned const size1 = 44;  //编译期常量
   enum { size2 = 45 };  //编译期常量
   int someIntegers[size1];  //常量表达式,在编译期计算
   double someDoubles[size2]; //常量表达式,在编译期计算
 };

与编译期常量对应的概念编译期常量表达式(compile-time constant expression)指的是,值不会改变且在编译期就可以计算出来的表达式。其实更好理解的说法是,任何不是用户自己定义的——而必须通过编译期计算出来的字面量都属于编译期常量表达式。需要注意的是,并不是所有的常量表达式都是编译期常量表达式,只有我们要求编译器计算出来时,才是编译期常量表达式。希望下边这个例子可以做很好的说明:我们通过把p安排在合适的位置——数组的size,强制编译器去计算p的值,即p此时变成了编译期常量表达式。

1
2
3
4
5
6
const int i = 100;        
const int j = i * 200;    //常量表达式,但不是编译期常量表达式

const int k = 100;        
const int p = k * 200;    //是编译期常量表达式,由下边数组确定
unsigned char helper[p] = {}; //要求p是编译期常量表达式,在编译期就需确定

编译期运算

从上边的例子可以看出,有时我们可以通过某些手段去“胁迫”编译器,把运算任务从运行时提前到编译期,这就是编译期运算的原理。正如“常量表达式”这个名字,我们可以做各种各样的编译期运算,实现在编译期就确定一个常量表达式的目的。事实上,由最简单的运算表达式出发,我们可以做到各种各样的编译期运算。比如非常简单:

1
2
 int const doubleCount = 10;
 unsigned char doubleBuffer[doubleCount * sizeof(double)] = {};

除此之外,我们也可以用许多其他的操作,比如考虑下边并没有什么意义的代码:

1
2
3
4
5
6
7
8
 std::string nonsense(char input) {
   switch(input) {
   case "some"[(sizeof(void*) == 4) ? 0 : 1]:
     return "Aachen";
   default:
     return "Wuhan";
   }
 }

上边的代码并没有什么实际的意义,但是我还是想解释一下。在上一篇文章我们解释过了,switch语句的每一个case label必须是编译期常量,表达式sizeof(void*) == 4的意思是当前系统是不是一个32位系统,这个表达式由于sizeof的原因是常量表达式,判断结果作为三元运算符的第一个参数,最后的case label由当前系统的位数分别是”some”的”s”(是32位系统)或”o”(不是32位系统)。返回的两个字符串分别是我的两个学校的城市。

尽管上边的例子是无意义的,我们仍然可以看出由这种方法写出的常量表达式很难读。我们可以改进可读性,将上边例子改写成:

1
2
3
4
5
6
7
8
9
10
 std::string nonsense(char input) {
   auto const index = (sizeof(void*) == 4) ? 0 : 1;
   auto const someLabel = "some"[index];
   switch(input) {
   case someLabel:
     return "Aachen";
   default:
     return "Wuhan";
   }
 }

使用模板进行编译期运算

在上篇文章我们提到,实例化模板的参数必须为编译期常数——换句话说编译器会在编译期计算作为实例化模板参数的常量表达式。回忆一下我们可以利用静态成员常量作为编译期常量,我们就可以利用以上特性去把函数模板当成函数来计算,其实这就是模板元编程(template meta programming)方法的雏形。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 template <unsigned N> 
 struct Fibonacci;
 
 template <>
 struct Fibonacci<0> {
   static unsigned const value = 0;   
 };
 
 template <>
 struct Fibonacci<1> {
   static unsigned const value = 1;   
 };
 
 template <unsigned N> 
 struct Fibonacci {
   static unsigned const value = Fibonacci<N-1>::value + Fibonacci<N-2>::value;
 };

最后一个模板比较有意思,仔细看代码就会发现,它递归式地去实例化参数为N的的模板,递归终止在模板参数为01时,就是我们的第二和第三个模板所直接返回的编译期常量。

这种模板元函数看起来啰啰嗦嗦的,但是在C++11出现前,它是唯一可以在编译期进行复杂编译期运算的方法。虽然它已经被证实为图灵完备的,但是往往编译器在递归的时候还是会设置一个模板最大初始化深度来避免无穷编译期递归。

结论

正如上所示,即使在C++11前,编译器运算已经可以大有作为了。别忘了所有我们所做的编译期运算的工作都是在为运行期减少负担。

在C++11和C++14中,一方面,可变参数模板的出现让更为复杂的模板元编程成为了可能;另一方面,constexpr的出现也完全改变了我们使用编译期常量的思路。在下一篇文章中,我们会着重介绍constexpr这个实战利器。