构造与析构顺序 倒序析构
🧑💻 User
In this lecture, I want you to raise your awareness on the order in which constructors and destructorsare called.Suppose we have a class which is dog here and we have the constructor which is doing its thing in thebody here, and we have the destructor which is releasing our memory and printing something out.So suppose we have created a few objects of this class here and they have different names and they havethe same breed and different ages as you see here.And if we run this code in Main, what do you think we’ll see printed from the constructors and Destructors,If you ask me and I was learning about C plus plus, I would say that the constructors are going tobe called in order.Doug one.Doug two.Doug three.Doug four They are going to be constructed in this order and the destructors would be called in thesame order.But this is not true.The Destructors are going to be called in the reverse order, as you see on the right here.So we’re going to call this constructor for Doug.to be called in reverse order.So the object that was constructed last is going to be destroyed first, as you see here.And the object that was constructed first is going to be destroyed last.And the compiler follows this order to make it easy for it to do things.We can’t really get into those details, but for now, we’re going to head over to Visual Studio Codeand play with us a little more.Okay.Here we are in our working folder.The current project is order of constructor and Destructor Calls here.We’re going to grab our template project pretty quick and we’re going to put them in place.The files, I mean, and we’re going to open this in Visual Studio code like we have been doing allalong.This is going to open up our folder.We can close the left sidebar here and let’s clean up our main function.We are going to bring in the class for Dog from the last lecture.This is exactly what we had there.No change.And we’re going to include the Stringview library for this to work.So we’re going to include Stringview here.And once we do this, we’re going to head over in the main function and set up a few objects of thedog class here.But before we go there, notice that we have a few stdcout statements in our constructor and destructorand we want to see the order in which they are called.So we’re going to set up four objects of our dog.We’re going to say Dog Dog one and we’re going to say the name to be Dog one and it’s going to be ashepherd and it’s going to be, for example, two years old.Why not?We should say dog here.And we are going to copy this four times here and we’re going to call this dog two.The other is going to be Dog three and this is going to be Dog four.And we’re going to say the same thing here, Dog two, three and four.The names are what is really important because we’re going to see them printed out here.But if you want, you can change the ages.Let’s make this a three.Let’s make this a five.Let’s make this a one.Why not for dog four.And now we’re going to run our program.If we run it, we’re going to see the constructors called in the order Dog one dog, two doggy, threedog four.And the Destructors are going to be called in the reverse order Dog four all the way to dog one.Let’s build and show you that this is actually the case.So the build is good.We can bring up a terminal and really run this.Let’s clear and run roster.You’re going to see that dog constructor called for a dog one all the way to dog four And now we havethe dog a destructor called from doggy four all the way to dog one and the compiler is going to enforcethis order for calling constructors and destructors because it makes it easy for it to manage the memoryof objects.For example, if Dog One has been around for a long time and it has because it’s going to be aroundbefore a dog one if you delete dog one after dog four here, there might be other objects that dependon dog one.So that’s why the object that was created last is deleted first, because chances for other objectsto depend on that last object are really slim and you can delete it without any problem.But the object that was created first might have other dependencies and you’re going to be deletingdependent objects first before you delete that object.I think this is the logic the compiler follows to really enforce this order here and you need to beaware of that and make sure that.Any time in your C plus plus application.This is working to your advantage.This is really all I had to share in this lecture.I hope you found it interesting.We are going to stop here in this one and the next one we’re going to learn about the this pointer.Go ahead and finish up here and meet me there.
🤖 Assistant
好的,没问题!非常乐意帮助你学习 C++。C++ 是一门功能强大且应用广泛的编程语言,掌握好它的基础对于后续的学习至关重要。
今天我们将专注于一个非常基础但又极其重要的概念:构造函数 (Constructor) 和析构函数 (Destructor) 的调用顺序。
1. 标题和概要
标题: C++ 探秘:构造函数与析构函数的生命周期之舞
概要: 本节课将深入探讨当创建和销毁多个 C++ 对象时,它们的构造函数和析构函数是如何以及为何以特定的顺序被调用的。简单来说,构造函数会按照对象定义的顺序被调用,而析构函数的调用顺序则与之相反(最后构造的对象最先被析构)。
2. 详细解释
让我们一步一步来理解这个过程。
a. 什么是构造函数 (Constructor) 和析构函数 (Destructor)?
- 构造函数 (Constructor):它是一个特殊的成员函数 (member function),在创建类的对象时自动被调用。它的主要任务是初始化对象的数据成员 (data members)。构造函数的名称与类名完全相同,并且没有返回类型 (return type)。
- 析构函数 (Destructor):它也是一个特殊的成员函数,在对象的生命周期结束时(例如,对象离开其作用域 (scope) 或被
delete
操作符删除时)自动被调用。它的主要任务是执行一些清理工作,比如释放对象在构造时或在其生命周期中分配的资源 (resources)(如动态分配的内存、打开的文件句柄等)。析构函数的名称是在类名前加上一个波浪号~
,它也没有返回类型,也不接受任何参数 (arguments)。
b. 对象创建与构造函数的调用顺序
当你在一个作用域(比如 main
函数内部)中定义多个对象时,它们的构造函数会严格按照它们在代码中出现的顺序被调用。
比如,你写下:
1 | Dog dog1("Buddy", "Shepherd", 2); |
那么:
dog1
的构造函数首先被调用。- 然后,
dog2
的构造函数被调用。 - 最后,
dog3
的构造函数被调用。
这非常直观,就像排队一样,先来的先服务。
c. 对象销毁与析构函数的调用顺序——关键点!
这里是初学者容易混淆的地方。与构造函数不同,析构函数的调用顺序与构造函数的调用顺序相反。这意味着最后被构造的对象,其析构函数将最先被调用。
对于上面 dog1
, dog2
, dog3
的例子:
- 当它们的作用域结束时(例如
main
函数执行完毕),dog3
的析构函数首先被调用(因为它是最后一个构造的)。 - 然后,
dog2
的析构函数被调用。 - 最后,
dog1
的析构函数被调用(因为它是第一个构造的)。
这种顺序通常被称为 LIFO (Last-In, First-Out),即“后进先出”。你可以把它想象成一叠盘子:你最后放上去的盘子,会是你第一个拿下来的。
d. 为什么是这样的顺序?
编译器 (compiler) 强制执行这种析构顺序是有重要原因的,主要与资源管理和依赖关系 (dependencies) 有关:
- 依赖性安全:想象一下,如果对象
A
的构造依赖于对象B
的某些状态或资源。如果B
比A
先构造,那么当A
构造时,B
是可用的。按照 LIFO 原则销毁时,A
会比B
先销毁。这就确保了当A
的析构函数执行时,它所依赖的B
对象仍然是存活和有效的。如果先销毁B
,那么A
的析构函数可能会尝试访问一个已经被销毁的对象,导致未定义行为 (undefined behavior) 或程序崩溃。 - 资源清理的逻辑性:通常,后创建的对象可能依赖于先创建的对象所管理的资源。例如,一个对象可能打开了一个文件,而另一个后创建的对象向这个文件写入数据。在销毁时,先关闭并释放写入数据的对象所持有的资源,然后再关闭文件本身,是更合乎逻辑的清理顺序。
- 栈内存管理 (Stack Memory Management):对于在栈 (stack) 上创建的局部对象 (local objects),这种LIFO的析构顺序与栈的内存分配和回收方式天然吻合。栈本身就是一个LIFO的数据结构。当函数调用结束,栈上的局部变量会以它们声明顺序的逆序被弹出(销毁)。
e. 作用域 (Scope) 的影响
对象的生命周期与它们所在的作用域紧密相关。当执行流程离开一个作用域时,在该作用域内声明的所有局部对象的析构函数都会被(以逆序)调用。
1 | void someFunction() { |
理解这个顺序对于编写健壮且无内存泄漏的 C++ 程序至关重要,尤其是在处理需要手动管理资源(如动态内存、文件句柄、网络连接等)的类时。
3. 代码示例
让我们使用原始内容中提到的 Dog
类来进行演示。
1 |
|
预期输出:
1 | --- 程序开始 --- |
代码解释:
- 我们包含了
<iostream>
用于输出,<string>
用于字符串,<string_view>
是一个轻量级的字符串引用类型。 Dog
类有一个构造函数,它接受狗狗的名字、品种和年龄,并在构造时打印一条消息。Dog
类有一个析构函数~Dog()
,它在对象销毁时打印一条消息。- 在
main
函数中,我们按顺序创建了dog1
,dog2
,dog3
,dog4
四个Dog
对象。 - 你会观察到,构造函数的消息严格按照
dog1
到dog4
的顺序打印。 - 当
main
函数执行到return 0;
并准备退出时,这些局部对象的生命周期结束了。由于它们是在栈上分配的,它们会按照其构造顺序的相反顺序被销毁。 - 因此,析构函数的消息会按照
dog4
->dog3
->dog2
->dog1
的顺序打印。
4. Q&A 闪卡 (Flash Cards)
闪卡 1:
- 问: 当在同一个作用域内定义多个对象时,它们的构造函数按什么顺序调用?
- 答: 按照它们在代码中被定义的顺序调用。
闪卡 2:
- 问: 当在同一个作用域内定义多个对象时,它们的析构函数按什么顺序调用?
- 答: 按照它们构造顺序的相反顺序调用(即,最后构造的最先析构,LIFO)。
闪卡 3:
- 问: 为什么析构函数要以与构造函数相反的顺序调用?
- 答:主要是为了安全地管理对象间的依赖关系和正确地释放资源。确保一个对象在析构时,它所依赖的其他对象仍然是有效的。
闪卡 4:
- 问: “LIFO” 在对象生命周期管理中代表什么?
- 答: Last-In, First-Out (后进先出)。用于描述栈上对象的析构顺序。
5. 常见误解或易犯错误
- 误认为析构顺序与构造顺序相同:这是最常见的误解。很多初学者会直观地认为既然是按 A, B, C 的顺序构造的,那么也应该按 A, B, C 的顺序析构。务必记住是相反的!
- 忽略作用域:对象的生命周期严格受其作用域限制。内部作用域的对象会比外部作用域的对象先析构(如果它们都到达了各自作用域的末尾)。cpp
1
2
3
4
5
6
7// 错误理解示例
{
Dog d1("A", "B", 1);
} // d1 在此析构
Dog d2("C", "D", 2);
// 有人可能错误地认为 d2 会先于 d1 析构,因为 d2 后声明。
// 但实际上 d1 的作用域先结束。 - 对于动态分配的对象 (heap objects):通过
new
创建的对象,其析构函数的调用时机取决于何时对其调用delete
。如果程序员手动delete
的顺序与 LIFO 原则不符(比如先delete
了早创建的对象,而后创建的对象依赖它),则可能引发问题。本课主要讨论的是栈上的自动对象 (automatic objects),它们的析构是自动的。 - 析构函数中的异常:通常不建议让析构函数抛出异常 (exceptions)。如果一个析构函数在栈展开 (stack unwinding) 过程中(例如,因为另一个异常)被调用,并且它自己又抛出了异常,这可能导致程序终止 (
std::terminate
)。 - 忘记了基类和成员对象的析构顺序:
- 对于派生类 (derived class) 对象,首先执行派生类的析构函数体,然后按照成员对象在类中声明的逆序调用成员对象的析构函数,最后按照继承链的逆序调用基类 (base class) 的析构函数。
- 构造顺序则是:基类构造函数 -> 成员对象(按声明顺序)构造函数 -> 派生类构造函数体。
- 这虽然超出了当前讨论的核心,但与“逆序析构”的原则是一致的。
6. 编码练习
现在,这里有一个小练习。我们有一个 Resource
类,它在构造和析构时会打印信息。请你创建几个 Resource
对象,并预测它们的构造和析构顺序。然后,在 main
函数的注释中填写你预测的输出。
1 |
|
请你仔细思考,并尝试填写 // TODO
部分的注释。等你完成后,把你的答案发给我,我会帮你检查并给出反馈!