C++ 构造函数的执行过程(一) 无继承

  • 2019 年 10 月 31 日
  • 筆記

 

引言

C++ 构造函数的执行过程(一) 无继承
本篇介绍了在无继承情况下, C++构造函数的执行过程, 即成员变量的构建先于函数体的执行, 初始化列表的数量和顺序并不对构造函数执行顺序造成任何影响.
还指出了初始化列表会影响成员变量的构造方式, 分析了为何要尽可能地使用初始化列表.

关于在继承的情况下, C++构造函数的执行过程, 请期待第二篇.

 

本文所依赖的环境如下:

平台: Windows 10 64位

编译器: Visual Studio 2019

 

一. 构造函数的执行顺序

 

1.1 声明一个类

首先我们声明一个类:

// Dog.h  class Dog;

如果我们创建一个该类的实例:

// main.cpp  Dog myDog = Dog( );

那么编译器会申请一块内存空间, 并调用Dog的构造函数, 构造这个实例.

 

1.2 添加构造函数

我们一点点补全这个类.

在这个类中, 添加一个构造函数, 一个析构函数.

在函数体内, 各打印一条日志, 方便我们在调试的过程中, 知道执行的顺序.

// Dog.h  class Dog  {  public:    Dog( )    {      std::cout << "Dog构造函数函数体"<< std::endl;    }    ~Dog( ) { }  };

现在再次执行:

// main.cpp  std::cout << "Dog构造函数 开始" << std::endl;  Dog myDog = Dog( );  std::cout << "Dog构造函数 结束" << std::endl;  std::cout << "程序即将结束" << std::endl;

程序会打印出日志:

// 日志, 每行开头的数字序号, 是我手动添加的, 数字后才是真实的日志.  1. Dog构造函数 开始  2. Dog构造函数函数体  3. Dog构造函数 结束  4. 程序即将结束

 

1.3 添加成员变量

文明养狗, 每只狗都应该有自己的项圈.

我们给Dog添加一个项圈collar属性.

注: 为了方便验证, 我们让collar也是一个类的实例, 原因在于, 我们需要让这个属性在构造的时候, 打印出一条日志, 这样我们才能判断出它是在何时被构造的.

// Collar.h  class Collar  {  public:    // 缺省构造函数    Collar( )    {      std::cout << "Collar缺省构造函数" << std::endl;    }  };

现在我们在Dog中添加整个成员变量:

// Dog.h  class Dog  {  public:    Dog( )    {      std::cout << "Dog构造函数函数体<< std::endl;    }    ~Dog(){ }  private:    Collar collar_;  };

现在再次执行:

// main.cpp    std::cout << "Dog构造函数 开始" << std::endl;    Dog myDog = Dog(myCollar);    std::cout << "Dog构造函数 结束" << std::endl;    std::cout << "程序即将结束" << std::endl;

程序会打印出日志:

// 日志, 每行开头的数字序号, 是我手动添加的, 数字后才是真实的日志.  1. Dog构造函数 开始  2. Collar缺省构造函数  3. Dog构造函数函数体  4. Dog构造函数 结束  5. 程序即将结束
目前的结论:

在创建一个类的实例的时候, 会先构造出它的成员变量, 然后才会执行它的构造函数函数体的语句.

观察上面的代码, 我们并没有在任何地方, 显式的调用Collar的构造函数, 也就是说:

编译器帮你完成了Collar构造函数的调用.

但是, 如果这个类, 不止有一个成员变量, 那么编译器先构造哪个成员变量呢?

 

1.4 成员变量的构造顺序

现在, 我们给狗狗一个玩具.

// Toy.h  class Toy  {  public:    // 缺省构造函数    Toy( )    {      std::cout << "Toy缺省构造函数" << std::endl;    }  };

Dog添加一个玩具Toy属性.

// Dog.h  class Dog  {  // 构造和析构与1.3相同, 在此省略  private:    Collar collar_;    Toy toy_;  };

现在执行程序, 得到日志:

// 日志, 每行开头的数字序号, 是我手动添加的, 数字后才是真实的日志.  1. Dog构造函数 开始  2. Collar缺省构造函数  3. Toy缺省构造函数  4. Dog构造函数函数体  5. // 其余日志与1.3相同, 在此省略

