C++ 中的全局常量

前些天偶然注意到项目的头文件里定义了大量全局常量,也就是类似 const int foo = 5 这种。这些常量,有些是简单的数值型,但大部分是 CComBSTRstd::string 之类的字符串,还有一些是数组,元素数多的能达到几万甚至几十万。

把不变的“变量”尽可能的定义成常量,是各种 C++ 书籍推荐的做法,所以上面提到的代码看似没什么问题。

但大家肯定知道,如果我们在头文件中定义了一个变量,然后把这个头文件包含到了多个实现文件中,链接阶段就会报“重复定义”之类的错误,因为这相当于每个实现文件中都有了这个变量的一份定义。解决这个问题有很多方法,其中之一是在变量定义前面加上 static

但如果我们定义的是常量,不加 static 链接器也不会跳出来抱怨。为什么?

因为,在 C++ 中,全局常量默认具有 internal linkage 属性,举个例子就是 const int foo = 5static const int foo = 5 等价,或者说编译器会偷偷的在常量定义前面给你加上一个 static。所以,看出在头文件中定义常量的问题了吗?

问题就是这些常量在程序中可能会有好多份,而实际上一份就够了。

我们知道,C++ 常量是运行时常量,而不是编译期常量。虽然如此,大多数数值型常量的值却可以在编译期确定,所以借助编译器和链接器的优化,说它们是编译期常量一般也问题不大。因此,如果头文件中定义的常量都是数值型的,应该也没什么事。

麻烦出在复杂类型上,这些类型的初始化要调用构造函数,销毁要调用析构函数,编译器和链接器并不知道这些函数会干什么,所以不可能把它们优化掉,而这至少会导致三个问题:

  1. 生成的二进制文件变大。虽然是全局常量,但因为这个常量是运行时的,所以跟全局变量一样,编译器也要在二进制文件中给它们预留空间。而且,编译器还要生成代码来逐个调用它们的构造函数和析构函数,而这些代码也是要占用二进制文件的空间的。

  2. 运行时内存需求增加。一方面是因为运行时二进制文件会被映射到内存中,所以二进制文件大了,内存占用也就相应的大了。另一方面,你一定知道如果这些常量的构造函数中动态分配了内存会导致什么结果。

  3. 运行时效率降低。内存占用的多了肯定会导致 CPU 缓存命中率降低,进而降低程序性能,这是其一。其二是对那些构造函数、析构函数的多余调用也要花时间。其三是一些函数比较两个对象的时候,可能会先比较它们的地址,如果地址相同,就认为它们相同,否则再比较其内容,这样可以让程序快一点。但是,如果这些常量有多份,比较它们时,地址肯定不同,所以只能比较具体内容,效率也就低了。

上面只是内存占用和效率方面的问题,其实这样定义常量也有可能导致逻辑错误,但这取决具体的场景,我就不多做说明了。

知道了问题在哪,下面就要考虑怎么改代码了。标准的做法是在头文件中给定义加上个 extern,把定义变成声明,然后再在某个实现文件中重新定义它们,看上去并不难,如果通过宏让减少代码量提高可维护性就更完美了。取巧的办法是在定义中加上 __declspec(selecctany)(VC)或 __attribute__((weak))(GCC),也很简单。

但你是否知道我面对的是一个有几万个文件、上千万行代码的项目呢?手工一个一个的改这事我想都没想就放弃了,就算我不嫌枯燥,不小心改出新问题来谁负责?

所以,我写了一个小程序来辅助我完成这项工作,用的是那个取巧的办法,也没有什么技术含量,就是正则表达式查找替换,可以修正大部分问题代码,剩下的,影响比较大的再手工改一下。结果也不错,效果最好的那个二进制文件的体积从最初的将近 30M 减小到了 3M 左右。

但这仅仅是个开始,后面还要进行大量的人工检查,因为我仍然有改出新问题的可能。比如,有人对某个常量使用了 const_cast 魔法,然后改了它的值。const_cast 还是好的,毕竟很容易通过搜索找到,但如果用的是 C 风格的类型转换呢?或者,先取了地址,然后经过一大堆指针运算什么的呢?上千万行代码的项目,出现这类代码的可能性非常大。

所以,这个看似简单的小问题,却不好解决,而它最开始可能只是项目的一个小失误。