写在前面
最近看到了这样一个题:
静态数据成员定义之后,必须在类外进行初始化
看完了Effective系列之后, 我会给出答案: 错误.
为什么呢? 下面来深入分析一下.
非常量静态数据成员
看下面这个例子, 对于非常量静态成员来说, 必须在类内声明, 类外初始化.
class P {
public:
static int cma; // 类内声明
};
int P::cma{}; // 必须类外初始化
void t1() {
P p;
cout << P::cma << endl; // 0
cout << p.cma << endl; // 0
}
这个没什么好说的, 因为所有类的实例共享同一份静态成员, 所以要这样操作.
常量静态数据成员
这里就要划重点了, 因为有一些小细节需要注意.
回到之前开头的问题, 表述错误的点就在于这里, 针对常量静态数据成员来说, 可以仅在类内声明并初始化了, 在类外还是可以直接使用, 而且不需要定义(初始化). 来看这样一个例子:
class P {
public:
static const int cma{}; // 如果只在类内声明并初始化, 就不能取地址, 不能引用
};
void t1() {
P p;
cout << P::cma << endl; // 0
cout << p.cma << endl; // 0
}
在没有类外定义的情况下, 也可以正常访问, 并且得到了值, 完美.
这里就要提到一个C++语法的特性了: 常量传播(const propagation), 这使得编译器不会为对应的类成员预留内存(我的理解是不会为这个静态常量成员的其他实例开辟一份新的空间), 这个特性很重要, 但是这样一来虽然取值没问题了, 但是还会出现其他的问题..
参考Effective Modern C++, 条款三十:熟悉完美转发失败的情况: 仅有声明的整型static const数据成员部分
That’s because compilers perform const propagation on such members’ values, thus eliminating the need to set aside memory for them.
首先来看调用成功的情形:(取值, 作为实参或作为数组长度)
void f(const int t) {}
void t11() {
vector<int> v(P::cma); // 数组长度
f(P::cma); // 作为实参
}
那么什么情况就不行呢? 对该静态常量取地址/引用时候:
auto q1 = &P::cma; // error
const int &q2 = P::cma; // error
当然, 还有一种情况就是完美转发时候作为转发函数实参的情形: (书中提到的)
void g(int val) { cout << val << endl; }
template <typename T>
void fwd1(T&& param) { // 接受任意实参
g(std::forward<T>(param)); // 转发该实参到g
}
// 调用
void t2() {
g(P::cma);
// fwd1(P::cma); // link error
// 转发失败, 其实是与取地址(指针), 引用类似
// 均发生链接错误
// auto p = &P::cma;
// const int& p = P::cma;
// ld: symbol(s) not found for architecture arm64
}
尽管代码中没有使用cma的地址,但是fwd1的形参是万能引用,而引用,在编译器生成的代码中,通常被视作指针。在程序的二进制底层代码中(以及硬件中)指针和引用是一样的。在这个水平上,引用只是可以自动解引用的指针。在这种情况下,通过引用传递 cma 实际上与通过指针传递 cma 是一样的,因此,必须有内存使得指针可以指向。通过引用传递的整型static const数据成员,通常需要定义它们,这个要求可能会造成在不使用完美转发的代码成功的地方,使用等效的完美转发时失败.
那么就是说, 尽管这种方式是可以的, 还是最好在类外义务性定义一下静态常量成员:
义务性定义, 取自More Effective C++ Item26: 限制某个类所能产生的对象数量, 代码注释部分 Obligatory definitions of class static
const int P::cma;
然后就不会有取地址等的问题了:
auto q1 = &P::cma; // ok
const int &q2 = P::cma; // ok
cout << q1 << endl; // 0x1046e3be0
cout << q2 << endl; // 0
初始化的顺序问题
鉴于上面的情况, 两种初始化(在类内直接声明+初始化或者在类内仅声明, 在类外进行初始化)均可, 但是不能都定义, 这样会报错.
不过, 最好还是遵循类内声明, 类外定义这种普适规则, 因为这样的话不会出问题.
从代码可维护性的角度出发, 因为一般来说类的声明与实现是要分离的, 如果把类的静态常量成员初始化动作放在类内, 那么之后修改起来要麻烦一些.
并且由于静态变量存储在全局区, 所以初始化的顺序不会影响调用.
class P {
public:
static const int cma;
// 如果只在类内声明并初始化, 就不能取地址, 不能引用
};
void t0() {
cout << P::cma << endl; //
}
const int P::cma{10};
针对类模板
针对类模板来说, 因为要生成多份模板类, 那么在类内初始化了, 就导致所有的模板类都会共用一份静态常量成员了:
template <typename T>
class Q {
public:
static const T cma{10};
};
输出一下:
cout << Q<int>::cma << endl; // 10
cout << Q<long>::cma << endl; // 10
所以, 对于类模板来说, 还是最好在类外定义(初始化), 方便为不同的模板来创建不同的静态常量成员. 下面通过特化来做: (此时类内不能初始化了, 初始化均在类外完成)
template <typename T>
const T Q<T>::cma{10};
template <>
const long Q<long>::cma{20};
输出一下:
cout << Q<int>::cma << endl; // 10
cout << Q<long>::cma << endl; // 20
其他类型
上面提到的都是针对int类型数据来说, 后来看了STL源码剖析发现, 上面的表述并不严谨, 事实上只有静态常量整数(integer, 是一族类型, 包括int, long, short, char等)成员可以进行类内初始化, 感觉这也应该是模板元编程所支持的类型了吧, double, float, 自定义类型等就不能这样初始化.
小结
综上所述:
- 不管是静态非常量成员还是静态常量成员, 都最好(但不是必须)遵循: 类内声明, 类外进行初始化(义务性定义).
- 对于静态常量整数成员的初始化, 类内类外均可完成(有且仅有一次初始化动作). 如果不给出类外初始化, 仅可以对该数据成员作取值操作, 如果遇到取地址/引用/万能引用(完美转发), 就会发生链接期错误.
- 对于类模板, 如果只在类模板内进行初始化, 则模板的所有具现类都共享同一份静态常量成员, 如果要为不同的类定义不同的常量静态成员, 就要在类外进行定义, 并为合适的模板类特化定义静态常量成员.