Effective C++条款04——让自己习惯C++(确定对象被使用前已先被初始化)

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

至于内置类型以外的任何其他东西,初始化责任落在构造函数( constructors)身上。规则很简单:确保每一个构造函数都将对象的每一个成员初始化。

这个规则很容易奉行,重要的是别混淆了赋值( assignment)和初始化( initialization)。考虑一个用来表现通讯簿的class,其构造函数如下:

class PhoneNumber {  };
class ABEntry {
public:
    // 构造函数
    ABEntry(const std::string& name, const std::string& address,
            const std::list& phones);

private:
    std::string theName;
    std::string theAddress;
    std::list thePhones;
    int numTimesConsulted;
};

ABEntry::ABEntry(const std::string& theName, const std::string& theAddress,
            const std::list& phones)
{
    // 这些都是赋值,而不是初始化
    theName = name;
    theAddress = address;
    thePhones = phones;
    numTimesConsulted = 0;
}

这会导致ABEntry对象带有你期望(你指定)的值,但不是最佳做法。C++规定,对象的成员变量的初始化动作发生在进入构造函数本体之前。在 ABEntry构造函数内,theName,theAddress和 thePhones都不是被初始化,而是被赋值。初始化的发生时间更早,发生于这些成员的default构造函数被自动调用之时(比进入ABEntry构造函数本体的时间更早)。但这对numTimesConsulted不为真,因为它属于内置类型,不保证一定在你所看到的那个赋值动作的时间点之前获得初值。

ABEntry构造函数的一个较佳写法是,使用所谓的member initialization list(成员初值列)替换赋值动作:

ABEntry::ABEntry(const std::string& theName, const std::string& theAddress,
            const std::list& phones)
    :theName(name),
    theAddress(address),
    thePhones(phones),
    numTimesConsulted(0)
{ }

这个构造函数和上一个的最终结果相同,但通常效率较高。基于赋值的那个版本(本例第一版本)首先调用default构造函数为theName, theAddress和thePhones设初值,然后立刻再对它们赋予新值。default构造函数的一切作为因此浪费了。成员初值列( member initialization list)的做法(本例第二版本)避免了这一问题,因为初值列中针对各个成员变量而设的实参,被拿去作为各成员变量之构造函数的实参。本例中的theName 以name为初值进行copy构造,theAddress以 address为初值进行copy构造,thePhones 以 phones为初值进行copy构造。

对大多数类型而言,比起先调用default构造函数然后再调用copy assignment操作符,单只调用一次copy构造函数是比较高效的,有时甚至高效得多。对于内置型对象如numTimesConsulted,只9o过万出你相再default构造一个成员变量,也通过成员初值列来初始化。同样道理,甚至当你想要default构造一个成员变量,你都可以使用成员初值列,只要指定无物(nothing)作为初始化实参即可。假设ABEntry有一个无参数构造函数,我们可将它实现如下:

ABEntry::ABEntry()
    :theName(),                    // 调用theName的默认构造函数
    theAddress(),                  // 类似  
    thePhones(),                   // 类似  
    numTimesConsulted(0)           //显示初始化为0
{ }

许多classes拥有多个构造函数,每个构造函数有自己的成员初值列。如果这种classes存在许多成员变量和/或base classes,多份成员初值列的存在就会导致不受欢迎的重复(在初值列内)和无聊的工作(对程序员而言))。这种情况下可以合理地在初值列中遗漏那些“赋值表现像初始化一样好”的成员变量,改用它们的赋值操作,并将那些赋值操作移往某个函数(通常是private),供所有构造函数调用。这种做法在“成员变量的初值系由文件或数据库读入”时特别有用。然而,比起经由赋值操作完成的“伪初始化”( pseudo-initialization),通过成员初值列(memberinitialization list)完成的“真正初始化”通常更加可取。

