引言

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

C++ 构造函数的执行过程(一) 无继承的更多相关文章

  1. JavaScript高级 面向对象(13)--构造函数的执行过程

    说明(2017-4-2 21:50:45) 一.构造函数是干什么用的: 1. 初始化数据的. 2. 在js给对象添加属性用的,初始化属性值用. 二.创建对象的过程: 1. 代码:var p = new ...

  2. C#类继承中构造函数的执行序列

    不知道大家在使用继承的过程中有木有遇到过调用构造函数时没有按照我们预期的那样执行呢?一般情况下,出现这样的问题往往是因为类继承结构中的某个基类没有被正确实例化,或者没有正确给基类构造函数提供信息,如果 ...

  3. (无)webservice执行过程深入理解

    前面我们搞了1,2个DEMO,基本对webservice服务发布,调用 ,执行 有一定的了解. 今天的话,我们再系统的梳理下webservice执行过程. 首先我们在webservice服务器端开发w ...

  4. 反爬虫:利用ASP.NET MVC的Filter和缓存(入坑出坑) C#中缓存的使用 C#操作redis WPF 控件库——可拖动选项卡的TabControl 【Bootstrap系列】详解Bootstrap-table AutoFac event 和delegate的分别 常见的异步方式async 和 await C# Task用法 c#源码的执行过程

    反爬虫:利用ASP.NET MVC的Filter和缓存(入坑出坑)   背景介绍: 为了平衡社区成员的贡献和索取,一起帮引入了帮帮币.当用户积分(帮帮点)达到一定数额之后,就会“掉落”一定数量的“帮帮 ...

  5. 从编译,执行过程理解c#

    上节我们说过C#所开发的程序源代码并不是编译成能够直接在操作系统上执行的二进制代码.与Java类似,它被编译成为中间代码,然后通过.NET Framework的虚拟机——被称之为通用语言运行时(CLR ...

  6. ASP.NET Web API 过滤器创建、执行过程(一)

    ASP.NET Web API 过滤器创建.执行过程(一) 前言 在上一篇中我们讲到控制器的执行过程系列,这个系列要搁置一段时间了,因为在控制器执行的过程中包含的信息都是要单独的用一个系列来描述的,就 ...

  7. ASP.NET Web API 控制器执行过程(一)

    ASP.NET Web API 控制器执行过程(一) 前言 前面两篇讲解了控制器的创建过程,只是从框架源码的角度去简单的了解,在控制器创建过后所执行的过程也是尤为重要的,本篇就来简单的说明一下控制器在 ...

  8. 通过源码了解ASP.NET MVC 几种Filter的执行过程

    一.前言 之前也阅读过MVC的源码,并了解过各个模块的运行原理和执行过程,但都没有形成文章(所以也忘得特别快),总感觉分析源码是大神的工作,而且很多人觉得平时根本不需要知道这些,会用就行了.其实阅读源 ...

  9. JavaWeb之 Servlet执行过程 与 生命周期

    Servlet的概念 什么是Servlet呢? Java中有一个叫Servlet的接口,如果一个普通的类实现了这个接口,这个类就是一个Servlet.Servlet下有一个实现类叫HttpServle ...

随机推荐

  1. python常用数据结构讲解

    一:序列     在数学上,序列是被排成一排的对象,而在python中,序列是最基本的数据结构.它的主要特征为拥有索引,每个索引的元素是可迭代对象.都可以进行索引,切片,加,乘,检查成员等操作.在py ...

  2. vue报错:[Vue warn]: Do not use built-in or reserved HTML elements as component id: header

    报错的信息大致是不要将内置或保留的HTML元素用作组件ID 解决的办法是修改name符合规范或者直接删除组件内的name属性.

  3. linux虚拟化简介

    为跨平台而生 在计算机发展的早期,各类计算平台.计算设备所提供的接口.调用方式纷繁复杂,没有像今天这样相对统一的标准.由于需要适配不同的平台,需要写很多繁琐的兼容代码,这无形中给开发者带来了很大的不便 ...

  4. calico的ipip与bgp的模式分析

    1.前言 BGP工作模式: bgp工作模式和flannel的host-gw模式几乎一样: bird是bgd的客户端,与集群中其它节点的bird进行通信,以便于交换各自的路由信息: 随着节点数量N的增加 ...

  5. let与var的区别

    1.let作用域局限于当前代码块 文章中//后面的均为打印结果 代码1: { var str1 = "小花"; let str2 = "小明"; console ...

  6. Linux 文件或文件夹重命名命令mv

    使用命令mv既可以重命名,又可以移动文件或文件夹.例如: 1.将目录A重命名为B mv A B 2.将/a目录移动到/b下,并重命名为c mv /a /b/c 3.将一个名为abc的文件重命名为123 ...

  7. [一]基本sqlplus命令

    基本sqlplus命令: 1: sqlplus scott/tiger ; #简化连接数据库 2:show user; #想知道当前登陆的用户是哪一位 3:conn 用户名[/密码] [AS SYSD ...

  8. Docker系列(五):.Net Core实现k8s健康探测机制

    k8s通过liveness来探测微服务的存活性,判断什么时候该重启容器实现自愈.比如访问 Web 服务器时显示 500 内部错误,可能是系统超载,也可能是资源死锁,此时 httpd 进程并没有异常退出 ...

  9. 面试题解析|ACL权限控制机制

    ACL(Access Control List)访问控制列表 包括三个方面: 一.权限模式(Scheme) 1.IP:从 IP 地址粒度进行权限控制 2.Digest:最常用,用类似于 usernam ...

  10. Zookeeper工作过程详解

    一.Zookeeper工作机制 分布式和集中式系统相比,有很多优势,比如更强的计算能力,存储能力,避免单点故障等问题.但是由于在分布式部署的方式遇到网络故障等问题的时候怎么保证各个节点数据的一致性和可 ...