Fork me on GitHub

读书笔记 effective c++ Item 4 确保对象被使用前进行初始化

 

C++在对象的初始化上是变化无常的,例如看下面的例子:

int x;

 在一些上下文中,x保证会被初始化成0,在其他一些情况下却不能够保证。看下面的例子:

class Point

{

int x,y;

};

Point p;

P的数据成员有时候保证能够被初始化(成0),有时候却不能。如果你从不存在未初始化对象的语言中转到c++, 就需要注意了,因为这很重要。

1. 使用未初始化对象的坏处 

读取未初始化的值会产生未定义的行为。在一些平台中,仅仅读取未初始化的值就会让你的程序停止。更有可能读入一些半随机的bits,这会污染你的对象,最终导致不可思议的程序行为和很多不愉快的程序调试。

2. 对于内建类型和非内建类型初始化的说明 

现在,有一些规则描述了什么时候对象初始化保证能够发生,什么时候不能够保证。不幸运的是,这些规则太复杂了-复杂的不值得我们去记住它。一般来说,如果你使用c++中的c部分,初始化可能会招致运行期成本,因此不保证发生初始化。如果你进入了c++的非C部分,事情就会发生改变。这就解释了为什么数组(来自c++的c语言部分)不保证它的内容被初始化,但是vector会保证(来自c++的STL部分)。

2.1. 如何保证内建类型进行初始化

 

处理这种看上去是不确定状态事务的最好方法是在你使用对象之前总是将它们进行初始化。对于内建类型的非成员对象,你需要手动初始化。举个例子:

int x = 0; // manual initialization of an int

const char * text = "A C-style string"; // manual initialization of a pointer (see also Item 3)

double d; // “initialization” by reading from

std::cin >> d; // an input stream

 

2.2. 如何保证非内建类型进行初始化

 

对于内置类型之外的其他东西,初始化的责任落在了构造函数身上。规则非常简单:确保所有的构造函数初始化对象的所有东西。

这个规则很容易遵守,但是不要将赋值和初始化搞混,这很重要。考虑一个表示地址簿的类,其构造函数如下:

class PhoneNumber { ... };

class ABEntry { // ABEntry = “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; // these are all assignments,

theAddress = address; // not initializations

thePhones = phones;

numTimesConsulted = 0;

}

 

3.构造函数中的初始化列表和赋值

 3.1. 使用初始化列表比赋值更具效率

这将会产生你所需要的ABEntry对象,但这仍然不是最好的方法。C++的规则中规定:对象的数据成员在进入构造函数体之前被初始化。在ABEntry构造函数内部,theName,theAdress和thePhones并没有被初始化,它们是被赋值。初始化发生的更早,在进入ABEntry的构造函数体之前这些数据成员的默认构造函数会被自动调用。但是这并不适用于numTimesConsulted,因为它是内建类型。对于内建类型来说,不能够保证在赋值之前被初始化。

写出ABEntry构造函数的更好的方法是使用成员初始化列表,而不是赋值。

ABEntry::ABEntry(const std::string& name, const std::string& address,

const std::list<PhoneNumber>& phones)

: theName(name),

theAddress(address), // these are now all initializations

thePhones(phones),

numTimesConsulted(0)

{} // the ctor body is now empty

这个构造函数会和上面的构造函数产生同样 的结果。但是它会更有效率。基于赋值的版本首先会调用默认构造函数来初始化theName,theAddress和thePhones,然后迅速的在默认构造出来的成员基础之上再进行赋值。在默认构造函数中进行的所有工作因此被浪费了。成员初始化列表的使用避免了这个问题,因为成员初始化列表中的参数被用作不同数据成员的构造函数的参数。在这种情况下,theName会以name作为参数拷贝构造出来,theAddress会以address作为参数拷贝构造出来,theAddress会以phones为参数拷贝构造出来。对于大多数类型,比起先调用默认构造函数然后调用拷贝赋值运算符,调用一个单一拷贝构造函数是更有效率的,而且有时效率能够大大提高。

对于像numTImeConsulted这样的内建类型来说,初始化和赋值的开销是相同的,但是为了一致性,最好通过初始化列表对所有东西进行初始化。类似的,在你想使用默认构造函数构造数据成员的时候,你仍然可以使用成员初始化列表:初始化参数不要指定任何东西。举个例子,如果ABEntry有一个构造函数不带任何参数,可以像下面这样来实现:

ABEntry::ABEntry()

: theName(), // call theName’s default ctor;

theAddress(), // do the same for theAddress;

thePhones(), // and for thePhones;

