【C++进阶】虚函数、虚表与虚指针:多态底层机制剖析

文章目录

一、概念

  • 多态就是通过相同的接口传入不同的参数,返回不同的结果;
  • 多态分为两种:
    • 运行时多态(动态多态):相同的行为、传入不同的对象,达到多种形态
    • 编译时多态(静态多态):即函数重载、函数模板

为什么会有多态?它解决了什么问题?

上一篇文章《继承》已经讲了:指向派生对象基类的指针和引用只能调用基类的函数,并不能调用派生类的方法。

下面这个示例展示了这一行为:

cpp 复制代码
#include <iostream>
#include <string>

class Base
{
public:
    std::string getName() const { return "Base"; }
};

class Derived: public Base
{
public:
    std::string getName() const { return "Derived"; }
};

int main()
{
    Derived derived {};
    Base& rBase{ derived };
    std::cout << "rBase is a " << rBase.getName() << '\n';

    return 0;
}

运行结果: rBase is a Base

  • 因为即使它引用的是派生类Derived,但是rBase引用的是派生类内的基类对象,调用时是以 Base 为静态类型解析得到的 Base::getName 函数;
  • getName定义为虚函数,就可以达到引用的是派生类调用的就是派生类的函数Derived::getName,这种行为就是多态,下面内容就是对虚函数作更详细的讲解;

二、动态多态的构成条件

2.1 两个必要条件

  1. 必须是基类的指针或引用调用虚函数。
    • 只有基类指针/引用才可以既指向基类对象又指向派生类对象;
  2. 被调用的函数必须是虚函数,并且完成了虚函数重写/覆盖。
    • 只有重写/覆盖了,基类和派生类之间才能有不同的函数,达到多态的效果;

