读取未初始化的值可能会导致不明确的行为。
结论:
- 对于内置类型,必须手工对变量进行初始化。
- 对于内置类型以外的其它类型,初始化责任落在构造函数中。确保每一个构造函数都将对象的每一个成员初始化。并且最好使用成员初值列初始化。
- 为免除“跨编译单元之初始化次序”问题,请以local static对象替换non-local static对象。
topic1: 构造函数初始化成员的两种方式
方案一
class PhoneNumber {...};
class ABEntry
{
public:
ABEntry(const std::string& name, const std::string& address, const std::list<PhoneNumber>& phones);
private:
std::string theName;
std::string theAddress;
std::list<PhoneNumber> thePhones;
int numTimesConsulted;
};
ABEntry::ABEntry(const std::string& name, const std::string& address,
const std::list<PhoneNumber>& phones)
{
theName = name; // 这些都是复制
theAddress = address; // 而非初始化
thePthones = phones;
numTimesConsulted = 0;
}
方案二
ABEntry::ABEntry(const std::string& name, const std::string& address,
const std::list<PhoneNumber>& phones)
: theName(name),
theAddress(address),
thePhones(phones),
numTimesConsulted(0)
{
}
C++规定,对象的成员变量的初始化动作发生在进入构造函数本体之前。在ABEntry构造函数内,theName,theAddress和thePhones都不是初始化,而是被赋值。初始化的发生时间更早,发生于这些成员的default构造函数调用之时(比进入ABEntry构造函数本体的时间更早)。但这对numTimesConsulted不为真,因为它属于内置类型,不保证一定在你所看到的那个那个赋值动作时间点之前获得初值。(最后一句话,没有太懂是什么意思。)
方案比较
两个方案构造函数的最终结果相同,但方案二效率更高。
原因:
- 方案一是基于赋值的版本,首先调用default构造函数为theName,theAddress和thePhones设初值,然后立刻再对他们赋予新值。default构造函数一切作为因此浪费了。
- 方案二是基于成员值列的做法,方案二避免了对default构造函数的浪费。因为初值列中针对各个成员变量而设的是惨,被拿去作为各成员变量构造函数的实参。本例中的theName以name为初值进行copy构造,theAddress以address为处置进行copy构造,thePhones以phones为初值进行copy构造。
- 对于大多数类型而言,比起县调用default构造函数然后再调用copy assignment操作符,单只调用一次copy构造是比较搞笑的。对于内置类型对象如numTimesConsulted,其初始化和赋值的成本相同,但为了一致性最好通过成员初值列来初始化。
规则:
- 总是在初值列中列出所有成员变量。
- 总是使用成员值列。因为比赋值更高效。
- 如果成员变量是const或reference,它们就一定需要初值,不能被赋值。(在class的声明中直接以花括号的形式赋初值)。
topic2: 变量初始化的次序同变量的声明次序有关,和变量顺序不同。
- C++有着十分固定的“成员初始化次序”。base class更早于其derived
- class被其初始化,而class的成员变量总是以其声明次序被初始化。
对于ABEntry,其theName成员永远最先被初始化,然后是theAddress,再来是thePhones,最后是numTimesConsulted。即使它们在成员初值话列值中以不同的次序出现,也不会有任何影响。
(tips:比如,初始化array时需要制定大小,代表大小的那个成员变量必须先有初值,这个顺序在变量声明的时候确定。)
topic3: non-local static对象初始化次序
static对象,其寿命从被构造出来到程序结束为止(stack和heap-based对象都被排除)。这种对象包括global对象、定义于namespace作用域内的对象、在class内、函数内、以及在file作用域内声明为static的对象。**函数内的static对象成为local static对象(因为它们对于函数而言是local),其他static对象成为non-static对象。**程序结束时,static对象会被自动销毁,也就是它们的析构函数会在main()结束时被调用。
所谓编译单元(translation unit)是指单一目标文件(single object file)的那些源码。基本上它是单一源码加上其所含入的头文件(#include files)。
我们需要关心的场景如下:
场景一
有两个源码文件,每个源码文件内含至少一个non-local static对象(也就是说该对象是global或位于namespace作用域内,抑或在class内或file作用域内声明为static。)
问题:如果某编译单元内的某个non-local static对象的初始化动作使用了另一编译单元内的某个non-local static对象,它所用到的这个对象可能尚未被初始化,因为C++对“定义于不同编译单元内的non-local static对象”的初始化次序并无明确定义。
举例:
假如你有一个FileSystem class,你可能会产生一个特殊的对象,位于global或namespace作用域内,象征单一文件系统:
class FileSystem // 来自自己的程序库
{
public:
...
std::size_t numDisks() const; //众多成员函数之一
...
};
extern FileSystem tfs; //预备给客户使用的对象;tfs代表“the file system”
note:你的客户如果在theFileSystme对象构造完成前就使用它,会得到惨重的灾情。
现在假设某些客户建立了一个class,用以处理文件系统内的目录。
class Directory // 由程序库客户建立
{
public:
Directory(params);
...
};
Directory::Directory(params)
{
...
std::size_t disks = tfs.numDisks(); // 使用tfs对象
...
}
进一步假设,这些客户决定创建一个Directory对象,用来放置临时文件:
Directory tempDir( params ); // 为临时文件而做出的目录
现在,初始化次序的重要性凸显出来了:除非tfs在tempDir之前先被初始化,否则tempDir的构造函数会用到未初始化的tfs。但tfs和tempDir是不同的人在不同的时间于不同的源码文件建立起来的,它们是定义于不同编译单元内的non-local static对象。如何能够确定tfs会在tempDir之前先被初始化?
实际上是无法确定的。C++对于“定义于不同编译单元内的non-local static对象”的初始化相对次序并无明确定义。这是有原因的:决定它们初始化次序相当困难,非常困难,根本无解。在其最常见的形式,也就是多个编译单元内的non-local static对象经由“模版隐式具现化,implicit template instantiations”形成,不单不可能决定正确的初始化次序,甚至往往不值得寻找“可决定正确次序”的特殊情况。
解决方案
将每一个non-local static对象搬到自己的专属函数内(该对象在此函数内被声明为static)。这些函数返回一个reference指向它所含的对象。然后用户调用这些函数,而不直接指涉这些对象。换句话说,non-local static对象被local static对象替换了。设计模式的迷哥迷妹们想必认出来了,这是单例模式的一个常见写法。
这个手法的基础在于:C++保证,函数内的local static对象会在“该函数被调用期间”“首次遇上该对象之定义式”时被初始化。所以,如果你以“函数调用”(返回一个reference指向local static对象)替换“直接访问non-local static对象”,你就获得了保证,保证你所获得的那个reference将指向一个历经初始化的对象。更棒的是,如果你从未调用non-local static 对象的“仿真函数”,就绝不会引发构造和析构成本;真正的non-local static对象可没这等便宜!
单例模式代码举例:
class FileSystem {...}; // 同前
FileSystem& tfs() // 这个函数用来替换tfs对象;
{ // 它在FileSystem class中可能是个static。
static FileSystem fs; // 定义并初始化一个local static对象
return fs; // 返回一个reference指向上述对象
}
class Directory {...}; // 同前
Directory::Directory(params) // 同前,但原本的reference to tfs
{ // 现改为tfs()
...
std::size_t disks = tfs().numDisks(); // 使用tfs对象
...
}
Directory& tempDir() // 这个函数用来替换tempDir对象
{ // 它在Directory class中可能是个static
static Directory td; // 定义并初始化local static对象
return td; // 返回一个reference指向上述对象
}
这么修改之后,这个系统程序的客户完全像以前一样用它,唯一不同的是,他们现在使用tfs()和tempDir()而不再是tfs和tempDir。也就是说它们使用函数返回的“指向static对象”的reference,而不再使用static本身。
总结:
这些函数“内含static对象”的事实使他们在多线程系统中带有不确定性。任何一种non-const static对象,不论它是local或non-local,在多线程环境下“等待某事发生”都会有麻烦。处理这个麻烦的一种做法是:在程序单线程启动阶段手工调用所有reference-returning函数,这可消除与初始化有关的“竞速形式”。(以上这段话关于non-const static对象多线程场景的阐述,不太懂。)
关于“reference-returning函数”防止初始化次序问题,前提是其中又着一个对对象而言合理的初始化次序。如果你有一个系统,其中对象A必须在对象B之前先初始化,但A的初始化能否成功却又受制于B是否已初始化,这个时候就有麻烦了。只要避开如此病态的境况,以上描述的方法应该可以提供良好的服务,至少在单线程程序中。