逸珺 · 2020年11月24日

构造管“生”对象?析构管“埋”对象?C++中构造析构还没整明白?

首发:嵌入式客栈
作者:逸珺

[导读] C++语言有时候也拿来写写应用代码,可是居然发现连构造、析构都还没弄明白,把这糟心的概念整理分享一下。
在谈类的构造前,先聊聊面向对象编程与面向过程的个人体会。

面向过程策略

要谈这个问题,先来对比一下传统面向过程的编程策略:
image.png
比较典型的–C, Pascal, Basic, Fortran语言,传统的做法是整了很多函数,整合时主要是整合各函数的调用,main函数协调对过程的调用,并将适当的数据作为参数在调用间进行传递流转而实现业务需求。信息流转主要靠:

  • 函数传递参数
  • 函数返回值

image.png
如上图,传统面向过程编程语言,比如C语言其编程的主要元为函数,这种语言有这些弊端:

  • 程序主要由函数组成,重用性较差。比如直接将从一个程序源文件中复制一个函数并在另一个程序中重用是非常困难的,因为该函数可能需要包含头文件,引用其他全局变量或者其他函数。换句话说,函数没有很好地封装为独立的可重用单元。相互牵扯、相互耦合比较复杂。
  • 过程编程语言不适合用于解决现实生活中问题的高级抽象。例如,C程序使用诸如if-else,for循环,array,function,pointer之类的结构,这些结构是低级的并且很难抽象出诸如客户关系管理(CRM)系统或足球实况游戏之类的实际问题。(想象一下,使用汇编代码来实现足球实况游戏,酸爽否?显然C语言比汇编好,但还不是足够好)

当然如C语言开发,现在的编程策略已经大量引入了面向对象编程策略了,但是要实现这些对象编程策略,则开发人员本身需要去实现这些面向对象本身的策略,需要自己手撕实现这些基础的对象思想。所以这里仅仅就语言本身特征做为说明,不必纠结。而这些策略如果由编程语言本身去实现,则显然是一个更优异的解决方案。但是比如嵌入式领域为嘛不直接用C++呢,而往往更多采用C的方式采用对象策略来撸代码呢? 嵌入式领域更多需要与底层硬件打交道,而C语言本身抽象层级相对更适合这种场景,结合对象策略编程则可以兼顾重用封装等考量。
回到技术发展历史来看,1970年代初期,美国国防部(DoD)成立了一个工作队,调查其IT预算为何总是失控的原因,其调查结果是:

  • 预算的80%用于软件(其余20%用于硬件)。
  • 超过80%的软件预算用于维护(仅剩余的20%用于新软件开发)。
  • 硬件组件可以应用于各种产品,其完整性通常不会影响其他产品。(硬件可以共享和重用!硬件故障是隔离的!)
  • 软件过程通常是不可共享且不可重用的。软件故障可能会影响计算机中运行的其他程序。

