关于将对象初始化这事,C++似乎反复无常。如果你这么写:
int x;
某些语境下x保证被初始化为0,但在其他语境却不保证。如果你这么写:
class Point {
int x, y;
};
...
Point p;
p的成员变量有时被初始化为0,有时不会。
读取未初始化的值会导致未定义行为。表面上这似乎是个无法决定的状态,而最佳处理办法就是:永远在使用对象之前将它初始化。对于无任何成员的内置类型,你必须手动完成此时。例如:
int x = 0;
const char* text = "A C-style string";
double d;
std::cin >> d;
至于内置类型以外的任何其他东西,初始化责任负责落在构造函数身上。规则很简单:确保每一个构造函数都将对象的每一个成员初始化。
这个规则很容易奉行,重要的是别混淆了赋值(assignment)和初始化(initialization)。考虑一个用来表现通讯簿的class,其构造函数如下:
class PhoneNumber {...};
class ABEntry { // abbr. Address Book Entry
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; // 这些都是赋值(assignment)
theAddress = address; // 而不是初始化(initialization)
thePhones = phones;
numTimesConsulted = 0;
}
这会导致ABEntry对象带有你指定的值,但不是最佳做法。C++规定,对象成员变量的初始化动作发生在进入构造函数本体之前。初始化的时间发生得更早,发生于这些成员的构造函数被调用之时(比进入构造函数体时间更早)。但这对numTimesConsulted不为真,因为它属于内置类型,不保证一定在你看到的那个赋值动作的时间点之前获得初值。
ABEntry构造函数的一个较好写法是,使用成员初始化列表替换赋值操作:
ABEntry::ABEntry(const std::string& name, const std::string& address,
const std::list<PhoneNumber>& phones)
: theName(name), // 这些都是初始化(initialization)
theAddress(address),
thePhones(phones),
numTimesConsulted(0) { }
这个构造函数和上一个的结果相同,但通常效率较高。
对于大多数类型而言,比起先调用默认构造函数然后再调用拷贝赋值运算符,单只调用一次拷贝构造函数是比较高效的,有时甚至高效得多。当你想要默认构造一个成员变量,你都可以使用成员初始化列表,只要指定nothing作为初始化实参即可。假设ABEntry有一个午餐构造函数,我们可以将它实现如下:
ABEntry::ABEntry()
: theName(),
theAddress(),
thePhones(),
numTimesConsulted() {}
两个成员变量的初始化带有次序性。例如初始化array时需要指定大小,因此代表大小的那个成员变量必须先有初值。
还需要操心的是,不同编译单元内定义的non-local static对象的初始化次序。
所谓static对象,包括global对象、定义于namespace作用域内的对象、在class、函数体、以及file作用域内被声明的static对象。函数体内的static对象成为local static对象,其他static对象成为non-local static对象。
所谓编译单元式指产出单一目标文件的源码。基本上它是单一源码文件加上其所包含的头文件。
现在,我们关心的问题涉及至少两个源文件,每一个内含至少一个non-local 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;
现在假设某些客户建立了一个class用以处理文件系统内的目录。很自然它们的class会用上theFileSystem对象:
class Directory {
public:
Directory(params);
...
};
Directory::Directory(params) {
...
std::size_t disks = tfs.numDisks();
...
}
这些客户决定创建一个Directory对象,用来放置临时文件:
Directory tempDir(params);
现在,初始化次序的重要性显现出来了:tfs必须在tempDir之前被初始化。如何能确定这个顺序?
你无法确定。不但不可能决定正确的初始化次序,甚至往往不值得寻找可决定正确顺序的特殊情况。
唯一要做的是:将每个non-local static对象移到自己的专属函数内(该对象在此函数内被声明为static)。这些函数返回一个引用指向所含的对象。然后用户调用这些函数,而不直接使用这些对象。换句话说,non-local static对象被local static对象替换了。这是Singleton模式的一个常见实现手法。
这个手法的基础在于,C++保证,函数内的local static对象会在该函数被调用时,首次遇上该对象定义时被初始化。这样函数调用保证你所获得的引用将指向一个经过初始化的对象。
以此技术重写,结果如下:
class FileSystem {...};
FileSystem& tfs() {
static FileSystem fs;
return fs;
}
class Directory {...};
Directory::Directory(params) {
...
std::size_t disks = tfs().numDisks();
...
}
Directory& tempDir() {
static Directory td;
return td;
}
修改之后,这个系统程序的客户完全可以像以前一样使用它,唯一不同的是它们现在使用 tfs()
和 tempDir()
返回的引用,而不是static对象自身。
这些函数内含static对象使它们在多线程系统中带有不确定性。任何一种non-const static对象,在多线程环境下“等待某事发生”都会有麻烦。处理这个麻烦的一种做法是:在程序的单线程启动阶段手动调用所有的reference returning函数,这可消除与初始化有关的竞争条件。
请记住
- 为内置对象进行手动初始化,因为C++不保证初始化它们。
- 构造函数最好使用初始化成员列表,而不要在构造函数体内使用赋值操作。初始化列表的成员变量,其排列次序应该和它们在class中声明次序相同。
- 为避免跨编译单元的初始化次序问题,请用local static对象替换non-local static对象。