返回

[Effective C++ 笔记]04.确定对象被使用前已被初始化

简介

  • 对于内置类型对象一定要进行手动初始化,因为 C++ 不保证在声明时会对他们进行初始化
  • 构造函数中最好使用初始化列表对成员变量进行初始化,而不是在函数内部对其进行赋值;另外初始化列表中成员变量的顺序应该和声明变量时顺序一致
  • 为了避免跨编译单元的初始化顺序问题,尽量以 local static 对象代替 non-local static 对象

内置类型变量的初始化

在学习其他语言的时候,有可能不会接触到“无初值对象”这个概念,意思是我们声明的对象无论是否对其进行初始化,总会有一个自动初始化的过程。但是在 C++ 却并非如此。对于内置类型变量而言(intfloat 等等),变量是否会初始化取决于他在内存中的哪一段(数据段或是栈空间等等)。例如如下一个简单语句,x 不一定会被初始化为 0,而是取决于具体情况(完整代码见 item01):

int x;

对于类中的内置类型成员变量也是如此,p 中的 xy 也不一定会被初始化:

class Point {
    int x, y;
};
...
Point p;

而读取未初始化的值会导致不明确的行为,引发不可知的后果。既有可能污染了正在读取该值的对象产生不正常的行为,也有可能导致程序直接崩溃。虽然正如上面所说,有一些现有的规则可以判断内置类型变量在何种情况一定会被初始化,何种情况下不会被初始化。通常来说:

  • 使用 C++ 中的关于 C 的部分时(见条款 1),初始化会招致运行期成本,不保证发生初始化,类似于 C 中的数组(int x[10];)就不会保证初始化
  • 使用 C++ 中的其他部分时则相反,考虑 std::vector 就会保证初始化。

但是更好地做法还是在声明每个变量时,对于没有任何成员的变量手动的对其进行初始化:

int x = 0;                              // 对 int 手动初始化
const char& text = "A C-Style string";  // 指针手动初始化
double d;
std::cin >> d;                          // 以读取输入流的方式初始化

自定义类对象的初始化

赋值和初始化的区别

而对于内置类型以外的其他变量,初始化的任务则是在构造函数上。基本规则是:保证每一个构造函数豆浆对象的每一个成员初始化。但是这里要注意不能将赋值(assignment) 和初始化 (initialization) 混淆。考虑以下例子(完整代码见 item02):

class PhoneNumber { ... };

