C++类内静态成员类外初始化情况分析与示例

 
Category: C_C++

写在前面

最近看到了这样一个题:

静态数据成员定义之后,必须在类外进行初始化

看完了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, 自定义类型等就不能这样初始化.

小结

综上所述:

  1. 不管是静态非常量成员还是静态常量成员, 都最好(但不是必须)遵循: 类内声明, 类外进行初始化(义务性定义).
  2. 对于静态常量整数成员的初始化, 类内类外均可完成(有且仅有一次初始化动作). 如果不给出类外初始化, 仅可以对该数据成员作取值操作, 如果遇到取地址/引用/万能引用(完美转发), 就会发生链接期错误.
  3. 对于类模板, 如果只在类模板内进行初始化, 则模板的所有具现类都共享同一份静态常量成员, 如果要为不同的类定义不同的常量静态成员, 就要在类外进行定义, 并为合适的模板类特化定义静态常量成员.