引言

对象在什么时候被创建,又在什么时候被销毁,这个问题在学习变量作用域时似乎很好回答,但一旦引入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,如有不妥之处欢迎指正。