Methods with covariant return types in VC ++

The following code seems to work fine when compiling with clang or gcc (on macOS), but it fails when compiling with MS Visual C ++ 2017. In the last, the foo_clone object seems to be corrupted, and the program crashes with access violation on the foo_clone->get_identifier() line foo_clone->get_identifier() .

It works on VC ++ if I delete the return covariance types (all clone-methods return IDO* ) or when std::enable_shared_from_this is deleted or when all inheritance is made virtual.

Why does it work with clang / gcc, but not with 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(); } 

Message:

An exception was thrown at 0x00007FF60940180B in the file foo.exe: 0xC0000005: The place to read the access violation is 0x0000000000000004.

+5
source share
1 answer

This is apparently a VC ++ compilation error. He leaves when there is no enable_shared_from_this no red herring; the problem is simply disguised.

Some prerequisites: resolving overridden functions in C ++ usually occurs through vtables. However, in the presence of multiple and virtual types of inheritance and co-option returns, there are some problems that need to be performed, and various ways of satisfying them.

Consider:

 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* 

Keep in mind that Foo::clone() returns Foo* no matter what, and converting from Foo* to IDO* or D* not a simple no-op. Within the complete Foo object, the IDO subobject lives at offset 32 ​​(assuming MSVC ++ and 64-bit compilation), and the subobject D lives at offset 8. To get from Foo* to D* it adds 8 to the pointer, and getting IDO* actually means loading information from Foo* , where exactly the IDO subobject is located.

However, look at the vtable generated for all of these classes. Vtable for IDO has this layout:

 0: destructor 1: const char* get_identifier() const 2: IDO* clone() const 

A vtable for D has this layout:

 0: destructor 1: const char* get_identifier() const 2: IDO* clone() const 3: D* clone() const 

Slot 2 exists because the IDO base class has this feature. Slot 3 exists because this function exists. Can we omit this slot and instead generate additional code from callites to convert from IDO* to D* ? Perhaps, but that would be less effective.

Vtable for Foo as follows:

 0: destructor 1: const char* get_identifier() const 2: IDO* clone() const 3: D* clone() const 4: Foo* clone() const 5: Foo* clone() const 

Again, it inherits layout D and adds its own slots. In fact, I have no idea why there are two new slots - this is probably just a suboptimal algorithm that adheres to compatibility considerations.

Now, what do we put in these slots for a specific object like Foo ? Slots 4 and 5 are simple to get Foo::clone() . But this function returns Foo* , so it is not suitable for slots 2 and 3. For them, the compiler creates stubs (called thunks) that call the main version and convert the result, that is, the compiler creates something like this for slot 3

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

Now we move on to the erroneous compilation: for some reason, the compiler sees this call:

 foo->clone(); 

It does not call slot 4 or 5, but slot 3. But slot 3 returns D* ! Then the code continues to use this D* as Foo* , or, in other words, you get the same behavior as if you did:

 Foo* wtf = reinterpret_cast<Foo*>( reinterpret_cast<char*>(foo_clone) + 8); 

It obviously won't end well.

In particular, what happens is that in the call foo_clone->get_identifier(); the compiler wants to pass Foo* foo_clone to IDO* ( get_identifier requires its this pointer to be IDO* because it was originally declared in the IDO ). As I mentioned earlier, the exact position of an IDO within any Foo not fixed; it depends on the full type of the object (32 if the full object is Foo , but it could be something else if it is a class derived from Foo ). To do the conversion, the compiler must therefore load the offset inside the object. In particular, it can load a "virtual base pointer" (vbptr) located at offset 0 of any Foo object that points to a "virtual base table" (vbtable) that contains the offset.

But remember, we have a damaged Foo* , which already indicates the offset 8 of the real object. So, we get offset 0, offset 8, and what is it? Well, as it happens, there is a weak_ptr from the enable_shared_from_this object, and it is null. So we get null for vbptr and try to dereference it to cause the object to crash. (The offset to the virtual database is stored with offset 4 in the vbtable, so the failure address you get is 0x000 ... 004.)

If you remove all covariant shenanigans, the vtable is compressed to a beautiful separate entry for clone() , and the wrong compilation does not appear.

But why does the problem go away if you remove enable_shared_from_this ? Good, because then the thing at offset 8 is not some null pointer inside weak_ptr , but vbptr of the DO subobject. (In the general case, each branch of the inheritance graph gets its own vbptr. IA has one that Foo splits, and DO has one that D splits.) And that vbptr contains the information needed to convert a D* to IDO* . Our Foo* really hidden D* , so everything happens correctly.

application

The MSVC ++ compiler has an undocumented option for creating object mockups. Here is its output for 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 

There is no:

 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 

Here's some cleared disassembly of the clone corrections:

  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 

This is where the disassembly clears:

 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) 

And here is some cleared emergency call disassembly:

 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) 
+7
source

Source: https://habr.com/ru/post/1273707/


All Articles