一、 多态概念
多态(polymorphism),通俗来说,就是多种形态。多态分为编译时多态(静态多态)和运行时多态(动态多态)。这里我们重点讲运行时多态,同时简单介绍编译时多态。
1.1动态绑定和静态绑定
-
静态绑定:
对于不满足多态条件(即指针或引用 + 调用虚函数)的函数调用,是在编译时绑定的,也就是说编译时已经确定了将要调用的函数地址。这种方式称为静态绑定。 -
动态绑定:
满足多态条件的函数调用(指针或引用 + 调用虚函数),是在运行时绑定的。也就是说,在运行时根据指针或引用实际指向的对象,在该对象的虚函数表中找到并调用相应的函数地址。这种方式称为动态绑定。
- 编译时多态(静态多态) 使用静态绑定,例如函数重载、模板等。
- 运行时多态(动态多态) 使用动态绑定,通过虚函数实现。
二、编译时多态(静态多态)
编译时多态通过函数重载、运算符重载和模板来实现。它在编译阶段确定,属于静态绑定所以也称静态多态。
它的实现方式也就是我们之前学过的:
- 函数重载:同一个函数名可以有多个不同的参数列表,编译器在编译时根据参数的类型和个数来决定调用哪个函数。
- 运算符重载:可以为用户自定义的类型(例如类)定义运算符的行为。
- 模板:允许编写与类型无关的通用代码,在编译时实例化为具体的类型。
举个简单例子:
#include <iostream>
using namespace std;
class Shape {
public:
// 编译时多态:函数重载
void draw() {
cout << "无参draw" << endl;
}
void draw(int size) {
cout << "传参draw 参数: " << size << endl;
}
};
int main() {
Shape shape;
shape.draw(); // 调用无参数的draw
shape.draw(10); // 调用有参数的draw
}
这里就不过多赘述,如果对 函数重载、运算符重载、模板 不熟悉的朋友可以翻看前面的篇章:
C++(一)-CSDN博客 ——函数重载
C++ (四) 类和对象part 2-CSDN博客 ——运算符重载
C++(七)模板_c++template typename-CSDN博客
C++(十四)模板进阶_c++14 模板-CSDN博客
三、运行时多态(动态多态)
接下来详细介绍本篇正题:
运行时多态通过虚函数实现,依赖于继承和动态绑定。在运行时根据实际对象的类型决定调用哪个函数。这种多态性常用于处理派生类对象而无需改变代码结构。
举个例子,假设有一个“买票”的行为(函数):
- 普通人买票时,是全价买票;
- 学生买票时,是优惠票(比如5折或7.5折);
- 军人买票时,则是优先买票。
再比如,同样是动物“叫”的行为(函数):
- 当传入猫对象时,发出的是“喵喵~~”的声音;
- 当传入狗对象时,发出的是“汪汪~~”的叫声。
3.1 虚函数
学习动态多态我们要先学习一个新的概念:虚函数
关键字:virtual
实现动态绑定,即在运行时决定应该调用哪个类的函数。通过虚函数,C++实现了运行时多态,允许你在父类中定义通用的接口,并在子类中根据具体需求实现不同的行为。
【注意】:非成员函数不能使用virtual修饰
文字比较难理解我们对照以上动物叫的例子来实现一段代码:
#include <iostream>
using namespace std;
// 基类
class Animal {
public:
// 声明为虚函数
virtual void sound()
{
cout << "基类中的 sound()" << endl;
}
};
// 派生类:猫
class Cat : public Animal {
public:
// 重写虚函数
void sound()
{
cout << "喵!" << endl;
}
};
// 派生类:狗
class Dog : public Animal {
public:
// 重写虚函数
void sound()
{
cout << "汪!" << endl;
}
};
int main() {
Animal* animal; // 基类指针
Cat cat;
Dog dog;
animal = &cat;
animal->sound(); // 调用Cat::sound()
animal = &dog;
animal->sound(); // 调用Dog::sound()
return 0;
}
程序运行结果:
喵!
汪!
在这个简单例子中,基类Animal
中的sound()
函数是虚函数(使用了关键字:virtual ),派生类Cat
和Dog
分别重写了该函数。当使用基类指针animal
指向不同的派生类对象时,程序在运行时根据实际对象类型调用相应的派生类的sound()
函数。这就是虚函数的动态绑定特性。
这就是动态多态的最简单用法。
3.2 重写/覆盖
上面我们提到一个虚函数的重写/覆盖的概念,那什么叫重写呢?
当派生类中有一个与基类完全相同的虚函数(即返回值类型、函数名、参数列表与基类虚函数完全一致),则称派生类的虚函数重写了基类的虚函数。
重点:构成虚函数重写的条件(完全一样的虚函数)
- 返回值类型 与基类虚函数完全一致
- 函数名 与基类虚函数完全一致
- 参数列表 与基类虚函数完全一致
(俗称:三同)
【注意】:
在重写基类虚函数时,虽然派生类的虚函数可以不加 virtual
关键字(因为基类的虚函数属性会被继承下来,派生类中的该函数仍然保持虚函数的属性),但这种写法不规范,不建议使用。在实际开发中,应显式使用 virtual
关键字进行声明。然而,在考试中,往往会故意设置这种情况,要求判断其是否构成多态。
依然用上面例子(无任何修改):
Cat::sound() 、 Dog::sound() 和 基类Animal 中sound() 构成重写
#include <iostream>
using namespace std;
// 基类
class Animal {
public:
// 声明为虚函数
virtual void sound()
{
cout << "基类中的 sound()" << endl;
}
};
// 派生类:猫
class Cat : public Animal {
public:
// 重写虚函数
void sound()
{
cout << "喵!" << endl;
}
};
// 派生类:狗
class Dog : public Animal {
public:
// 重写虚函数
void sound()
{
cout << "汪!" << endl;
}
};
int main() {
Animal* animal; // 基类指针
Cat cat;
Dog dog;
animal = &cat;
animal->sound(); // 调用Cat::sound()
animal = &dog;
animal->sound(); // 调用Dog::sound()
return 0;
}
3.3 实现多态还有两个必须重要条件
- 必须指针或者引用调用虚函数
- 被调用的函数必须是虚函数
说明:
- 第一必须是基类的指针或引用,基类指针或引用指向的是基类类型,但它们可以指向派生类的对象。这是因为派生类是从基类继承而来的,派生类对象中包含基类的部分,所以基类的指针或引用可以指向它们。这种机制被称为向上转型。如果你直接用派生类的指针或引用,那你就只能操作具体的派生类对象,无法泛化到多个派生类对象。这就失去了灵活性。而通过基类的指针或引用,你可以操作任何派生自该基类的对象,具有很大的扩展性和灵活性。
- 第二派生类必须对基类的虚函数重写 / 覆盖,重写了,派生类才能有不同的函数,多态的不同形态效果才能达到。
我们把上面例子稍作改动,大家体会一下多态语法的灵活性(把sound()独立到一个普通函数中):
#include <iostream>
using namespace std;
// 基类
class Animal {
public:
// 声明为虚函数
virtual void sound()
{
cout << "基类中的 sound()" << endl;
}
};
// 派生类:猫
class Cat : public Animal {
public:
// 重写虚函数
void sound()
{
cout << "喵!" << endl;
}
};
// 派生类:狗
class Dog : public Animal {
public:
// 重写虚函数
void sound()
{
cout << "汪!" << endl;
}
};
//普通函数
void animalSound(Animal* a)
{
a->sound();
}
int main() {
Animal animal; // 基类指针
Cat cat;
Dog dog;
animalSound(&animal);
animalSound(&cat);
animalSound(&dog);
return 0;
}
运行结果:
基类中的 sound()
喵!
汪!
3.4 虚函数重写的一些其他问题
3.4.1 协变(了解)
- 概念:当派生类重写基类的虚函数时,如果其返回值类型与基类不同,但具有关联性,即基类虚函数返回基类对象的指针或引用,派生类虚函数返回派生类对象的指针或引用,这种现象称为协变。
- 实际意义:协变在实际开发中使用较少,意义并不大,因此只需简单了解即可。
3.4.2 析构函数的重写
接下来我们重点来关注一下析构函数的重写:
- 虚析构函数的重要性:基类的析构函数被声明为虚函数时,派生类的析构函数无论是否加上
virtual
关键字,都会与基类的析构函数构成重写。这是因为虽然基类和派生类的析构函数名称不同,但编译器对析构函数进行了特殊处理,最终统一将析构函数的名称处理为destructor
。 - 内存泄漏问题:如果基类的析构函数没有声明为虚函数,那么在使用基类指针指向派生类对象并通过
delete
释放时,只会调用基类的析构函数,而不会调用派生类的析构函数。这会导致派生类中的资源无法正确释放,可能引发内存泄漏。
看以下代码:
次代码为错误示范(不可取)
#include <iostream>
using namespace std;
class A {
public:
~A() {
cout << "A的析构函数被调用" << endl;
}
};
class B : public A {
public:
B() {
_member = new int(10);
}
~B() {
cout << "B的析构函数被调用" << endl;
}
private:
int* _member;
};
int main() {
B* p1 = new B();
A* p2 = p1;
delete p2; // 仅调用 A 的析构函数,不会调用 B 的析构函数
return 0;
}
代码中,基类 A
的析构函数不是虚函数,所以当通过基类指针 p2
删除对象时,只会调用基类 A
的析构函数,而不会调用派生类 B
的析构函数。这意味着 B
类中的 _member
指针所指向的内存不会被释放,从而导致内存泄漏。
代码运行结果:
A的析构函数被调用
为了避免这种情况,我们需要将基类 A
的析构函数声明为虚函数。这样,当通过基类指针删除对象时,会正确调用派生类 B
的析构函数,从而释放所有动态分配的内存。
修改后的正确代码如下:
#include <iostream>
using namespace std;
class A {
public:
// 将析构函数声明为虚函数
virtual ~A()
{
cout << "A的析构函数被调用" << endl;
}
};
class B : public A {
public:
B()
{
_member = new int(10);
}
~B()
{
delete _member; // 释放动态分配的内存
cout << "B的析构函数被调用" << endl;
}
private:
int* _member;
};
int main() {
B* p1 = new B();
A* p2 = p1;
delete p2; // 现在会调用 B 的析构函数
return 0;
}
代码运行结果:
B的析构函数被调用
A的析构函数被调用
【总结】
在设计类的继承结构时,如果基类有可能会通过基类指针来操作派生类对象,基类的析构函数应该声明为虚函数。这样可以确保正确调用派生类的析构函数,避免内存泄漏和资源未释放的问题。
3.5 override和 final关键字
在C++中,override
和 final
关键字是用于控制类继承和函数重写行为的两个重要特性。它们在C++11中引入,旨在提高代码的安全性和可维护性。
- 关键字:override
- 关键字:final
3.5.1 override
override
关键字用于明确指示派生类中的虚函数是对基类中虚函数的重写。这有助于编译器进行检查,确保函数签名匹配,避免无意中创建新的虚函数。
如果在C++中使用 override
关键字,但函数并没有正确地重写基类中的虚函数,编译器会报错。这是因为 override
关键字告诉编译器该函数应该重写基类中的虚函数,如果没有匹配的基类虚函数,编译器会检测到这一点并发出错误信息。
用法也很简单,只要在派生类重写的函数声明末尾加上关键字即可:
#include <iostream>
using namespace std;
class Base {
public:
virtual void show()
{
cout << "Base::show()" << endl;
}
};
class Derived : public Base {
public:
void show() override // 重写基类的虚函数加上关键字:override
{
cout << "Derived::show()" << endl;
}
};
int main() {
Base* b = new Derived();
b->show(); // 输出 "Derived::show()"
delete b;
return 0;
}
【总结】在我们平常的项目代码编写中是建议在重写虚函数时接入override关键字,有利于提高代码易读性和更容易发现错误。
3.5.2 final
final
关键字用于防止类被继承或虚函数被重写。它可以应用于类或虚函数。
用法1:
防止类被继承: 当一个类被声明为 final
时,表示该类不能被继承。
示例:
class Base final {
public:
virtual void show()
{
cout << "Base::show()" << endl;
}
};
//次段会报错,错误:无法继承被 final 修饰的类
/**
class Derived : public Base {
public:
void show() override // 重写基类的虚函数加上关键字:override
{
cout << "Derived::show()" << endl;
}
};
**/
用法2:
防止虚函数被重写: 当一个虚函数被声明为 final
时,表示该函数不能在派生类中被重写。
示例:
#include <iostream>
using namespace std;
class Base {
public:
virtual void final show()
{
cout << "Base::show()" << endl;
}
};
class Derived : public Base {
public:
/**
void show() override // 错误:无法重写被 final 修饰的函数
{
cout << "Derived::show()" << endl;
}
**/
};
3.6 重载/重写/隐藏的对比
四、纯虚函数和抽象类
4.1纯虚函数
纯虚函数
- 定义:在虚函数的后面写上
=0
,则这个函数为纯虚函数。- 特点:纯虚函数不需要定义实现,只要声明即可。虽然语法上可以实现,但通常没有意义,因为它们要被派生类重写。
用法如下:
#include <iostream>
using namespace std;
class A{
public:
virtual void Function() = 0; // 纯虚函数,只声明不实现
};
class B: public A{
public:
void Function() override {
cout << "B::Function()" << endl;
}
};
class C: public A{
public:
void Function() override {
cout << "C::Function()" << endl;
}
};
int main() {
// A a; // 错误:无法实例化抽象类
B b;
b.Function(); // 输出 "B::Function()"
C c;
c.Function(); // 输出 "C::Function()"
return 0;
}
4.2 抽象类
抽象类
- 定义:包含纯虚函数的类叫做抽象类。
- 特点(语法规则):
- 抽象类不能实例化出对象。
- 如果派生类继承了抽象类但不重写纯虚函数,那么派生类也是抽象类。
- 纯虚函数某种程度上强制了派生类重写虚函数,因为不重写就无法实例化对象。
依然看上面代码:
int main() {
// A a; // 错误:无法实例化抽象类
B b;
b.Function(); // 输出 "B::Function()"
C c;
c.Function(); // 输出 "C::Function()"
return 0;
}
五、虚函数表
(这部分内容比较接近底层,比较复杂,这里做简单分析、大家别晕没看懂也不太影响平常代码编写)
5.1虚函数概念
基本概念
- 基类对象的虚函数表:存放基类所有虚函数的地址。
- 虚函数表共享:同类型对象共享同一个虚函数表,不同类型对象有各自独立的虚函数表。
派生类的构成
- 组成部分:派生类由继承下来的基类部分和派生类自己的成员组成。
- 虚函数表指针:通常情况下,继承下来的基类部分包含虚函数表指针,派生类自身不会再生成新的虚函数表指针。
- 独立性:派生类中继承的基类部分的虚函数表指针与基类对象的虚函数表指针不同,就像基类对象的成员和派生类对象中的基类对象成员是独立的一样。
虚函数的重写
- 覆盖机制:派生类重写基类的虚函数时,派生类的虚函数表中对应的虚函数地址会被覆盖为派生类重写的虚函数地址。
- 虚函数表内容:派生类的虚函数表包含基类的虚函数地址、派生类重写的虚函数地址以及派生类自己的虚函数地址。
虚函数表的本质
- 指针数组:虚函数表本质上是一个存储虚函数指针的指针数组。通常情况下,这个数组的最后一个元素是
0x00000000
标记(C++标准没有规定,各个编译器自行定义,VS系列编译器会在后面放一个0x00000000
标记,而G++系列编译器不会)。
虚函数的存储位置
- 代码段:虚函数和普通函数一样,编译后是一段指令,存储在代码段中。虚函数的地址存储在虚函数表中。
虚函数表的存储位置
- 存储位置:严格来说,C++标准没有规定虚函数表的存储位置。不同编译器可能有不同的实现。例如,在VS编译器中,虚函数表通常存储在代码段(常量区)。
示例分析
#include <iostream>
using namespace std;
class Base {
public:
virtual void Func1(){
cout << "Base::Func1()" << endl;
}
virtual void Func2() {
cout << "Base::Func2()" << endl;
}
virtual void Func3() {
cout << "Base::Func3()" << endl;
}
virtual void Func4() {
cout << "Base::Func4()" << endl;
}
private:
int a;
};
class Derived : public Base {
public:
void Func2() override
{
cout << "Derived::Func2()" << endl;
}
};
int main() {
Base b;
cout << sizeof(b) << endl;
Derived d;
cout << sizeof(d);
return 0;
}
运行结果:
8
8
运行结果分析
-
类
Base
的内存占用:Base
类有一个私有成员变量int a
,占用4
字节(假设int
类型在你的系统上占用4
字节)。- 由于
Base
类包含虚函数,编译器会为每个Base
类对象添加一个虚函数表指针(vptr
),这个指针指向该类的虚函数表。虚函数表指针的大小通常是指针的大小,在大多数系统上是4
字节(32位系统)或8
字节(64位系统)。
-
类
Derived
的内存占用:Derived
类继承了Base
类,并重写了Func2
虚函数。Derived
类对象也包含一个虚函数表指针(vptr
),指向Derived
类的虚函数表。Derived
类对象的大小包括继承自Base
类的成员变量和虚函数表指针。
下面通过图形来更直观的理解:
5.2虚函数表和虚基表的区别(了解)
虚函数表(vtable)
- 用途:用于支持多态性和动态绑定。它的主要作用是实现对虚函数的动态分派,即在运行时根据对象的实际类型调用正确的函数。
- 工作原理:
- 当一个类包含虚函数时,编译器为该类生成虚函数表。
- 虚函数表是一个指针数组,表中的每个指针指向该类的虚函数实现。
- 每个类实例都有一个虚表指针(vptr),指向它所属类的虚函数表。
- 在继承中,派生类可以重写基类的虚函数,派生类的虚函数表会更新,指向派生类的实现。
- 通过基类指针或引用调用虚函数时,虚表指针用于查找实际对象的虚函数表,以调用正确的函数。
虚基表(vbtable)
- 用途:用于解决多重继承中虚基类的共享问题,确保在多重继承中,虚基类只被构造一次,并且被派生类共享。
- 工作原理:
- 当派生类通过虚继承(
virtual
关键字)从基类继承时,编译器为派生类生成虚基表。 - 虚基表中存储了虚基类在内存中的偏移量,确保派生类能够正确访问虚基类的成员。
- 虚基表帮助派生类管理对虚基类的共享访问,保证虚基类只会被初始化和析构一次。
- 当派生类通过虚继承(