C++虚继承、虚析构函数和dynamic_cast<void*>

3
我正在努力理解为什么以下代码中第二个 assert 失败(如果有关系的话,是在 Visual Studio 2019 中使用 MSVC):
#include <cstdlib>

class grandparent
{
public:
    virtual ~grandparent() {};
};

class parent : public virtual grandparent 
{};

class child : public parent
{};

int main() 
{
    void* mem = malloc(sizeof(child));
    child* c = new (mem) child;
    assert(dynamic_cast<void*>(c) == mem); // ok
    std::destroy_at(c);
    assert(dynamic_cast<void*>(c) == mem); // fails
}

据我所知,对于多态类型的指针,dynamic_cast<void*> 将返回该指针最终派生类的地址。在通过 std::destroy_at 进行销毁之前,这个过程完全正常。但是,在销毁后它不再提供指向最初分配的内存的指针,但我不明白为什么会这样。
因此我的问题如下:
1. dynamic_cast<void*> 如何获取给定指针的最终派生类型的地址? 2. 析构函数做了什么使得它更改了 dynamic_cast<void*> 的返回值?
在搜索并尝试自我学习时,我在有关虚表的一系列博客文章中发现了以下内容:https://shaharmike.com/cpp/vtable-part4/。在博客文章中有一个简短的关于析构函数的片段提到:
“这里是一个快速的思考练习:为什么析构函数会将 vtable 指针更改为指向自己的类,而不是保持指向具体类?答案:因为在析构函数运行时,任何继承类都已经被销毁了。不希望调用这些类的方法。”
从这里,我猜想 dynamic_cast<void*> 的工作方式是通过查看给定指针的虚表,就像引用的那句话一样,当调用析构函数时更改了虚表指针。这个猜测正确吗?如果是,我想了解底层发生了什么,因此任何解释或进一步阅读资料将不胜感激。

2
std::destroy_at 后使用 dynamic_cast<void*>(c) 会导致未定义行为。 - R Sahu
相信你的答案在回答std::destroy_at(c)做什么方面。 - smac89
@NathanOliver 给一个已经销毁的对象赋值是个坏主意。对于非平凡类型当然如此,但据我所知,对于平凡类型也是如此;尽管实现可能不一定关心后者。这与未初始化指针并不完全相同;例如,您可以将指针与其他指针进行比较。 - eerorika
@eerorika,我说的是将指针分配给一个新对象,而不是刚刚销毁的对象。 - NathanOliver
@eerorika 怎么做?他们仍然可以使用mem,一旦完成所有操作就可以释放它。 - NathanOliver
显示剩余3条评论
2个回答

3
  1. dynamic_cast<void*>如何获取给定指针的最派生类型的地址?

dynamic_cast如何满足其要求取决于语言的具体实现。在实践中,一些数据必须与对象一起存储。这些数据称为运行时类型信息。

  1. 析构函数执行什么操作,以更改dynamic_cast的返回值?

它当然是销毁对象。对象的生命周期已经结束。在对象的生命周期之外使用指向该对象的指针进行dynamic_cast的行为是未定义的(在析构/构造期间使用它有特殊规则)。

标准说明(引用最新的草案)

[basic.life]

...当对象o(类型为T)的生命周期结束时:

  • 如果T是类类型,则析构函数调用开始

... 对象的生命周期结束后,在被释放或重复使用之前占用对象的存储空间时,... 如果程序出现下列情况,则其行为未定义:

  • ...
  • 将该指针用作dynamic_­cast表达式([expr.dynamic.cast])的操作数。

谢谢提供标准的引用,我需要养成每当遇到关于工作原理或原因的问题时查看标准的习惯。我一直在使用https://en.cppreference.com/w/cpp/language/dynamic_cast 作为我的参考,并没有看到dynamic_cast在对象生命周期结束后会导致未定义行为的提及。 - tdashroy
由于某种原因,我也没有想到dynamic_cast的工作方式取决于语言的实现而不是语言本身的定义。这也是我仍在适应的事情之一,所以感谢您澄清了这一点。 - tdashroy