class AddressBookEntry {
public:
    AddressBookEntry(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 = 12312;
};

AddressBookEntry::AddressBookEntry(const std::string& name, const std::string& address, const std::list<PhoneNumber>& phones) {

    // 下面都是赋值操作,而非初始化!
    theName = name;
    theAddress = address;
    thePhones = phones;
    numTimesConsulted = 0;
}

初始化列表的使用

通过以上构造函数构造出来的对象的所有成员变量都是我们期望的值(输入参数),但这并不是最佳做法,因为构造函数里不是对成员变量初始化而是进行了赋值。实际上成员变量(非内置类型)的初始化发生在进入构造函数之前(每个成员变量的 default 构造函数会被自动调用)。因此实际上我们相当于对其“赋值”了两次,一次发生在初始化,一次发生在构造函数内部。这里注意一下,上述情况只针对非内置类型,因为其会自动初始化。对于内置类型(上例中 numTimesConsulted),在第一部分我们已经讲过了内置类型不会自动初始化,所以赋值操作还是只进行了一次。因此为了提高效率,更好的做法是使用初始化列表(initialization list),如下所示(详细代码演示见 item03):

class PhoneNumber {
    //...
};

class AddressBookEntry {
public:
    AddressBookEntry(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 = 12312;
};

AddressBookEntry::AddressBookEntry(const std::string& name, const std::string& address, const std::list<PhoneNumber>& phones):
                                                                                theName(name),
                                                                                theAddress(address),
                                                                                thePhones(phones),
                                                                                numTimesConsulted(0) {}

使用初始化列表的好处是成员变量的构造函数只会被调用一次,调用的构造函数取决于初始化列表中传入的参数。对于大多数类型而言,使用一次 copy 构造函数的成本比使用一次 default 构造函数再使用一次 copy assignment 是要低的。而针对基本类型,虽然其不会在声明的时候初始化因而初始化和赋值的成本相同,但是为了一致性最好也使用一个初值来进行初始化。另外,当我们确实想要使用 default 构造函数时同样也可以使用初始化列表,只需要不传入参数即可,如下所示:

AddressBookEntry(): theName(),
                    theAddress(),
                    thePhones(),
                    numTimesConsulted(0),
                    thePerson() {}

由于编译器会为用户自定义类型的成员变量自动调用 default 构造函数,所以有时候即便不将其写在初始化列表中也不会出错,而如果我们将所有使用 default 构造函数的变量也放入初始化列表中可能会造成代码看上去有点夸张。但是避免记忆哪些变量已被/未被初始化,我们还是应该将所有成员变量都放入初始化列表中。

同时,尤其记住 基本类型的成员变量一定要在初始化列表中通过初值来进行初始化,否则会因为其没有初值而发生未定义行为。并且,当该内置类型变量是 const 或者 reference 的时候,则不能被赋值,而只能通过初值来进行初始化,这个时候如果不放入初始化列表中就不能通过编译。

例外

通常来说,很多类会拥有多个构造函数,而每个构造函数都有自己初始化列表。如果这个类的成员变量(或者基类)很多的话,多个初始化列表会造成程代码的重复。在这种情况下,我们可以有限制地将某些赋值和初始化成本相近的成员变量从初始化列表中移除,并通过赋值操作对他们进行赋值。这里可以将所有赋值操作移入一个 private 函数中来供所有的构造函数调用。这种情况比较适用于初值是由文件或数据库读入的情况。当然通常情况下还是使用初始化列表中对变量初始化这种操作更为可取。

变量初始化顺序

成员变量初始化的顺序

虽然在初始化列表中,我们"看似"可以不按声明顺序放入成员变量。但实际上我们无论以什么顺序写初始化列表,C++ 始终以一个固定的顺序对成员变量进行初始化:父类 -> 子类 以及按顺序初始化成员变量,即下面两种构造函数写法的成员变量初始化顺序是一样的(详细代码演示见 item04):

// type 1
class C{
public:
    C() : a(), b() {}

private:
    A a;
    B b;
};

// type 2
class C{
public:
    C(): b(), a() {}

private:
    A a;
    B b;
};

虽然我们以不同顺序写入成员变量不影响实际初始化顺序,但为了避免使用者/自己混淆,以及出现可能的错误(例如初始化 array 时必须先知道长度,因此代表长度的成员变量必须先初始化),我们在实际编码的时候还是应该严格按照声明顺序写初始化列表。

不同编译单元内定义的 non-local static 对象的初始化顺序

要理解上述标题首先我们要清楚编译单元和 non-local static 对象指的是什么:

