使用clang或gcc(在macOS上)编译时,以下代码似乎运行良好,但使用MS Visual C++ 2017编译时,以下代码崩溃。 foo_clone->get_identifier()
。
如果删除协变返回类型(所有克隆方法都返回IDO*
),删除std::enable_shared_from_this
或将所有继承设为虚拟,则它在VC++上确实有效。
为什么它可以与clang/gcc一起使用,但不适用于VC++?
#include <memory>
#include <iostream>
class IDO {
public:
virtual ~IDO() = default;
virtual const char* get_identifier() const = 0;
virtual IDO* clone() const = 0;
};
class DO
: public virtual IDO
, public std::enable_shared_from_this<DO>
{
public:
const char* get_identifier() const override { return "ok"; }
};
class D : public virtual IDO, public DO {
D* clone() const override {
return nullptr;
}
};
class IA : public virtual IDO {};
class Foo : public IA, public D {
public:
Foo* clone() const override {
return new Foo();
}
};
int main(int argc, char* argv[]) {
Foo* foo = new Foo();
Foo* foo_clone = foo->clone();
foo_clone->get_identifier();
}
信息:
在foo.exe中的0x00007FF60940180B处引发异常:0xC0000005:访问冲突读取位置0x0000000000000004。
最佳答案
这似乎是VC++的错误编译。当enable_shared_from_this
不存在时,它消失了。问题只是被掩盖了。
一些背景:解决C++中的重写函数通常是通过vtables进行的。但是,在存在多个虚拟继承和协变量返回类型的情况下,必须解决一些挑战以及满足这些挑战的不同方式。
考虑:
Foo* foo = new Foo();
IDO* ido = foo;
D* d = foo;
foo->clone(); // must call Foo::clone() and return a Foo*
ido->clone(); // must call Foo::clone() and return an IDO*
d->clone(); // must call Foo::clone() and return a D*
请记住,无论如何,
Foo::clone()
返回Foo*
,并且从Foo*
转换为IDO*
或D*
并非简单的禁止操作。在完整的Foo
对象中,IDO
子对象位于偏移量32(假设MSVC++和64位编译),而D
子对象位于偏移量8。要从Foo*
转换为D*
,意味着将指针加8,并得到一个IDO*
实际上意味着从Foo*
子对象所在的确切位置的IDO
加载信息。但是,让我们看一下为所有这些类生成的vtable。
IDO
的vtable具有以下布局:0: destructor
1: const char* get_identifier() const
2: IDO* clone() const
D
的vtable具有以下布局:0: destructor
1: const char* get_identifier() const
2: IDO* clone() const
3: D* clone() const
插槽2在那里,因为基类
IDO
具有此功能。插槽3在那里,因为该功能也存在。我们可以省略此插槽,而是在callsite处生成其他代码以从IDO*
转换为D*
吗?也许可以,但是那样会降低效率。Foo
的vtable如下所示:0: destructor
1: const char* get_identifier() const
2: IDO* clone() const
3: D* clone() const
4: Foo* clone() const
5: Foo* clone() const
同样,它继承了
D
的布局并附加了自己的插槽。我实际上不知道为什么会有两个新的插槽-可能只是出于兼容性原因而存在的次优算法。现在,对于这些类型为
Foo
的具体对象,我们要在这些插槽中放入什么?插槽4和5简单获得Foo::clone()
。但是该函数返回Foo*
,因此它不适合插槽2和3。为此,编译器会创建 stub (称为thunk)来调用主版本并转换结果,即,编译器会为插槽3创建类似的代码:D* Foo::clone$D() const {
Foo* real = clone();
return static_cast<D*>(real);
}
现在我们来进行编译错误:由于某种原因,编译器在看到此调用后:
foo->clone();
调用的不是插槽4或5,而是插槽3。但是插槽3返回
D*
!然后,代码继续将D*
用作Foo*
,换句话说,您将获得与完成时相同的行为:Foo* wtf = reinterpret_cast<Foo*>(
reinterpret_cast<char*>(foo_clone) + 8);
显然,这不会很好地结束。
具体来说,发生的事情是,在调用
foo_clone->get_identifier();
时,编译器希望将Foo* foo_clone
转换为IDO*
(get_identifier
要求其this
指针为IDO*
,因为它最初是在IDO
中声明的)。如前所述,IDO
对象在任何Foo
对象中的确切位置都是不固定的。它取决于对象的完整类型(如果完整对象是Foo
,则为32;但是,如果它是从Foo
派生的类,则可能是其他类型)。因此,要进行转换,编译器必须从对象内部加载偏移量。具体来说,它可以加载位于任何Foo
对象的偏移量0处的“虚拟基本指针”(vbptr),该对象指向包含该偏移量的“虚拟基本表”(vbtable)。但是请记住,我们的
Foo*
已损坏,它已经指向实际对象的偏移量8。那么我们访问偏移量8的偏移量0,那里是什么?好吧,碰巧的是,weak_ptr
对象中有enable_shared_from_this
,它为null。因此,对于vbptr,我们得到了null,并且尝试对其取消引用以使对象崩溃。 (虚拟基准的偏移量存储在vbtable的偏移量4中,这就是为什么您获得的崩溃地址为0x000 ... 004的原因。)如果删除所有协变的恶作剧,则vtable会缩小为
clone()
的单个条目,并且不会出现错误编译的情况。但是,如果删除
enable_shared_from_this
,为什么问题仍然存在?好吧,因为偏移量为8的东西不是weak_ptr
内的某个空指针,而是DO
子对象的vbptr。 (通常来说,继承图的每个分支都有其自己的vbptr。IA
具有Foo
共享的一个,而DO
具有D
共享的一个。)并且该vbptr包含将D*
转换为IDO*
所需的信息。我们的Foo*
实际上是变相的D*
,因此一切都可以正确进行。附录
MSVC++编译器有一个未记录的选项来转储对象布局。这是
Foo
和enable_shared_from_this
的输出:class Foo size(40):
+---
0 | +--- (base class IA)
0 | | {vbptr}
| +---
8 | +--- (base class D)
8 | | +--- (base class DO)
8 | | | +--- (base class std::enable_shared_from_this<class DO>)
8 | | | | ?$weak_ptr@VDO@@ _Wptr
| | | +---
24 | | | {vbptr}
| | +---
| +---
+---
+--- (virtual base IDO)
32 | {vfptr}
+---
Foo::$vbtable@IA@:
0 | 0
1 | 32 (Food(IA+0)IDO)
Foo::$vbtable@D@:
0 | -16
1 | 8 (Food(DO+16)IDO)
Foo::$vftable@:
| -32
0 | &Foo::{dtor}
1 | &DO::get_identifier
2 | &IDO* Foo::clone
3 | &D* Foo::clone
4 | &Foo* Foo::clone
5 | &Foo* Foo::clone
Foo::clone this adjustor: 32
Foo::{dtor} this adjustor: 32
Foo::__delDtor this adjustor: 32
Foo::__vecDelDtor this adjustor: 32
vbi: class offset o.vbptr o.vbte fVtorDisp
IDO 32 0 4 0
这里没有:
class Foo size(24):
+---
0 | +--- (base class IA)
0 | | {vbptr}
| +---
8 | +--- (base class D)
8 | | +--- (base class DO)
8 | | | {vbptr}
| | +---
| +---
+---
+--- (virtual base IDO)
16 | {vfptr}
+---
Foo::$vbtable@IA@:
0 | 0
1 | 16 (Food(IA+0)IDO)
Foo::$vbtable@D@:
0 | 0
1 | 8 (Food(DO+0)IDO)
Foo::$vftable@:
| -16
0 | &Foo::{dtor}
1 | &DO::get_identifier
2 | &IDO* Foo::clone
3 | &D* Foo::clone
4 | &Foo* Foo::clone
5 | &Foo* Foo::clone
Foo::clone this adjustor: 16
Foo::{dtor} this adjustor: 16
Foo::__delDtor this adjustor: 16
Foo::__vecDelDtor this adjustor: 16
vbi: class offset o.vbptr o.vbte fVtorDisp
IDO 16 0 4 0
这是对返回值调整
clone
填充程序的一些清理后的反汇编: mov rcx,qword ptr [this]
call Foo::clone ; the real clone
cmp rax,0 ; null pointer remains null pointer
je fin
add rax,8 ; otherwise, add the offset to the D*
jmp fin
fin: ret
这是一些故障调用的清理反汇编:
mov rax,qword ptr [foo]
mov rcx,rax
mov rax,qword ptr [rax] ; load vbptr
movsxd rax,dword ptr [rax+4] ; load offset to IDO subobject
add rcx,rax ; add offset to Foo* to get IDO*
mov rax,qword ptr [rcx] ; load vtbl
call qword ptr [rax+24] ; call function at position 3 (D* clone)
这是崩溃调用的一些清理后的反汇编:
mov rax,qword ptr [foo_clone]
mov rcx,rax
mov rax,qword ptr [rax] ; load vbptr, loads null in the crashing case
movsxd rax,dword ptr [rax+4] ; load offset to IDO subobject, crashes
add rcx,rax ; add offset to Foo* to get IDO*
mov rax,qword ptr [rcx] ; load vtbl
call qword ptr [rax+8] ; call function at position 1 (get_identifier)
关于c++ - 具有协变返回类型的方法在VC++上崩溃,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/47527366/