2
你想知道它是如何工作的。你不会得到一个明确的答案。最好的回答应该是,在使用 std::destroy_at 后,使用 dynamic_cast 就是未定义行为(UB)。这是标准规定的,编译器可以按照自己的意愿进行操作,只要它们遵守了标准。
话虽如此,你仍然可以推测在实践中可能会发生什么。
std::destroy_at(c);

不会释放内存。它只能修改由malloc分配的内存。

dynamic_cast

应该逻辑上只查看内存缓冲区。除了本地优化之外,它唯一可以查找的地方是由malloc分配的缓冲区。

这将为您提供工具以确定您的实现正在执行的操作。在放置新位置后,打印位于c的child内存的sizeof(child)字节。然后在每个步骤之后再次打印内存。

这将告诉您您的实现正在做什么。它不能保证其他编译器/机器可能会做什么。但我猜您会看到v表指针在销毁期间被重置为零。

如果我在我的编译器上尝试以下操作:

#include <cstdlib>
#include <cassert>
#include <memory>
#include <iostream>

class grandparent
{
public:
virtual ~grandparent() {};
};

class parent : public virtual grandparent 
{};

class child : public parent
{
int x = 0x42;// just to see where this is in memory
};

void print(void* c, size_t size)
{
for (size_t i = 0; i < size; i++)
{
    std::cout << std::hex << (int)(((unsigned char*)c)[i]) << " ";
}
std::cout << std::endl;
}

int main() 
{
void* mem = malloc(sizeof(child));
child* c = new (mem) child;

print(c, sizeof(*c));

assert(dynamic_cast<void*>(c) == mem); // ok
std::destroy_at(c);

print(c, sizeof(*c));

assert(dynamic_cast<void*>(c) == mem); // fails
}

我得到如下结果:

60 ab d7 0 42 0 0 0 58 ab d7 0

60 ab d7 0 42 0 0 0 34 ab d7 0

断言失败:dynamic_cast(c) == mem,文件

d:\projects\test1\test1.cpp,第41行

因此,在对象之前有一些额外的内容。这里存储了0x42,三个填充零和一个指针(这是在调试器中显示的v-table)。指针在删除期间被修改,并防止下一个dynamic_cast起作用。然后可以检查内存并找出更多信息。 但是,无法保证您在另一个实现上或甚至在下一次构建中找到的任何内容都是正确的。

我想知道为什么v-table指针会被设置为零?这是一个需要时间的操作,对代码的操作应该没有影响。 - Mark Ransom
是的,我也一样(请参见详细信息的编辑)。但我会深入挖掘并弄清这两个指针是什么。 - Jeffrey
我想我找到了自己问题的答案——重置虚函数表指针可以帮助指出在析构函数中调用虚函数的错误。显然,这只是编译器的一种礼貌方式,可能仅适用于调试版本。 - Mark Ransom
实际上,由于某种原因,子类的vtpr被修改为成为祖先类的vptr。如果您尝试两次使用std::destroy()(非常糟糕的UB),第二次将直接调用祖先析构函数。 - Jeffrey
2
如果我猜的话,我会打赌vptr在每个析构函数调用后逐渐更改为其父级,并最终停留在最后一个值上。这将允许中间析构函数调用虚函数并使它们在某种程度上称为有效对象。 - Jeffrey
非常感谢您展示如何深入挖掘!这正是我一直在寻找的。我曾经有一个误解,但通过这个例子,我意识到对象的销毁可能会产生更多的影响,而不仅仅是析构函数中的内容。在这种情况下,它修改了运行时类型信息,dynamic_cast<void*>使用该信息将对象转换为最派生类型。我知道这是与实现相关的,但这帮助我更好地理解了可能存储在对象旁边的元数据以及不同操作可能如何影响/使用该元数据。 - tdashroy

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