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

 

引言

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. 进入函数体, 执行语句.
posted @ 2019-10-30 22:55  Silenzio  阅读(...)  评论(...编辑  收藏