C有着十分固定的“成员初始化次序”。是的,次序总是相同: base classes更早于其 derived classes被初始化(见条款12),而class 的成员变量总是以其声明次序被初始化。回头看看ABEntry,其 theName成员永远最先被初始化,然后是theAddress,再来是thePhones,最后是numTimesConsulted。即使它们在成员初值列中以不同的次序出现(很不幸那是合法的),也不会有任何影响。为避免你或你的检阅者迷惑,并避免某些可能存在的晦涩错误,当你在成员初值列中条列各个成员时,最好总是以其声明次序为次序。

所谓static对象,其寿命从被构造出来直到程序结束为止,因此 stack 和heap-based对象都被排除。这种对象包括global对象、定义于namespace作用域内的对象、在classes内、在函数内、以及在file作用域内被声明为static的对象。函数内的static对象称为local static对象(因为它们对函数而言是local),其他static对象称为non-local static对象。程序结束时static对象会被自动销毁,也就是它们的析构函数会在main()结束时被自动调用。

现在,我们关心的问题涉及至少两个源码文件,每一个内含至少一个non-local-static对象(也就是说该对象是global或位于namespace作用域内,抑或在class内或file作用域内被声明为static)。真正的问题是:如果某编译单元内的某个non-local static对象的初始化动作使用了另一编译单元内的某个non-local static对象,它所用到的这个对象可能尚未被初始化,因为C+对“定义于不同编译单元内的non-local static对象”的初始化次序并无明确定义。

实例可以帮助理解。假设你有一个File system class,它让互联网上的文件看起来好像位于本机(local)。由于这个class 使世界看起来像个单一文件系统,你可能会产出一个特殊对象,位于global或namespace作用域内,象征单一文件系统:

class FileSystem {
public:
    // ...
    std::size_t numDisks() const;        // 众多成员函数之一
    // ...
};
extern FileSystem tfs;                   // 预备给客户使用的对象,tfs代表the file system

FileSystem对象绝不是一个稀松平常无关痛痒的( trivial)对象,因此你的客户如果在theFilesystem对象构造完成前就使用它,会得到惨重的灾情。

现在假设某些客户建立了一个class用以处理文件系统内的目录(directories)。很自然他们的class会用上theFileSystem对象:

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是不同的人在不同的时间于不同的源码文件建立起来的,它们是定义于不同编译单元内的non-local static对象。如何能够确定tfs会在tempDir之前先被初始化?

幸运的是一个小小的设计便可完全消除这个问题。唯一需要做的是:将每个non-local static对象搬到自己的专属函数内(该对象在此函数内被声明为static)这些函数返回一个reference 指向它所含的对象。然后用户调用这些函数,而不直接指涉这些对象。换句话说,non-local static对象被local static对象替换了。DesignPatterns迷哥迷姊们想必认出来了,这是Singleton模式的一个常见实现手法。

这个手法的基础在于:C++保证,函数内的local static对象会在“该函数被调用期间”“首次遇上该对象之定义式”时被初始化。所以如果你以“函数调用”(返回一个reference指向local static对象)替换“直接访问non-local static对象”,你就获得了保证,保证你所获得的那个reference将指向一个历经初始化的对象。更棒的是,如果你从未调用non-local static对象的“仿真函数”,就绝不会引发构造和析构成本;真正的non-local static对象可没这等便宜!

既然这样,为避免在对象初始化之前过早地使用它们,你需要做三件事。第一,手工初始化内置型non-member对象。第二,使用成员初值列(member initializationlists)对付对象的所有成分。最后,在“初始化次序不确定性”(这对不同编译单元所定义的non-local static对象是一种折磨)氛围下加强你的设计。

请记住

  • 为内置型对象进行手工初始化,因为C++不保证初始化它们。
  • 构造函数最好使用成员初值列( member initialization list),而不要在构造函数本体内使用赋值操作(assignment)。初值列列出的成员变量,其排列次序应该和它们在class 中的声明次序相同。
  • 为免除“跨编译单元之初始化次序”问题,请以local static对象替换non-local static对象。

你可能感兴趣的:(Effective,C++,c++,开发语言,学习,keep,studying,Effective+C++)