C++中的多态

文章目录

多态的成立条件

多态:同一接口或操作在不同对象上表现出不同的行为

同样一段代码,根据所作用的对象类型不同,执行不同的函数版本

多态成立的两个必要条件:

  1. 必须通过父类的指针或引用来调用函数
    • 因为只有通过指针或引用访问函数,才能实现"动态绑定"(记载运行中确定调用哪个函数),动态绑定仅对指针和引用生效
    • 如果直接通过对象名调用(静态绑定),编译器就已经确定了调用的函数版本,不会形成多态
  2. 必须在派生类中对父类的虚函数进行重写
    • 父类函数必须使用virtual关键字声明为虚函数
    • 子类中必须定义一个具有相同函数名,参数列表,返回类型的函数
    • 只有同时满足这些条件时,函数才能构成"虚函数覆盖"关系,从而实现运行时多态
      注意:"虚函数的virtual关键字和虚基类中的virtual虽然都使用virtual,但他们完全无关
      前者用于实现多态(控制函数动态绑定),后者用于控制继承结构(解决菱形继承问题)
      满足多态的条件:根对象指向有关,与调用对象类型无关,指向哪个函数就调用他的虚函数
      不满足多态的条件:与类型有关,调用的类型是谁的,调用的就是谁的虚函数

虚函数机制

虚函数介绍

C++中,虚函数是实现多态的基础

虚函数的声明方式:

c 复制代码
virtual 返回类型 函数名(参数列表)

当类中含有虚函数时,编译器会为该类自动生成一个虚函数表

并在对象中添加一个指针成员(vptr ),用于在运行时指向该表

每个类的虚函数表都保存了该类可调用的所有虚函数地址,运行时根据对象的实际类型,通过vptr定位到对应的虚函数版本

当类中定义了至少一个虚函数时,对象的内存布局会多出一个指向虚函数表的指针

通常在32位环境下增加4字节,在64位环境下增加8字节

虚函数和虚继承的区别

