为什么自动对象的析构函数会被调用两次?

3
我的问题答案涉及到复制构造函数,但复制发生在函数返回时,而不是在对另一个类的方法调用中。我确实看到了参考可能会重复的内容,但没有推断出由vector :: push_back进行的复制也适用于我的函数。也许我应该使用。 我试图理解自动对象的构建/销毁。我遇到了一些看起来可疑的代码,因此我编写了自己的版本以便理解它。简而言之,原始代码包括一个返回函数的对象,该对象是函数局部的(自动)。这看起来不安全,所以我编写了这个程序来探究它:
#include <stdio.h>

class Phantom
{
private:
    static int counter;
    int id;

public:
    Phantom()
    {
        ++counter;
        id = counter;
        printf("Phantom %d constructed.\n", id);
    };

    virtual ~Phantom()
    {
        printf("Phantom %d destructed.\n", id);
    };

    void speak()
    {
        printf("Phantom %d speaks.\n", id);
    };
};

int Phantom::counter = 0;

Phantom getPhantom()
{
    Phantom autoPhantom;

    return autoPhantom; // THIS CAN'T BE SAFE
}

int main()
{
    Phantom phantom;

    phantom = getPhantom();

    phantom.speak();

    return 0;
}

我得到了这个输出:
幽灵1建造完毕。 幽灵2建造完毕。 幽灵2被销毁。 幽灵2被销毁。 幽灵2说话。
让我困惑的是输出中的第四行。
当进入 main 时,幽灵 1 会自动构造。
当进入 getPhantom 时,幽灵 2 会自动构造。
当退出 getPhantom 时,幽灵 2 会自动销毁(这就是为什么我认为从 getPhantom 返回它是不安全的原因)。
但在此之后,我感到困惑。根据调试器,在输出的第四行出现之前,getPhantom 已经返回。 当第二次调用 Phantom 的析构函数时,调用堆栈如下:
main ~Phantom
在一个托管语言中,我能理解:
phantom = getPhantom();

虽然这段代码会摧毁Phantom 1,但不会影响Phantom 2。而且这是C++,不是Java。

是什么导致了对Phantom 2析构函数的第二次调用?


7
每当你想要计算构造函数/析构函数的调用次数时,你需要记得同时打印出拷贝构造函数的调用。 - NathanOliver
3
当然,通过值返回对象是安全的。否则,语言将本质上是有缺陷的。 - Jonathan Wakely
2
应该有一个关于如何正确计算构造函数和析构函数的常见问题解答,因为这个问题经常出现。 - Jonathan Wakely
3
遵守“三板斧法则”(Rule of Three)是指在C++编程中,当一个类需要显式定义复制构造函数、赋值运算符或析构函数时,必须同时实现这三个函数,以避免内存泄漏和悬空指针等错误。 - n. m.
3
@StevensMiller 是的。有一些东西,比如复制省略和返回值优化,但返回某个东西意味着将该东西复制到函数返回空间中。 - NathanOliver
显示剩余7条评论
6个回答

8
你返回的是一个副本。因此,在作用域的末尾,getPhantom() 中的变量被销毁,你留下它的副本,其ID也为2。这是因为返回时调用了复制构造函数(也是默认构造函数),它不会增加ID。

Sam Varshavchik的回答也非常有帮助,所有指向复制构造函数的评论也同样如此。像Nathan和其他回答问题而不侮辱提问者的人一样,你们是伟大的同事。谢谢! - Stevens Miller

5
您忘记正确处理以下内容: 1. 复制构造函数。 2. 赋值运算符。
在这两种情况下,您将得到多个具有相同ID的对象,这些对象最终都会在其析构函数中打印相同的ID。在复制构造函数的情况下,由于您没有定义自己的复制构造函数,因此构造函数中不会打印任何消息。在赋值运算符的情况下,构造函数中分配的ID会被来自另一个对象的重复ID覆盖。这就是在此处发生的情况:
phantom = getPhantom();

因此,您的会计处理存在错误。

1
赋值运算符实际上不需要打印任何内容,因为被赋值的对象已经构造完成了。 - NathanOliver
1
@NathanOliver:赋值运算符不需要打印任何内容,但是它确实需要防止对象的“id”被覆盖。 - Martin Bonner supports Monica

3

我将评论您的担忧,即返回具有自动存储的对象不安全:

Phantom getPhantom()
{
    Phantom autoPhantom;

    return autoPhantom; // THIS CAN'T BE SAFE
}

