Skip to content

Latest commit

 

History

History
162 lines (122 loc) · 6.89 KB

File metadata and controls

162 lines (122 loc) · 6.89 KB

条款04:确定对象被使用前已先被初始化

关于将对象初始化这事,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对象。