可以看到, 我们在class Dog的声明中, 先声明了Collar, 再声明了Toy, 实际执行过程, 就是先调用了Collar缺省构造函数, 再调用了Toy缺省构造函数.

如果修改为:

// Dog.h  class Dog  {  // 构造和析构与1.3相同, 在此省略  private:    Toy toy_; // 调换了位置    Collar collar_; // 调换了位置  };

日志也会变成:

// 日志, 每行开头的数字序号, 是我手动添加的, 数字后才是真实的日志.  1. Dog构造函数 开始  2. Toy缺省构造函数  3. Collar缺省构造函数  4. Dog构造函数函数体  5. // 其余日志与1.3相同, 在此省略
目前的结论:

类的成员变量, 是按照类的定义中, 成员变量的声明顺序进行构造的. 且构造都早于类构造函数的函数体.

 

1.5 初始化列表的顺序, 不影响成员变量构造顺序

我们将对初始化列表做3个测试.
 

测试1: 初始化列表的顺序 和 成员变量声明顺序一致.
// Dog.h  class Dog  {  public:    Dog(const Collar& myCollar, const Toy& myToy)      : collar_(myCollar)      , toy_(myToy)    {      std::cout << "Dog构造函数函数体开始"<< std::endl;      std::cout << "Dog构造函数函数体结束" << std::endl;    }  private:    Collar collar_;    Toy toy_;  };

现在执行程序, 得到日志:

// 日志, 每行开头的数字序号, 是我手动添加的, 数字后才是真实的日志.  1. Dog构造函数 开始  2. Collar缺省构造函数  3. Toy缺省构造函数  4. Dog构造函数函数体  5. // 其余日志与1.3相同, 在此省略

 

测试2: 初始化列表的顺序 和 成员变量声明顺序不一致.
// Dog.h  class Dog  {  public:    Dog(const Collar& myCollar, const Toy& myToy)      : toy_(myToy)      , collar_(myCollar)    {      std::cout << "Dog构造函数函数体开始"<< std::endl;      std::cout << "Dog构造函数函数体结束" << std::endl;    }  private:    Collar collar_;    Toy toy_;  };

现在执行程序, 得到日志:

// 日志, 每行开头的数字序号, 是我手动添加的, 数字后才是真实的日志.  1. Dog构造函数 开始  2. Collar缺省构造函数  3. Toy缺省构造函数  4. Dog构造函数函数体  5. // 其余日志与1.3相同, 在此省略

日志没有任何变化.

 

测试3: 初始化列表中的数量少于成员变量的数量.
// Dog.h  class Dog  {  public:    Dog(const Collar& myCollar, const Toy& myToy)      : collar_(myCollar)      // 删除了toy_(myToy)    {      std::cout << "Dog构造函数函数体开始"<< std::endl;      std::cout << "Dog构造函数函数体结束" << std::endl;    }  private:    Collar collar_;    Toy toy_;  };

现在执行程序, 得到日志:

// 日志, 每行开头的数字序号, 是我手动添加的, 数字后才是真实的日志.  1. Dog构造函数 开始  2. Collar缺省构造函数  3. Toy缺省构造函数  4. Dog构造函数函数体  5. // 其余日志与1.3相同, 在此省略

日志没有任何变化.

 

目前的结论:

初始化列表的数量和顺序, 均不影响成员变量构造顺序.

构造顺序仍然是按照类的定义中, 成员变量的声明顺序进行构造的. 且构造都早于类构造函数的函数体.

 

1.6 目前的构造函数执行顺序

  1. 开辟内存空间.
  2. 按照成员变量声明的顺序开始构造成员变量.
  3. 进入函数体, 执行语句.

 

二. 成员变量如何被构造

2.1 在构造函数体内, 给成员变量赋值

现在, 我们显示的指定collar的构造, 给Collar添加另一个构造函数:

