为什么纯虚拟成员函数必须是虚拟的?

Why does a pure virtual member function have to be virtual?

提问人:Riccardo Caiulo 提问时间:10/3/2023 最后编辑:Jan SchultkeRiccardo Caiulo 更新时间:11/9/2023 访问量:122

问:

我有一个关于 C++ 中纯虚函数声明的问题。我有 Java 背景,所以我认为纯虚函数是定义抽象类和接口思想的一种方式。我的问题很简单,当我们在 C++ 中定义一个纯虚函数时,我们必须写这样的东西:

virtual void function() =0

void 可以是任何类型,但我们必须包含等于 0 和 virtual 关键字。我把“等于 0”部分理解为用于定义纯函数,但我的问题是为什么它必须是虚拟的?我们不能在没有虚拟关键字的情况下定义它吗?这只是包含在纯虚函数定义中的 C++ 的一部分,还是有逻辑上的理由说明为什么“抽象方法”也必须是虚的?

C++ 接口 语言-律师 抽象类 纯虚拟

评论

0赞 Marek R 10/3/2023
在 Java 中,interface 中的所有方法都是隐式虚拟的。在 C++ 中,接口没有特殊的关键字,因此任何方法都没有隐式(基类中的虚拟方法除外)。 是编译器的信息,他不必寻找给定方法的实现(您可以提供它)。virtual= 0
0赞 LiuYuan 10/3/2023
如果不指定,它将是普通成员函数,不支持运行时多态性。只有指定为虚拟,才会将它的地址放入虚拟表中,并且在编译时不会进行硬编码,从而支持程序运行时多态性virtual
0赞 Peter 10/3/2023
嗯....并非所有非静态成员函数都是(与 Java 等其他语言不同)。虚函数是可以由派生类重写的函数。纯虚函数是必须由派生类重写的函数,如果派生类必须能够实例化(即可实例化)。一个非虚拟的纯函数(如果派生类是可实例化的,则必须被重写)只是一个矛盾。virtual
2赞 Jarod42 10/3/2023
你的问题只是关于语法,即暗示吗?(有点像我们在写 / 时不必写)。= 0virtualvirtualoverridefinal
1赞 Holger 10/6/2023
@MarekR在 Java 中,所有抽象方法都是隐式虚拟的。因此,这仍然适用于 OP 的问题,该问题是关于抽象方法的,但这种细微差别很重要,因为如今,接口可以具有非虚拟的私有和静态方法。

答:

2赞 Jan Schultke 10/3/2023 #1

非虚拟纯函数没有多大意义。 从根本上说,函数是可以以某种方式调用的代码的命名部分。如果你让它变得纯粹,这意味着(可能)根本没有代码部分;只有名字。

这对于成员函数来说确实有意义,因为实现可以由其中一个派生类提供。即使不存在,呼叫也可能通过动态调度进行调用。但是,如果没有 ,就不存在这样的机制。virtualBase::foo()Base::foo()Derived::foo()virtual

同样,方法在 Java 中也没有多大意义。可以考虑 C++ 中的非虚拟成员函数,因为没有覆盖它的机制。abstract finalfinal

2赞 LiuYuan 10/3/2023 #2

为什么它必须是虚拟的?我们不能在没有虚拟关键字的情况下定义它吗?

答案是否定的。虚拟成员函数和普通成员函数的机制有很大不同。

成员函数的工作原理

成员函数的地址由链接器在链接周期内确定。

检查此代码。在这段代码中,链接后,部分将被替换为真实的偏移地址,这意味着它是硬编码的。无论是否存在派生类,如果调用 的引用/指针,则确定要调用的函数。void Test::member(void)call void Test::member(void)member()Test

然而,这并不是虚拟函数在 C++ 中的工作方式。

虚拟成员函数的工作原理

在 C++ 中,如果一个类具有虚拟函数,它将有一个虚拟表。TLDR,这里有一个小例子:

#include <iostream>

class Test {
  virtual void test() = 0;
};

int main() { 
  std::cout << "sizeof(Test): " << sizeof(Test) << std::endl; 
}
sizeof(Test): 8

虽然为空,但它包含指向虚拟表的指针,这使得多态性成为可能。Test

通过指定一个函数,只有编译器会将函数地址的地址放入虚拟表中,而不是用硬编码的函数地址替换调用语句。virtual

每当您声明一个虚拟函数时,都会在 vtable 中创建一个项目。在此示例中,一旦声明 ,第一项将存储 的函数地址,但由于它是一个纯虚函数,因此该项的内容可能是或其他任何内容,具体取决于编译器的实现。一旦你从 派生了一个类,假设 ,并在 中覆盖,第一项的内容将更新为 的地址。Test::testTest::testnullptrTestDerivedtestDerivedDerived::test

当你得到一个指针/引用,并尝试调用对象的函数时,程序将首先获取vtable中的第一项,获取的地址,然后调用该地址的函数。Testtest<ObjectType>::test

下面是一个小例子:

#include <iostream>

class Test {
public:
  virtual void test() = 0;
};

class Derived_A : public Test {
public:
  virtual void test() override { std::cout << "A::" << __func__ << std::endl; }
};

class Derived_B : public Test {
public:
  virtual void test() override { std::cout << "B::" << __func__ << std::endl; }
};

int main() {
  std::cout << "sizeof(Test): " << sizeof(Test) << std::endl;

  Derived_A a;
  Derived_B b;

  long *vtable = (long *)(&a);          // get vtable address
  long *first_item = (long *)(*vtable); // get the address of the first item
  void (*func)() = (void (*)())(*first_item);
}

结果:

sizeof(Test): 8
A::test

由于我们得到了 的 vtable 的地址,因此我们用表的第一项调用的函数是 。然而,如果你使用相同的方法对 的 vtable 进行操作,你将调用 ,这就是多态性在 C++ 中的工作方式。Derived_ADerived_A::testDerived_BDerived_B::test