numTimesConsulted(0) // but explicitly initialize

{} // numTimesConsulted to zero

 

3.2. 在构造函数中使用初始化列表更不容易犯错

 

对于没有在成员初始化列表中列出来的用户自定义类型的数据成员,编译器会自动为其调用默认构造函数,对于这种想法是过多的考虑了。这种做法可以理解,但是如果有一个规则:总是在成员初始化列表中列出所有数据成员。这样我们就不必特地的记住哪些数据成员在被忽略的情况下不会被初始化。因为numTimesConsulted是一个内建类型,如果不将其放入成员初始化列表,它就不会被初始化,也就会打开未定义行为的大门。

 

有时候成员初始化列表必须被使用,甚至对于内建类型也是这样。举个例子,const数据成员或者引用数据成员必须被初始化而不能够被赋值(Item5)。为了避免需要记住什么时候数据成员必须被初始化什么时候是可选的,最简单的选择是总是使用初始化列表。有时候这样做是需要的,它比赋值更加有效率。

 

3.3.例外,什么时候用赋值会更好

 

许多类有多个构造函数,每个构造函数有自己的初始化列表。如果这些类有许多数据成员和(或者)基类,多个初始化列表的存在就会引入令人不愉快的重复(不同的构造函数初始化列表重复),程序员也会厌倦。在这种情况下,在成员初始化列表中省略那些赋值和真正进行初始化有相同效率的成员就是合理的,将这些赋值移动到一个单独的函数(private)中供所有的构造函数调用。如果数据成员的初始化值是从文件中或查找数据库得来的,这种方法特别有帮助。总之,真正的数据成员初始化(通过成员初始化列表)比“假的”通过赋值进行的初始化要更好。

 4. 关于初始化顺序的说明

4.1.对于类对象成员初始化顺序的说明

 

C++中一个不变的地方是对象的数据成员被初始化的顺序。这种顺序总是会相同的:基类在派生类初始化之前进行初始化(Item12),在类内部,数据成员根据其在类中声明的顺序进行初始化。举个例子,在ABEntry中,theName首先被初始化,theSecond其次,thePhones第三个被初始化,numTimesConsulted最后被初始化,即使这些数据成员在初始化列表中被列出的顺序不同(很不幸这是合法的),初始化顺序也是按照声明顺序。为了防止读代码的人产生迷惑,同时为了防止一些模糊不清的错误,最好成员初始化列表中列出的数据成员顺序和声明顺序一致。

 

4.2.对于non-local静态对象初始化顺序的说明

  • 问题描述

一旦你很小心的对内建类型的成员显示的进行初始化,并且你能够确保在构造函数中使用初始化列表对基类和数据成员进行初始化,那么只剩下一件你需要担心的事情。这件事情是,在不同编译单元中定义的非本地静态对象的初始化顺序。

我们一点一点的分析这句话。

 

静态对象的生存时间会从对象被构建开始直至程序结束。基于栈和堆的对象生存周期不在此列。在此列中的对象类型包括全局对象,命名空间范围内定义的对象,类内部的静态对象,函数内部声明的静态对象,文件范围内声明的静态对象。函数内部的静态对象被叫做本地静态对象(相对于函数来说是local的)其他类型的静态对象则是非本地的(non-local)对象。静态对象在程序退出时被销毁,例如:main函数执行完成时会调用析构函数。

 

一个编译单元是能够产生单一Obj文件的源码。基本上来说就是一个单一的源文件,加上所有#include进来的文件。

 

我们关心的问题涉及到至少两个单独编译的源文件,每个源文件至少包含一个非本地(non-local)静态对象(例如:命名空间范围内的全局对象或者类或文件范围内的static对象)。实际的问题是:如果一个编译单元中的非本地(non-local)静态对象的初始化使用了不同编译单元中的非本地静态对象,它使用的对象有可能没有被初始化,因为在不同编译单元中定义的非本地静态对象的初始化相对顺序是未定义的。 

看下面例子。假设你有一个FileSystem类,它让互联网上文件看起来像在本地。既然你的类使世界看起来像一个单一的文件系统,你会创建一个全局的或者命名空间范围内的特殊对象来表示这个单一的文件系统。

class FileSystem { // from your library’s header file

public:

...

std::size_t numDisks() const; // one of many member functions

...

};

extern FileSystem tfs; // declare object for clients to use (“tfs” = “the file system” ); definition                                                                                                                             // is in some .cpp file in your library


一个FileSystem是很重要的对象,因此在初始化tfs对象之前使用tfs会是灾难性的。

 

