【问题标题】:memcpy derived class to base class, why still called base class functionmemcpy派生类转基类,为什么还要叫基类函数
【发布时间】:2017-04-26 22:13:22
【问题描述】:

我正在阅读深入了解 C++ 对象模型。在第 1.3 节中

那么,既然如此,为什么会这样呢

Bear b; 
ZooAnimal za = b; 

// ZooAnimal::rotate() invoked 
za.rotate(); 

调用的 rotate() 实例是 ZooAnimal 实例而不是 Bear 的实例?此外,如果按成员初始化将一个对象的值复制到另一个对象,为什么 za 的 vptr 不寻址 Bear 的虚拟表?

第二个问题的答案是编译器在一个类对象与另一个类对象的初始化和赋值中进行了调解。编译器必须确保如果一个对象包含一个或多个 vptr,源对象不会初始化或更改这些 vptr 值

于是我写了下面的测试代码:

#include <stdio.h>
class Base{
public:
    virtual void vfunc() { puts("Base::vfunc()"); }
};
class Derived: public Base
{
public:
    virtual void vfunc() { puts("Derived::vfunc()"); }
};
#include <string.h>

int main()
{
    Derived d;
    Base b_assign = d;
    Base b_memcpy;
    memcpy(&b_memcpy, &d, sizeof(Base));

    b_assign.vfunc();
    b_memcpy.vfunc();

    printf("sizeof Base : %d\n", sizeof(Base));

    Base &b_ref = d;
    b_ref.vfunc();

    printf("b_assign: %x; b_memcpy: %x; b_ref: %x\n", 
        *(int *)&b_assign,
        *(int *)&b_memcpy,
        *(int *)&b_ref);
    return 0;
}

result

Base::vfunc()
Base::vfunc()
sizeof Base : 4
Derived::vfunc()
b_assign: 80487b4; b_memcpy: 8048780; b_ref: 8048780

我的问题是为什么 b_memcpy 仍然调用 Base::vfunc()

【问题讨论】:

  • 我想答案是反汇编,提示是&amp;b_memcpy == &amp;b_ref
  • 行为未定义。使用不同的编译器、不同的编译器选项、优化等,您看到的任何结果都很容易不同。
  • 我把测试代码改成了this,结果和我预期的一样。
  • @PaulMcKenzie vptr 甚至不正式存在,因此对 vptr 的任何操作都将是未指定的或 UB。不过,只要我们可以合理地声称没有违反规则,一些操作应该会起作用。

标签: c++ undefined-behavior lifetime dynamictype vptr


【解决方案1】:

您所做的在 C++ 语言中是非法的,这意味着您的 b_memcpy 对象的行为是未定义的。后者意味着任何行为都是“正确的”,您的期望完全没有根据。尝试分析未定义的行为没有多大意义——它不应该遵循任何逻辑。

实际上,很有可能您对memcpy 的操作确实将Derived 的虚拟表指针复制到b_memcpy 对象。你对b_ref 的实验证实了这一点。但是,当通过直接对象调用虚拟方法时(如b_memcpy.vfunc() 调用的情况),大多数实现优化了对虚拟表的访问并执行直接非virtual) 调用目标函数。该语言的正式规则规定,任何法律行动都不能使b_memcpy.vfunc() 调用以调度到Base::vfunc() 以外的任何东西,这就是为什么编译器可以安全地用直接调用Base::vfunc() 来替换这个调用。这就是为什么任何虚拟表操作通常不会对b_memcpy.vfunc() 调用产生影响。

【讨论】:

  • 我上次的 printf 输出了 "b_assign, b_memcpy, b_ref" 的 vptr 值,可以看到 b_memcpy 和 b_ref 的 vptr 是一样的,所以它们指向同一个 vtable,但是当调用 vfunc() ,结果不一样。
  • @Divlaker -- GIGO.
  • @Divlaker:你真的读过答案吗?正如我上面所说,直接通过b_memcpy 进行的调用不使用vtable,根本不关心vtable。 b_memcpy 的 vptr 是什么并不重要。但是通过b_ref 拨打的电话使用vtable。这就是结果不同的原因。
  • @AnT 谢谢。我明白了。
  • “立即”自动?
【解决方案2】:

您调用的行为是未定义的,因为标准说它是未定义的,而您的编译器利用了这一事实。让我们看一下 g++ 的具体示例。它为禁用优化的b_memcpy.vfunc(); 行生成的程序集如下所示:

lea     rax, [rbp-48]
mov     rdi, rax
call    Base::vfunc()

如您所见,vtable 甚至没有被引用。由于编译器知道b_memcpy 的静态类型,因此没有理由多态地调度该方法调用。 b_memcpy 只能是 Base 对象,因此它只会生成对 Base::vfunc() 的调用,就像与任何其他方法调用一样。

再进一步,让我们添加一个这样的函数:

void callVfunc(Base& b)
{
  b.vfunc();
}

现在如果我们调用callVfunc(b_memcpy);,我们可以看到不同的结果。在这里,根据我编译代码的优化级别,我们得到不同的结果。在 -O0 和 -O1 上调用 Derived::vfunc() 并在 -O2 和 -O3 上打印 Base::vfunc()。同样,由于标准规定程序的行为是未定义的,编译器不会努力产生可预测的结果,而只是依赖于语言所做的假设。由于编译器知道b_memcpy 是一个Base 对象,因此它可以在优化级别允许时简单地内联对puts("Base::vfunc()"); 的调用。

【讨论】:

  • 感谢您的回答。我已经明白了。只有使用指针或引用才能触发 vptr。