如果这样不安全,那么C++就没什么用了,你觉得呢?为了理解我在说什么,尝试将类型替换成...比如int:
int getPhantom()
{
    int autoPhantom = 0;

    return autoPhantom; // How does this look to you now?
}

明确一点:这是完全安全的,因为你返回的是值(即对象的副本)。

不安全的是返回指向该对象的指针或引用:

int* getInt()
{
   int a = 0;
   return &a;
}

是的,你说得很对,我现在明白了。我来自一个漫长的C编程背景,对C++还很陌生。我把返回对象和返回本地指针混淆了。感谢你帮助我澄清了这个想法。 - Stevens Miller
1
@StevensMiller在这方面C语言并没有任何不同。 - underscore_d
没错,但将指向本地变量的指针返回是一个常见的错误,而且不知怎么的,这正是我看到的情况。 - Stevens Miller
@StevensMiller,正如你在问题中所写的,“这是C++,不是Java。” 返回一个对象意味着按值返回,而不是按引用返回。 - Jonathan Wakely
1
啊,没错。预标准化的C语言是完全不同的故事。但是C89标准化已经有将近30年的历史了。 - Martin Bonner supports Monica
显示剩余3条评论

2

不要质疑这段简单的代码是否会导致摧毁一个从未构造过的对象,或者重复地摧毁某些东西,考虑更有可能的情况是对象已经被构造,每个对象只被销毁一次,但是你没有准确地跟踪构造和销毁。

现在考虑C++中其他方式构造对象的方式,并考虑如果在任何时候使用了复制构造函数会发生什么。然后考虑如何从函数中返回本地对象,以及是否使用了复制构造函数。

如果您想改进测试代码,请在析构函数中打印出this指针的值,您将看到为每个对象分配ID的尝试是有缺陷的。您有多个带有不同标识符(即在内存中的地址)但相同"ID"的对象。


4
“// THIS CAN'T BE SAFE” 和 “which is why I believe returning it from getPhantom is unsafe” 是一个相当大的假设。我认为将其从getPhantom返回是不安全的。 - Jonathan Wakely
@JonathanWakely:“在你开始假设编译器不理解C++之前”-- 你似乎在暗示C++编译器总是会告诉你是否正在进行不安全的操作。 - Benjamin Lindley
@BenjaminLindley,这从我所说的话中得出如此巨大的概括是不太恰当的。从函数返回一个对象并不是语言中黑暗和危险的角落。 - Jonathan Wakely
@JonathanWakely:这并不比你所做的将语句“THIS CAN'T BE SAFE”等同于“编译器不理解C++”这一假设更加概括。 - Benjamin Lindley
@BenjaminLindley 这个问题的标题是“为什么自动对象的析构函数会被调用两次?”这就是我所指的,但我会编辑答案。 - Jonathan Wakely
显示剩余3条评论

2

幻影自动幻影;

返回autoPhantom; // 这不安全

这是完全安全的。该函数通过值返回对象,也就是说会创建并返回一个副本(可能被“返回值优化”(RVO)省略掉)。

如果该函数返回了对局部变量的引用或指针,那么您就是正确的,这将是不安全的。

“额外”的析构函数调用的原因很简单,就是局部变量被销毁,稍后被返回的副本也被销毁。


3
返回本地变量的const引用不会延长其生命周期。 - TartanLlama
1
@underscore_d 不可以通过返回对它们的引用(lvalue或rvalue)来延长自动变量的生命周期。 - TartanLlama
1
你说得对,只有在他返回一个临时对象时才能延长生命周期。这很容易让人感到困惑,也容易出错... - Jesper Juhl
1
不,返回的临时对象也不能通过引用返回来延长它们的生命周期。[class.temporary]/5.2:在函数返回语句中绑定到返回值的临时对象的生命周期不会被延长;该临时对象将在返回语句中的完整表达式结束时被销毁。 - TartanLlama
1
我认为你们可能会混淆返回的临时对象和像这样绑定的临时对象 const Foo& f = make_foo();,后者是有效的。 - TartanLlama
显示剩余4条评论

1
在你的类中添加这样的代码:
Phantom& operator=(const Phantom& inPhantom)
{
    printf("Assigning.\n");
}

您会发现第二个对象没有被销毁两次。原因很简单,赋值操作会将第一个对象的所有字段值更改为第二个对象的值,但它并不会被销毁。因此它仍然是第一个对象。 更新后的示例:http://cpp.sh/6b4lo


这非常有启发性。非常感谢您抽出时间对我的代码进行编辑。这让我真正清楚了我的“id”并不是我所想的那样告诉我它是什么。 - Stevens Miller

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接