Effective C++ 阅读心得-条款04:确定对象被使用前已先被初始化

最重要的事情说在前面

1. C++为什么不保证内置类型的初始化呢?

  • 首先使用书中的观点:条款01中说需要视C++为一个语言联邦,通常如果你使用C part of C++,那么C++不保证发生初始化。一旦进入non-C parts of C++,比如STL领域,规则将有些变化。这就很好地解释了为什么array(来自C part of C++)不保证其内容被初始化,而vector(来自STL part of C++)却有此保证。
  • 高效性是C++设计目标之一:对于内置类型,它们的初始化可能会导致不必要的开销,因为它们可能会被频繁地创建和销毁。因此,C++编译器默认不会对内置类型进行初始化,这可以提高程序的执行效率。
  • 程序员有时候希望使用未初始化的内置变量类型:例如,当你在写一个高性能的程序时,可能会需要创建大量的内置类型变量,并且在使用它们之前没有必要进行初始化,因为它们在接下来的计算中会被重新赋值。在这种情况下,如果编译器默认对内置类型进行初始化,将会导致程序性能下降。
  • 多说几句:有些编译器可能会将未初始化的内置类型变量设置为 0 或其他默认值,但是这种行为是不可靠的,不同的编译器可能会有不同的行为,因此不应该依赖这种行为。
  • 为了避免出现未定义这种情况,最好在声明变量时就进行初始化,以确保它们具有已知的值。如果不能立即为变量设置值,可以使用默认构造函数或列表初始化语法来设置默认值。例如:
int i{};    // 使用默认构造函数初始化 i 为 0
int j = {}; // 列表初始化语法,将 j 初始化为 0
//当然,这只是语法,我觉得还不如直接赋值为0

2. 构造函数最好使用成员初值列,而不是在构造函数本体内使用赋值操作

  • 说在前面:前面说了内置类型的相关要素,那除了内置类型,其他所有的类型的初始化工作都由构造函数负责

2.1 构造函数初始化类成员变量的方法

  • 成员初始化列表来初始化对象:先举个栗子
class Example {
public:
  Example(int num, string val)
    : num_(num), val_(val)  // 使用成员初始化列表初始化成员变量
  {
    // 构造函数的其他代码
  }

private:
  int num_;      // 整数
  string val_;   // 浮点数
};
  • 构造函数内使用赋值操作符:更改上面的栗子
class Example {
public:
  Example(int num, string val)
  {
  	num_ = num;
  	val_ = val;  //赋值操作符初始化成员变量
    // 构造函数的其他代码
  }

private:
  int num_;      // 整数
  string val_;   // 字符串
};
  • 尽管这两种方式都可以达到相同的效果,但成员初始化列表的方式更好,有以下几个原因:
  1. 底层原因:使用赋值操作初始化的成员都已经是在对象构造出来之前初始化过了的,说白了,赋值操作符只是赋值,只是伪初始化,而成员初始化列表对于对象成员的初始化才是真的(以上描述对内置类型除外,因为内置类型在被赋值之前不保证能否获得初值,前面有讲过哦)。
  2. 效率更高。成员初始化列表的方式可以在对象创建时直接初始化成员变量,而赋值操作符则需要在对象创建后再进行赋值,这会造成额外的开销和性能损失。
  3. 对于某些成员变量,只能使用成员初始化列表的方式进行初始化。例如,对于const成员变量和引用类型的成员变量,只能在成员初始化列表中进行初始化,否则会导致编译错误。
  4. 语义更明确。使用成员初始化列表的方式可以清晰地表示成员变量是在对象创建时进行初始化的,而使用赋值操作符则会给人一种错觉,以为对象已经被创建好了,然后才进行赋值。
  5. 代码更易读。使用成员初始化列表可以将初始化和其他代码分离开来,使代码更加清晰和易于阅读。

2.1 成员初始化列表的奇奇怪怪的其他样子

  • 先说明:C++有着十分固定的“成员初始化次序”。base classes更早于其derived classes被初始化,class 的成员变量总是以其声明次序被初始化。
  • 对象的成员分为内置变量和非内置变量:如果那些用户自定义类型( user-defined types)之成员变量在“成员初值列”中没有被指定初值的话,编译器会为其自动调用default构造函数:
class Example {
public:
    Example(): str(), val(0) { //将用户自定义类型str指定无物作为初始化的实参,也就是啥也不指定

    }
private:
    string str;
    int val;
};

这样构造函数会调用str的默认(default)构造函数,这是一种合法的构造函数,因为它保证了所有成员变量都被正确初始化。

  • 委托构造函数:C++11引入了一种新的构造函数委托语法,允许一个构造函数委托给同一个类中的另一个构造函数完成部分或全部初始化。
class Example {
public:
  Example(int num)
    : num(num), val(3.14), ref_num(num)
  {
    // 构造函数的其他代码
  }

  Example()
    : Example(0)
  {
    // 构造函数的其他代码
  }

private:
  int num;
  double val;
  int& ref_num;
};

int main() {
  Example example1(10);     // 使用只有一个参数的构造函数
  Example example2;         // 使用无参数的构造函数
  return 0;
}
  • 不用觉得要记好多东西,就记住一样:构造函数中使用成员初始化列表,且顺序按照对象成员声明顺序来(不强制保证但建议如此)就好啦!

3. 为免除“跨编译单元之初始化次序”问题,请以local static对象替换non-local static对象。