// Collar.h  class Collar  {  public:    // 缺省构造函数    Collar( )    {      std::cout << "Collar缺省构造函数" << std::endl;    }      // 含参构造函数    Collar(std::string color)    {      std::cout << "Collar含参构造函数" << std::endl;      color_ = color;    }      // 拷贝构造函数, 这里直接使用了const引用, 是出于性能考虑. 如果用值拷贝, 会多构造一个collar出来, 然后再析构它.    Collar(const Collar& collar)    {      std::cout << "Collar拷贝构造函数" << std::endl;      this->color_ = collar.color_;    }      // 拷贝赋值运算符    Collar& operator = (const Collar& collar)    {      std::cout << "Collar拷贝赋值运算符" << std::endl;      this->color_ = collar.color_;      return *this;    }      // 析构函数    ~Collar()    {      std::cout << "Collar析构函数" << std::endl;    }    private:    std::string color_;  };

主要做了几个改动

  1. Collar添加了一个带参构造函数. 便于和缺省构造函数进行区分.
  2. 添加一个拷贝构造函数.
    // todo 还没有解释
  3. 添加一个拷贝赋值运算符.
    拷贝赋值运算符其实就是我们常用的"="(更准确的说是"operator ="), 它存在于所有的类中, 当你在执行dog1 = dog2;的时候, 就是调用了这个函数来完成的赋值工作.
    不管你在类的定义中, 有没有定义这个"operator ="函数, 你都可以使用它, 因为编译器已经帮助你自动合成了它.
    C++允许用户自己对"operator ="进行重载, 在这段代码中, 我重载了这个函数, 额外添加了一条日志.

修改Dog的构造函数:

// Dog.h  class Dog  {  public:    Dog(const Collar& myCollar)    {      std::cout << "Dog构造函数 函数体开始"<< std::endl;      // 将参数`collar`赋值给成员变量`collar_`      collar_= collar;      std::cout << "Dog构造函数 函数体结束" << std::endl;    }      ~Dog(){ }    private:    Collar collar_;  };

主要做了以下改动:

  1. 修改了Dog自身的构造函数声明, 添加了一个参数.
  2. 在构造函数的函数体内, 将参数collar赋值给成员变量collar_.
  3. 由于本构造函数内, 会调用其他函数, 所以我们在函数体内最上方和最下方都打印了一条日志, 便于分析函数调用链.

修改main.cpp

  Collar myCollar = Collar("yellow");    std::cout << "Dog构造函数 开始" << std::endl;    Dog myDog = Dog(myCollar);    std::cout << "Dog构造函数 结束" << std::endl;    std::cout << "程序即将结束" << std::endl;

实际运行后打印的日志如下:

// 日志, 每行开头的数字序号, 是我手动添加的, 数字后才是真实的日志.  1. Collar含参构造函数  2. Dog构造函数开始  3. ----Collar缺省构造函数  4. ----Dog构造函数函数体开始  5. --------Collar拷贝赋值运算符  6. ----Dog构造函数函数体结束  7. Dog构造函数结束  8. 程序即将结束  9. Collar析构函数"  10. Collar析构函数"

但是第二行日志指出, 编译器还是帮你完成了Collar缺省构造函数的隐式调用, 并且该调用早于Dog构造函数的调用.

> 第一条日志, 调用`Collar`的含参构造函数, 构造出一个对象.  > 第二条日志, 标志着程序开始调用`Dog`构造函数.  > 第三条日志, 调用成员变量的`Collar`缺省构造函数, 将`collar_`构造出来.  > 第四条日志, 进入`Dog`的构造函数的函数体.  > 第五条日志, 调用拷贝赋值运算符, 将参数`myCollar`赋值给成员变量`collar_`;  > 第六条日志, `Dog`的构造函数的函数体结束.  > 第七条日志, 标志着`Dog`构造函数彻底结束.  > 第八条日志, 标志着程序即将结束, 开始进入析构阶段.  > 第九条日志, 在析构`Dog`实例的过程中, 会析构成员变量`collar_`, 执行`Collar`的析构函数.  > 第十条日志, 仍然是程序结束阶段, 会析构第一步建立的`myCollar`, 执行`Collar`的析构函数.
总结一下:

在构造Dog实例的过程中, 总共有5个步骤涉及了Collar:

  1. 带参构造
  2. 缺省构造
  3. 拷贝赋值运算符
  4. 析构"缺省构造"
  5. 析构"带参构造"

 

