洛译小筑

别来无恙,我的老友…
随笔 - 45, 文章 - 0, 评论 - 172, 引用 - 0
数据加载中……

[ECPP读书笔记 条目4] 确保对象在使用前得到初始化

C++在对象值的初始化问题上显得变幻莫测。比如说,你写下了下面的代码:

int x;

在一些上下文里,x会确保得到初始化(为零),但是另一些情况下则不会,如果你这样编写:

class Point {

  int x, y;

};

...

Point p;

p的数据成员在一些情况下会确保得到初始化(为零),但是另一些情况则不会。如果你以前学习的语言没有对象初始化的概念,那么请你注意了,因为这很重要。

读取未初始化的数据将导致未定义行为。在一些语言平台中,通常情况下读取未初始化的数据仅仅是使你的程序无法运行罢了。更典型的情况是,这样的读取操作可能会得到内存中某些位置上的半随机的数据,这些数据将会“污染”需要赋值的对象,最终,程序的行为将变得十分令人费解,你也会陷入烦人的除错工作中。

现在,人们制定了规则来规定对象在什么时候必须被初始化,以及什么时候不会。但是遗憾的是,这些规则太过复杂了——在我看来,你根本没必要去记忆它们。整体上讲,如果你正在使用C++中C语言的一部分(参见条目1),并且这里的初始化会引入运行时开销,那么此时初始化工作无法确保完成。但当你使用非C的C++部分时,情况有时就会改变。这便可以解释为什么数组(C++中的C语言)不会确保得到初始化,而一个vector(C++中的STL)会。

解决这类表面上的不确定性问题最好的途径就是:总是在使用对象之前对它们进行初始化。对于内建类型的非成员对象,你需要手动完成这一工作。请看下边的示例:

int x = 0;                                   // 手动初始化一个int

const char * text = "A C-style string"; // 手动初始化一个指针(见条目3

double d;

std::cin >> d;                          // 通过读取输入流进行“初始化”

对于其他大多数情况而言,初始化的重担就落在了构造函数的肩上。这里的规则很简单:确保所有构造函数都对整个对象做出完整的初始化。

遵守这一规则是件很容易的事情,但是还有件重要的事:不要把赋值和初始化搞混了。请看下边示例中的构造函数,它是通讯录中用于表示条目的类:

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 num TimesConsulted;

};

 

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

                 const std::list<PhoneNumber>& phones)

{

  theName = name;                  // 以下这些是赋值,而不是初始化

  theAddress = address;

  thePhones = phones;

  numTimesConsulted = 0;

}

上边的做法可以让你得到一个包含你所期望值的ABEntry对象,但是这仍不是最优的做法。C++的规则约定:一个对象的数据成员要在进入构造函数内部之前得到初始化。在ABEntry的构造函数内部,theNametheAddress以及thePhones并不是得到了初始化,而是被赋值了。初始化工作应该在更早的时候进行:在进入ABEntry构造函数内部之前,这些数据成员的默认构造函数应该自动得到调用。注意这对于numTimesConsulted不成立,因为它是内建数据类型的。对它而言,在被赋值以前,谁也不能确保它得到了初始化。

编写ABEntry的构造函数的一个更好的办法是使用成员初始化表,而不是为成员一一赋值:

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

                 const std::list<PhoneNumber>& phones)

: theName(name),

  theAddress(address),             // 现在这些是初始化

  thePhones(phones),

  numTimesConsulted(0)

{}                                 // 现在构造函数内部是空的

如果仅看运行结果,上面的构造函数与更靠前一些的那个是一样的,但是后者的效率更高些。为数据成员赋值的版本首先调用了theNametheAddress以及thePhones的默认构造函数来初始化它们,在默认构造函数已经为它们分配好了值之后,立即又为它们重新赋了一遍值。于是默认构造函数的所有工作就都白费了。使用成员初始化表的方法可以避免这一浪费,这是因为:初始化表中的参数对于各种数据成员均使用构造函数参数的形式出现。这样,theName就通过复制name的值完成了构造,theAddress通过复制address的值完成构造,thePhones通过复制phones的值完成构造。对于大多数类型来说,相对于“先调用默认构造函数再调用拷贝运算符”而言,通过单一的调用拷贝构造函数更加高效——在一些情况下尤其明显。

对于内建类型的对象,比如numTimeConsulted,初始化与赋值的开销是完全相同的,但是为了保证程序的一致性,最好通过成员初始化的方式对所有成员进行初始化。类似地,即使你期望让默认构造函数来构造一个数据成员,你仍可以使用成员初始化表,只是不为初始化参数指定一个具体的值而已。比如,如果ABEntry拥有一个没有参数的构造函数,它可以这样实现:

ABEntry::ABEntry()

:theName(),                        // 调用theName的默认构造函数;

 theAddress(),                     // theAddressthePhones同上;

 thePhones(),                      // 但是numTimesConsulted

 numTimesConsulted(0)              // 一定要显性初始化为零

 {}

