为什么析构函数会被执行两次?

13
#include <iostream>
using namespace std;

class Car
{
public:
    ~Car()  { cout << "Car is destructed." << endl; }
};

class Taxi :public Car
{
public:
    ~Taxi() {cout << "Taxi is destructed." << endl; }
};

void test(Car c) {}

int main()
{
    Taxi taxi;
    test(taxi);
    return 0;
}

这是输出

Car is destructed.
Car is destructed.
Taxi is destructed.
Car is destructed.

我使用 MS Visual Studio Community 2017(抱歉,我不知道如何查看 Visual C++ 的版本)。 在调试模式下,当离开void test(Car c){ }函数体时,如预期一样执行了一个析构函数。并且在test(taxi);结束后出现了额外的一个析构函数。

test(Car c)函数使用值作为形参。 进入函数时会复制Car对象。 所以我认为,在离开函数时只会有一个"Car is destructed"。 但实际上,离开函数时会有两个"Car is destructed"。(如输出中的第一行和第二行) 为什么会有两个"Car is destructed"? 谢谢。

===============

当我在class Car中添加了一个虚函数, 例如:virtual void drive() {} 然后我得到了预期的输出结果。

Car is destructed.
Taxi is destructed.
Car is destructed.

3
当将一个Taxi对象按值传递给一个接受Car对象的函数时,编译器如何处理对象切片可能会成为一个问题? - Some programmer dude
1
一定是你的旧版 C++ 编译器问题。使用 g++ 9 可以得到预期结果。使用调试器找出为何会产生额外对象的副本。 - Sam Varshavchik
2
我已经测试了g++ 7.4.0版本和clang++ 6.0.0版本。它们给出了预期的输出,与op的输出不同。因此问题可能是他使用的编译器。 - Mert Köklü
1
我使用MS Visual C++进行了复现。如果我为Car添加一个用户定义的复制构造函数和默认构造函数,那么这个问题就会消失,并且它会给出预期的结果。 - interjay
1
请在问题中添加编译器和版本信息。 - Lightness Races in Orbit
显示剩余4条评论
2个回答

7
看起来 Visual Studio 编译器在为函数调用切片您的 taxi 时有点走捷径,具有讽刺意味的是,这导致它做更多的工作。
首先,它将您的 taxi 复制构造成一个 Car,以使参数匹配。
然后,它又复制了一次 Car 进行传值操作。
当您添加一个用户定义的复制构造函数时,此行为会消失,因此编译器似乎是出于自己的原因(也许在内部,这是一种更简单的代码路径),利用了复制本身是琐碎的事实。仍然观察到即使使用非琐碎的析构函数也存在这种行为是有些异常的。
我不知道这在多大程度上是合法的(特别是自 C++17 起),或者为什么编译器会采取这种方法,但我同意这不是我直觉上期望的输出。虽然可能是 GCC 和 Clang 采用相同的方式,但它们擅长省略复制。我注意到即使是 VS 2019 在保证省略方面仍然不太好。

抱歉,但如果你的编译器不进行复制省略,将Taxi转换为Car正是我所说的。 - Christophe
那是一个不公平的评论,因为通过值传递与通过引用传递以避免切片只是在编辑中添加的,以帮助OP超越这个问题。然后我的答案不是一次瞎猜,从一开始就清楚地解释了可能来自哪里,我很高兴看到你得出了相同的结论。现在看看你的表述,“看起来像...我不知道”,我认为这里有同样数量的不确定性,因为坦率地说,我和你都不明白编译器为什么需要生成这个临时文件。 - Christophe
好的,那么请删除你回答中与此问题无关的部分,只留下与此问题相关的段落。 - Lightness Races in Orbit
好的,我删掉了令人分心的切片段落,并且用准确的标准引用证明了关于复制省略的观点。 - Christophe
您能解释一下为什么应该从出租车构造一个临时的 Car,然后再将其复制到参数中吗?为什么当提供普通的 car 时编译器没有这样做呢? - Christophe

