CPP多态
多态
一、什么是多态
C++ 中的多态是指通过一个基类指针或引用调用一个虚函数时,会根据具体对象的类型来调用该虚函数的不同实现。
我们一般所说的多态都指的是动态多态。C++ 支持两种多态形式:静态多态(编译时多态)和动态多态(运行时多态)。
静态多态(编译时多态)
重载(Overloading)是静态多态的一个例子,包括函数重载和运算符重载。它允许你使用相同的函数名称或运算符实现不同功能,具体调用哪个版本由传入的参数类型及数量在编译时决定。
#include <iostream>void print(int i) { std::cout << "整数: " << i << std::endl; }
void print(double d) { std::cout << "浮点数: " << d << std::endl; }int main() {print(10); // 调用 print(int)print(3.14); // 调用 print(double)return 0;
}
另一种形式的静态多态是模板,允许编写泛型代码,可以在编译时根据实际参数类型生成相应的函数或类实例。
动态多态(运行时多态)
动态多态依赖于虚函数(virtual functions)和继承机制实现。多态发生在运行期间,调用哪个函数版本取决于对象的实际类型,而不是指针或引用的声明类型。
#include <iostream>
using namespace std;class Animal {
public:virtual void eat() {cout << "动物吃东西" << endl;}
};class Dog : public Animal {
public:void eat() override {cout << "狗吃骨头" << endl;}
};class Cat : public Animal {
public:void eat() override {cout << "猫吃鱼" << endl;}
};int main() {Animal* animal1 = new Dog();Animal* animal2 = new Cat();animal1->eat(); // 输出:狗吃骨头animal2->eat(); // 输出:猫吃鱼delete animal1;delete animal2;return 0;
}
为什么要强调“而不是声明类型”?
因为如果没有多态(虚函数机制),用 animalPtr->eat()
调用的函数会固定调用 Animal::eat()
,因为指针的声明类型是 Animal*
。
但是开启虚函数后,调用变成“运行时绑定”:
- 程序根据
animalPtr
指向的实际对象类型(Dog)调用Dog::eat()
, - 而不管
animalPtr
声明时是Animal*
。
总结
多态类型 | 机制 | 发生阶段 | 说明 |
---|---|---|---|
静态多态 | 函数重载、运算符重载、模板 | 编译时 | 根据参数类型和数量选择函数版本 |
动态多态 | 虚函数、继承 | 运行时 | 根据对象实际类型选择函数版本 |
二、虚函数(Virtual Functions)
虚函数是在基类中使用 virtual
关键字声明的成员函数,目的是允许派生类覆盖(override)它的实现。通过基类指针或引用调用虚函数时,实际执行的是派生类中对应对象的函数版本,实现了多态。
1、虚函数的定义
基类中声明虚函数:
Animal.h
#pragma once
class Animal
{
public:virtual void eat(); // 虚函数声明
};
Animal.cpp
#include "Animal.h"
#include <iostream>void Animal::eat()
{std::cout << "Animal eat..." << std::endl;
}
2、子类重写父类的虚函数
多态的两个必要条件:
- 存在继承关系
- 子类必须重写基类的虚函数
Dog 类重写 eat
Dog.h
#pragma once
#include "Animal.h"class Dog : public Animal
{
public:void eat() override; // override 表示重写基类虚函数(可加可不加,但推荐加)
};
Dog.cpp
#include "Dog.h"
#include <iostream>void Dog::eat()
{std::cout << "Dog eat..." << std::endl;
}
Cat 类重写 eat
Cat.h
#pragma once
#include "Animal.h"class Cat : public Animal
{
public:void eat() override;
};
Cat.cpp
#include "Cat.h"
#include <iostream>void Cat::eat()
{std::cout << "Cat eat..." << std::endl;
}
3、虚函数调用示例
#include <iostream>
#include "Animal.h"
#include "Dog.h"
#include "Cat.h"int main()
{Animal* a1 = new Dog();Animal* a2 = new Cat();a1->eat(); // 输出:Dog eat...a2->eat(); // 输出:Cat eat...delete a1;delete a2;return 0;
}
3、动态绑定(运行时多态)
在 C++ 中实现多态的方式是,定义基类对象的指针或引用,指向子类对象,程序在运行时进行动态绑定。即同一指针或引用类型,使用不同的实例而执行不同的操作。
#include <iostream>
#include "Dog.h"
#include "Cat.h"int main()
{Animal* dog = new Dog();Animal* cat = new Cat();dog->eat(); // 输出:Dog eat...cat->eat(); // 输出:Cat eat...delete dog;delete cat;return 0;
}
Animal* ptr = new Dog(); // ptr是Animal类型指针,但实际指向Dog对象
ptr->eat(); // 调用的是Dog类中的eat函数,不是Animal的eat
这里 ptr 声明是 Animal*,但程序会根据它实际指向的是 Dog 类型,执行 Dog::eat()。
三、多态实现原理
plaintext复制编辑Animal 对象:vptr ----> 虚函数表 (Animal)|--- eat() -> Animal::eat()Cat 对象:vptr ----> 虚函数表 (Cat)|--- eat() -> Cat::eat()Dog 对象:vptr ----> 虚函数表 (Dog)|--- eat() -> Dog::eat()调用 p->eat():通过 p 的 vptr 找到虚函数表 -> 调用表中 eat 对应的函数(动态绑定)
1. 基本概念
- 虚函数表(vtable)
- 每个包含虚函数的类,编译器会自动生成一个虚函数表。
- 虚函数表是一个函数指针数组,存放该类所有虚函数的地址。
- 虚函数指针(vptr)
- 每个含虚函数的对象实例内部会隐藏一个指针,称为虚函数指针(vptr)。
- vptr 指向该对象所属类的虚函数表。
2. 类和对象的关系示意
类定义
class Animal {
public:virtual void eat() { // 虚函数std::cout << "Animal is eating." << std::endl;}
};class Cat : public Animal {
public:void eat() override { // 重写虚函数std::cout << "Cat is eating fish." << std::endl;}
};class Dog : public Animal {
public:void eat() override { // 重写虚函数std::cout << "Dog is eating bone." << std::endl;}
};
编译器生成的虚函数表(示意)
类名 | 虚函数表(vtable) |
---|---|
Animal | [地址 -> Animal::eat()] |
Cat | [地址 -> Cat::eat()] |
Dog | [地址 -> Dog::eat()] |
对象内部示意
- Animal 对象
- 内部隐藏成员
vptr
指向 Animal 的虚函数表
- 内部隐藏成员
- Cat 对象
- 内部隐藏成员
vptr
指向 Cat 的虚函数表
- 内部隐藏成员
- Dog 对象
- 内部隐藏成员
vptr
指向 Dog 的虚函数表
- 内部隐藏成员
3. 动态绑定流程详解
Animal* p = new Dog(); // 基类指针指向派生类对象
p->eat(); // 调用虚函数
p
指向 Dog 对象,Dog 对象内部的vptr
指向 Dog 类的虚函数表。- 调用
p->eat()
时,不是简单调用 Animal 的eat()
,而是:- 通过
p
找到对象的vptr
vptr
指向 Dog 的虚函数表- 从虚函数表中找到
eat
函数的地址(此处是 Dog::eat) - 调用 Dog::eat 函数
- 通过
所以,真正调用的是派生类的重写版本,实现了运行时多态(动态绑定)。
4. 关键点总结
关键点 | 说明 |
---|---|
虚函数表(vtable) | 类级别的表,存储虚函数指针,支持派生类重写 |
虚函数指针(vptr) | 对象级别的指针,指向该对象所属类的虚函数表 |
动态绑定 | 通过 vptr 找到虚函数表,调用正确的重写函数,实现多态 |
四、多态优势
在 C++ 中,多态是一种重要的面向对象编程特性,主要优势是可以提高代码的可扩展性和可维护性。通过多态,可以在不修改现有代码的情况下,添加新的派生类,从而扩展程序的功能。
派生类重写虚函数与虚函数表的关系
- 每个含有虚函数的类(包括基类和派生类),编译器都会生成一个虚函数表(vtable)。
- 如果派生类没有重写基类的虚函数,则它的虚函数表中对应位置仍然指向基类的实现。
- 如果派生类重写了基类的虚函数,那么派生类的虚函数表中该函数对应的位置就会指向派生类的新实现。
- 换句话说:
- 基类有虚函数,基类有自己的虚函数表。
- 派生类继承基类后,默认会继承虚函数表的结构。
- 当派生类重写某个虚函数时,它就“覆盖”了虚函数表里对应函数指针的位置,指向派生类自己的实现。
- 如果派生类没有重写虚函数,则该位置指向基类的函数地址。
class Animal {
public:virtual void eat() { std::cout << "Animal eating\n"; }virtual void sleep() { std::cout << "Animal sleeping\n"; }
};class Cat : public Animal {
public:void eat() override { std::cout << "Cat eating fish\n"; }// sleep()没有重写,继承基类版本
};
Animal
的虚函数表有两个条目:eat
和sleep
,都指向Animal
的实现。Cat
继承Animal
,虚函数表结构相同,但它重写了eat()
,所以Cat
的虚函数表中eat
指向Cat::eat()
,而sleep
仍指向Animal::sleep()
。
1. 多态的本质是“同一接口,不同实现”
- 基类定义了统一接口(虚函数),派生类实现不同版本。
- 只有通过基类指针或引用,才能在运行时根据实际对象类型调用对应的函数(动态绑定)。
2. 直接使用派生类对象或者派生类指针的区别
写法 | 结果 | 说明 |
---|---|---|
Dog dog; | 静态绑定,调用 Dog::eat() | 编译时确定调用,没多态效果 |
Dog* p = new Dog(); | 静态绑定,调用 Dog::eat() | 指针类型是 Dog*,仍是静态绑定 |
Animal* p = new Dog(); | 动态绑定,调用 Dog::eat() | 指针类型是基类,运行时决定调用 |
3. 只有基类指针/引用指向派生类对象,才可以实现多态
- 运行时调用函数时,程序通过指针/引用的**虚函数指针(vptr)**去找到正确的虚函数表,动态调用派生类的函数。
- 这是多态的关键。
4. 实际应用价值
假设你有一个动物园程序,需要管理很多动物:
void feedAnimal(Animal* animal) {animal->eat(); // 不管是什么动物,都能正确调用对应的eat()
}Animal* dog = new Dog();
Animal* cat = new Cat();feedAnimal(dog); // 调用 Dog::eat()
feedAnimal(cat); // 调用 Cat::eat()
- 这样,
feedAnimal
不需要知道具体是什么动物,代码更加灵活和可扩展。
这个函数参数类型是 Animal*
,它不关心你传进来的是猫还是狗,只要是动物,它就能调用 eat()
。
具体调用哪个 eat()
,是运行时根据对象实际类型(猫、狗)来决定的,函数内部不需要写判断逻辑。
如果没有多态,你可能得这样写:
void feedAnimal(Animal* animal) {if (Cat* cat = dynamic_cast<Cat*>(animal)) {cat->eat();} else if (Dog* dog = dynamic_cast<Dog*>(animal)) {dog->eat();}// 还要继续写更多判断……
}
- 代码里得写大量判断,每增加一个动物类型,都要修改代码,代码复杂且难维护。
- 如果动物种类很多,这样写不可行。
五、抽象类
抽象类和纯虚函数
- 纯虚函数:纯虚函数是在基类中声明的虚函数,其在基类中没有具体的实现,并且在声明时被赋值为 = 0 ,形式为
virtual 返回类型 函数名() = 0;
- 抽象类:包含至少一个纯虚函数的类,不能实例化,只能被继承,强制派生类必须覆盖它。
Animal.h(抽象基类)
#pragma onceclass Animal
{
public:Animal() = default;virtual ~Animal() = default; // 虚析构函数,确保派生类正确析构virtual void eat() = 0; // 纯虚函数,强制派生类必须实现
};
Dog.h(派生类)
#pragma once
#include "Animal.h"class Dog : public Animal
{
public:Dog() = default;~Dog() override = default;void eat() override; // 重写纯虚函数
};
Dog.cpp(派生类实现)
#include "Dog.h"
#include <iostream>void Dog::eat()
{std::cout << "Dog is eating..." << std::endl;
}
说明
Animal
类有纯虚函数eat()
,是抽象类,不能实例化。Dog
继承自Animal
,必须实现eat()
。- 这样设计确保所有派生类都有自己的
eat()
实现,符合接口规范。
五、虚析构函数
- 基类的析构函数必须声明为
virtual
,才能确保通过基类指针删除派生类对象时,先调用派生类析构函数,再调用基类析构函数。 - 否则,派生类析构函数不会被调用,可能导致资源泄漏。
Animal.h
#pragma onceclass Animal
{
public:Animal();virtual ~Animal(); // 虚析构函数,确保派生类析构被调用virtual void eat() = 0; // 纯虚函数
};
Animal.cpp
#include "Animal.h"
#include <iostream>Animal::Animal()
{std::cout << "Animal 构造函数" << std::endl;
}Animal::~Animal()
{std::cout << "Animal 析构函数" << std::endl;
}
Dog.h
#pragma once
#include "Animal.h"class Dog : public Animal
{
public:Dog();~Dog() override; // 重写虚析构函数void eat() override;
};
Dog.cpp
#include "Dog.h"
#include <iostream>Dog::Dog()
{std::cout << "Dog 构造函数" << std::endl;
}Dog::~Dog()
{std::cout << "Dog 析构函数" << std::endl;
}void Dog::eat()
{std::cout << "Dog eat..." << std::endl;
}
main.cpp
#include "Dog.h"int main()
{Animal* p = new Dog(); // 用基类指针指向派生类对象p->eat();delete p; // 通过基类指针删除,触发虚析构函数,先调用 Dog::~Dog,再调用 Animal::~Animalreturn 0;
}
运行结果示意
Animal 构造函数
Dog 构造函数
Dog eat...
Dog 析构函数
Animal 析构函数
1. 为什么需要虚析构函数?
当你通过基类指针删除一个派生类对象时:
Animal* p = new Dog();
delete p; // 这里触发析构过程
- 如果基类的析构函数是虚析构函数(
virtual ~Animal()
),- 会先调用派生类
Dog
的析构函数,释放派生类资源, - 然后调用基类的析构函数,释放基类资源。
- 会先调用派生类
- 如果基类的析构函数不是虚函数,
- 只会调用基类的析构函数,
- 派生类的析构函数不会被调用,导致派生类资源未释放,出现内存泄漏或资源泄露。
2. 不写虚析构函数,默认会怎样?
- 基类析构函数默认是非虚的(除非你显式写
virtual
)。 - 这样通过基类指针删除派生类对象时,只调用基类析构函数。
- 派生类析构函数不会被执行,这会导致派生类成员资源没被释放。
3. 小结
是否有虚析构函数 | 通过基类指针删除派生类对象时的效果 |
---|---|
有虚析构函数(virtual ) | 调用派生类析构函数 + 调用基类析构函数,正确释放资源 |
无虚析构函数 | 只调用基类析构函数,派生类析构函数不执行,资源泄漏风险 |
4. 什么时候必须写虚析构函数?
- 基类会作为多态基类被继承,并且通过基类指针删除派生类对象时,必须写虚析构函数