由于成员初始化表中没有为用户定义类型的数据成员指定初始值时,编译器会自动为这些成员调用默认构造函数,因此一些程序员会认为上文中的做法显得有些多余。这是可以理解的,但是“总将每个数据成员列在初始化表中”这一策略可以避免你去回忆列表中是哪个成员被忽略了从而无法得到初始化。比如说,如果你因为numTimesConsulted是内建数据类型的,就不将其列入成员初始化表中,那么你的代码便极有可能呈现出未定义行为。

有些时候使用初始化表是必须的,即使是对于内建类型。举例说,const或者引用的数据成员必须得到初始化,它们不能被赋值(另请参看条目5)。至于数据成员什么时候必须在成员初始化表中进行初始化,什么时候没有必要,如果你不希望去记忆这些规则,那么最简便的选择就是永远都使用初始化表。一些时候初始化表是必须的,而且通常会获得比赋值更高的效率。

许多类都包含多个构造函数,每个构造函数都有自己的成员初始化表。如果某个类拥有非常多的数据成员和/或基类时,这些初始化列表中将会存在不少无意义的重复代码,程序员们也会感到厌烦。在这种情况下,忽略表中的一些条目也并非毫无意义,这些忽略的数据成员应符合这一条件:对它们进行赋值还是真正的初始化没有什么差别。可以把这些赋值语句放在一个单一(当然是私有的)的函数里,并让所有的构造函数在必要的时候调用这个函数。在数据成员要接收的真实的初始化数据需要从某个文件中读取时,或者要到某个数据库中去查找时,这一方法尤其有用。但是总体而言,真正的成员初始化终究要比通过赋值进行伪初始化要好。

C++也不是总那么变幻莫测,对象中数据的初始化的顺序就是C++的稳定因素之一。这个次序通常情况下是一致的:基类应在派生类之前得到初始化(另参见条目12),在类的内部,数据成员应以它们声明的顺序得到初始化。比如说在ABEntry内部,theName永远都是第一个得到初始化的,theAddress第二,thePhones第三,numTimesConsulted最后。即使它们在成员初始化表中的排列顺序不同于声明次序,(尽管这样做看上去应该算作非法,但不幸的是事实并非这样。)上述初始化顺序也会得到遵循。为了不使读者陷入困惑,也为了避免日后出现让人难以理解的bug,你应该保证初始化表中成员的顺序与它们被声明时的顺序严格一致。

在你完成了对内建类型的非成员对象的显式初始化,并且确保了构造函数使用成员初始化表对基类和数据成员进行了初始化之后,需要你关心的内容就仅剩下了一个,那就是(先长舒一口气):在不同的置换单元中,非局部静态对象的初始化次序是怎样的。

让我们来抽丝剥茧分析这个问题:

【静态对象(static object)】一个静态对象在被构造之后,它的寿命一直延续到程序结束。保存在栈或堆中的对象都不是这样。静态对象包括:全局对象、名字空间域对象、类内部的static对象、函数内部的static对象,文件域的static对象。函数内部的静态对象通常叫做局部静态对象(这是因为它们对于函数而言是局部的),其它类型的静态对象称为非局部静态对象。静态对象在程序退出的时候会被自动销毁,换句话说,在main中止运行的时候,静态对象的析构函数会自动得到调用。

【置换单元(translation unit)】一个置换单元是这样一段源代码:由它可以生成一个目标文件。总的来说置换单元就是单一一个代码文件,以及所有被#include进来的文件。

于是,我们所要解决的问题中,至少包含两个需要单独编译的源码文件,每一个都至少包含一个非局部静态对象(换句话说,是一个全局的,或者名字空间域的,或类内部或者文件域的static对象)。真正的问题是:如果一个置换单元内的一个非局部静态对象的初始化工作利用了另一个置换空间内的另一个非局部静态变量,那么所使用的对象应该是未经初始化的,这是因为:定义在不同置换单元内的非静态对象的初始化工作的顺序是未定义的。

这里一个示例可以帮助我们理解这一问题。假设你编写了一个FileSystem类,它可以让Internet上的文件看上去像是本地的。由于你的类要使得整个世界看上去像是一个单一的文件系统,你应该创建一个专门的类来代表这个单一的文件系统,让这个类拥有全局的或者名字空间的作用域:

class FileSystem {                 // 来自你的库

public:

  ...

  std::size_t numDisks() const;    // 许多成员函数中的一个

  ...

};

 

extern FileSystem tfs;             // 供客户端使用的对象

                                   // "tfs" = "the file system"

一个FileSystem对象绝对是重量级的,所以说在tfs对象被构造之前使用它会带来灾难性后果。

现在设想一下,一些客户为文件系统创建了一个文件夹的类。很自然地,他们的类会使用tfs对象。

class Directory {                  // 由类库的客户创建

public:

   Directory( params );

  ...

};

 

Directory::Directory( params )

{

  ...

  std::size_t disks = tfs.numDisks();  // 使用 tfs 对象

  ...

}

进一步设想,客户可能会为临时文件创建一个单独的Directory对象:

Directory tempDir( params );      // 存放临时文件的文件夹

