C++第14课:C++类的多态性(c++有几种多态)
1.概念与分类
C++ 类的多态性实现了 “一个接口,多种方法”的开发模式。同一操作作用于不同的对象,可以有不同的解释,产生不同的执行结果。就像一个遥控器(接口),可以控制不同品牌的电视(对象),虽然操作方式一样(按下电源键),但每台电视的响应(开机画面、启动速度等)可能不同。在 C++ 中,多态性主要分为编译时多态和运行时多态。
1.1.编译时多态
编译时多态也叫静态多态,是在编译阶段就确定函数调用的版本。这种多态性主要通过函数重载和模板来实现。函数重载是指在同一个类中,多个函数可以具有相同的函数名,但参数列表(参数类型、个数或顺序)不同。如定义一个print函数,既可以打印整数,也可以打印字符串:
// 打印整数
void print(int num) {
std::cout << "打印整数: " << num << std::endl;
}
// 打印字符串
void print(const char* str) {
std::cout << "打印字符串: " << str << std::endl;
}
int main() {
print(10); // 调用打印整数的版本
print("Hello, C++"); // 调用打印字符串的版本
return 0;
}
在这个例子中,编译器会根据传入参数的类型来决定调用哪个print函数,这就是编译时多态的体现。模板则允许我们编写泛型代码,在编译时根据具体的类型实例化相应的函数或类,进一步增强了代码的复用性和灵活性。
编译时多态的优点是效率高,因为函数调用的版本在编译阶段就已经确定,不需要在运行时进行额外的开销。但是,它的灵活性相对较低,一旦编译完成,函数调用的版本就固定下来了。
1.2.运行时多态
运行时多态也叫动态多态,是在程序运行时根据对象的实际类型来决定函数调用的版本。它主要通过虚函数和继承来实现。当基类中定义了虚函数,派生类可以重写(override)这个虚函数。在运行时,通过基类指针或引用调用虚函数时,会根据指针或引用实际指向的对象类型,来决定调用哪个类的虚函数版本 。例如:
public:
// 虚函数
virtual void speak() const {
std::cout << "动物发出声音" << std::endl;
}
};
class Dog : public Animal {
public:
// 重写虚函数
void speak() const override {
std::cout << "狗汪汪叫" << std::endl;
}
};
class Cat : public Animal {
public:
// 重写虚函数
void speak() const override {
std::cout << "猫喵喵叫" << std::endl;
}
};
int main() {
Animal* animal1 = new Dog();
Animal* animal2 = new Cat();
animal1->speak(); // 输出 "狗汪汪叫"
animal2->speak(); // 输出 "猫喵喵叫"
delete animal1;
delete animal2;
return 0;
}
在这个例子中,Animal类中的speak函数被声明为虚函数,Dog类和Cat类重写了speak函数。通过Animal类型的指针调用speak函数时,实际调用的是指针所指向对象的具体类型(Dog或Cat)的speak函数版本,这就是运行时多态的体现。
2.函数重载与模版
2.1.函数重载
函数重载是指在同一个作用域内,定义多个同名函数,但这些函数的参数列表(参数个数、类型或顺序)不同。编译器会根据调用函数时传入的实际参数,在编译阶段选择合适的函数版本进行调用。例如:
void print(int num) {
std::cout << "打印整数: " << num << std::endl;
}
void print(double num) {
std::cout << "打印双精度浮点数: " << num << std::endl;
}
在上述代码中,我们定义了两个名为print的函数,一个接受int类型参数,另一个接受double类型参数。当我们调用print(5)时,编译器会根据参数类型选择第一个print函数;当调用print(3.14)时,编译器会选择第二个print函数。
2.2.模版
模板包括函数模板和类模板,它允许开发者编写通用的代码,根据不同的类型参数生成不同的函数或类。例如,下面是一个简单的函数模板:
template<typename T>
T add(T a, T b) {
return a + b;
}
这个函数模板可以用于不同类型的加法运算。当我们调用add(3, 5)时,编译器会根据传入的参数类型int,生成一个专门处理int类型的add函数;当调用add(3.14, 2.71)时,编译器会生成处理double类型的add函数。
3.虚函数
虚函数是 C++ 运行时多态的核心机制。简单来说,虚函数是在基类中使用virtual关键字声明的成员函数,它可以在派生类中被重新定义。当通过基类指针或引用调用虚函数时,程序会在运行时根据指针或引用实际指向的对象类型,来决定调用哪个类的虚函数版本,这就是所谓的动态绑定。
举个例子,假设有一个图形类Shape,它有一个绘制函数draw,而Circle和Rectangle是从Shape派生出来的具体图形类,它们都有自己独特的绘制方式 。代码如下:
class Shape {
public:
// 虚函数
virtual void draw() const {
std::cout << "绘制一个形状" << std::endl;
}
};
class Circle : public Shape {
public:
// 重写虚函数
void draw() const override {
std::cout << "绘制一个圆形" << std::endl;
}
};
class Rectangle : public Shape {
public:
// 重写虚函数
void draw() const override {
std::cout << "绘制一个矩形" << std::endl;
}
};
void drawShape(const Shape& shape) {
shape.draw();
}
int main() {
Circle circle;
Rectangle rectangle;
drawShape(circle); // 输出 "绘制一个圆形"
drawShape(rectangle); // 输出 "绘制一个矩形"
return 0;
}
在这个例子中,Shape类中的draw函数被声明为虚函数。Circle类和Rectangle类重写了draw函数,提供了各自的绘制逻辑 。在main函数中,我们定义了Circle和Rectangle的对象,并将它们传递给drawShape函数。drawShape函数接受一个Shape类型的引用,通过这个引用调用draw函数时,实际调用的是传入对象的具体类型(Circle或Rectangle)的draw函数版本,这就是虚函数实现运行时多态的过程。
对比一下普通函数和虚函数的调用。如果Shape类中的draw函数不是虚函数,那么通过Shape类型的指针或引用调用draw函数时,无论指针或引用实际指向的是哪个派生类对象,都会调用Shape类中的draw函数版本 ,这就无法实现多态效果。例如:
class Shape {
public:
// 普通函数
void draw() const {
std::cout << "绘制一个形状" << std::endl;
}
};
class Circle : public Shape {
public:
// 这个函数与基类的draw函数不是重写关系,而是隐藏
void draw() const {
std::cout << "绘制一个圆形" << std::endl;
}
};
class Rectangle : public Shape {
public:
// 这个函数与基类的draw函数不是重写关系,而是隐藏
void draw() const {
std::cout << "绘制一个矩形" << std::endl;
}
};
void drawShape(const Shape& shape) {
shape.draw();
}
int main() {
Circle circle;
Rectangle rectangle;
drawShape(circle); // 输出 "绘制一个形状"
drawShape(rectangle); // 输出 "绘制一个形状"
return 0;
}
3.1.虚函数重写规则
在 C++ 中,虚函数的重写需要遵循一定的规则,以确保多态行为的正确实现:
(1)函数签名一致
派生类中重写的虚函数必须与基类中的虚函数具有相同的函数名、参数列表(参数类型、个数和顺序)以及返回值类型 (除了协变返回类型的特殊情况,稍后会介绍)。例如:
class Base {
public:
virtual void func(int a) {
std::cout << "Base::func(int)" << std::endl;
}
};
class Derived : public Base {
public:
// 正确重写,函数名、参数列表相同
void func(int a) override {
std::cout << "Derived::func(int)" << std::endl;
}
};
在这个例子中,Derived类中的func函数与Base类中的func函数具有相同的函数名和参数列表,因此是正确的重写。
(2)协变返回类型
在 C++11 及以后的标准中,如果派生类重写的虚函数返回的是基类中虚函数返回类型的派生类类型,这种情况是允许的,称为协变返回类型 。例如:
class BaseClass {
public:
virtual ~BaseClass() {}
};
class DerivedClass : public BaseClass {
public:
virtual ~DerivedClass() {}
};
class Base {
public:
virtual BaseClass* clone() {
return new Base();
}
};
class Derived : public Base {
public:
// 协变返回类型,返回DerivedClass* 是BaseClass* 的派生类类型
DerivedClass* clone() override {
return new Derived();
}
};
在这个例子中,Base类的clone函数返回BaseClass*,Derived类重写的clone函数返回DerivedClass*,DerivedClass是BaseClass的派生类,这种重写是合法的。
(3)析构函数的重写
当基类的析构函数被声明为虚函数时,派生类的析构函数会自动重写基类的析构函数,即使它们的函数名不同 (因为析构函数的名字是固定的,与类名相关)。这一点非常重要,因为当通过基类指针删除派生类对象时,如果基类析构函数不是虚函数,可能会导致派生类的析构函数无法被正确调用,从而产生内存泄漏等问题 。例如:
class Base {
public:
// 虚析构函数
virtual ~Base() {
std::cout << "Base析构函数" << std::endl;
}
};
class Derived : public Base {
public:
~Derived() override {
std::cout << "Derived析构函数" << std::endl;
}
};
int main() {
Base* basePtr = new Derived();
delete basePtr; // 会先调用Derived的析构函数,再调用Base的析构函数
return 0;
}
在这个例子中,Base类的析构函数被声明为虚函数,当通过Base指针删除Derived对象时,会先调用Derived的析构函数,再调用Base的析构函数,确保资源的正确释放。如果Base的析构函数不是虚函数,那么delete basePtr时只会调用Base的析构函数,Derived的析构函数不会被调用,可能会导致Derived对象中分配的资源无法释放。
3.2.虚函数表与虚指针
为了实现虚函数的动态绑定,C++ 引入了虚函数表(vtable)和虚指针(vptr)的概念 。
虚函数表:每个包含虚函数的类都有一个虚函数表,它是一个函数指针数组,存储了该类中所有虚函数的地址 。虚函数表是在编译时生成的,并且对于每个类来说是唯一的。当一个类从另一个包含虚函数的类继承时,它会继承基类的虚函数表,并根据需要更新虚函数表中的条目(如果派生类重写了基类的虚函数) 。
虚指针:每个包含虚函数的类的对象都有一个虚指针,它指向该类的虚函数表 。虚指针通常位于对象内存布局的开头(在不同编译器实现中可能略有不同,但一般都在对象头部),它是对象的一部分,用于在运行时找到对象所属类的虚函数表,从而实现虚函数的动态调用 。
下面通过一个示例来更直观地理解虚函数表和虚指针的工作原理 :
class Base {
public:
virtual void func1() {
std::cout << "Base::func1" << std::endl;
}
virtual void func2() {
std::cout << "Base::func2" << std::endl;
}
};
class Derived : public Base {
public:
void func1() override {
std::cout << "Derived::func1" << std::endl;
}
};
int main() {
Base* basePtr = new Derived();
// 获取对象的虚指针,假设虚指针位于对象内存布局的开头
auto vptr = *(reinterpret_cast<void**>(basePtr));
// 获取虚函数表中func1函数的指针
auto func1Ptr = *reinterpret_cast<void***>(vptr);
// 调用func1函数
auto func1 = reinterpret_cast<void(*)()>(func1Ptr);
func1();
delete basePtr;
return 0;
}
Base类包含两个虚函数func1和func2,编译器会为Base类生成一个虚函数表,并在Base类的对象中添加一个虚指针,指向这个虚函数表。
当Derived类继承Base类并重写func1函数时,Derived类会继承Base类的虚函数表,并将虚函数表中func1函数的指针更新为指向Derived::func1的地址 。
在main函数中,创建了一个Derived类的对象,并通过Base指针指向它。通过获取对象的虚指针,进而获取虚函数表中func1函数的指针,最后调用func1函数 。由于Derived类重写了func1函数,所以实际调用的是Derived::func1,这就展示了虚函数表和虚指针如何在运行时实现多态调用 。
需要注意的是,上述通过指针直接操作虚函数表和虚指针的代码只是为了演示原理,在实际编程中,我们通常不会这样做,而是直接使用虚函数的语法来实现多态,这样更安全、更易读 。虚函数表和虚指针是 C++ 编译器内部实现多态的机制,了解它们的工作原理有助于我们更好地理解运行时多态的实现过程,但在日常开发中,我们更多地是利用虚函数带来的多态特性来编写灵活、可扩展的代码 。
4.多态性练习—图形绘制系统
在图形绘制系统中,多态性有着广泛的应用。我们可以定义一个抽象的图形基类Shape,并在其中声明一个纯虚函数draw 。这个纯虚函数为所有派生的具体图形类提供了一个统一的绘制接口。例如:
class Shape {
public:
// 纯虚函数,没有具体实现,要求派生类必须重写
virtual void draw() const = 0;
};
然后,从Shape类派生出不同的具体图形类,如Circle(圆形)、Rectangle(矩形)等,并在各自的派生类中实现draw函数 ,来提供具体的绘制逻辑 。
class Circle : public Shape {
public:
void draw() const override {
std::cout << "绘制一个圆形" << std::endl;
}
};
class Rectangle : public Shape {
public:
void draw() const override {
std::cout << "绘制一个矩形" << std::endl;
}
};
在实际绘制图形时,我们可以使用Shape类型的指针或引用来存储不同类型的图形对象 。当调用draw函数时,根据对象的实际类型,会自动调用相应派生类中的draw函数实现 。这样,我们就可以通过一个统一的接口来绘制各种不同的图形,而不需要为每种图形编写单独的绘制函数 。例如:
void drawShapes(const Shape* shapes[], int count) {
for (int i = 0; i < count; ++i) {
shapes[i]->draw();
}
}
int main() {
Shape* shapes[2];
shapes[0] = new Circle();
shapes[1] = new Rectangle();
drawShapes(shapes, 2);
// 释放内存
for (int i = 0; i < 2; ++i) {
delete shapes[i];
}
return 0;
}
在这个例子中,drawShapes函数接受一个Shape指针数组和数组的大小,通过循环调用每个Shape对象的draw函数,实现了对不同类型图形的统一绘制 。当需要添加新的图形类型时,只需要从Shape基类派生新的类,并实现draw函数,而不需要修改现有的绘制代码,这大大提高了系统的可扩展性和灵活性 。
5.多态性练习—游戏开发
在游戏开发领域,多态性同样发挥着重要作用。以角色扮演游戏(RPG)为例,游戏中有各种不同类型的角色,如战士(Warrior)、法师(Mage)、弓箭手(Archer)等 ,每个角色都有自己独特的行为和属性 。我们可以定义一个抽象的角色基类Character,并在其中声明一些虚函数,如attack(攻击)、move(移动)等 ,来描述角色的基本行为。
class Character {
public:
virtual void attack() const = 0;
virtual void move() const = 0;
};
然后,从Character类派生出不同的具体角色类,并在每个派生类中实现这些虚函数,以体现不同角色的行为差异 。
class Warrior : public Character {
public:
void attack() const override {
std::cout << "战士挥舞大剑进行攻击" << std::endl;
}
void move() const override {
std::cout << "战士快速冲向敌人" << std::endl;
}
};
class Mage : public Character {
public:
void attack() const override {
std::cout << "法师释放魔法进行攻击" << std::endl;
}
void move() const override {
std::cout << "法师瞬移到安全位置" << std::endl;
}
};
class Archer : public Character {
public:
void attack() const override {
std::cout << "弓箭手射出利箭进行攻击" << std::endl;
}
在游戏循环中,我们可以使用Character类型的指针或引用来操作不同类型的角色 。当角色执行攻击或移动等操作时,会根据角色的实际类型调用相应派生类中的函数实现 。这样,游戏引擎就可以通过一个统一的接口来管理和控制各种不同类型的角色,使得代码更加简洁、灵活,易于维护和扩展 。例如:
void playGame(Character* characters[], int count) {
for (int i = 0; i < count; ++i) {
characters[i]->attack();
characters[i]->move();
}
}
int main() {
Character* characters[3];
characters[0] = new Warrior();
characters[1] = new Mage();
characters[2] = new Archer();
playGame(characters, 3);
// 释放内存
for (int i = 0; i < 3; ++i) {
delete characters[i];
}
return 0;
}
在这个例子中,playGame函数模拟了游戏的运行过程,通过调用每个Character对象的attack和move函数,实现了不同角色的攻击和移动行为 。如果后续需要添加新的角色类型,只需要从Character基类派生新的类,并实现相应的虚函数,而游戏的核心逻辑(如游戏循环、角色管理等)无需大幅修改,这充分展示了多态性在游戏开发中的优势 。