  • 编译单元:指产出单一目标文件(single object file)的源码,包含源码文件加上所含入的头文件。基本上可以理解一个 cpp 文件对应一个编译单元
  • non-local static 对象:所谓 static 对象指的是其寿命从被构造开始至程序退出后结束,包括:
    • global 对象;
    • 定义于 namespace 作用域的对象;
    • 在 classes 内,在函数内,在 file 作用域内被声明为 static 的对象。
    • 其中在函数内的对 static 对象为 local-static 对象,其余均为 non-local static 对象

了解了以上两个概念之后,我们考虑以下场景:当两个定义在不同编译单元的 non-local static 对象存在依赖关系时,会出现未定义行为!如下例子(详细代码演示见 item05):

// FileSystem.hpp 代表文件系统类
class FileSystem {
public:
    ...
    std::size_t numDisks() const;
private:
    ...
};

extern FileSystem tfs;  // 提供一个 non-local static FileSystem 供所有人使用, tfs -- the File System

// FileSystem.cpp 编译单元 1
std::size_t FileSystem::numDisks() const { return m_numDisks; }
extern FileSystem tfs = FileSystem();

// Directory.hpp 文件夹类
class Directory {
public:
    Directory();
};

extern Directory tdr;   // tdr -- the directory

// Diectory.cpp 编译单元 2
#include "Directory.hpp"
#include "FileSystem.hpp"

Directory::Directory() {
    std::size_t disks = tfs.numDisks(); // 使用 tfs 对象获取参数
}

// tfs might not be iniatilized when tdr is initialized
extern Directory tdr = Directory();

如上例子,由于 tdr 是依赖于 tfs 构造的,所以保证 tfstdr 之前初始化是很有必要。但很不幸,C++ 对定义于不同的编译单元内的 non-local static 对象的初始化相对顺序没有明确定义! 那么我们要怎么避免出现这类现象呢?很简单,只需要一点小改变,将每个 non-local static 对象移至自己的专属函数内(变成 local static 对象),该对象在该函数内声明为 static,调用该函数时返回该 static 对象的 reference,用户在需要使用这些变量时只需要调用这些函数即可。其原理在于: C++ 保证了函数内 local static 对象会在函数被调用期间,并且首次遇上该对象定义式时进行初始化。因此使用该函数可以保证我们使用的 local static 对象是一定经过初始化的,并且还有一个额外的好处,加入我们不需要调用该函数时,该 static 对象从未被初始化,因此也省去了构造函数和析构函数的成本。事实上,设计模式中的单例模式的实现方式大多数就是这样。将上述例子利用这个方法可以改写如下(详细代码演示见 item06):

// FileSystem.hpp 代表文件系统类
class FileSystem {
public:
    ...
    std::size_t numDisks() const;
private:
    ...
};

FileSystem& tfs();  // 提供一个 non-local static FileSystem 对象供所有人使用, tfs -- the File System

// FileSystem.cpp 编译单元 1
std::size_t FileSystem::numDisks() const { return m_numDisks; }
FileSystem& tfs() {
    static FileSystem fs;   // 第一次被调用时初始化,之后不再重新初始化
    return fs;
}

// Directory.hpp 文件夹类
class Directory {
public:
    Directory();
};

Directory& tempDir();  // 提供一个static Directory 对象供使用

// Diectory.cpp 编译单元 2
#include "Directory.hpp"
#include "FileSystem.hpp"

Directory::Directory() {
    std::size_t disks = tfs().numDisks(); // 使用 tfs 对象获取参数,这里 tfs() 返回的 FileSystem 对象一定经过初始化
}

Directory& tempDir() {
    static Directory td;
    return td;
}

通过上面的设计方法可以有效避免出现依赖的 static 对象未被初始化的问题。而且我们可以发现,这一类“专属函数”的结构通常都非常简单,第一行声明(并定义)该 static 对象,第二行返回该对象的 reference。由于其简洁性,非常适合用于作为 inline 函数;但是要注意一点,在多线程系统中,函数内含的 static 对象有可能会带有不确定性,因为我们无法确认哪个函数先被调用,所以也无法确定初始化顺序,在同时使用多个单例函数有可能会出现问题,解决方法通常是在单线程启动阶段先手动调用所有单例函数以保证所有 static 对象都已被初始化。

同样,假如我们有两个对象 A 和 B,其中 A 的初始化必须要在 B 之前,但是 A 初始化结果又依赖于 B 是否已初始化。这种情况下上述方法也无法结果,应该重新思考是否有另一种设计方法以避免出现这种相互依赖的情况。

总结

总而言之,本条款可以概括如下:

  • 对于内置类型对象一定要进行手动初始化,因为 C++ 不保证在声明时会对他们进行初始化
  • 构造函数中最好使用初始化列表对成员变量进行初始化,而不是在函数内部对其进行赋值;另外初始化列表中成员变量的顺序应该和声明变量时顺序一致
  • 为了避免跨编译单元的初始化顺序问题,尽量以 local static 对象代替 non-local static 对象

完整可运行代码地址:Effective-Cpp-Reading-Note

Built with Hugo
Theme Stack designed by Jimmy