使用基 class 引用而不是指针时出现意外的虚函数调度

Unexpected virtual function dispatch when using base class reference instead of pointer

假设我有一个简单的 class 层次结构,如下所示,其中包含一个常见的 api:

#include <memory>

class Base {
    public:
        void api() {
            foo();
        }

    protected:
        virtual void foo() {
            std::cout << "Base" << std::endl;

        }
    };

    class FirstLevel : public Base {
    protected:
        virtual void foo() {
            std::cout << "FirstLevel" << std::endl;
        }
    };

当我使用基本 class 指针时,我得到正确的调度如下:

std::shared_ptr<Base> b = std::make_shared<Base>();
std::shared_ptr<Base> fl = std::make_shared<FirstLevel>();

b->api();
fl->api();

正确打印:

Base
FirstLevel

然而,当我使用基础 class 引用时,行为是意外的:

Base &b_ref = *std::make_shared<Base>();
Base &fl_ref = *std::make_shared<FirstLevel>();

b_ref.api();
fl_ref.api();

打印:

FirstLevel
FirstLevel

为什么使用引用而不是指针时调度不同?

您有未定义的行为,因为引用在您使用它们调用 api() 时悬空。共享指针管理的对象在用于初始化 b_reffl_ref.

的行之后不复存在

您可以通过引用仍然存在的对象来修复它:

auto b = std::make_shared<Base>();
auto fl = std::make_shared<FirstLevel>();

Base &b_ref = *b;
Base &fl_ref = *fl;

最后一个例子中 std::make_shared 的 return 值未绑定到右值 (std::shared_ptr<...>&&) 或 const 限定的左值引用 (const std::shared_ptr<...>&),因此它的寿命不会延长。相反,临时实例的std::shared_ptr::operator*的return值绑定到表达式的左侧(b_refl_ref),这会导致未定义的行为。

如果您想通过对 BaseFirstLevel 的非 const 左值引用访问虚拟 api() 方法,您可以通过

auto b = std::make_shared<Base>();
Base& b_ref = *b;

b_ref.api();

FirstLevel 类似。但是,不要在 b 超出范围后使用 b_ref。您可以通过

实现寿命延长
auto&& b = std::make_shared<Base>();
Base& b_ref = *b;

b_ref.api();

尽管这与上面几乎相同。

临时使用智能指针(或任何欠对象)是糟糕的设计。

该设计问题会导致糟糕的生命周期管理,特别是破坏仍在使用的对象。这会导致未定义的行为;根据定义,未定义的行为未定义甚至不受标准限制(它可能受其他原则、工具、设备的限制)。

在很多情况下,我们仍然可以尝试理解带有UB的代码在实践中是如何翻译的。您观察到的特定行为

which prints:

FirstLevel
FirstLevel

肯定是由于将销毁对象留下的内存解释为活动对象造成的;因为那个内存当时没有被重用(由于偶然,对程序或实现的任何更改都可能破坏它属性),你会看到一个对象处于它在销毁期间的状态。

在析构函数中,被析构对象的虚函数的调用总是解析为析构函数class中函数的重写:在Base::~Base中,对[=的调用12=] 解析为 Base::foo();使用 vptrs 和 vtables 的编译器(实际上,所有编译器)通过在执行基础 class 开始时将 vptr 重置为 Base 的 vtable 来确保以这种方式解析虚拟调用析构函数。

所以你看到的是 vptr 仍然指向基础 class vtable。

当然,调试实现有权在基类的析构函数末尾将 vptr 设置为某个其他值 class 以确保尝试调用已销毁对象上的虚函数失败一种清晰明确的方式。