2.2 问题在哪里?

在刚才总结出的5个步骤中, 第2和3步, 存在浪费.

现在我们单独看这两步:

第一步: 先使用缺省构造, 构造出collar_对象.
这个缺省构造过程中, 如果collar_是一个很复杂的对象, 我们假设它包含了多个成员变量, 且每个成员变量要么是类的对象, 要么是结构体.
这个缺省构造, 将花费很多时间, 将每一个成员变量正确构造出来, 给它们一个默认值, 记住, 默认值通常都是没用的, 比如是’0’或者’nullptr’.

紧接着, 进入第二步, 拷贝赋值运算符:
在这个步骤之前, 我们已经将myCollar作为参数传递了进来, 这个myCollar早就已经构造完成了, 它所有的成员变量的值都是正确的且有意义的, 现在我们把它复制给collar_, 完成对collar_的创建, 其中collar_的默认值, 被一一覆盖.

现在你可能意识到了问题:

第一步的默认值完全是多余的!

我们需要执行第一步的前半部分, 将collar_对象构造出来.
但是我们不需要第一步的后半部分, 不需要默认值.
我们直接使用第二步, 将myCollar的值, 拷贝给collar_就行了.

 

2.3 使用初始化列表

我们仅仅对Dog.h进行一些修改:

// Dog.h  class Dog  {  public:    Dog(const Collar& myCollar)      : collar_(myCollar)    {      std::cout << "Dog构造函数函数体开始"<< std::endl;      std::cout << "Dog构造函数函数体结束" << std::endl;    }      ~Dog(){ }    private:    Collar collar_;  };

主要做了以下改动:

  1. Dog构造函数中, 添加初始化列表, 直接用myCollar来初始化collar_.
  2. 既然collar_已经初始化了, 函数体内的拷贝赋值运算符就可以删掉了.

其他内容保持不变, 执行:

1. Collar含参构造函数  2. Dog构造函数开始  3. Collar拷贝构造函数  4. Dog构造函数函数体开始  5. Dog构造函数函数体结束  6. Dog构造函数结束  7. 程序即将结束  8. Collar析构函数"  9. Collar析构函数"

对比上一次的日志可以发现:

本次运行使用了初始化列表, Collar拷贝构造函数一个步骤, 替代了上次运行的Collar缺省构造函数+拷贝赋值运算符两个步骤.

避免了Collar缺省构造, 也就避免了多余的默认值.

目前的结论:

对于一个类的成员变量, 一定会在进入该类的构造函数之前构造完成.
如果成员变量在初始化列表中, 就会执行该变量类型的拷贝构造函数.
如果成员变量没有在初始化列表中, 就会执行该变量类型的缺省构造函数.

 

2.4 尽可能地使用初始化列表

使用初始化列表, 首要原因是性能问题.

按照我们刚才的分析, 如果不使用初始化列表, 而是用构造函数函数体来完成初始化, 会额外调用一次缺省构造.

对于内置类型, 如int, double, 在初始化列表和在构造函数函数体内初始化, 性能差别不是很大, 因为编译器已经进行了优化.

但是对于类类型, 性能差别可能是巨大的, 数倍的.

另一个原因是, 有一些情况必须使用初始化列表:

  • 常量成员, 因为常量只能初始化不能赋值, 所以必须放在初始化列表里面.

  • 引用类型, 引用必须在定义的时候初始化, 并且不能重新赋值, 所以也要写在初始化列表里面.

  • 没有默认构造函数的类类型, 因为使用初始化列表可以不必调用缺省构造函数来初始化, 而是直接调用拷贝构造函数初始化.

注: 对于还不知道具体值的变量, 使用零值或没有具体含义的值, 比如int类型使用0, std::string类型使用"", 指针类型使用nullptr.

 

三 构造函数执行顺序

  1. 开辟内存空间.
  2. 按照成员变量声明的顺序开始构造成员变量.
    • 如果成员变量在初始化列表中, 就会执行该变量类型的拷贝构造函数.
    • 如果成员变量没有在初始化列表中, 就会执行该变量类型的缺省构造函数.
  3. 进入函数体, 执行语句.