C++中的编译器常量和模板元编程

"C++中的编译器常量和模板元编程"

Posted by Simon on October 20, 2020

“Better code, better life. ”

前几天看了眼C++20的新特性,从20起开始支持虚函数的constexpr了,今天我们就来研究下所谓的constexpr是个什么东西、编译期常量和constexpr的关系、它能解决什么问题。

编译器常量

想要用编译期常量就要首先知道它们是什么,一般出现在哪里和运行期常量有什么区别。

编译期常量(Compile-time constants)是C++中相当重要的一部分,整体而言他们有助提高程序的正确性,并提高程序的性能。这篇文章中出现的编译期常量都是在C++11之前就可以使用的,constexpr是C++11的新特性,所以各位不要有心理包袱。

总有些东西是编译器要求编译期间就要确定的,除了变量的类型外,最频繁出现的地方就是数组、switchcase标签和模板了。

数组大小

如果我们想要创建一个不是动态分配内存的数组,那么我们就必须给他设定一个size——这个size必须在编译期间就知道,因此静态数组的大小是编译期常量。

 int someArray[520];

只有这么做,编译器才能准确地解算出到底要分配给这个数组多少内存。如果这个数组在函数中,数组的内存就会被预留在该函数的栈帧中;如果这个数组是类的一个成员,那么编译器要确定数组的大小以确定这个类成员的大小——无论哪种情况,编译器都要知道这个数组具体的size。

有些时候我们不用显示得指明数组的大小,我们用字符串或花括号来初始化数组的时候,编译器会实现帮我们数好这个数组的大小。

 int someArray[] = {5, 2, 0};
 char charArray[] = "Ich liebe dich.";

模板

除了类型以外,数字也可以作为模板的参数。这些数值变量包括intlongshortboolchar和弱枚举等。

 enum Color {RED, GREEN, BLUE};
 
 template<unsigned long N, char ID, Color C>
 struct someStruct {};
 
 someStruct<42ul, 'e', GREEN> theStruct;

switch-case语句

既然编译器在初始化模板的时候必须知道模板的类型,那么这些模板的参数也必须是编译期常量。

switch语句的分支判断也必须是编译期常量,和上边模板的情况非常类似。

 void comment(int phrase) {
   switch(phrase) {
   case 42:
   std::cout << "You are right!" << std::endl;
   break;
   case BLUE:
   std::cout << "Don't be upset!" << std::endl;
   break;
   case 'z':
   std::cout << "You are the last one!" << std::endl;
   break;
   default:
   std::cout << "This is beyond what I can handle..." << std::endl;
   }
 }

编译器常量的好处

如果编译期常量的使用方法只有上边呈现的几种,那你大概会感觉有些无聊了。事实上,关于编译期常量我们能做的事情还有许多,他们能帮助我们去实现更高效的程序。

更安全

编译期常量能让我们写出更有逻辑的代码——在编译期就体现出逻辑。比如矩阵相乘:

 class Matrix{
   unsigned rowCount;
   unsigned columnCount;
   //...
 };

我们都知道,两个矩阵相乘,当且仅当左矩阵的列数等于右矩阵的行数,如果不满足这个规则的话,那就完蛋了,所以针对上边矩阵的乘法,我们在函数中要做一些判断:

 Matrix operator*(Matrix const& lhs, Matrix const& rhs) {
   if(lhs.getColumnCount() != rhs.getRowCount()) {
     throw OhWeHaveAProblem(); 
   }
   
   //...
 }

但是如果我们在编译期就知道了矩阵的size,那么我们就可以把上边的判断放在模板中完成——这样的话不同size的矩阵一下子就成了不同类型的变量了。这样我们的矩阵乘法也相应变得简单了一些:

 template <unsigned Rows, unsigned Columns>
 class Matrix {
   /* ... */
 };
 
 template <unsigned N, unsigned M, unsigned P>
 Matrix<N, P> operator*(Matrix<N, M> const& lhs, Matrix<M, P> const& rhs) {
   /* ... */
 }
 
 Matrix<1, 2> m12 = /* ... */;
 Matrix<2, 3> m23 = /* ... */;
 auto m13 = m12 * m23; // OK
 auto mX = m23 * m13;  // Compile Error!

在这个例子中,编译器本身就阻止了错误的发生,还有很多其他的例子——更复杂的例子在编译期间使用模板。从C++11后有一堆这样的模板都定义在了标准库STL中,这个之后再说。所以大家不要觉得上边这种做法是脱裤子放屁,相当于我们把运行时的条件判断交给了编译期来做,前提就是矩阵的类型必须是编译期常量。你可能会问,除了像上边直接用常数来实例化矩阵,有没有其他方法来告诉编译器这是个编译期常量呢?请往下看。

编译优化

编译器能根据编译期常量来实现各种不同的优化。比如,如果在一个if判断语句中,其中一个条件是编译期常量,编译器知道在这个判断句中一定会走某一条路,那么编译器就会把这个if语句优化掉,留下只会走的那一条路。

 if (sizeof(void*) == 4) {
   std::cout << "This is a 32-bit system!" << std::endl;
 } else {
   std::cout << "This is a 64-bit system!" << std::endl;
 }

在上例中,编译器就会直接利用其中某一个cout语句来替换掉整个if代码块——反正运行代码的机器是32还是64位的又不会变。 另一个可以优化的地方在空间优化。总体来说,如果我们的对象利用编译期常数来存储数值,那么我们就不用在这个对象中再占用内存存储这些数。就拿本文之前的例子来举例:

  • someStruct结构中包含一个unsigned long,一个char,和一个color,尽管如此他的实例对象却只占用一个byte左右的空间。
  • 矩阵相乘的时候,我们在矩阵中也没必要花费空间去存储矩阵的行数和列数了。

编译器常量的定义

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

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

 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),而枚举常量常常作为它的替换也出现在类中。

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

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

const int i = 100;        
const int j = i * 200;    //常量表达式,但不是编译期常量表达式

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

编译器运算

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

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

模板元编程

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

我们来看一个例子:

 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出现前,它是唯一可以在编译期进行复杂编译期运算的方法。虽然它已经被证实为图灵完备的,但是往往编译器在递归的时候还是会设置一个模板最大初始化深度来避免无穷编译期递归。