现在假设在一个文件系统中客户端为文件夹创建了一个类。很自然的,这个类会使用到tfs对象:

class Directory { // created by library client

public:

Directory( params );

...

};

Directory::Directory( params )

{

...

std::size_t disks = tfs.numDisks(); // use the tfs object

...

}

 

进一步假设客户端决定为临时文件创建单一的文件夹对象:

Directory tempDir( params ); // directory for temporary files

 

现在初始化顺序的重要性就很明显了:除非在tempDIr初始化之前对tfs进行初始化,否则tempDir的构造函数会尝试使用未初始化的tfs.因为tfs和tempDir是由不同的人在不同的时间不同的源文件中被创建的,它们是被定义在不同编译单元中的非本地静态对象。怎么才能够保证tfs在tempDIr之前被初始化呢?

 

你不能够保证,因为在不同编译单元中定义的非本地静态对象的初始化相对顺序是未定义的。这是有原因的,决定非本地静态对象的“合适的”初始化顺序是很难的。其最常见的形式,在多个编译单元内存在着通过隐式模板具现化产生的非本地静态对象,在这种情况下,不仅不能够决定初始化的正确顺序,并且为可能能够决定初始化的正确顺序寻找特定的cases也是不值得的。

  • 问题如何解决?

 

幸运的是,一个小的设计改动能够消除整个问题。所有需要做的就是将每个非本地静态对象移动到一个函数中,并且在函数中将其声明成static.这些函数返回其包含的对象的引用。客户端就可以调用函数而不是直接引用对象了。换句话说,非本地静态对象被本地静态对象替换掉了。(设计模式的爱好者会发现这是单例模式的一般实现。)

 

C++保证在函数调用时首次碰到函数内定义的本地静态对象,这个对象会被初始化。因此,如果你通过调用以本地静态对象作为返回值的函数来代替直接访问非本地静态对象,就能够保证返回的对象引用指向的是被初始化的对象。还有一个好处,如果你从来没有调用替代非本地对象的这个函数,永远不会有构造函数和析构函数的开销,这个对于非本地对象来说就不会生效了。 

将上面的技术应该在tfs和tempDir上:

 1 class FileSystem { ... }; // as before
 2 
 3 FileSystem& tfs() // this replaces the tfs object; it could be static in the FileSystem class
 4 
 5 { 
 6 static FileSystem fs; // define and initialize a local static object
 7 return fs; // return a reference to it
 8 }
 9 
10 class Directory { ... }; // as before
11 
12 Directory::Directory( params ) // as before, except references to tfs are now to tfs()
13 { 
14 ...
15 std::size_t disks = tfs().numDisks();
16 ...
17 
18 }
19 
20 Directory& tempDir() // this replaces the tempDir object; it could be static in the Directory class
21 {  
22 static Directory td( params ); // define/initialize local static object
23 return td; // return reference to it
24 }

 

 

这个修改过的系统程序客户端和修改之前是一样的,只不过现在使用tfs()和tempDir()而不是tfs和tempDir.也就是,现在使用以指向对象引用作为返回值的函数来替代使用对象本身。

  • 解决方法的局限性和使用场景 

这个规则描述的引用-返回函数通常是简单的:在第一行定义并且初始化一个本地对象,在第二行返回。这种简单性使得其成为内联函数的绝对候选人,特别是在它们被频繁调用的情况下(Item30)。另外,这些函数包含静态对象的事实使得其在多线程系统中使用会出现问题。再说一次,任何类型的non-const静态对象(不管是local的还是non-local的),在多线程环境下等待某事发生都会出现问题。解决这个麻烦的一个方法是在程序的单线程启动阶段手动触发所有引用-返回函数。这种方法可以消除初始化相关的竞速形式(race conditions)。

当然,使用引用-返回函数的方法可以防止初始化顺序问题,前提是需要进行初始化的对象首先要有一个合理的初始化顺序。如果在一个系统中对象A必须在对象B初始化之前进行初始化,而对象A的初始化依赖于对象B,这就会出现问题了。如果你能够避开这种病态的场景,这里描述的方法能够很好的为你服务,至少是在单线程应用中。 

5.总结

为了避免在对象初始化之前被使用,你需要做三件事情。

第一,  手动初始化内建对象。

第二,  使用成员初始化列表初始化一个对象的所有部分。

第三,  对初始化顺序不确定的场景进行重新设计。

 

转载请注明出处

posted @ 2017-02-11 20:23  HarlanC  阅读(1014)  评论(0编辑  收藏  举报