用法场景 关键字含义 作用
virtual 修饰成员函数 表示函数为虚函数 ,用于实现多态机制 支持运行时动态绑定
virtual 修饰继承方式(如 class B : virtual public A 表示虚继承 ,用于解决菱形继承中的数据冗余和二义性问题 控制数据成员唯一性

虚函数的重写

虚函数重写是派生类对基类虚函数的一种重新定义

要求:

条件 描述
函数名相同 派生类函数与基类函数同名
参数列表相同 形参数量、类型、顺序完全一致
返回值类型相同 返回类型一致(C++11 起支持协变返回类型)
均为虚函数 父类函数必须使用 virtual 声明
当满足以上条件时,派生类函数会覆盖基类的虚函数,使得通过父类指针或引用调用时,根据实际对象类型执行对应函数
复制代码
  若仅函数名相同但参数或返回值不同,则属于**函数重载**,而不是重写
  若未声明为虚函数,但派生类定义了同名函数,则属于**函数隐藏**
  不具备多态特性

虚函数重写的两个例外:

  1. 斜变返回类型
    重写函数必须函数名,参数,返回值都完全相同这是不对的,C++允许一个例外,当返回值是指针或引用类型,且他们指向的类型存在继承关系,可以不同,这种情况称为斜变返回类型
c 复制代码
#include <iostream>
using namespace std;

class Person {
public:
    virtual Person* clone() {  // 返回父类类型指针
        cout << "Person clone()" << endl;
        return this;
    }
};

class Student : public Person {
public:
    // 返回类型不同,但合法 ------ 因为 Student 是 Person 的派生类
    Student* clone() override {  
        cout << "Student clone()" << endl;
        return this;
    }
};

int main() {
    Person* p = new Student();
    Person* q = p->clone();  // 调用 Student::clone()
    delete p;
}

Student::clone() 返回的是 Student*,不是 Person*。但因Student派生自 Person,返回类型满足斜变规则。所以这属于合法的虚函数重写(override )**,仍然能产生多态。

  1. 父类函数是虚函数,但子类中没写virtual还是多态吗?

是的,仍然是多态

  • 一旦父类函数被声明为virtual,他在所有子类中自动继承虚函数属性
  • 即使子类函数省略了virtual关键字,他依然是虚函数
c 复制代码
#include <iostream>
using namespace std;

class Person {
public:
    virtual void buyTicket() { cout << "Person buyTicket" << endl; }
};

class Student : public Person {
public:
    void buyTicket() { cout << "Student buyTicket" << endl; }  // 省略 virtual
};

int main() {
    Person* p = new Student();
    p->buyTicket();  // 输出:Student buyTicket(仍是多态)
    delete p;
}

虚析构函数

为什么要定义析构虚函数

析构函数隐藏导致析构函数需要定义为析构虚函数

由于析构函数会被编译器处理为同一个distructor,因此会导致如下结果

c 复制代码
class Person {
public:
    Person() { cout << "Person 构造" << endl; }
    ~Person() { cout << "Person 析构" << endl; }  // 非虚函数
};

class Student : public Person {
public:
    Student() { cout << "Student 构造" << endl; }
    ~Student() { cout << "Student 析构" << endl; }
};

int main() {
    Person* p1 = new Person();
    delete p1
    Person* p2 = new Student();
    delete p2; 
    return 0;
}

输出结果:

复制代码
~Person()
~Person() //只调用了父类的析构函数,Student的析构函数未被调用

原因:

delete执行过程:

  1. 调用p所指向类型的析构函数
  2. 释放内存
    调用哪个析构函数,取决于指针的静态类型(编译期类型)
    ~Person()不是虚函数
    p的静态类型是Person*,所一编译器只会生成调用Person::~Person()的代码,不会知道p实际指向Student对象
    如果Student析构函数未被调用,会发生内存泄漏
  • 若 Student 持有动态分配资源(如 new、文件句柄、数据库连接等),这些资源不会释放;
  • 导致 内存泄漏(Memory Leak);
  • 严重时甚至触发未定义行为(因为内存布局不完整)。
    这就是为什么标准库和所有正确设计的基类(如 std::iostreamstd::thread)都将析构函数定义为虚函数。
    解决方法:
    在基类中加上virtual
c 复制代码
class Person {
public:
    virtual ~Person() { cout << "Person destructor\n"; }
};

class Student : public Person {
public:
    ~Student() override { cout << "Student destructor\n"; }
};

int main() {
    Person* p = new Student;
    delete p;
}

输出:

复制代码
Student destructor
Person destructor\\子类析构被调用-》父类析构被调用->内存正确释放

虚析构函数如何实现多态?

析构函数虽然名称不同但在编译器层面把他们统一处理为相同的虚函数签名(destructor),满足"同名同参数同返回"

与普通虚成员函数一样,如果基类函数是虚函数,则所有派生类的重写都将被视为虚函数,无论它们是否被指定为虚函数。没有必要为了将其标记为虚函数而创建一个空的派生类析构函数。

请注意,如果您希望基类有一个虚析构函数,而该析构函数本身是空的,则可以这样定义析构函数:

c 复制代码
virtual ~Base() = default;

在极少数情况下,会需要忽略某些函数的虚拟化

c 复制代码
#include<string_view>
#include<iostream>
class Base
{
public:
	virtual ~Base() = default;
	virtual std::string_view getName() const { return "Base"; }

};

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

int main() {
	Derived derived{};
	const Base& base{ derived };
	//引用本质是一个指针,指向derived,而const Base& 限制base只能访问继承Base或者Base的函数且对函数内容不能修改,const Base& base 限制的是通过这个引用 不能修改对象状态,包括:
	//调用非 const 成员函数,对引用对象进行赋值,直接修改成员变量,且derived独有的函数不能访问,并且引用不会发生切割,本质还是derived对象
	std::cout << base.Base::getName() << '\n';//由于有作用域的限制,所以会直接限定为Base对象的getName,而不会发生动态绑定
	return 0;
}

小结

我们应该把所有析构函数都设为虚函数?

  1. 如果您打算从您的类中继承,请确保您的析构函数是虚函数切实公有的
  2. 如果您不打算从您的类中继承,请将您的类标记为final,这叫首先组织其他类继承他,且不会对类中本身施加任何其他使用限制
    问:析构函数要不要写成虚函数?为什么?
    答:
    当一个类被设计为"多态基类"时,析构函数必须是虚函数。
    否则,通过基类指针 delete 派生类对象时,只会调用基类析构,派生类资源不会被释放,从而造成内存泄漏。
    虚析构函数通过虚函数表实现动态绑定,使得 delete 操作能根据对象的实际类型依次调用派生类与基类析构函数。
c 复制代码
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(int argc, char* argv[]) {
    B* p = new B;
    p->test();
    return 0;
}

A:A->1,B:B->1,C:B->0,D:A->0

输出结果为:B

p指向B的对象区域,然后查找B的对象中有没有test,发现派生类没有test,往上找到基类的test,调用A::test(),test中调用func()函数,默认函数参数在调用点决定,并且默认参数由同一作用域的函数确定,因此在调用func()函数是率先传入参数1,然后查找func()函数结构体,发现func()函数是虚函数,又因为p指向B对象因此实际调用的为B对象中func()函数体,所以打印结果为B->1

总结为:
pB*,它指向一个B 对象。

因为 B 没有定义 test(),所以调用的是 A::test()

A::test() 中调用 func() 时,默认参数在编译阶段绑定为 1(由 A::func(int val=1) 决定)。

运行时,虚函数机制使得调用动态绑定到 B::func()

因此最终执行 B::func(1),输出B->1

构造函数

为什么构造函数不能是虚函数

原因:

  • 构造阶段对象尚未完全形成,vptr还没绑定
  • 无法进行虚表查找
  • 构造函数必须在编译器确定调用哪个函数(静态绑定)

override

检查子类的虚函数是否完成重写

c 复制代码
class Base {
public:
    virtual void show() const { cout << "Base::show()" << endl; }
};

class Derived : public Base {
public:
    void show() const override { cout << "Derived::show()" << endl; }  //正确重写
};

它的作用是:

如果函数名或参数错误导致没能正确重写,编译器会报错提醒。

c 复制代码
class Derived2 : public Base {
public:
    void shwo() const override { cout << "拼写错误!" << endl; }  //编译错误:未重写任何函数
};

final

修饰阻止父类被继续继承或重写

  1. 修饰虚函数:阻止被子类重写
c 复制代码
class A {
public:
    virtual void func() final { cout << "A::func()" << endl; }
};

class B : public A {
public:
    void func() override { cout << "B::func()" << endl; }  // 错误:final 函数不能被重写
};
  1. 修饰类:阻止被继承
c 复制代码
class FinalClass final {
public:
    void display() { cout << "FinalClass" << endl; }
};

class Derived : public FinalClass {};  //错误:final 类不能被继承

重载(Overload)、重写(Override)、重定义(Hide)区别

名称 英文 发生位置 函数名 参数列表 返回值类型 虚函数要求 含义
重载 Overload 同一作用域 相同 不同 无要求 无要求 在同类中定义多个同名函数
重写 Override 基类与派生类 相同 相同 相同(或协变) 必须是虚函数 子类重新实现父类虚函数,实现多态
重定义 Redefine / Hide 基类与派生类 相同 无要求 无要求 无要求 子类定义同名函数隐藏父类函数
说明:
  • "重载" 在同类内,同名但参数不同;
  • "重写" 是虚函数的重定义,实现多态;
  • "重定义" 是隐藏父类的同名函数,若非虚函数或签名不同,则不构成重写。

抽象类与纯虚函数:

虚函数后面加上=0,当类中含有纯虚函数,他就是抽象类,不能被实例化出对象

纯虚函数

c 复制代码
class Abstract {
public:
    virtual void func() = 0;  // 纯虚函数,无需实现
};

且派生类继承抽象类,也不能实例化出对象(继承纯虚函数)

纯虚函数作用:

  1. 强制子类必须重写
  2. 表示抽象类,抽象就是在现实中没有对应实体
c 复制代码
class Animal
{
protected:
	std::string m_name;

	Animal(const std::string_view& name)
		:m_name{ name }
	{

	}
public:
	const std::string& getName() const { return m_name; }
	/*virtual std::string_view speak() const { return "???"; }*/
	virtual std::string_view speak() const = 0; //Animal不能被实例化,只有派生类提供具体实现才能通过实例化派生类对象来使用这个接口



	virtual ~Animal() = default;
};


class Cat :public Animal
{
public:
	Cat( std::string_view name)
		:Animal{ name }
	{

	}
	std::string_view speak() const override { return "Meow"; }
	
};

class Dog :public Animal
{
public:
	Dog(std::string_view name)
		:Animal{ name }
	{

	}
	std::string_view speak() const override { return "Woof"; }
};

int main()
{
	Cat cat{ "Betsy" };
	std::cout << cat.getName() << "says" << cat.speak() << '\n';

	return 0;

}

纯虚函数在我们想在基类中放置一个函数,但只有派生类知道它应该返回什么时非常有用。纯虚函数使得基类不能被实例化,并且派生类在实例化之前被迫定义这些函数。这有助于确保派生类不会忘记重新定义基类期望它们重新定义的函数

任何带有纯虚函数的类也应该有一个虚析构函数。

析构函数可以是纯虚的,但必须给出一个定义,以便在派生对象析构时调用它。

接口继承:子类继承父类方法的接口(函数名,参数列表和返回值)

接口类

没有成员变量,而且所有函数都是纯虚函数

c 复制代码
class IErrorLog
{
public:
    virtual bool openLog(const char* filename) = 0;
    virtual bool closeLog() = 0;
 
    virtual bool writeError(const char* errorMessage) = 0;
 
    virtual ~IErrorLog() {} // make a virtual destructor in case we delete an IErrorLog pointer, so the proper derived destructor is called
};

!换言之,这个类就是一个纯定义,并没有实际实现。当我们需要定义出派生类必须实现的功能时,并且将这些功能都留给派生类实现时,使用接口类是很有用的。

实现继承:子类直接继承父类函数实现,从而可以复用父类已有代码

知识考察:

复制代码
下面哪种面向对象的方法可以让你变得富有?(A)
A. 继承
B. 封装
C. 多态
D. 抽象
(D)是面向对象程序设计语言中的一种机制。这种机制实现了方法的定义与具体的对象无关,而对方法的调用则可以关联于具体的对象。
A. 继承
B. 模板
C. 对象的自身引用
D. 动态绑定
面向对象设计中的继承和组合,下面说法错误的是?(C)
A. 继承允许我们覆盖重写父类的实现细节,父类的实现对于子类是可见的,是一种静态复用,也称为白盒复用
B. 组合的对象不需要关心各自的实现细节,之间的关系是在运行时候才确定的,是一种动态复用,也称为黑盒复用
C. 优先使用继承,而不是组合,是面向对象设计的第二原则
D. 继承可以使子类能自动继承父类的接口,但在设计模式中认为这是一种破坏了父类的封装性的表现
以下关于纯虚函数的说法,正确的是?(A)
A. 声明纯虚函数的类不能实例化
B. 声明纯虚函数的类是抽象基类
C. 子类必须实现基类的
D. 纯虚函数必须是空函数
关于虚函数的描述正确的是?(B)
A. 派生类的虚函数与基类的虚函数具有不同的参数个数和类型
B. 内联函数不能是虚函数
C. 派生类必须重新定义基类的虚函数
D. 虚函数可以是一个 static 型的函数

多态的原理

C++实现多态必须满足两个条件:

  1. 函数为虚函数
  2. 通过父类指针或引用
c 复制代码
Base* p = new Derived();
p->func();  // 调用 Derived::func()
  • 父类指针或引用指向父类对象->调用父类虚函数
  • 父类指针或引用指向子类对象->调用子类重写后的虚函数
    多态就是在运行中时刻到指向的对象的虚表中查找要调用的虚函数来进行调用

虚表机制

多态的底层实现依靠虚函数表虚表指针

举例:

c 复制代码
class Base
{
public:
    virtual void Func1()
    {
        cout << "Func1()" << endl;
    }

private:
    int _b = 1;
};

int main()
{
    cout << sizeof(Base) << endl;
    
    return 0;
}

sizeof(Base)是多少?

在64位系统中,应是16字节

复制代码
8 (vptr) + 4 (_b) + 4 (padding) = 16

vptr指针:8字节

int类型变量:4字节

对齐原则:最大字节的倍数 ->16字节

对象模型:

  • 每个含虚函数的对象,在内存布局中会增加一个指向虚表的指针(vptr)

  • 成员函数本身不占对象空间,它们编译后存放在代码中

  • 类的大小 = 它的所有非静态成员变量的大小 + 编译器为了对齐而加的填充字节 + 特殊机制开销(如虚函数表指针)

  • 对象大小 = 成员变量大小+vptr大小

    对象内存布局:

    | vptr(指向虚表) |
    | 成员变量 p |

    成员变量 s

虚表

  • 虚表是一个指针数组,每个元素指向对应虚函数的实现
  • 对象通过vptr只想虚表,从而根据实际类型调用正确的函数
  • 如果子类重写了父类虚函数,虚表中对应指针会被替换为子类实现

每个有虚函数的类,编译器都会为它自动生成一张虚表,虚表是函数指针数组,表中记录了该类的所有虚函数地址,虚表是全局共享的静态结构,存在于代码区或静态区,所有该类对象的vptr都指向同一张虚表

c 复制代码
Derived d1, d2;

d1.vptr == d2.vptr因为两者是同一个类

因此每个含虚函数的对象,编译器都会在内存布局中偷偷加一个指针,vptr,指向虚表

举例:

c 复制代码
Base* p = new Derived();
p->f();

编译器会翻译成汇编语言,等同于下面

c 复制代码
// 等价逻辑
(*(p->vptr)[0])(p);
  1. p->vptr:去除对象中的虚表指针
  2. (p->vptr)[0]:虚表的第0个元素,找到函数地址
  3. (*(p->vptr)[0])(p):调用那个函数,并把p作为this指针传进去
    对象中有一个 vptr(虚表指针)
  • 每个拥有虚函数的对象实例内部都会有一个隐藏成员:vptr。
  • 它的作用是:指向该对象所属类的虚表(vtable)。
    类对应一张虚表(vtable)
  • 每个带虚函数的类(包括派生类)都有自己的一张虚表。
  • 虚表是一个函数指针数组,每个元素对应一个虚函数实现的地址。
    虚表中的每个函数指针
  • 指向对应虚函数在代码段中的实际函数实现(函数体)。
  • 比如:
c 复制代码
vtable(Base):   [0] → &Base::func
vtable(Derived):[0] → &Derived::func

对象调用虚函数时

  • 编译器生成类似下面的逻辑:

    p->func();
    // 编译器生成的等价伪码:
    (*(p->vptr)[0])(p);

  • 也就是:

    • 先通过 p->vptr 找到虚表;
    • 再通过虚表的第0个函数指针 vtable[0] 确定调用哪个版本;
    • 最后把 p 传入函数作为 this。
      总结来说
  1. 对象 p 中保存着一个 虚表指针 vptr(这是对象里的一个隐藏成员);
  2. vptr 指向类的 虚函数表 vtable(这个表在全局区,程序启动时构建好的);
  3. 虚表中存放的是虚函数的地址(即函数指针),每个类一份;
  4. 根据当前对象的 vptr 找到对应类的虚表,再根据虚表中存放的函数指针,确定调用哪个版本的虚函数;
  5. 调用虚函数时,编译器自动把 p 的地址作为 this 传进去,确定调用哪个对象,让那段代码段中共享的函数知道"我该操作哪个对象的数据"。

重写VS覆盖

重写(Override)

  • 概念层面:子类重新实现父类的虚函数,提供具体功能。
  • 条件:函数名、参数、返回值与父类虚函数匹配。
    覆盖(Override 实现于虚表)
  • 原理层面:子类的虚表指针替换了父类虚表中对应位置的指针。
  • 当父类指针指向子类对象时,通过 vptr 查表,调用子类函数。
    总结:
  1. 子类未重写 → 虚表指向父类实现 → 调用父类函数。
  2. 子类重写 → 虚表指向子类实现 → 调用子类函数。
    虚表覆盖的概念
    覆盖(override in vtable) 并非运行时动态修改代码段,只是 编译器在生成 vtable 时,将子类重写的虚函数条目指向子类实现。
    vtable 本质是 只读存储(代码段 / 常量区),运行时不可修改。

虚函数和虚表的存储位置

  1. 虚函数(函数体)
    • 存储在代码段
    • 本质是一段指令序列,vtable中存储的首条指令地址
  2. 虚表
    • 存储在只读的全局/代码段,编译器进行,进程启动时加载
    • 运行时不可修改(只读)
    • 对象通过vptr指向虚表即可实现动态绑定
      访问虚表:
      对象头部的前几个字节通常存储vptr
      通过这种方式观察对象 vptr 与虚表条目。
cpp 复制代码
#include<iostream>
using namespace std;
class Base
{
public:
    virtual void Func1()
    {
        cout << "Func1()" << endl;
    }
    void func() {
        Base b1;
        printf("vfptr:%p\n", *(void**)&b1);//虚表指针地址
        int i = 0;
        int* p1 = &i;
        int* p2 = new int;
        const char* p3 = "hello";//隐式转换
        printf("栈变量:%p\n", p1);
        printf("堆变量:%p\n", p2);
        printf("代码段常量:%p\n", p3);
        printf("Base::Func1: %p\n", &Base::Func1);//虚函数地址
        printf("普通函数地址:%p\n", &Base::func);//&取函数地址
    }

private:
    int _b = 1;
};

int main()

{
    Base b1;
    b1.func();
    


    /*cout << sizeof(Base) << endl;*/

    return 0;
}

多虚函数情况

假设父类有func1,func2两个虚函数

父类vtable:

复制代码
[func1_address, func2_address, nullptr]

子类继承父类:

  • vtable拷贝父类虚表

  • 若子类重写func1->覆盖第0条目

  • 未重写的func2->保持指向父类函数
    也就是子类虚表=父类虚表+重写条目覆盖
    通过程序验证C++对象虚表的地址和虚函数,普通函数,常量,堆栈变量的存储位置
    对象头部的虚表指针(vptr)
    每个含虚函数的对象在头部存有 vptr(虚表指针),指向该对象类型的 vtable。
    在 32 位系统中,可以通过强制类型转换取出前 4 个字节,即 vptr 地址:

    Base b;
    int* vptr = (int*)&b; // 获取对象头部的 vptr

解引用 vptr 可以访问 vtable 中的第一个虚函数地址:

复制代码
void (*func)() = (void (*)())vptr[0];
func(); // 调用虚函数

⚠️ 注意:

  • 对象头部前 4 个字节存的是 虚函数表地址,不是虚函数本身。
  • 直接强转对象为 int* 取地址不被允许,必须通过指针类型转换。

存储位置

验证存储位置的方法

栈变量:

复制代码
int local_var = 0;
  • 存在 栈(Stack) 上

  • 地址通常较低,随函数调用结束而释放
    堆变量:

    int* heap_var = new int(0);

存在 堆(Heap) 上

地址通常高于栈,需手动释放

常量/字符串:

c 复制代码
const char* str = "Hello";
  • 存在 常量区(.rodata / read-only data segment)
  • 地址固定不可修改
    函数/虚函数:
c 复制代码
void Base::func();

函数体编译后存放在 代码段(Text Segment)

vtable 中存储的是函数的首条指令地址

多对象共用同一份 vtable

地址对比

栈变量地址 < 堆变量地址 < 常量区地址 ≈ 虚表地址 ≈ 代码段函数地址

通过打印对象的 vptr 与各类变量地址,可以验证 虚表确实存在于常量/代码段,并且 不同对象共享同一虚表:

c 复制代码
[std::cout << "vptr: " << vptr << "\n";
std::cout << "栈变量: " << &local_var << "\n";
std::cout << "堆变量: " << heap_var << "\n";
std::cout << "常量: " << str << "\n";
std::cout << "函数地址: " << &Base::func << "\n";](std::cout << "\n";

单继承与多继承虚表布局

单继承:

  • 派生类继承父类虚函数
  • 重写父类虚函数会替换父类虚表对应条目
  • vtable顺序:父类函数(未重写/重写)+派生类新增函数
    单继承时,派生类对象通常包含:
  1. 父类虚表指针
  2. 父类继承的成员变量
  3. 派生类自由成员变量
    虚表存储虚函数指针:
  • 重写父类虚函数时替换父类虚表条目
  • 派生类新增虚函数追加到虚表末尾

多继承

多继承概念

多继承:

  • 每个基类有自己的虚表
  • 派生类对象可能含有多个vptr(每个基类一份)
  • 派生类自有成员变量不需额外vptr
  • 每个vptr指向对应基类虚表
  • 虚函数重写作用于对应基类的虚表条目
  • 内存布局
c 复制代码
  Derived object:
[ vptr_Base1 ][ b1 ]   <- Base1 部分
[ vptr_Base2 ][ b2 ]   <- Base2 部分
[ d ]                   <- Derived 自有成员

派生类虚函数分配:

  • f1(重写Base1::f1)->Base1的虚表
  • f2(重写Base::f2)->Base2的虚表
  • f3(Derived自有虚函数)->Base1的虚表末尾

多继承对象

多继承对象大小计算

  • 派生类对象大小 = ∑基类成员 + 基类 vptr + 派生类自有成员
  • 注意:
    • 虚表本身不算入对象大小,只存指针
    • 多继承导致对象拥有多个 vptr,每个基类独立虚表
    • 增加虚表解析复杂度
      虚表打印与访问(多继承)
      派生类有多个虚表:
  1. vptr_Base1 指向 Base1 的虚表
  2. vptr_Base2 指向 Base2 的虚表
    打印方式:
c 复制代码
typedef void (*VFuncPtr)();

// 打印 Base1 虚表
VFuncPtr* vtable1 = *(VFuncPtr**)&derived;
for (int i = 0; vtable1[i] != nullptr; ++i)
    vtable1[i]();

// 打印 Base2 虚表
VFuncPtr* vtable2 = *(VFuncPtr**)((char*)&derived + sizeof(Base1));
for (int i = 0; vtable2[i] != nullptr; ++i)
    vtable2[i]();

注意事项:

  • 指针偏移不能写死8字节,而应使用sizeof(Base)保持可移植性
  • Derived自由虚函数f3被放置在第一个继承的基类虚表末尾

类成员函数和虚函数的限制

类成员函数:使用static关键字修饰的成员函数,该类函数属于整个类,不属于任何对象实例

类函数不能是虚函数:

  • 类函数没有对象实例,也就没有this指针
  • 虚表(vtable)存储的是通过对象访问的函数地址
  • 没有对象指针,虚表无法存放类函数的地址
    内联函数
  • 内联函数在编译器展开
  • 不需要函数地址,不能作为虚函数
    静态成员函数
  • 静态成员函数没有this指针
  • 无法通过对象访问,因此也不能放入虚表
    虚表中的条目必须是通过对象访问的成员函数(非静态,非类函数),以便支持动态绑定

多继承和对象切片:

按值传递和引用

  • 按值传递时,如果形参是基类,实参是派生类,则会被切片,导致派生类中虚函数无法调用最保险的是按引用传递,此时即使转换成了基类的引用,也是不妨碍虚函数工作
  • 确保函数·形参是引用(或指针),并在派生类中尽量避免任何形式的值传递
c 复制代码
class Base
{
protected:
	int my_value{};
public:
	Base(int value)
		:my_value{ value }
	{
	}

	virtual ~Base() = default;

	virtual std::string_view getName() const { return "Base"; }
	int getValue()const { return my_value; }
};

class Derived :public Base
{
public:
	Derived(int value)
		:Base{ value }
	{
	}

	std::string_view getName() const override { return "Derived"; }
};


int main()
{
	/*Cat cat{ "Betsy" };
	std::cout << cat.getName() << "says" << cat.speak() << '\n';*/
	//Copier copier{ 1,2,3 };//Scanner 1,3-》P 3->S 1->P->3->P-》2
	Derived derived{ 5 };
	std::cout << "derived is a " << derived.getName() << " and has value "<<derived.getValue() << '\n';

	Base& ref{ derived };
	std::cout << "ref is a " << ref.getName() << "  and has value " << ref.getValue() << '\n'; //引用Derived。ref是derived对象的别名

	Base* ptr{ &derived };
	std::cout << "ptr is a " << ptr->getName() << " and has value " << ptr->getValue() << '\n';//基类指针指向派生类的地址,其实还是指向派生类对象
	Base base{ derived };
	std::cout << "base is a " << base.getName() << " and has value " << base.getValue() << '\n';
    
	return 0;

}

按值传递,将Derived对象赋给Base对象,只有Derived对象的Base部分被复制,Derived部分不会被复制,base接收derived的Base部分的副本,但没有接受Derived部分,Derived部分实际已经被切掉了,将一个Derived类对象赋值给一个Base类对象被称为对象切片,因此base.getName()被解析为Base::getNmae()

一些切片的经常情况:

c 复制代码
void printName(const Base base)
{
	std::cout << "I am a " << base.getName() << '\n';
}

int main()
{
    Derived d{ 5 };
    printName(d); 

    return 0;
}

base 是一个值参数,而不是引用。

因此:当调用 printName(d) 时,虽然我们可能期望 base.getName() 调用虚函数 getName() 并打印"I am a Derived",但这并没有发生。

相反

  1. Derived 对象 d 被切片,只有 Base 部分被复制到 base 参数中。

    • 也就是创建一个 新的 Base 类型对象
    • 在栈上分配内存。
    • 用 d 中的 Base 部分的变量拷贝到新对象里同时拷贝 Base 的 vptr,指向 Base 的虚表。
    • 当 base.getName() 执行时,即使 getName() 函数是虚化的,也没有 Derived 部分可供它解析。

    I am a Base

因此我们将函数形参设为引用,而不是按值传递,可以避免这里的切片(也是将类按引用而不是按值传递的原因)

vector切片

c 复制代码
std::vector<Base> v{};
v.push_back(Base{ 5 });
v.push_back(Derived{ 6 });

for (const auto& element : v)
	std::cout << "I am a " << element.getName() << "with value  " << element.getValue() << '\n';

解决办法:

  1. 创建一个指针向量
c 复制代码
std::vector<Base*>v{};//指针数组,存储对象的地址

Base b{ 5 };//创建值为5的Base对象
Derived d{ 6 };//创建值为6的Derived对象

v.push_back(&b);//将对象b的地址传入数组
v.push_back(&d);//再把对象d的地址传入数组对象d的地址其实就是一种Derived*的指针,然后Derived*指针可以转向Base*指针

for (const auto* element : v)
	std::cout << "I am a " << element->getName() << " with value" << element->getValue() << '\n';
  1. 指针或引用类型决定可以访问哪些成员(包括函数和变量)。
    • 比如 Base* ptr 只能访问 Base 定义的成员,Derived 特有的成员不能访问。
  2. 虚函数的调用结果由对象的实际类型决定(动态绑定)。
    • 即使 Base* ptr = new Derived();,调用虚函数时会调用 Derived 重写的版本。
  3. 普通函数(非虚函数)调用是静态绑定,由指针/引用类型决定调用的函数。
    • 不管对象实际类型是什么,调用的都是编译时类型的函数。
      因此:
      element->getName():
  4. 解引用指针element->找到对象内存
  5. 读取对象开头的vptr->这里的vptr指向Derived的虚函数表
  6. 从vtable找到getName()对应的函数地址->Derived::getName()
  7. 调用函数,this指针就是element(指向Derived对象的地址)

  1. 使用std::reference_wrapper,这是一个模仿可重新赋值引用的类
c 复制代码
std::vector<std::reference_wrapper<Base>> v{};
Base b{ 5 };
Derived d{ 6 };

v.push_back(b);
v.push_back(d);

for (const auto& element : v)
	std::cout << "I am a " << element.get().getName() << " with value" << element.get().getValue() << '\n';
return 0;

C++允许把子类对象的引用或指针隐式转换为父类引用或指针

再将d放入vector->std::reference_wrapper<Base>时类似于下面

c 复制代码
Derived d;
Base& b_ref = d;  // 隐式转换

缝合怪对象Frankenobject

c 复制代码
Derived d1{ 5 };//创建d1对象
Derived d2{ 6 };//创建d2对象
Base& b{ d2 };//b指向d2

b = d1;//d2 = d1,b是虚函数

C++为类提供的操作符=在默认情况下不是虚函数的,只有d1的Base部分复制到d1,d2有d1基类部分,d2派生部分,会导致不好的情况,避免这样的赋值

绑定

绑定指定就是将标识符(变量名或函数名)转换为地址的过程

早期绑定

在编译时就能确定函数调用目标的绑定方式

实现方式;

  • 函数重载
  • 模板函数
    特点:
  • 地址在编译器确定
  • 不依赖运行时对象类型
    直接函数调用可以使用称为早期绑定的过程来解决。
    意味着编译器(或链接器)能够直接将标识符名(如函数名或变量名)与机器地址关联起来==。记住,所有函数都有唯一的地址。因此,当编译器(或链接器)遇到函数调用时,它会用一个机器语言指令替换函数调用,该指令告诉CPU跳转到函数的地址。
c 复制代码
#include<iostream>
int add(int x, int y)
{
	return x + y;
}

int substract(int x, int y) {
	return x - y;
}

int multiply(int x, int y)
{
	return x * y;
}


int main() {
	int x{};
	std::cout << "Enter a number: ";
	std::cin >> x;

	int y{};
	std::cout << "Enter another number";
	std::cin >> y;

	int op{};
	do
	{
		std::cout << "Enter an operation(0=add,1=subtract,2=multiply):";
		std::cin >> op;
	} while (op < 0 && op>2);

	int result{};
	switch (op)
	{
	case 0:result = add(x, y); break;
	case 1:result = substract(x, y); break;
	case 2:result = multiply(x, y); break;
	}

	std::cout << "The answer is: " << result << '\n';
	return 0;
}

因为add(),subtract(),multiply都是直接函数调用,因此用早期绑定,直接将add()函数调用替换为一个指令,告诉CPU如何跳转到add()函数地址

延迟绑定

动态绑定:

定义:函数调用目标在运行时才确定,通常依赖于对象的实际类型

实现机制:

  • 通过虚函数表对象头部虚表指针(vptr)
    在某些程序中,在runtime|运行时]之前不可能知道将调用哪个函数。这被称为Late-binding(后期绑定),在C++中,获得后期绑定的一种方法是使用函数指针,函数指针是一种指向函数而不是变量的指针,函数指针所指向的函数可以通过指针上使用函数调用操作符()来调用
c 复制代码
int (*pFcn)(int, int) { nullptr }; //函数指针声明
switch (op)
{
case 0:pFcn = add; break;
case 1:pFcn = substract; break;
case 2:pFcn = multiply; break;
}

std::cout << "The answer is: " << pFcn(x, y) << '\n';

使用后期绑定,程序必须读取指针中保存的地址,然后跳转到该地址。

动态类型转换

c 复制代码
#include <iostream>
#include<string>
#include<string_view>

class Base
{
protected:
    int m_value{};
public:
    Base(int value)
        :m_value{ value }
    {
    }//Base构造函数
    virtual ~Base() = default;
};

class Derived : public Base
{
protected:
    std::string m_name{};//受保护的成员变量
public:
    Derived(int value, std::string_view name)
        :Base{ value }, m_name{ name }
    {
    }//Derived构造函数

    const std::string& getName() const { return m_name; }//Derived独有函数
};

Base* getObject(bool returnDerived)
{
    if (returnDerived)
        return new Derived{ 1,"Apple" };//指向Derived对象
    else
        return new Base{ 2 };//指向Base对象
}//返回Base*指针

int main()
{
    Base* b{ getObject(true) };//返回Derived对象
    b.getName();//出错,Base中没有getName(),指针类型限制你访问的函数接口
    //如何打印派生类对象名字
    delete b;
    return 0;
}

如果采用在基类添加虚函数实现:

基类加上虚函数,可以让父类指针(或引用)在指向子类对象时实现动态绑定(也就是多态),实现向上转型,但是在基类中加入只有派生类才有意义的虚函数,对基类本身是无意义的,

而且会导致基类被"污染"(即接口设计变脏、逻辑不统一)。

因此:

要将Base指针转换为Derived指针,不需要虚函数解析

dynamic_cast

动态类型转换-》将基类指针转换为派生类指针,向下转型

c 复制代码
 Base* b{ getObject(true) };//返回Derived对象
/* b.getName();*/
 //如何打印派生类对象名字
 Derived* d{ dynamic_cast<Derived*>(b) };//d指向Drived对象,将d转换为Derived*类型指针不忍返回nullptr
 std::cout << "The name of the Derived is "<<d->getName()<<'\n';
 delete b;

b指向一个派生对象。如果b不指向派生对象呢?这很容易通过将getObject()的参数从true更改为false来测试。在这种情况下,getObject()将返回一个Base对象的Base指针。当我们尝试将dynamic_cast转换为派生类型时,它会失败,因为无法进行转换。

如果 dynamic_cast 失败,则转换结果会是一个空指针。

因为我们没有检查空指针的结果,所以我们访问d->getName(),它将尝试解引用空指针,导致未定义行为。

c 复制代码
int main()
{
	Base* b{ getObject(true) };
 
	Derived* d{ dynamic_cast<Derived*>(b) }; 
	if (d) // 确保 d 是非空
		std::cout << "The name of the Derived is: " << d->getName() << '\n';
 
	delete b;
 
	return 0;
}

为了使这个程序安全,需要确保 dynamic_cast 的结果实际上是成功的:
总是要检查返回值是否为空指针来确保动态转换成功

注意,由于 dynamic_cast 在运行时进行一些一致性检查(以确保可以进行转换),因此使用 dynamic_cast 确实会导致性能损失。

以下情形使用dynamic_cast不成功的

  1. 受保护继承和私有继承的类
  2. 对于没有声明和继承人和虚函数(因此没有虚表的类)->源类型是不是多态
  3. 在某些情况下设计虚基类的情况

static_cast

使用static_cast进行向下转换

c 复制代码
 Base* b{ getObject(true) };//b指向Derived
 if (b->getClassID() == ClassID::derived) //调用Derived的getClassID函数,判断指针是否指向派生类对象
 {
     Derived* d{ static_cast<Derived*>(b) };//d转换为Derived*指针
     std::cout << "The name of the Derived is " << d->getName() << '\n';
 }
 delete b;

static_cast 本身不会做运行时类型检查,

但我们人为先判断过类型,所以这里是安全的。但是如果将 Base* 转换为Derived*,即使基类指针没有指向派生类对象,它也会"成功"。当尝试访问结果派生指针(实际上指向 Base 对象)时,这将导致未定义的行为。

dynamic_cast和引用

c 复制代码
Derived apple{ 1,"Apple" };//创建一个Derived对象
Base& b = { apple };//向上转型但是只能访问Base接口
Derived& d{ dynamic_cast<Derived&>(b) };//向下转型,查找到b的实际指向对象,转换为实际指向对象的引用类型

std::cout << "The name of the derived is: " << d.getName() << '\n';

因为C++中没有"空引用",所有 dynamic_cast 在失败时不能返回空引用。所以,如果引用的 dynamic_cast 失败,则会抛出std::bad_cast 类型的异常。

dynamic_cast vs static_cast

什么时候使用static_castdynamic_cast

除非是向下类型转换(dynamic_cast是更好的选择),否则一律使用static_cast,但是应该考虑完全避免强制转换,只是用虚函数

向下转换vsdynamic_cast

一般来说,使用虚函数应该由于向下转换,但是有些时候向下转换会更好

  • 当你不能修改基类来添加虚函数(例如:因为基类是标准库的一部分)
  • 当你需要访问特定与派生类的东西时(例如:一个只存在派生类的访问函数)
  • 在基类中添加虚函数是没有意义的(例如:基类没有适当的返回值),如果不需要实例化基类,则可以使用纯虚函数

对 dynamic_cast 和 RTTI 的一些警示

RTTI 提供的三种机制

  1. typeid
    用来获取 对象的实际类型信息。
c 复制代码
#include <typeinfo>

Base* ptr = new Derived();
std::cout << typeid(*ptr).name() << '\n';

输出的类型名取决于对象的真实类型,比如:

c 复制代码
class Derived

注意:必须写 ptr,否则 typeid(ptr) 只会告诉你是个 Base 指针。

  1. dynamic_cast

用来 在继承体系中安全地转换类型。

c 复制代码
Base* ptr = new Derived();

Derived* d = dynamic_cast<Derived*>(ptr); // 安全
if (d)
    std::cout << "ptr actually points to a Derived\n";
else
    std::cout << "conversion failed\n";

如果 ptr 实际上不是 Derived 类型(例如它真的是 Base 对象),dynamic_cast 会返回 nullptr。这比 static_cast 安全得多。

  1. std::type_info
    typeid 返回的是一个 std::type_info 对象引用,你可以用它比较类型:
c 复制代码
if (typeid(*ptr) == typeid(Derived))
    std::cout << "This is a Derived\n";

RTTI 的原理(简要)

  • 每个带有 虚函数 的类在内存中都有一个隐藏的 vtable(虚函数表)。
  • RTTI 的信息(例如 type_info 对象)也存放在虚表旁边。
  • 当你调用 typeid 或 dynamic_cast 时,程序通过 对象的 vptr(虚表指针) 去查找这些类型信息。
    所以:
    RTTI 依赖于 虚函数表(vtable),只有带虚函数的类才能使用 dynamic_cast、typeid(*p) 等功能。

使用<<运算符打印继承类

  • 友元函数不属于成员函数,因此不能是虚函数
  • 不同类<<需要传入不同类的对象,所以即使能定义为虚函数,派生类也没办法重写它
  • 友元运算符可以将实际工作委派给一个普通的成员函数(虚函数),而且无需再派生类中实现该运算符,只需实现该虚函数的重写函数即可
c 复制代码
#include <iostream>

class Base
{
public:
	virtual void print() const { std::cout << "Base"; }

	friend std::ostream& operator<<(std::ostream& out, const Base& b)
	{
		out << "Base";
		return out;
	}
};

class Derived : public Base
{
public:
	void print() const override { std::cout << "Derived"; }

	friend std::ostream& operator<<(std::ostream& out, const Derived& d)
	{
		out << "Derived";
		return out;
	}
};

int main()
{
	Base b{};
	std::cout << b << '\n';

	Derived d{};
	std::cout << d << '\n';

	return 0;
}

如果我们想要用以下方式

c 复制代码
int main()
{
    Derived d{};
    Base& bref{ d };
    std::cout << bref << '\n';

    return 0;
}

这个程序打印

c 复制代码
Base

因为operator<<不是虚函数版本,所以是std::cout<<bref调用处理Base对象的operator<<版本

因此可以让operator<<成为虚函数吗?

不可以

  1. 只有成员函数才可以成为虚函数
  2. 即使将operator<<虚化,Base::operator和Derived::operator<<函数参数不同的问题,因此,Derived 版本不会被视为 Base 版本的重写,因此不符合虚函数解析的条件。
    解决方案:
c 复制代码
class Base
{
    friend std::ostream&operator<<(std::ostream & out, const Base & b)
    {
        out << b.identify();//调用虚函数
        return out;
    }

    virtual std::string identify() const {
        return "Base";
    }//定义虚函数,返回Base
};

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

int main()
{
	Base b{};
	std::cout << b << '\n';

	Derived d{};
	std::cout << d << '\n'; 
	Base& bref{ d };
	std::cout << bref << '\n';

	return 0;
}

Derived d 的情况下,编译器首先检查是否存在接受 Derived 对象的 operator<<。没有,因为我们没有定义。接下来编译器检查是否存在接受 Base 对象的 operator<<。存在,因此编译器将我们的 Derived 对象隐式向上转换为 Base& 并调用该函数(我们可以自己进行这种向上转换,但编译器在这方面很有帮助)。因为参数 b 引用的是 Derived 对象,所以虚函数调用 b.identify() 解析为 Derived::identify(),它返回"Derived"以供打印

请注意,我们不需要为每个派生类定义 operator<<!处理 Base 对象的版本对于 Base 对象和任何从 Base 派生出来的类都同样适用!

第三种情况是前两种情况的混合。首先,编译器将变量 bref 与接受 Base 引用的 operator<< 匹配。因为参数 b 引用的是 Derived 对象,所以 b.identify() 解析为 Derived::identify(),返回"Derived"。

还有一个更好的解决办法,在处理流对象会有更好的效果

c 复制代码
class Base
{
public:
    friend std::ostream& operator <<(std::ostream& out, const Base& b)
    {
        return b.print(out);
    }

    virtual std::ostream& print(std::ostream& out)const
    {
        out << "Base";
        return out;
    }
};

struct Employee
{
    std::string name{};
    int id{};

    friend std::ostream& operator<<(std::ostream& out,const Employee& e)
    {
        out << "Employee(" << e.name << "," << e.id << ")";
        return out;
    }
};

class Derived :public Base
{
private:
    Employee m_e{};
public:
    Derived(const Employee& e)
        :m_e{ e }
    {
    }

    std::ostream& print(std::ostream& out)const override
    {
        out << "Derived: ";
        out << m_e;
        return out;
    }
};
相关推荐
ysa0510302 小时前
虚拟位置映射(标签鸽
数据结构·c++·笔记·算法
m0_748248023 小时前
C++中的位运算符:与、或、异或详解
java·c++·算法
草莓熊Lotso4 小时前
C++ 方向 Web 自动化测试实战:以博客系统为例,从用例到报告全流程解析
前端·网络·c++·人工智能·后端·python·功能测试
共享家95274 小时前
LRU 缓存的设计与实现
开发语言·c++
草莓熊Lotso5 小时前
Linux 基础开发工具入门:软件包管理器的全方位实操指南
linux·运维·服务器·c++·人工智能·网络协议·rpc
小龙报5 小时前
算法通关指南:数据结构和算法篇 --- 队列相关算法题》--- 1. 【模板】队列,2. 机器翻译
c语言·开发语言·数据结构·c++·算法·学习方法·visual studio
晨非辰5 小时前
【数据结构初阶】--从排序算法原理分析到代码实现操作,参透插入排序的奥秘!
c语言·开发语言·数据结构·c++·算法·面试·排序算法
2301_795167209 小时前
玩转Rust高级应用 如何避免对空指针做“解引用”操作,在C/C++ 里面就是未定义行为
c语言·c++·rust
不染尘.14 小时前
2025_11_7_刷题
开发语言·c++·vscode·算法