【解决方案3】:

你不能这样做

memcpy(&b_memcpy, &d, sizeof(Base));

- 这是未定义的行为,因为 b_memcpyd 不是“普通旧数据”对象(因为它们具有虚拟成员函数)。

如果你写了:

b_memcpy = d;

然后它将按预期打印Base::vfunc()

【讨论】:

  • 我只是测试一下我的想法。可以看到 sizeof(Base) 是 4,所以我只是复制了 vptr 指针。
  • @Divlaker 确实,但你不能复制 vptr 指针。
  • @immibis 官方不允许你这样做,ctors 可以。你需要有一个借口来运行一个演员。
  • @immibis 我不确定您所说的“复制”是什么意思。我知道读写,而不是副本。
  • @curiousguy 我的意思是你的意思。你是那个说“ctors can [copy the vptr pointer]”的人
【解决方案4】:

vptr 的任何使用都超出了标准的范围

当然,这里memcpy的使用有UB

答案指出,任何使用memcpy,或对非 POD 的其他字节操作,即任何具有 vptr 的对象,都具有未定义的行为,在技术上是严格正确的,但不回答问题。 这个问题的前提是存在一个甚至不是标准强制要求的 vptr(vtable 指针):当然,答案将涉及标准之外的事实,并且结果法案不能得到标准的保证标准!

标准文本与 vptr 无关

问题不在于不允许操纵vptr;标准允许操纵甚至没有在标准文本中描述的任何东西的想法是荒谬的。当然,不存在更改 vptr 的标准方法,这是题外话。

vptr 编码多态对象的类型

这里的问题不是标准对 vptr 的规定,问题是 vptr 代表什么,以及标准对此有何规定:vptr 代表对象的动态类型。每当操作的结果取决于动态类型时,编译器将生成代码以使用 vptr。

[关于 MI 的注意事项:我说的是“the” vptr(好像只有一个 vptr),但是当涉及到 MI(多重继承)时,对象可以有多个 vptr,每个 vptr 代表被视为一个特定对象的完整对象多态基类类型。 (多态类是具有至少一个虚函数的类。)]

[关于虚拟基的注意事项:我只提到了 vptr,但一些编译器插入了其他指针来表示动态类型的各个方面,例如虚拟基子对象的位置,而其他一些编译器为此目的使用 vptr。关于 vptr 的正确之处也适用于这些其他内部指针。]

所以vptr 的一个特定值对应一个动态类型:这是最衍生对象的类型。

对象生命周期内动态类型的变化

在构造过程中,动态类型会发生变化,这就是为什么从构造函数内部调用虚函数可能会“令人惊讶”。有人说构造时调用虚函数的规则很特殊,但绝对不是:调用final overrider;该覆盖是与已构造的最派生对象对应的类,在构造函数C::C(arg-list) 中,它始终是类C 的类型。

在销毁期间,动态类型以相反的顺序发生变化。从析构函数内部调用虚函数遵循相同的规则。

未定义的东西意味着什么

您可以进行标准中未批准的低级别操作。 C++ 标准中未明确定义行为并不意味着其他地方未对其进行描述。仅仅因为在 C++ 标准中明确描述了操作的结果具有 UB(未定义的行为)并不意味着您的实现不能定义它。

您还可以利用您对编译器工作方式的了解:如果使用严格的单独编译,即编译器无法从单独编译的代码中获取任何信息,则每个单独编译的函数都是一个“黑匣子”。您可以使用这个事实:编译器将不得不假设单独编译的函数可以做的任何事情都会完成。即使在给定的函数内部,您也可以使用 asm 指令来获得相同的效果:没有约束的 asm 指令可以做任何在 C++ 中合法的事情。其效果是“忘记你在那个时候从代码分析中知道的内容”指令。

该标准描述了可以更改动态类型的内容,除了构造/销毁之外,不允许更改它,因此只有“外部”(黑盒)函数才允许执行构造/销毁可以更改动态类型。

不允许在现有对象上调用构造函数,除非使用完全相同的类型(并且有限制)重建它,请参阅[basic.life]/8

如果,在对象的生命周期结束之后并且在存储之前 被占用的对象被重用或释放,一个新的对象是 在原始对象占用的存储位置创建,a 指向原始对象的指针,引用的引用 到原始对象,或者原始对象的名称将 自动引用新对象,并且一旦生命周期 新对象已启动,可用于操作新对象,如果:

(8.1) 新对象的存储正好覆盖存储 原始对象占据的位置,以及

(8.2) 新对象与原对象的类型相同 (忽略顶级 cv 限定符),以及

(8.3) 原始对象的类型不是 const 限定的,并且,如果 类类型,不包含其类型的任何非静态数据成员 是 const 限定或引用类型,并且

(8.4) 原始对象是最衍生的对象([intro.object]) 类型 T 并且新对象是类型 T 的最派生对象(即 是,它们不是基类子对象)。

这意味着您可以调用构造函数(使用新位置)并且仍然使用用于指定对象的相同表达式(其名称、指向它的指针等)的唯一情况是动态类型将不会改变,所以 vptr 还是一样的。

换句话说,如果你想使用低级技巧覆盖 vptr,你可以;但前提是你写的值相同

换句话说,不要试图破解 vptr。

【讨论】:

    猜你喜欢
    • 2017-12-18
    • 2011-02-24
    • 1970-01-01
    • 2017-01-08
    • 2013-11-17
    • 1970-01-01
    • 2012-08-28
    相关资源
    最近更新 更多