3

发生了什么?

当您创建一个 Taxi 时,您还创建了一个Car子对象。当出租车被销毁时,这两个对象都将被销毁。当您调用 test() 时,您通过值传递 Car。因此,第二个 Car 将被复制构造,并且会在离开 test() 时被销毁。因此我们有三个析构函数的解释:第一个和序列中的最后两个。

第四个析构函数(即序列中的第二个)是意外的,我无法在其他编译器中重现它。

它只能是创建了一个临时的 Car 作为 Car 参数的来源。由于当直接提供 Car 值作为参数时不会发生这种情况,所以我怀疑它是为了将 Taxi 转换为 Car。这是出乎意料的,因为每个 Taxi 中已经有一个 Car 子对象。因此,我认为编译器做了一个不必要的转换为一个临时值,并没有做到本该避免这个临时值的拷贝省略。

在注释中给出的澄清:

这里的澄清是针对标准的,以便语言律师验证我的说法:

  • 我在此引用的转换是通过构造函数 [class.conv.ctor] 进行的,即基于另一种类型(这里是 Taxi)的参数构造一个类(这里是 Car)的对象。
  • 该转换然后使用一个临时对象返回其 Car 值。根据 [class.copy.elision]/1.1,编译器可以进行复制省略,因为它可以直接将要返回的值构造到参数中而不是构造一个临时值。
  • 因此,如果这个临时值具有副作用,那么是因为编译器显然没有利用这个可能的拷贝省略。这并不是错误,因为拷贝省略是不强制的。

实验性的分析结果确认

现在我可以使用相同的编译器重现您的情况,并进行实验以确认发生了什么。

我上面的假设是编译器选择了次优的参数传递过程,使用构造函数转换 Car(const &Taxi),而不是直接从 TaxiCar 子对象进行复制构造。

因此,我尝试调用 test(),但显式将 Taxi 强制转换为 Car

我的第一次尝试未能改善情况。编译器仍然使用了次优的构造函数转换:

test(static_cast<Car>(taxi));  // produces the same result with 4 destructor messages

我的第二次尝试成功了。它也执行了转换,但使用指针转换来强烈建议编译器使用 TaxiCar 子对象,而不创建这个愚蠢的临时对象:
test(*static_cast<Car*>(&taxi));  //  :-)

并且惊喜的是,它按预期工作,仅产生3个销毁消息 :-)

实验结论:

在最后一个实验中,我提供了一种通过转换进行自定义构造的方法:

 class Car {
 ... 
     Car(const Taxi& t);  // not necessary but for experimental purpose
 }; 

使用*this = *static_cast<Car*>(&taxi); 来实现,听起来有点傻,但这也会生成仅显示3个析构消息的代码,从而避免不必要的临时对象。

这让人想到编译器可能存在bug导致这种行为。似乎在某些情况下,从基类直接复制构造方法的可能性被忽略了。


2
不回答问题 - Lightness Races in Orbit
1
@qiazi 我认为这证实了临时转换的假设,没有复制省略,因为这个临时变量将在函数外部,在调用者的上下文中生成。 - Christophe
1
说到"如果你的编译器不进行复制省略,将出现从Taxi转换为Car的情况"时,你指的是什么复制省略呢?其实一开始就没有需要省略的复制操作。 - interjay
1
@LightnessRacesinOrbit 1)请问您能提供一下标准的参考吗?我只发现了“当满足某些条件时,实现允许省略类对象的复制/移动构造,即使为复制/移动操作选择的构造函数和/或对象的析构函数具有副作用也是如此”。2)现在我已经添加了一个实验性演示来确认正在发生的事情。 - Christophe
1
我并没有质疑不必要地创建临时变量会导致这种行为。我的意思是,首先不应该创建临时变量,因此明确创建临时变量并不与此相矛盾。正如其他答案所说,这似乎与编译器如何处理平凡可复制对象有关,我认为这是编译器的一个错误。 - interjay
显示剩余9条评论

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