c++之多态篇

📅 2026/7/16 20:47:39 👁️ 阅读次数 📝 编程学习
c++之多态篇

一、多态的概念

多态(Polymorphism)​​ 的字面意思是“多种形态”。在面向对象编程中,它指的是:​同一个行为(方法),在不同的对象上会表现出不同的实现方式

举个例子:对于移动这个词,汽车的移动方式是行驶,飞机的移动方式是飞行,人的移动方式是走路,虽然我们都称之为“移动”这个行为,但不同对象执行它的具体方式完全不同。这就是多态的思想。

在编程中,多态意味着:​我们可以使用一个统一的接口(父类引用)来操作不同的对象(子类实例),而具体执行哪个对象的代码,则由其实际类型在运行时决定。​

二、多态的定义

2.1构成多态要三个条件:

1.存在一个继承 hierarchy(层级结构),即父类和子类,子类对父类中定义的某些方法提供了自己的、不同的实现(即方法重写)

2.必须通过基类的指针或者引用调用虚函数,因为只有基类的指针或引用才能既指向基类 对象又指向派⽣类对象(继承文章有提到)

3.被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写

2.2虚函数:

虚函数:即被virtual修饰的类成员函数称为虚函数。

class Animal { public: // 使用 virtual 关键字声明虚函数 virtual void makeSound() { cout << "动物发出某种叫声..." << endl; } };

2.3虚函数重写

虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的
返回值类型、函数名字、参数列表完全相同
),称子类的虚函数重写了基类的虚函数。注意:继承的时候有一个重定义的概念,但是重写和重定义是两回事,重定义是没有virtual关键字的,并且重定义只要求函数名一样,参数以及返回值根本不在乎。

注意:这里的重写可以理解为覆盖,但是只是覆盖了定义的内容,声明不变,也就是相当于把接口里的方法变了,但是传入的接口名,缺省参数的初始值不变

