问题描述
这个程序:
#include <iostream>
using namespace std;
struct B {
B() { cout << "B"; }
//B(const B& b) { cout << "copyB"; }
~B() { cout << "~B"; }
};
struct C : B {
};
void f(B b) {
}
int main() {
C c;
f(c);
return 0;
}
仅在 MSVC 中。 Clang 和 GCC 输出 B~B~B
(这很可能是正确的)。
有趣的是:如果你取消对 copy-ctor 的注释,它会输出 BcopyB~B~B
,这是正确的(析构函数调用了两次)。
这是 MSVC 编译器中的错误吗?或者这是正确的行为?
(Visual Studio 2019 最新版,cl.exe
版本 19.28.29337)
解决方法
如果您打印地址:
#include <stdio.h>
struct B {
B() { printf(" B() <%p>\n",(void*)this); }
~B() { printf("~B() <%p>\n",(void*)this); }
};
struct C : B { };
void f(B b) { }
int main() {
C c;
f(c);
}
输出为:
B() <000000A013FFFAC4>
~B() <000000A013FFFAA0>
~B() <000000A013FFFBA4>
~B() <000000A013FFFAC4>
如您所见,没有对象被破坏 2 次。似乎有一个临时的参与,据我所知,这是不允许的。这让我相信这是一个错误。这仅在复制构造函数微不足道时才会发生。由于析构函数不是微不足道的,因此它是可观察的行为,不受 as-if 规则的约束。
,查看禁用优化的 Godbolt 编译器资源管理器 compilation result,查看此 main
函数:
sub rsp,56 ; 00000038H
lea rcx,QWORD PTR c$[rsp]
call C::C(void)
npad 1
movzx eax,BYTE PTR $T2[rsp]
mov BYTE PTR $T1[rsp],al
movzx ecx,BYTE PTR $T1[rsp]
call void f(B) ; f
npad 1
lea rcx,QWORD PTR $T2[rsp]
call B::~B(void) ; B::~B
npad 1
lea rcx,QWORD PTR c$[rsp]
call C::~C(void)
xor eax,eax
add rsp,56 ; 00000038H
ret 0
所以 c$[rsp]
是堆栈上的变量 c
。此处将其切片为 B 类型的临时 $T2[rsp]
,然后复制到临时 $T1[rsp]
,然后将 $T1[rsp]
传递给 f(B)
并在那里销毁,以及 $T2[rsp]
在本地销毁。
同样值得注意的是,B 的复制是通过 al
寄存器进行的,但是将 C 切片到 B 是无操作的。看起来两者都适合没有成员的班级;我们添加成员,我们将看到将 C 切片到 b 和复制 B。
我不确定标准是否允许或禁止为转换(切片)和按值传递制作单独的副本,但这似乎是这里发生的事情。
,结论是MSVC的一个bug。虽然标准规定在将参数传递给函数时可以创建一个临时对象,但是,应该遵守许多限制。相关规则如下:
[class.temporary#3]
当类类型 X 的对象被传递给函数或从函数返回时,如果 X 至少有一个符合条件的复制或移动构造函数([特殊]),每个这样的构造函数都是平凡的,并且X 的析构函数不是平凡的就是被删除的,允许实现创建一个临时对象来保存函数参数或结果对象。临时对象分别由函数参数或返回值构造,并且函数的参数或返回对象被初始化,就像使用符合条件的普通构造函数复制临时对象一样(即使该构造函数不可访问或者不会被重载决议选中来执行对象的复制或移动)。
在您的示例中,传递的参数属于类 C
,它派生自基类 B
,该基类具有非用户提供的复制构造函数和用户提供的析构函数,它将结果派生类 C
可以有一个普通的复制构造函数,但不能有一个普通的析构函数,如 [class.copy.ctor#11.2] 和 [class.dtor#8.2],它们是以下规则:
类 X 的复制/移动构造函数是微不足道的,如果它不是用户提供的,并且如果:
- 选择用于复制/移动每个直接基类子对象的构造函数很简单
如果析构函数不是用户提供的并且如果:
- 其类的所有直接基类都有简单的析构函数
类 C
不会满足所有这些限制,因此在这种情况下无法创建临时对象。这意味着应该使用复制构造函数从参数初始化参数。这两个具有自动存储期限的对象将分别在块退出时销毁。也就是说,此处只应打印两次 ~B
。