而面向对象编程语言则很好的解决了这些弊端:

  • OOP的基本单元是一个类,该类将静态属性和动态行为封装在一个“黑盒”里,并开放使用这些黑盒的公共接口。由于类相对函数具有更好的封装,因此重用这些类更加容易。换句话说,OOP将同一黑盒中的软件实体的数据结构和算法组合在一起。良好的类封装不需要去读实现代码,只要知道接口怎么用,实现真正意义上的造砖,哪里需要哪里搬!
  • OOP语言允许更高级别的抽象来解决现实生活中的问题。传统的过程语言例如C需要程序猿根据计算机的结构(例如内存位和字节,数组,决策,循环)进行思考,而不是根据您要解决的问题进行思考。OOP语言(例如Java,C ++,C#)使开发人员可以在问题空间中进行思考,并使用软件对象来表示和抽象问题空间的实体进行建模从而解决问题。因此OOP会更聚焦于问题域!

面向对象策略

而现代面向对象编程语言(OOP: Object-Oriented Programming) ,从语言本身角度,其编程的场景则变成下面一种截然不一样的画风:

image.png

程序的运行态:是不同的实例化对象一起协作干活的场景

应用程序通过对象间消息传递,对象执行自身的行为进而实现业务需求。编写代码,设计类,撰写类的代码,然而应用程序运行时,却是以相应的类实例化的对象协作完成逻辑,这就是所谓面向对象编程的含义。那么对于对象而言,具有哪些属性呢?

  • 对象是一种抽象,所谓抽象就是类。比如MFC中的Window
  • 代表现实世界的实体
  • 类是定义共享公共属性或属性的数据类型
  • 对象是类的实例存在,类本身在程序的运行态并不存在,以对象存在。
  • 对象具有状态,或者称为属性,是运行时值。比如MFC窗体大小,title等
  • 对象具有行为,亦称为方法或者操作。比如常见MFC中窗体具有show方法
  • 对象具有消息,通过发送消息来请求对象执行其操作之一,消息是对象之间交换数据的方式。

从上面的描述,应用程序本质是很多对象相互协作一起在干活,就好比一个车间,有机器、工人、工具等一起相互在一起产生相互作用,从而完成某项产品的制造。那么,这些对象从哪里来呢?
对象来自于类的实例化,谁负责实例化对象呢?这就是类中构造函数干的活,那么析构函数就是销毁对象的。所以构造函数管生,析构函数管埋

构造管 “生”

构造函数按照类的样式生成对象,也称为实例化对象,那么C++中有哪几种构造函数呢?

image.png
构造函数的相同点:

  • 函数名都与类的名字一样;
  • 构造函数都没有返回类型;
  • 创建对象时会自动调用构造函数;

那为嘛又要整这么几个不同的构造函数呢?举个生活中你或许遇到过的栗子:

  • Case 1: 打比方你去商店对售货员说买个灯泡,没有什么附加信息(比如品牌、功率、发光类型、尺寸等),那么售货员把常见的灯泡给你一个,这就相当于C++语言中创建对象时,按照默认方式创建,故调用默认构造函数。
  • Case 2: 拿到灯之后回家一装,擦,太大了装不上!这回你聪明了,量了下安装尺寸,跑去给售货员说你要XX大小的灯,此时就相当于C++利用参数化构造函数实例化对象。
  • Case 3: 擦,用了很久灯又挂了,这回你更聪明了,把坏灯卸下来带到商店照着买一个,这场景就有点像C++中的拷贝构造函数了~。

那么,到底不同的构造函数有些什么不同呢?为嘛C++语言设计这么多种不同的构造函数呢?

  • 默认构造函数:默认构造函数不带任何参数。如果不指定构造函数,则C ++编译器会为我们生成一个默认构造函数(不带参数,并且具有空主体)。
  • 参数化构造函数:参数传递给构造函数,这些参数用于创建对象时初始化对象。要实现参数化构造函数,只需像向其他函数一样向其添加参数即可。定义构造函数的主体时,使用参数初始化对象的数据成员。
  • 拷贝构造函数:拷贝构造函数,顾名思义,就是按照现有对象一模一样克隆出一个新的对象。其参数一般为现有对象的引用,一般具有如下函数原型:

`//函数名为类名,参数为原对象const引用
ClassName(const ClassName &old_object); 
`

析构管“埋”

析构函数通常用于释放内存,并在销毁对象时对类对象及其类成员进行其他清理操作。当类对象超出生命周期范围或被显式删除时,将为该类对象调用析构函数。

那么析构函数具有哪些特点呢?

  • 销毁对象时,将自动调用析构函数。
  • 不能将其声明为static或const。
  • 析构函数没有参数,也没有返回类型。
  • 具有析构函数的类的对象不能成为联合的成员。
  • 析构函数应在该类的public部中声明。
  • 程序员无法访问析构函数的地址。
  • 一个类有且仅有一个析构函数。
  • 如果没有显式定义析构函数,编译器会自动生成一个默认的析构函数。

既然析构函数是构造函数的反向操作,对于对象管"埋",那么什么时候“埋”呢?

  1. 函数返回退出
  2. 程序被关掉,比如一个应用程序被kill
  3. 包含局部对象的块结尾
  4. 主动调用删除符delete

前面说如果程序猿没有显式定义析构函数,编译器会自动生成一个默认的析构函数。言下之意是有的时候需要显式定义析构函数,那么什么时候需要呢?

当类中动态分配了内存时,或当一个类包含指向在该类中分配的内存的指针时,应该编写一个析构函数以释放该类实例之前的内存。否则会造成内存泄漏。

“生”与“埋”举例

前面说构造管“生”,析构管“埋”,那么到底怎么“生”的呢?怎么“埋”呢?,看看栗子:

`#include <iostream>
using namespace std;
class Rectangle
{
public: 
 Rectangle(); 
 Rectangle(int w, int l);
  Rectangle(const Rectangle &rct) {width = rct.width; length = rct.length; }

 ~Rectangle();
public:
  int width, length;
};
Rectangle::Rectangle()
{
  cout << "默认矩形诞生了!" << endl;
}
Rectangle::Rectangle(int w, int l)
{
 width  = w;
  length = l;
 cout << "指定矩形诞生了!" << endl;
}
Rectangle::~Rectangle()
{
  cout << "矩形埋掉了!" << endl;
}
int main()

  Rectangle rct1;
  Rectangle *pRct = new Rectangle(2,3);
   Rectangle rct2  = rct1;
  
 return 0;
}
`

这个简单的代码,实际运行的输出结果:

`默认矩形诞生了!
指定矩形诞生了!
矩形埋掉了!
矩形埋掉了!
`

技术人总是喜欢眼见为实:因为看见,所以相信!,看看其对应的汇编代码(VC++ 2010汇编结果,这里仅贴出main函数,仅为理解原理,对于汇编指令不做描述,其中#为对汇编注释):

`31: int main()

32: {      

012C1660 55 push ebp
012C1661 8B EC mov ebp,esp
012C1663 6A FF push 0FFFFFFFFh
012C1665 68 76 53 2C 01 push offset __ehhandler$_main (12C5376h)
012C166A 64 A1 00 00 00 00 mov eax,dword ptr fs:[00000000h]
012C1670 50 push eax
012C1671 81 EC 14 01 00 00 sub esp,114h
012C1677 53 push ebx
012C1678 56 push esi
012C1679 57 push edi
012C167A 8D BD E0 FE FF FF lea edi,[ebp-120h]
012C1680 B9 45 00 00 00 mov ecx,45h
012C1685 B8 CC CC CC CC mov eax,0CCCCCCCCh
012C168A F3 AB rep stos dword ptr es:[edi]
012C168C A1 00 90 2C 01 mov eax,dword ptr [___security_cookie (12C9000h)]
012C1691 33 C5 xor eax,ebp
012C1693 50 push eax
012C1694 8D 45 F4 lea eax,[ebp-0Ch]
012C1697 64 A3 00 00 00 00 mov dword ptr fs:[00000000h],eax

33:     Rectangle rct1;  

012C169D 8D 4D E8 lea ecx,[ebp-18h]

调用默认构造函数管“生”

012C16A0 E8 32 FA FF FF call Rectangle::Rectangle (12C10D7h)
012C16A5 C7 45 FC 00 00 00 00 mov dword ptr [ebp-4],0

34:     Rectangle *pRct = new Rectangle(2,3);  

012C16AC 6A 08 push 8
012C16AE E8 41 FB FF FF call operator new (12C11F4h)
012C16B3 83 C4 04 add esp,4
012C16B6 89 85 F4 FE FF FF mov dword ptr [ebp-10Ch],eax
012C16BC C6 45 FC 01 mov byte ptr [ebp-4],1
012C16C0 83 BD F4 FE FF FF 00 cmp dword ptr [ebp-10Ch],0
012C16C7 74 17 je main+80h (12C16E0h)
012C16C9 6A 03 push 3 #传参
012C16CB 6A 02 push 2 #传参
012C16CD 8B 8D F4 FE FF FF mov ecx,dword ptr [ebp-10Ch]

调用参数化构造函数

012C16D3 E8 B8 FA FF FF call Rectangle::Rectangle (12C1190h)
012C16D8 89 85 E0 FE FF FF mov dword ptr [ebp-120h],eax
012C16DE EB 0A jmp main+8Ah (12C16EAh)
012C16E0 C7 85 E0 FE FF FF 00 00 00 00 mov dword ptr [ebp-120h],0
012C16EA 8B 85 E0 FE FF FF mov eax,dword ptr [ebp-120h]
012C16F0 89 85 E8 FE FF FF mov dword ptr [ebp-118h],eax
012C16F6 C6 45 FC 00 mov byte ptr [ebp-4],0
012C16FA 8B 8D E8 FE FF FF mov ecx,dword ptr [ebp-118h]
012C1700 89 4D DC mov dword ptr [ebp-24h],ecx

35:     Rectangle rct2  = rct1;  

012C1703 8D 45 E8 lea eax,[ebp-18h]
012C1706 50 push eax
012C1707 8D 4D CC lea ecx,[ebp-34h]

调用拷贝构造函数

012C170A E8 3C F9 FF FF call Rectangle::Rectangle (12C104Bh)

36:   
37:     return 0;  

012C170F C7 85 00 FF FF FF 00 00 00 00 mov dword ptr [ebp-100h],0
012C1719 8D 4D CC lea ecx,[ebp-34h]

调用析构函数,销毁rct2

012C171C E8 15 FA FF FF call Rectangle::~Rectangle (12C1136h)
012C1721 C7 45 FC FF FF FF FF mov dword ptr [ebp-4],0FFFFFFFFh
012C1728 8D 4D E8 lea ecx,[ebp-18h]

调用析构函数,销毁rct1

012C172B E8 06 FA FF FF call Rectangle::~Rectangle (12C1136h)
012C1730 8B 85 00 FF FF FF mov eax,dword ptr [ebp-100h]

38: }`

这里引发几个问题:
问题1:为什么先析构rct2,后析构rct1呢?

这是由于这两个对象在栈上分配内存,所以基于栈的特性,显然rct2位于C++运行时栈的顶部,而rct1位于栈底。

你如不信,将上述代码修改一下,测测:

`Rectangle::~Rectangle()
{
    cout <<"当前宽为:" << width << "矩形埋掉了!" << endl;
}
int main()

 Rectangle rct1;
  rct1.width = 1;
  Rectangle *pRct = new Rectangle(2,3);
   Rectangle rct2  = rct1;
   rct2.width = 2;
 return 0;
}
`

其输出结果为:

`默认矩形诞生了!
指定矩形诞生了!
当前宽为:2矩形埋掉了!
当前宽为:1矩形埋掉了!
`

问题2:请问上述代码,构造函数被调用了几次?析构函数又被调用了几次?这是经常面试会考察的基础知识。显然前面的打印以及给出了答案。
问题3:该代码有啥隐患?

答:调用了new,而没有调用delete,为啥这是隐患,new属于动态申请内存,是在堆上为对象申请内存,这属于典型的管“生”不管“埋”,造成内存泄漏,如果整的多了,**必然尸体埋 “堆”!**造成程序引发不可预料的崩溃!

所以应该修正一下:

`Rectangle::~Rectangle()
{
    cout <<"当前宽为:" << width << "矩形埋掉了!" << endl;
}
int main()

 Rectangle rct1;
  rct1.width = 1;
 Rectangle *pRct = new Rectangle(2,3);
   Rectangle rct2  = rct1;
   rct2.width = 3;
 delete pRct;
 cout << "手动埋掉!" << endl;
 return 0;
}
`

看看输出结果:

`默认矩形诞生了!
指定矩形诞生了!
当前宽为:2矩形埋掉了!
手动埋掉!
当前宽为:3矩形埋掉了!
当前宽为:1矩形埋掉了!
`

总结一下

  • OOP的基本单元是类,该类将静态属性和动态行为封装在一个黑盒中,并指定使用这些框的公共接口。由于该类具有很好的封装(与功能相比),因此重用这些类会更加容易。换句话说,OOP将同一黑盒中软件实体的数据结构和算法较好结合在一起。
  • OOP语言允许更高级别的抽象来解决现实生活中的问题。传统的过程语言例如C迫使您根据计算机的结构(例如内存位和字节,数组,决策,循环)进行思考,而不是根据您要解决的问题进行思考。OOP语言(例如Java,C ++,C#)使您可以在问题空间中进行思考,并使用软件对象来表示和抽象问题空间的实体以解决问题。
  • 对于C++语言,构造函数与析构函数是基础中的基础,类在运行态并不存在,类以对象形式在运行态实现业务需求。对象如何按照类黑盒样式如何在运行态诞生,利用类的构造函数而诞生,对象生存期结束,析构函数管“埋”,销毁对象。
  • 对于局部对象,非new产生的对象,诞生地为栈,在栈中诞生,编译器会插入析构函数使得程序运行态在对象生命周期结束时自动管“埋”,而如果利用new动态创建的对象,则需要手动管“埋”,如手动管“生”(new),不手动管“埋”(delete),对象必成孤魂野鬼,严重时,对象尸体满“堆”。

对于拷贝构造函数,还有一个所谓深拷贝、浅拷贝的要点没有涉及,下次学习总结分享一下,敬请关注期待~,如发现文中有错误,敬请留言指正,不胜感激~

_END_—

推荐阅读

更多硬核嵌入式技术干货请关注嵌入式客栈专栏。
推荐阅读
关注数
2882
内容数
267
分享一些在嵌入式应用开发方面的浅见,广交朋友
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息