C++ 在将对象初始化问题上变化多端。比如说,你写下了下面的代码:
int x;
在许多情况下, x 会确保得到初始化(为零),但是另一些情况下则不会,如果这样写:
class Point {
intx, y;
};
...
Point p;
p 的数据成员在某些情况下会被初始化(为零),但是另一些情况就不会了。如果你以前学习的语言没有对象初始化的概念,那么请你注意了,因为这很重要。
读取未初始化的数据时,程序会出现无法预知的行为。在一些语言平台下,通常情况读取未初始化的数据程序将终止运行。还有情况会得到内存中某些位置上的“半随机”的数据,这些数据将会“污染”需要赋值的对象,最终程序的行为将变得不可预知。
现在,人们制定了规则:对象在何时一定会得到初始化,何时不一定会。但是遗憾的是,这些规则太过复杂——在我看来,你根本没必要去记忆它们。整体上讲,如果你正在使用 C++ 中 C 语言的一部分(参见第 1 项),那么初始化会引入一些额外的运行时开销,这一部分中对象不一定会初始化。但当你使用 non-C part of C++时,情况就有所改变。这便可以解释为什么数组(C part of C++)不会确保得到初始化,而一个 vector ( STL part of C++ )会。
解决上面不确定性的最好方法就是:总是在对象使用之前对它们进行初始化。对于内置类型的非成员对象,你需要手动完成这一工作。请看下边的示例:
int x = 0; // 手动初始化一个 int 值
const char * text = "A C-stylestring"; // 手动初始化一个指针(见第 3 项)
double d;
std::cin >> d ; // 通过读取输入流进行“初始化”
对于内置类型以外的东西,初始化的重担就落在了构造函数身上。这里的规则很简单:确保所有的构造函数初始化了对象中的所有东西。
遵守这一规则很容易,但重要的是:不要把赋值和初始化搞混了。考虑下面一个类,它的构造函数如下:
class PhoneNum{...};
class Entry //入口
{
public:
Entry(conststd::string& name,const std::string& address,conststd::list<PhoneNum> phone);
private:
std::stringtheName;
std::stringtheAddress;
std::list<PhoneNum>thePhone;
intcount;
};
Entry::Entry(const std::string&name,const std::string& address,const std::list<PhoneNum> phone)
{
theName=name;//这些均为赋值而非初始化
theAddress=address;
thePhone=phone;
count=0;
}
上边的做法可以使得 Entry的对象包含你所期望的值,但是这仍不是最优做法。 C++ 规定:对象成员变量的初始化动作发生在进入构造函数体之前。在Entry 的构造函数内部,theName 、 theAddress 以及 thePhone 并不是得到了初始化,而是被赋值。初始化工作应该在更早的时候进行:即在进入Entry构造函数内部之前,这些数据成员的默认构造器应该自动得到调用。注意这对于count不成立,因为它是内置数据类型。对它而言,在被赋值以前,谁也不能确保它得到了初始化。
编写Entry 的构造函数的更好的办法是使用成员初始化表:
Entry::Entry(const std:string&name,const std::string& address,std::list<PhoneNum>& phone):theName(name),theAddress(address),count(0) //这些为初始化
{} //构造函数体为空
上面两个构造函数最终结果相同,但后者效率更高。第一个版本首先调用了 theName 、 theAddress 以及 thePhones 的默认构造函数来初始化它们,然后又为它们重新赋了一遍值。于是默认构造函数的工作就都白费了。使用成员初始化表的方法可以避免这一浪费,这是因为:初值列表中针对各个成员而设的实参,被用于各成员变量的默认构造函数的实参。本例中的theName以name为初始值进行了copy构造。。。对于大多数类型来说,和先调用default构造函数再调用copy assignment操作(拷贝运算符)相比,只调用一次copy构造函数更加高效。
对于内置类型对象,比如上面的count,初始化与赋值的开销是完全相同。但是为了一致性,也通过成员初始化列表来实现。同样道理,即使你当你想用default构造一个成员变量,你仍可以使用成员初始化表,只是不为初始化参数指定一个具体的值而已。比如,如果 Entry 拥有一个无参构造器,它可以这样实现:
Entry::Entry()
:theName(), //调用theName的default构造函数;
theAddress(), //同上
thePhones(), //同上
count(0); //记得为count显示初始化为0
这是因为:如果用户自定义类型的成员变量在“成员初始值列表中”没有指定初值的话,编译器会为其自动调用default构造函数。因而引发某些程序员过度夸张地采用上述写法。这是可以理解的。请立下一个规则:“总是在初始值列表中列出所有成员变量”,以免出现某些遗漏(同时也避免了要记住哪些成员变量可以无初始值)。比如说,如果因为count 是内置数据类型就不将其列入成员初始化表中,那么你的代码便可能出现出无法预知的行为。
有些时候必须使用初始化表,即使是内置类型。举例说, const 或者引用的数据成员必须得到初始化。它们不能被赋值(另请参看第 5 项)。对于那些既可以初始化又可以赋值的数据成员,为了省去记忆何时必须使用成员初始化表来初始化它们,最简单的方法是:总是使用初始化表。这样做有时绝非必要,但在多数情况下比赋值效率更高。
许多class有多个构造函数,每个构造函数都有自己的成员初始化列表。如果有很的数据成员/ 或基类时,存在的多个初始化表会导致无意义的重复。在这种情况下,可以考虑忽略表中一些“赋值和初始化效率一样好”的成员变量。可以把这些赋值语句放在一个单一(private)的函数里,供所有的构造函数调用。这一方法在“成员变量初始值是由文件或数据库导入”时特别有用。然而,真正的成员初始化终究要比通过赋值进行伪初始化要好。
C++ 有着十分固定的“成员初始化次序”。这个次序通常情况下是这样的:base classes早于drived classes类被初始化(另参见第 12 项),class的成员变量总是以它们声明的顺序得到初始化。比如说在Entry 内部, theName 永远都是第一个得到初始化的, theAddress 第二, thePhones 第三,叉烧包count 最后。即使它们在成员初始化表中的排列顺序和声明次序不同,(这是合法的)也不会有影响。为了避免出现晦涩错误 ,最好保证初始化顺序和声明顺序一样。
(上面指的晦涩错误,指的是两个成员变量的初化带有顺序,比如声明array时需要指定大小,因为代表大小的那个值要先得到初始化)
一旦你将“内置型成员变量”显式地加以初始化,也确保了构造函数使用“成员初始化列表”对基类和数据成员进行了初始化之后,需要你关心的工作就仅剩下了一个:在不同的编译单元内,non-local static对象的初始化次序是怎样的。
让我们一步一步地解决这个问题:
static对象,其寿命从被构造出来直到程序结束。保存在栈或堆中的对象都不是这样。静态对象包括:global对象、定义于namespace作用域内的对象、在class内、函数内,文件域内被声明为static的对象。函数内部通常叫做局部静态对象(这是因为它们对于函数而言是局部的),其它类型的静态对象称为非局部静态对象。静态对象在程序退出的时候会被自动销毁,即在 main 函数运行结束的时候,静态对象的析构函数会自动调用。
编译单元(translationunit)是指产生“目标文件”的源码。通常它是以单一源文件为基础,再要包括所有被 #include 进来的文件。
于是,我们所要解决的问题至少包含两个需要单独编译的源码文件,每一个都至少包含一个non-local static对象(该对象是global的或者位于namespace作用域内,抑或是类内部或者文件域的 static 对象)。问题的本质在于:如果一个编译单元内的一个非局部静态对象的初始化工作利用了另一个编译单元的一个非局部静态变量,它所用的这个对象可能尚未被初始化。这是因为:C++对于“定义在不同编译单元内的非静态对象”的初始化工作的顺序是未定义的。
这里有一个实例帮助理解。假设有一个 FileSystem 类,它可以让 Internet 上的文件看上去像是本地的。由于你的类要使得整外部看起来像一个单一的文件系统,应创建一个单一的类,让这个类拥有全局的或者名字空间的作用域:
class FileSystem { // 来自你的程序库
public:
...
std::size_t numDisks() const; // 许多成员函数中的一个
...
};
extern FileSystem tfs; // 供客户端使用的对象
//"tfs" = "the file system"
一个 FileSystem 对象相当重要,在 tfs 对象被构造之前使用它会带来灾难性后果。
现在设想一下,一些程序员创建一个类处理文件系统目录(Directory),该类会使用FileSystrm对象:
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 是由不同的人、在不同的时间、在不同的源码文件中创建的——这两者都是非局部静态对象,它们定义于不同的编译单元中。那么你如何保证 tfs 在 tempDir 之前得到初始化?
事实上这是不可能的。重申一遍, C++对“定义在不同编译单元内的非静态对象”的初始化顺序是未定义的 。因为为非局部静态对象确定初始化顺序非常困难,基本无解。其最常见的形式:由隐式模板实例化产生的多个编译单元和非局部静态对象(也许它们是自己产生的,只是产生的过程借助了隐式模板实例化的力量)——这不仅使得确认初始化的顺序变得不可能,甚至寻找一种可行的初始化顺序的特殊情况,都显得毫无意义。
幸运的是一个小的方法可以完全排除这个难题。要做的是:把每个非局部静态对象移入自己的专用函数中(该对象在函数内要声明为static)。这些函数返回一个它们所包含的对象的引用。然后用户可以调用这些函数,而不是直接使用那些对象。也就是说,non-local static对象被local static对象取代了。(这是 Singleton 模式一个通用实现。)
这方法基于 C++ 的一个约定:函数内的local static对象会在“该函数被调用期间”“首次遇上该对象的定义时”被初始化。所以说对于局部静态对象,改用“通过函数返回的引用来调用”代替“直接访问local static对象”,你就保证了你得到的这一引用所指向的是一个经过初始化的对象。更好的是,如果你从未调用non-localstatic对象的“仿真函数”,就不会引发构造和析构的开销。真正的non-local static对象没这等好处。
下面是对这一方法的应用,仍然以 tfs 和 tempDir 为示例:
class FileSystem { ... }; // 同上
FileSystem& tfs() // 这一函数代替了 tfs 对象;它在
// FileSystem 类中应该是 static 的
{
static FileSystem fs; // 对局部静态对象的定义和初始化
return fs; // 返回该对象的引用
}
class Directory { ... }; // 同上
Directory::Directory( params )// 同上,但对 tfs 的引用现在为对 tfs()
{
...
std::size_t disks = tfs().numDisks();
...
}
Directory& tempDir() // 这个函数取代了 tempDir 对象;它在
// Directory 类中可以是 static 的
{
static Directory td; // 对局部静态对象的定义和初始化
return td; // 返回该对象的引用
}
这一改进系统不需要客户端程序员做出任何改变,不同的是他们所引用的是 tfs() 和 tempDir() 而不是 tfs 和 tempDir 。也就是说,他们使用的是函数返回的引用而不是直接使用对象本身。
这种结构下的reference-returning十分简单 :在第 1 行定义和初始化一个局部静态对象,在第 2 行返回它的引用。如此的简单用使得这类函数非常适合作为内联函数,尤其是对它们的调用非常频繁时(参见第 30 项)。另外,这些函数中“包含着静态对象”使他们在多线程系统中带有不确定性。在此声明,任何类的non-const static对象,无论是local的还是non-local的,它们面对多线程都会碰到这样那样的问题。解决这一问题的方法之一是:在程序的单线程启动阶段(single-threaded startup portion),手动调用所有reference-returning的函数。这可以排除与初始化相关的“竞争状态”的出现。
当然,使用reference-returning可以防止“初始化次序问题”,前提是有着一个对对象而言合理的初始化次序。如果你的 系统要求对象 A 必须在对象 B 之前得到初始化,但是 A 的初始化需要以 B 的初始化 为前提,你将会面临一些问题。只要避开此类情况,这里介绍的解决方法仍然提供良好的服务,至少对于单线程应用程序来说是这样的。
为了避免在对象初始化之前使用它,需要做三件事。第一,手动初始化内置类型的non-member对象。第二,使用成员初始化表来处理对象的所有成分。最后,初始化次序的不确定性会使定义于不同编译单元中的非局部静态对象之间产生冲突,要避免这样的设计。
需要记住的:
1.对内置类型型对象进行手动初始化,因为C++不保证初始化它们。
2.构造函数最好使用初始化成员列表(member initialization list),避免在构造函数内部进行赋值操作。初始化表中的次序要与成员在类中被声明的次序相一致。
3.要避免“跨编译单元初始化次序”问题,可以使用local-static对象来代替non-local-static对象。