引言

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. 进入函数体, 执行语句.

最新文章

  1. [Java] JSP笔记 - EL、JSTL 常用标签
  2. JavaScript函数的概念
  3. Android拓展系列(10)--使用Android Studio阅读整个Android源码
  4. 【转载】Linux中强大且常用命令:find、grep
  5. Java汉字转成汉语拼音工具类
  6. Swift - 添加、修改、删除通讯录联系人
  7. android usb挂载分析---MountService启动
  8. vuejs模板使用方法
  9. 关于Oracle连接超时的问题
  10. HighCharts中的Ajax请求的2D折线图
  11. RxJava2.0入门篇
  12. iOS中图片拉伸,类似Android中的点9图片
  13. Python基础【第二篇】
  14. android studio样式文件汇总
  15. KMP、扩展KMP、Manacher习题
  16. git创建仓库,并提交代码(第一次创建并提交)(转)
  17. Cannot initialize Cluster. Please check your configuration for mapreduce.framework.name and the correspond server addresses.
  18. C++实现 找出10000以内的完数
  19. win7 安装 node-sass报错
  20. jQuery中animate设置属性的一个问题

热门文章

  1. asp.net core + layui.js 搭建仓储系统
  2. ASP.NET Core 3.0 : 二十四. 配置的Options模式
  3. java、if判断和循环
  4. FreeSql 导航属性的联级保存功能
  5. 通过父级id获取到其下所有子级(无穷级)——Mysql函数实现
  6. ef core实现无感知软删除
  7. logback颜色
  8. 关于未来实现API管理系统的几个关键词
  9. Git设置分支保护实现CodeReview卡点
  10. Android开发——RecyclerView实现下载列表