3.1 编译单元

  • 什么是编译单元?
  1. 在C++中,编译单元(compilation unit)是指可以被编译器单独编译的代码单元。一个编译单元通常对应一个源代码文件,但它也可以由多个源代码文件组成。
  2. 编译单元是编译器编译代码的基本单位。编译器将编译单元中的源代码转换为目标代码,这个目标代码可以是二进制代码、汇编代码、中间代码等。在编译多个编译单元时,编译器会将它们分别编译成目标代码,并最终将这些目标代码链接起来形成一个可执行程序或库文件。
  3. 每个编译单元都必须包含一个唯一的标识符,通常是一个文件名,以便在编译和链接时进行引用。在C++中,每个编译单元可以包含一个或多个函数、类、变量、宏等,但是每个编译单元中的标识符必须是唯一的,以避免命名冲突。
  • 编译次序问题
  1. 编译单元的编译次序可以影响到程序的行为和正确性,因为在C++中,程序的行为是由多个编译单元组合而成的。
  2. 在C++中,编译单元通常是按照编译次序进行编译的,也就是说,一个编译单元的编译依赖于其他编译单元的编译结果。例如,如果编译单元A包含了对编译单元B中的函数的调用,那么编译单元B必须先被编译,才能保证在编译单元A中正确地链接到该函数。
  3. 如果编译单元的编译次序不正确,可能会导致链接错误、程序崩溃等问题。为了避免这种情况,通常会使用头文件(header file)来在不同的编译单元之间共享声明(declaration),从而在编译时避免出现未声明的错误。
  4. 在大型项目中,管理编译单元的编译次序变得更加复杂,因此需要使用构建系统(build system)来管理整个项目的编译过程,以保证编译次序的正确性和可维护性。

3.2 local static对象和non-local static对象

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

3.3 跨编译单元的初始化次序问题

  1. C++跨编译单元之间的初始化次序可能会对程序的行为产生影响,特别是在涉及到non-local static对象初始化的时候。
  2. 如果不同编译单元(即不同的源文件)定义的non-local static对象之间存在依赖关系,并且它们的初始化顺序不正确,就可能会导致程序出现未定义的行为。
  3. 具体来说,如果一个non-local static对象的初始化依赖于另一个non-local static对象,那么这两个对象必须按照正确的顺序进行初始化。例如,如果non-local static对象A的初始化依赖于non-local static对象B的值,那么必须先初始化B,然后才能初始化A。
  4. 在C++中,编译器保证同一个编译单元内的non-local static对象的初始化顺序按照它们在代码中出现的顺序进行,也就是声明顺序。但是,对于不同编译单元内的变量,C++标准并没有指定它们的初始化顺序,因此,不同编译单元内的non-local static对象的初始化顺序是未定义的。这意味着,程序员必须保证跨编译单元的non-local static对象的初始化顺序正确,以避免程序出现未定义的行为。

3.4 local static对象替换non-local static对象解决跨编译单元的初始化次序问题

  • 为了避免跨编译单元的初始化次序问题,可以使用一些技巧。例如,可以使用单例模式:将每个non-local static对象搬到自己的专属函数内(该对象在此函数内被声明为static)。这些函数返回一个reference指向它所含的对象。然后用户调用这些函数,而不直接指涉这些对象。此外,可以使用头文件(header file)来在不同的编译单元之间共享声明(declaration),从而在编译时避免出现未声明的错误
  • 单例模式
  1. 是一种保证一个类只有一个实例,并提供一个全局访问点的设计模式。通过将需要在整个程序中共享的对象封装在一个单例类中,可以保证该对象只被初始化一次,并且可以在任何需要访问该对象的地方通过单例类的接口进行访问
  2. 在跨编译单元的程序中,可以将需要共享的对象封装在一个单例类中,并将该类的实例作为全局变量在程序中使用。这样,在程序启动时,单例类的实例会被正确初始化,并且可以在整个程序中通过全局变量进行访问,避免了跨编译单元的初始化顺序问题
  3. 此时,non-local static对象将变为local static对象,但因为这个实例提供了全局访问点,所以其可以实现non-local static对象的效果,使用书中例子:
class Filesystem {		//来自你的程序库,处理文件系统的类
public:
	...
	std: :size_t numDisks () const;		//众多成员函数之一
	...
};
extern Filesystem tfs;		//预备给客户使用的对象;tfs代表“the file system"

//如果客户在tfs对象被构造之前就使用了它,那程序就要GG

//使用tfs的类,由用户建立
class Directory {
public:
	Directory ( params ) ;
	...
} ;
Directory:: Directory ( params ){
	...
	std: :size_t disks = tfs.numDisks ();		//使用tfs对象
	...
}

//在用户初始化了一个Directory类时,初始化次序就很重要了
Directory tempDir(params);//此时如果tfs如果没有被初始化,那tempDir又如何能被初始化出来呢?

使用单例模式将tfs放到全局访问点中,作为local-static对象来提供:

class Filesystem {		//来自你的程序库,处理文件系统的类
public:
	...
	std: :size_t numDisks () const;		//众多成员函数之一
	...
};
Filesystem& tfs(){		//将tfs放到tfs的全局访问点中,由函数来初始化对象,保证在访问后,tfs对象已经存在
	static Filesystem fs;
	return fs;
}

//用户书写的程序:
Directory:: Directory ( params ){
	...
	std: :size_t disks = tfs().numDisks ();		//使用tfs()函数获取程序库提供的Filesystem对象
	...
}

打完收工!
都看到这了,留个赞再走呗~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值