具有协变 return 类型的方法在 VC++ 上崩溃

Methods with covariant return types crashes on VC++

以下代码在使用 clang 或 gcc(在 macOS 上)编译时似乎 运行 正常,但在使用 MS Visual C++ 2017 编译时崩溃。在后者上,foo_clone 对象似乎被损坏并且程序崩溃并在行 foo_clone->get_identifier().

上出现访问冲突

如果我删除协变 return 类型(所有克隆方法 return IDO*),或者当 std::enable_shared_from_this 被删除,或者当所有继承都变为虚拟时。

为什么它适用于 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 发生的。然而,在存在多重继承和虚拟继承以及共变 return 类型的情况下,必须应对一些挑战,以及应对这些挑战的不同方式。

考虑:

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() return 是 Foo* 无论如何,从 Foo*IDO*D* 的转换是不是简单的空操作。在完整的 Foo 对象中,IDO 子对象位于偏移量 32(假设 MSVC++ 和 64 位编译),而 D 子对象位于偏移量 8 . 从Foo*D*就是指针加8,得到IDO*其实就是从Foo*加载信息,而IDO 子对象位于。

但是,让我们看看为所有这些 classes 生成的 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是因为底座classIDO有这个功能。插槽 3 在那里,因为这个功能也存在。我们可以省略这个插槽,而是在调用点生成额外的代码以将 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()。但是那个函数 return 是 Foo*,所以它不适合插槽 2 和 3。对于这些,编译器创建调用主版本并转换结果的存根(称为 thunk),即编译器创建插槽 3 是这样的:

D* Foo::clone$D() const {
  Foo* real = clone();
  return static_cast<D*>(real);
}

现在我们来看看错误编译:出于某种原因,编译器在看到这个调用时:

foo->clone();

调用的不是插槽 4 或 5,而是插槽 3。但是插槽 3 return是 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 派生的 class,则可能是其他类型)。因此,要进行转换,编译器必须从对象中加载偏移量。具体来说,它可以加载位于任何 Foo 对象的偏移量 0 处的 "virtual base pointer" (vbptr),它指向包含偏移量的 "virtual base table" (vbtable)。

但是请记住,我们有一个损坏的 Foo* 已经指向真实对象的偏移量 8。所以我们访问偏移量 8 的偏移量 0,那里有什么?好吧,碰巧的是 enable_shared_from_this 对象中的 weak_ptr,它是空的。所以我们为 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 with 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

这是 return-调整 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)