2.2 虚函数(重点)

  • 虚函数(virtual function)是一种特殊的成员函数 。++当通过指针或引用进行调用时,函数的解析结果取决于被引用或指向对象的实际类型,并最终调用该对象对应成员函数的实现。++
  • 类的成员函数前面加上virtual关键字,这个函数就是虚函数;(注意:非成员函数不能加virtual修饰)
  • 重写/覆盖: ++当派生类中的成员函数与基类中的对应函数具有相同的函数声明(包括函数名、参数类型以及是否为 const 修饰)并且返回类型一致时,该派生类函数才被认为与基类函数匹配,这类函数称为对基类虚函数的重写(override++
    • 在重写基类虚函数时,派生类的虚函数不加virtual也构成重写,因为基类的虚函数被继承下来了在派生类依旧保持虚函数属性(这种写法不规范,可读性差,不建议使用)

代码示例

cpp 复制代码
#include <iostream>
#include <string>

class Base
{
public:
    virtual std::string getName() const 
    { 
        return "Base"; 
    }
};

class Derived : public Base
{
public:
    virtual std::string getName() const 
    { 
        return "Derived"; 
    }
};

void func(Base& b)
{
    std::cout << "rBase is a " << b.getName()<<endl;
}

int main()
{
    Base base{};
    Derived derived{};
    func(base);
    func(derived);

    return 0;
}

运行结果:

代码解释

由于 func函数的形参b 是对 Derived 对象中 Base 子对象的引用,在执行b.getName()时,按照常规的静态绑定规则,本应解析为 Base::getName()。然而,Base::getName() 被声明为虚函数 ,这会指示程序在运行时进一步检查该对象是否存在该函数的覆盖。如果存在,则最终解析并调用 Derived::getName();传入Base对象时的运行过程同理。

经典陷阱题

以下程序输出结果是什么()

A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确

cpp 复制代码
class A {
public:
  virtual void func(int val = 1) {
      std::cout << "A->" << val << std::endl;
  }

  virtual void test() {
      func();
  }
};

class B : public A {
public:
  void func(int val = 0) {
      std::cout << "B->" << val << std::endl;
  }
};

int main() {
  B* p = new B;
  p->test();
  return 0;
}

答案及讲解:

选择B,B->1

  1. test()是虚函数,p指向的是B对象B类没有重写test(),所以调用的是A::test()
  2. func()函数的默认参数val是在编译时确定的,只与调用点的静态类型有关,与virtual无关;所以是A::func(int val=1)
  3. func()是虚函数且派生类进行了重写,func()的函数实现是在运行时确定的;当前对象的类型是B,所以调用B::func(int val)
  4. 编译阶段默认参数val确定为1,运行阶段使用的是B::func()的实现;

基础考察题

以下程序将会打印什么?不要用编译器,旨在观察
程序一:

cpp 复制代码
#include <iostream>
#include <string>

class A
{
public:
    virtual std::string getName() const { return "A"; }
};

class B: public A
{
public:
    virtual std::string getName() const { return "B"; }
};

class C: public B
{
public:
// 这里没有实现getName函数;
};

class D: public C
{
public:
    virtual std::string getName() const { return "D"; }
};

int main()
{
    C c {};
    A& rBase{ c };
    std::cout << rBase.getName() << '\n';

    return 0;
}

程序二:

cpp 复制代码
#include <iostream>
#include <string>

class A
{
public:
    virtual std::string getName() const { return "A"; }
};

class B: public A
{
public:
    // 注意:B、C、D没有virtual关键字
    std::string getName() const { return "B"; }
};

class C: public B
{
public:
    std::string getName() const { return "C"; }
};

class D: public C
{
public:
    std::string getName() const { return "D"; }
};

int main()
{
    C c {};
    B& rBase{ c }; 
    std::cout << rBase.getName() << '\n';

    return 0;
}

程序三:

cpp 复制代码
#include <iostream>
#include <string>

class A
{
public:
    virtual std::string getName() const { return "A"; }
};

class B: public A
{
public:
    // 提示:B,C,D类的getName是非const的
    virtual std::string getName() { return "B"; }
};

class C: public B
{
public:
    virtual std::string getName() { return "C"; }
};

class D: public C
{
public:
    virtual std::string getName() { return "D"; }
};

int main()
{
    C c {};
    A& rBase{ c };
    std::cout << rBase.getName() << '\n';

    return 0;
}

答案及讲解

  • 程序一:B.rBase 是一个指向 C 对象A 引用。通常情况下,rBase.getName()会调用 A::getName(),但由于 A::getName()是虚函数,因此它调用 AC之间最派生匹配的函数。也就是说 B::getName(),它会打印 B
  • 程序二:即使 B 和 C 没有被标记为虚函数,A::getName()是虚函数,而 B::getName() C::getName()是重载。因此,B::getName()和 C::getName()被视为隐式虚函数,所以对 rBase.getName()的调用解析为 C::getName(),而不是 B::getName()
  • 程序三: B::getName()C::getName()不是 const,所以它们不被视为重载!因此,这个程序打印 A

注意:

  • 虚函数解析仅在通过基类类型的指针或引用调用虚成员函数时才有效。
  • 如果使用对象直接调用该对象的虚函数,则会直接调用当前对象的虚函数
cpp 复制代码
Derived d1;
d1.getName();//始终执行Derived::getName()
  • 不要在构造/析构函数中调用虚函数
    • 因为继承中始终是先构造基类后构造派生类对象,在基类的构造函数中调用虚函数时,派生类对象还没被构造,它会调用基类的虚函数;析构会先析构派生类,后析构基类,当你在基类对象析构时调用虚函数时,派生类已经被销毁了,它只会调用基类的虚函数;

2.3 虚析构函数

虚析构函数主要是为了解决:通过基类指针/引用来释放派生类、基类对象的;

cpp 复制代码
#include <iostream>
class Base
{
public:
    virtual ~Base()
    {
        std::cout << "Calling ~Base()\n";
    }
};

class Derived : public Base
{
private:
    int* m_array{};

public:
    Derived(int length)
        : m_array{ new int[length] }
    {
    }

    virtual ~Derived()
    {
        std::cout << "Calling ~Derived()\n";
        delete[] m_array;
    }
};

int main()
{
    Derived* derived{ new Derived(5) };
    Base* base{ derived };
    
    delete base;//调用derived::~Derived()
    return 0;
}
  • 如果析构函数不指定为虚的,delete base只会调用derived内部基类对象的析构函数,派生类对象内的析构不会被调用,也就是只会释放基类对象的内存,派生类不会随之释放,m_array数组就会内存泄漏;
  • 在析构函数前面加上virtual,就是虚析构函数了;
  • 这时使用delete释放基类指针/引用指向的对象时,就会先动态绑定调用类型derived::~Derived(),在派生类析构函数在结束时,会自动、隐式地调用基类析构函数。

三、overridefinal限定符

从上面可以看出,C++对虚函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写错参数写错等导致无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此C++11提供了override,可以帮助用户检测是否重写。如果我们不想让派生类重写这个虚函数,那么可以用final去修饰。

cpp 复制代码
#include <iostream>
#include <string>

class A
{
public:
	virtual std::string getName1(int x) { return "A"; }
	virtual std::string getName2(int x) { return "A"; }
};

class B : public A
{
public:
	virtual std::string getName1(short x) { return "B"; } 
	virtual std::string getName2(int x) const { return "B"; } 
};

int main()
{
	B b{};
	A& rBase{ b };
	std::cout << rBase.getName1(1) << '\n';
	std::cout << rBase.getName2(2) << '\n';

	return 0;
}

运行结果:

cpp 复制代码
A
A
  • 由于 rBase 是一个指向 B 对象 A 引用,这里的意图是使用虚函数来访问 B::getName1()B::getName2()。然而,因为 B::getName1()short类型的参数而不是 int 类型,所以它不构成 A::getName1()的覆盖。还有 B::getName2()const A::getName2()不是,所以B::getName2()也不构成 A::getName2()的覆盖。
  • 所以为了避免本应重写的函数因为疏忽细节没有重写的问题,就可以++使用override修饰虚函数,编译器就会强制要求这个函数构成重写;++
  • override 修饰符放置在成员函数声明的末尾(与函数的const放置位置相同)。如果一个成员函数是 const 且需要重写,则 override写在const 后面。

四、多态的原理

4.1 静态绑定和动态绑定

静态绑定

在 C++中,++当直接调用非成员函数或非虚成员函数时,编译器可以确定应该将哪个函数定义与调用匹配。++这有时被称为静态绑定,因为它可以在编译时执行。然后编译器(或链接器)可以生成机器语言指令,告诉 CPU 直接跳转到函数的地址。

cpp 复制代码
#include <iostream>
using namespace std;

struct Base {
    virtual void foo() {
        cout << "Base::foo\n";
    }
};

struct Derived : Base {
    void foo() override {
        cout << "Derived::foo\n";
    }
};

void test_static(Base* p) {
    p->Base::foo();   // 强制静态绑定
}

void test_dynamic(Base* p) {
    p->foo();         // 动态绑定
}

int main() {
    Base* p = new Derived;
    test_static(p);
    test_dynamic(p);
}

通过以上案例,我们查看 printValue(5) 调用生成的汇编代码,可以清除的看到直接确定了函数的调用地址;

动态绑定

++满足多态条件的函数调用是在运⾏时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址,也就做动态绑定++

还是使用的上述案例,可以看到先通过指针p找到对象的起始地址,在对象的虚函数表中找到foo函数地址,最后调用;

4.2 虚函数表&虚表指针(重点)

  • 每个使用虚函数的类(或继承自使用虚函数的类)都有一个相应的虚函数表vtable这个表只是一个编译器在编译时设置的++静态数组++ 。虚函数表包含每个类对象可以调用的每个虚函数的一个条目。虚函数表中的每个条目只是一个++函数指针++,指向该类可访问的虚函数。
  • 编译器还会添加一个隐藏指针,它是基类的成员,++我们将它称为 __vfptr ,也就是虚表指针++ 。 __vfptr 在类对象创建时被设置(自动设置),它指向该类的虚拟表。与 this 指针不同,后者实际上是编译器用于解析自身引用的函数参数, __vfptr 是一个真正的指针成员 。因此,它使每个类对象的大小增加一个指针的大小 。这也意味着 __vfptr 会被派生类继承,这一点很重要

通过以下程序理解如何通过虚函数表实现多态的:

cpp 复制代码
class Base
{
public:
    virtual void function1() {};
    virtual void function2() {};
};

class D1: public Base
{
public:
    void function1() override {};
};

class D2: public Base
{
public:
    void function2() override {};
};
  • 当创建一个 Base 类型的对象时, __vfptr 被设置为指向 Base 的虚拟表。当创建 D1 D2 类型的对象时, __vfptr 分别被设置为指向 D1D2的虚拟表。
  • 本示例中:Base 类型的对象只能访问 Base 的成员。Base 无法访问 D1 或 D2 的函数。因此,Base的虚函数表中的function1 指向 Base::function1(),而 function2 指向 Base::function2()
  • D1类型的对象可以访问D1Base的成员,D1重写了Basefunction1(),因此,D1的虚函数表中的function1指向D1::function1()function2没有被重写,则指向Base::function2();(D2的虚函数表与D1同理,不在赘述)

虚函数表元素指向关系示意图:

注意:

  • __vfptr指针存放在派生类对象中的基类部分(在基类子对象的起始位置),这也就是为什么使用基类的指针或引用可以调用派生类的虚函数;
  • 任何使用虚函数的类都有一个 __vfptr ,因此该类的每个对象都会比普通对象多一个指针。虚函数很强大,但存在性能开销。

五、纯虚函数&抽象类

  • 在虚函数声明的后面写上 =0 ,没有实现,这个函数就是纯虚函数;这种函数没有主体,只是一个占位符,其目的是由派生类重新定义。
  • 包含纯虚函数的类是抽象类,抽象类不能实例化出对象;
  • 任何派生类都必须为这个函数定义一个主体,否则该派生类也会被视为抽象基类;某种程度上强制了派生类重写这个纯虚函数;
  • 纯虚函数也可以使用基类的引用/指针来调用

代码示例:

cpp 复制代码
class Car
{
public :
	virtual void Drive() = 0;
};
class Benz :public Car
{
public:
	virtual void Drive()
	{
		cout << "Benz-舒适" << endl;
	}
};
class BMW :public Car
{
public:
	//没有重写纯虚函数
};
int main()
{
	// 编译报错:error C2259: "Car": ⽆法实例化抽象类
	Car car;

	Car* pBenz = new Benz;
	pBenz->Drive();


	//编译报错:error E0322:不允许使用抽象类类型 "BMW" 的对象:
	Car* pBMW = new BMW;
	pBMW->Drive();
	return 0;
}

代码解释:
Car类实例化对象时,编译报错,抽象类不能被实例化;BMW类没有重写纯虚函数Drive,所以BMW类是个抽象类,也不能实例化对象;

带定义的纯虚函数

  • 纯虚函数的定义必须在类外实现(不能内联)。
  • 纯虚函数的实现只是为派生类函数提供可复用的基础逻辑;派生类仍需重写纯虚函数。

代码示例:

cpp 复制代码
class Car
{
public :
	virtual void Drive() = 0;
};

void Car::Drive()
{
	cout << "智能驾驶" << endl;
}
class Benz :public Car
{
public:
	virtual void Drive()
	{
		cout << "Benz-舒适" << endl;
	}
};
class BMW :public Car
{
public:
	virtual void Drive()
	{
		Car::Drive();//使用纯虚函数的实现
	}
};
int main()
{
	Car* pBenz = new Benz;
	pBenz->Drive();

	Car* pBMW = new BMW;
	pBMW->Drive();
	return 0;
}

代码解释pBMW->Drive();内部就复用的纯虚函数的实现;


拓展

MSVC + x86(32 位)寄存器

寄存器 名字来源 本质
eax accumulator 通用寄存器
ebx base 通用寄存器
ecx counter 通用寄存器
edx data 通用寄存器
esi source index 通用寄存器
edi destination index 通用寄存器
esp stack pointer 栈顶指针
ebp base pointer 栈帧基址

本篇文章就到此结束了,如果本文对你有帮助,麻烦你 👍点赞 ⭐收藏 ❤️关注 吧~

相关推荐
小马爱打代码2 小时前
MyBatis:缓存体系设计与避坑大全
java·缓存·mybatis
老骥伏枥~2 小时前
C# 控制台:Console.ReadLine / WriteLine
开发语言·c#
近津薪荼2 小时前
优选算法——滑动窗口1(单调性)
c++·学习·算法
头发还没掉光光2 小时前
Linux 高级 IO 深度解析:从 IO 本质到 epoll全面讲解
linux·服务器·c语言·c++
爱装代码的小瓶子2 小时前
【C++与Linux基础】进程如何打开磁盘文件:从open()到文件描述符的奇妙旅程(更多源码讲解)
linux·开发语言·c++
diediedei2 小时前
嵌入式C++驱动开发
开发语言·c++·算法
时艰.2 小时前
Java 并发编程:Callable、Future 与 CompletableFuture
java·网络
80530单词突击赢2 小时前
C++容器对比:map与unordered_map全解析
c++
码云数智-园园2 小时前
深入理解与正确实现 .NET 中的 BackgroundService
java·开发语言