现在,初始化次序的重要性已然浮出水面:除非tfstempDir初始化之前得到初始化,否则tempDir的构造函数将会尝试在tfs被初始化之前使用它。但是tfstempDir是由不同的人、在不同的时间、在不同的源码文件中创建的——这两者都是非局部静态对象,它们定义于不同的置换单元中。那么你如何保证tfstempDir之前得到初始化呢?

事实上这是不可能的。重申一遍,定义在不同置换单元内的非静态对象的初始化工作的顺序是未定义的。当然这是有理由的:为非局部静态对象确定“恰当的”初始化顺序是一件很有难度的工作。非常有难度。难到根本无法解决。在其大多数形式——由隐式模板实例化产生的多个置换单元和非局部静态对象(也许它们是通过隐式模板实例化自行生成的)——这不仅使得确认初始化的顺序变得不可能,甚至寻找一种可行的初始化顺序的特殊情况,都显得毫无意义。

幸运的是,一个小小的方法可以完美的解决这个难题。所要做的仅仅是把每个非局部静态对象移入为它创建的专用函数中,函数要声明为static的。这些函数返回一个它们所属对象的引用。于是客户就可以调用这些函数,而不是直接使用那些对象。也就是说,非局部静态对象被局部静态对象取代了。(设计模式迷们很容易发现,这是单例模式(Singleton Pattern)一个通用实现。)

这一方法基于C++的一个约定,那就是:对于局部静态对象来说,在其被上述函数调用的时候,程序中第一次引入了该对象的定义,它在此时就一定会得到初始化。所以如果你不去直接访问非局部静态对象,而改用“通过函数返回的引用来调用局部静态对象”,那么你就保证了你得到的这一引用将指向一个已经初始化的对象。作为奖励,如果你从未调用过模仿非局部静态对象的函数,你的程序就永远不会引入对这类对象进行构造和析构的开销,而这对于真正的非局部静态对象来说是不可能的。

下面是关于tfstempDir对这一技术的应用:

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()而不是tfstempDir。也就是说,他们使用的是返回引用的函数而不是直接使用对象本身。

编写这一类返回引用的函数所需要遵循的方针总是十分简单的:第1行定义和初始化一个局部静态对象,第2行返回它的引用。如此的简单易用使得这类函数非常适合作为内联函数,尤其是对它们的调用非常频繁时(参见条目30)。另外,这些函数中包含着静态对象,这一事实使得他们在多线程系统中也会遇到问题。在此声明,任何种类的非const静态对象,无论是局部的还是非局部的,它们面对多线程都会碰到这样那样的问题。解决这一问题的方法之一是:在程序还以单线程状态运行时,手动调用所有的这类返回引用的函数。这可以排除与初始化相关的竞争状态的出现。

当然,使用此类返回引用的函数来防止初始化次序问题的理念,首先基于此处存在一个合理的初始化次序。如果你的系统要求对象A必须在对象B之前得到初始化,但是A的初始化需要以B的初始化为前提,你将会面临一个问题,坦白说,你是咎由自取。然而,如果你能够驾驭这一不正常的境况,这里介绍的解决方法仍然可以良好的为你服务,至少对于单线程应用程序来说是这样的。

为了避免在对象初始化之前使用它,你仅仅需要做三件事。第一,手动初始化内建类型的非成员对象。第二,使用成员初始化表来初始化对象的每一部分。最后,初始化顺序的不确定性使得定义于不同置换空间里非局部静态对象难以正常运行,你需要寻求一个新的设计方案。

时刻牢记

由于C++只在某些情况下对于内建类型对象进行初始化,所以对它们要进行手动初始化。

对于构造函数,要尽量使用成员初始化表,避免在构造函数内部进行复制。初始化表中的次序要与成员在类中被声明的次序相一致。

要避免跨置换单元的初始化次序问题发生,可以使用局部静态对象来代替非局部静态对象的方案来解决。

posted on 2007-04-15 20:23 ★ROY★ 阅读(1241) 评论(4)  编辑 收藏 引用 所属分类: Effective C++

评论

# re: 【翻译】Effective C++ (第4项:确保对象在使用前得到初始化)   回复  更多评论   

转载一下
2007-04-16 12:03 | sandy

# re: 【翻译】Effective C++ (第4项:确保对象在使用前得到初始化)   回复  更多评论   

呵呵(常用的表示友好的语气词)
欢迎转载!!
2007-04-16 15:50 | 田德健

# re: 【翻译】Effective C++ (第4项:确保对象在使用前得到初始化)   回复  更多评论   

int num TimesConsulted; 这个是吧~
其他的没看出来.
谢谢你发表的这些文章。我求了很久了
2007-04-18 12:43 | sandy

# re: 【翻译】Effective C++ (第4项:确保对象在使用前得到初始化)   回复  更多评论   

……作为奖励,如果你从未调用过模仿非局部静态对象的函数……
这句翻译成reference-returning函数比较好
2013-01-21 15:25 | chopin

只有注册用户登录后才能发表评论。
【推荐】超50万行VC++源码: 大型组态工控、电力仿真CAD与GIS源码库
网站导航: 博客园   IT新闻   BlogJava   知识库   博问   管理