​ class Animal { public: // 使用 virtual 关键字声明虚函数 virtual void makeSound() { cout << "动物发出某种叫声..." << endl; } }; ​// 派生类:狗 class Dog : public Animal { public: virtual void makeSound() { cout << "汪汪汪!" << endl; } }; // 派生类:猫 class Cat : public Animal { public: virtual void makeSound() { cout << "喵喵喵!" << endl; } };

2.4虚函数重写的两个例外

1. 协变(基类与派生类虚函数返回值类型不同)(了解即可)

派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指
针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变
。(了解)所以说虚函数重写并非要满足前面3个条件。

class A{}; class B : public A {}; class Person { public: virtual A* f() {return new A;} }; class Student : public Person { public: virtual B* f() {return new B;} };

2. 析构函数的重写(基类与派生类析构函数的名字不同)

如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,
都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,
看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处
理,编译后析构函数的名称统一处理成destructor。

class Person { public: virtual ~Person() {cout << "~Person()" << endl;} }; class Student : public Person { public: virtual ~Student() { cout << "~Student()" << endl; } }; // 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函 数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。 int main() { Person* p1 = new Person; Person* p1 = new Student; delete p1; delete p2; return 0; }

2.5与虚函数相关的关键字:override和final

final关键字:

作用:修饰虚函数,表示该虚函数不能再被重写

class Car { public: virtual void Drive() final {} }; class Benz :public Car { public: virtual void Drive() {cout << "Benz-舒适" << endl;} //会报错,因为基类有的Drive()函数有final关键字修饰,子类不能重写Drive()虚函数 };

override关键字:

作用:检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。

class Car{ public: virtual void Drive(){} }; class Benz :public Car { public: virtual void Drive() override {cout << "Benz-舒适" << endl;} };

override是一个不错的有利于纠正我们一些错误的关键字,比如我们派生类想重写的虚函数名错误,此时override就发力了,编译器会报错。

三、抽象类

3.1概念

在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口
类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生
类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。

#include <iostream> using namespace std; // 抽象基类:动物 class Animal { public: virtual void makeSound() const = 0; // 纯虚函数 virtual ~Animal() {} // 虚析构函数 }; // 具体派生类:狗 class Dog : public Animal { public: void makeSound() const override {//这里派生类用不用都构成重写,只要基类有virtual关键字就行 cout << "汪汪!" << endl; } }; // 具体派生类:猫 class Cat : public Animal { public: void makeSound() const override { cout << "喵喵!" << endl; } }; int main() { // Animal animal; // 错误:不能创建抽象类的对象 Dog dog; Cat cat; // 多态调用 Animal* animal1 = &dog; Animal* animal2 = &cat; animal1->makeSound(); // 输出: 汪汪! animal2->makeSound(); // 输出: 喵喵! return 0; }

3.2接口继承和实现继承

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实
现。纯虚函数的继承是一种接口继承,派生类继承的是基类纯虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。

举个例子:

接口继承

#include <iostream> using namespace std; // 抽象基类 - 主要提供接口继承 class Animal { public: // 纯虚函数 - 只继承接口,不继承实现 // 派生类 MUST 提供自己的实现 virtual void makeSound() const = 0; // 虚析构函数是必须的 virtual ~Animal() {} }; class Dog : public Animal { public: // 重写纯虚函数,提供具体实现 void makeSound() const override { cout << "汪汪汪!" << endl; } }; class Cat : public Animal { public: void makeSound() const override { cout << "喵喵喵!" << endl; } };
  • Animal类规定了所有动物都必须有一个makeSound()行为(接口)。
  • 但它没有提供makeSound()的具体实现(=0)。
  • Dog和Cat​继承了这个接口​(必须实现makeSound),并提供了各自的实现

实现继承:

// 基类 - 提供接口和默认实现 class AnimalWithDefault { public: // 非纯虚函数 - 既继承接口,也继承默认实现 // 派生类可以使用默认实现,也可以重写它 virtual void sleep() const { cout << "Zzz... (动物在睡觉)" << endl; } }; class LazyDog : public AnimalWithDefault { // 没有重写 sleep(), 直接继承并使用基类的默认实现 };

LazyDog类继承了基类已经实现的函数。

四、多态的原理

4.1虚指针表

我们先来看下面一个代码

class Base { public: virtual void Func1() { cout << "Func1()" << endl; } protected: int _b = 1; char _ch = 'x'; }; int main() { Base b; cout << sizeof(b) << endl; return 0; }

上述代码运行结果是16字节,但是int是4字节,char是1字节,就算内存对齐也是8字节,为什么最后结果是16字节呢?(注意:这里的16字节是在64位系统下,如果32位系统下,则运行结果是12)

我们调试了一下发现

诶!怎么多了一个void指针的指针类型。

对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代 表function)(注意:可能不同的编译器名称不同,有的是vptr)。一个含有虚函数的类中都至少都有⼀个虚函数表指针,这个虚函数表指针指向虚函数表的地址,一个类所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表(vtable)。

4.2多态的原理

满足多态条件后,底层不再是编译时通过调用对象确定函数的地址,而是运行时到指向的对象的虚表中确定对应的虚函数的地址,这样就实现了指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类对应的虚函数。

4.3动态绑定和静态绑定

1.对不满足多态条件(指针或者引用+调用虚函数)的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。

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

扩展:我们想要将我们写的Cpp程序代码进行运行,我们首先得将Cpp的代码通过编译转成二进制编码,然后才可以进行运行,我们编译期分为四部:预处理(#include头文件插入、#define宏替换等等)、语法检查、生成汇编代码、将所有汇编代码文件(.o)进行合并也就是链接的过程(就生成了可执行文件exe),然后我们再通过gcc命令运行可执行文件exe,我们由于虚函数我们在编译器无法确认对象指向的是哪一个,所以我们并不能在编译器就把函数调用的地址直接绑定,例如下面这段代码:

class Base { public: virtual void func() { cout << "Base\n"; } }; class Derived1 : public Base { public: void func() override { cout << "Derived1\n"; } }; class Derived2 : public Base { public: void func() override { cout << "Derived2\n"; } }; // 甚至可以后续新增 Derived3、Derived4,编译原代码时根本不知道存在 void foo(Base* p) { p->func(); // 关键调用 } int main() { int x; cin >> x; Base* p; if(x == 1) p = new Derived1; else if(x == 2) p = new Derived2; else p = new Base; foo(p); return 0; }

这段代码很明显我们是不能通过编译器直到它对应的函数地址也就是它具体要调用的是哪一个作用域的函数,因为他要根据x输入的值来进行转化,如果foo函数不是多态的那么就说明子类和派生类用的这个函数的实现都是一样的所以可以直接绑定函数地址在编译器的时候,但是如果它是多肽的话我们就不能在编译期绑定函数地址我们必须在运行时根据虚函数函数表来绑定它的函数地址。

4.4虚函数表

  1. 基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共用同一张虚表,不同类型的对象各自有独立的虚表,所以基类和派生类有各自独立的虚表(同类型的对象共用一张虚表,这也说明了为什么一个对象只有虚函数表指针,而不是虚函数表,目的是为了更加高效节省空间)。
  2. 派生类由两部分构成,继承下来的基类和自己的成员,一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意的这里继承下来的基类部分虚函数表指针指向的地址和基类对象的虚函数表指针指向的地址不是同一个。
  3. 派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。
  4. 派生类的虚函数表中包含,(1)基类的虚函数地址,(2)派生类重写的虚函数地址完成覆盖,派生类自己的虚函数地址两个部分。
  5. 虚函数存在哪的?虚函数和普通函数⼀样的,编译好后是一段指令,都是存在代码段的,只是虚函数的地址又存到了虚表中。
  6. 虚函数表存在哪的?这个问题严格说并没有标准答案C++标准并没有规定,vs下是存在代码段(常量区)