C++中对象生命周期的一些理解
引言
对象在什么时候被创建,又在什么时候被销毁,这个问题在学习变量作用域时似乎很好回答,但一旦引入new、拷贝构造和函数返回,事情就变得没那么直观了。
于是我写了一个带"观测仪器"的类,让构造、拷贝、析构都在发生时自己说话,通过输出的顺序来观察对象的一生。
在此篇文章中,我将记录下自己对对象生命周期的理解,以供思考,如有不足之处欢迎指正。
正文
会说话的类
实验的核心是一个在构造、拷贝、析构时都打印信息的Test类:
class Test {
private:
std::string m_name{};
unsigned int m_id{};
public:
Test() : Test{""} {}//委托构造,交给带参版本
//explicit确保显式构造,不允许隐式类型转换
explicit Test(const std::string &name, const unsigned int &id = 0)
: m_name{name}, m_id{id} { cout << m_name << " has been created" << endl; }
Test(const Test &test)
: m_name{test.m_name}, m_id{test.m_id} { cout << m_name << " has been copied" << endl; }
~Test() { cout << m_name << " has been deleted" << endl; }
};
这么看来,只要在main里按不同方式创建对象,观察打印顺序,生命周期就一目了然了。
栈与堆上的对象
先看两种最基本的创建方式:
Test *test1 = new Test{"second"};//堆上创建,不会自动销毁
Test test3{"third", 3}; //栈上创建,作用域结束时自动销毁
delete test1;//必须手动delete,否则泄漏
堆上的对象在new时构造,在delete时析构,完全由我们掌控。
栈上的对象则由编译器管理,main结束出作用域时按构造的逆序依次析构。
类内成员
m_name{}、m_id{}用了类内初始化,配合委托构造Test() : Test{""} {},默认构造的对象也有确定的初始状态。
拷贝发生的时机
拷贝构造的存在,让"创建"这个动作多了一种来源:
Test test2{*test1};//用已有对象拷贝构造,打印copied
Test test4{add(*test1, test2)};//函数返回值继续拷贝构造test4
而add函数内部还有好几次创建与拷贝:
static Test add(const Test &test1, const Test &test2)
{
std::string name{test1.getName() + test2.getName()};
unsigned int id{test1.getId() + test2.getId()};
Test test{name, id}; //局部对象,函数结束时销毁
Test testtest{test}; //一次多余的拷贝,仅作观察用
return test; //按值返回,发生拷贝(或被优化)
}//test和testtest在这里析构
从输出能明显看出,add内的局部对象在函数返回时析构,而返回出去的那份则由调用方接手,继续活到main结束。
另外形参用的是const Test &引用传参,所以传参本身不产生拷贝。
现代编译器大多会做返回值优化(RVO),
return test可能连拷贝都省掉,但从语义上讲按值返回就是一次拷贝构造,这个概念还是应该建立起来。
析构顺序
把输出串起来看,main里创建的顺序是test1、test2、test3、test4,而析构时test4最先消失,test3次之,最后轮到test2。
栈上对象的析构顺序与构造顺序严格相反,像一层层退栈。
而test1在中间就被我们手动delete了,它的析构时机完全由代码决定,这也是堆对象需要小心的地方。
小结
对象的生命周期由它的创建方式决定:栈对象跟着作用域走,构造逆序析构,堆对象跟着new和delete走,拷贝则在传值、返回和显式拷贝构造的地方发生。让类在关键节点打印信息,是观察这些机制最直接的办法。
生命周期看似是入门话题,但把new、拷贝和返回串起来看一遍之后,才算是真正在脑子里有了画面。
本文所用的测试代码收录于Learncpp,如有不妥之处欢迎指正。
$ 正在加载评论…