1. 简介
多态按字面的意思就是多种形态。当类与类之间存在继承关系的时候,就会用到多态。
C++ 多态意味着调用成员函数时,会根据调用函数的对象的类型 来执行不同的函数。多态分为为静态多态 和 动态多态 两种。
平常说的多态是 动态多态
2. 静态多态
静态多态是编译器在编译期间 完成的,编译器会根据实参类型 来选择调用合适的函数,如果有合适的函数可以调用就调,没有的话就会发出警告或者报错 。 该种方式的出现有两处地方: 函数重载 和 泛型编程 | 函数模板
- 特点:
- 在编译时确定函数调用或代码生成,效率高。
- 可以根据不同的参数类型或数量进行函数重载或模板特化。
- 静态多态决策发生在编译阶段,对于每次运行都是固定的。
- 使用场景
- 函数重载(Function Overloading): 在同一个作用域内定义了多个同名但参数列表不同的函数。根据函数调用时传递的参数类型或数量,编译器会决定调用哪个具体的函数。
cpp
#include <iostream>
void print(int num) {
std::cout << "Integer: " << num << std::endl;
}
void print(double num) {
std::cout << "Double: " << num << std::endl;
}
int main() {
int a = 20;
double b = 3.14;
print(a); // 调用print(int)
print(b); // 调用print(double)
return 0;
}
- 模板(Template): 使用模板可以编写泛型代码,在编译时生成对应特定类型的代码,从而实现静态多态。
cpp
#include <iostream>
template<typename T>
void print(T value) {
std::cout << "Value: " << value << std::endl;
}
int main() {
int a = 20;
double b = 3.14;
print(a); // 根据实参类型生成print<int>(int)
print(b); // 根据实参类型生成print<double>(double)
return 0;
}
3. 动态多态
动态多态: 指只有在运行的时候才能决定到底调用哪个类的函数
动态多态的必须满足两个条件:
- 父类中必须包含虚函数,并且子类中一定要对父类中的虚函数进行重写。
- 父类的 指针 | 引用 接收子类对象 ,使用这个指针 | 引用 来调用同名的虚函数。
代码:
cpp
#include <iostream>
using namespace std;
class father{
public:
void doSomething(){
cout << "父亲在做事..." <<endl;
}
};
class son : public father{
public:
void doSomething(){
cout << "儿子在干活..." <<endl;
}
};
int main() {
//father &f0 = father f0; 报错,'father' does not refer to a value
//这是父类的对象
father f;
f.doSomething();
//父类的引用能接受父类的对象
father &f1 = f;
f1.doSomething();
//父类的指针接收父类的对象。
father * f2 =new father();
f2->doSomething();
//子类的指针接收子类的对象
son * s = new son();
s->doSomething();
//父类的指针接收子类对象
father *f4 = new son();
f4->doSomething();
// son * s1 =new father(); 报错 Cannot initialize a variable of type 'son *' with an rvalue of type 'father *'
return 0;
}
运行结果:
cpp
父亲在做事...
父亲在做事...
父亲在做事...
儿子在干活...
父亲在做事...
- 特点
- 虚函数:动态多态性依赖于虚函数。在基类中使用 virtual 关键字声明虚函数,派生类可以对其进行重写。当通过基类的指针或引用调用虚函数时,实际调用的是对象的派生类函数,而不是基类函数。
- 运行时确定:动态多态性在运行时确定,而不是在编译时。这意味着在程序运行时根据实际对象的类型来动态选择调用的函数,而不是根据指针或引用的类型来决定。
- 基类的通用接口:动态多态性使得可以通过基类的指针或引用来访问派生类的对象,并调用它们的成员函数。这为实现基类的通用接口提供了便利,可以处理一组派生类对象,统一地访问它们的接口,提高代码的灵活性和可维护性。
- 使用场景
- 多态行为:当有多个派生类对象,但是希望以一种统一的方式处理它们时,动态多态性非常有用。通过使用基类的指针或引用,可以在运行时根据实际对象的类型来选择调用的函数,实现多态行为。
cpp
#include <iostream>
// 基类 Shape
class Shape {
public:
virtual double area() const = 0;
};
// 派生类 Rectangle
class Rectangle : public Shape {
private:
double width;
double height;
public:
Rectangle(double w, double h) : width(w), height(h) {}
double area() const override {
return width * height;
}
};
// 派生类 Circle
class Circle : public Shape {
private:
double radius;
public:
Circle(double r) : radius(r) {}
double area() const override {
return 3.14159 * radius * radius;
}
};
int main() {
Shape* shape1 = new Rectangle(4, 3);
Shape* shape2 = new Circle(5.0);
std::cout << "Rectangle area: " << shape1->area() << std::endl;
std::cout << "Circle area: " << shape2->area() << std::endl;
delete shape1;
delete shape2;
return 0;
}
- 统一接口:通过使用基类的指针或引用,可以定义一个通用的接口,处理一组派生类对象。这样可以在不了解具体派生类的情况下,统一地访问它们的接口,提供代码的灵活性和可维护性。
cpp
#include <iostream>
// 基类 Drawable
class Drawable {
public:
virtual void draw() const = 0;
};
// 派生类 Rectangle
class Rectangle : public Drawable {
public:
void draw() const override {
std::cout << "Drawing a rectangle." << std::endl;
}
};
// 派生类 Circle
class Circle : public Drawable {
public:
void draw() const override {
std::cout << "Drawing a circle." << std::endl;
}
};
void drawShapes(const Drawable& shape) {
shape.draw();
}
int main() {
Rectangle rectangle;
Circle circle;
drawShapes(rectangle);
drawShapes(circle);
return 0;
}
- 扩展性:通过继承和虚函数,动态多态性提供了一种灵活的方式来扩展代码。当需要添加新的派生类时,只需继承基类并重写虚函数即可,而不需要修改已有的代码。
cpp
#include <iostream>
// 基类 Animal
class Animal {
public:
virtual void sound() const {
std::cout << "Animal makes sound." << std::endl;
}
};
// 派生类 Dog
class Dog : public Animal {
public:
void sound() const override {
std::cout << "Dog barks." << std::endl;
}
};
// 派生类 Cat
class Cat : public Animal {
public:
void sound() const override {
std::cout << "Cat meows." << std::endl;
}
};
// 扩增派生类 Bird
class Bird : public Animal {
public:
void sound() const override {
std::cout << "Bird sings." << std::endl;
}
};
int main() {
Animal* animal1 = new Dog();
Animal* animal2 = new Cat();
Animal* animal3 = new Bird();
animal1->sound();
animal2->sound();
animal3->sound();
delete animal1;
delete animal2;
delete animal3;
return 0;
}
- 注意点
- 必须使用虚函数:为了实现动态多态性,必须在基类中将需要在派生类中重写的函数声明为虚函数。
- 析构函数必须为虚函数:如果在基类中使用了虚函数,那么基类的析构函数也必须声明为虚函数。这是为了确保在使用基类指针删除派生类对象时,能够正确调用派生类的析构函数,避免内存泄漏。
- 注意对象切片问题:当将派生类对象赋值给基类对象时,会发生对象切片(Object Slicing)问题。这意味着只会复制基类部分的成员变量和函数,派生类特有的成员将被截断。为了避免对象切片问题,通常需要使用基类的指针或引用来操作派生类对象。
- 避免在析构函数中调用虚函数:在基类的析构函数中调用虚函数可能导致意外行为,因为在析构函数期间,派生类的部分可能还未初始化或已被销毁。因此,应该避免在构造函数和析构函数中调用虚函数,或者使用非虚函数或静态函数来完成相应的操作。
- 注意函数重载与函数重写:函数重载是在同一个类中定义了多个同名函数,它们的参数列表不同。而函数重写是在派生类中重写了基类的虚函数,函数名、参数列表和返回类型都必须相同。在使用基类指针或引用调用函数时,会根据对象的实际类型选择适当的函数,因此要确保在派生类中正确重写基类的虚函数。
4. 虚函数
C++中的虚函数的作用主要是实现了多态的机制 , 有了虚函数就可以在父类的指针或者引用指向子类的实例的前提下,然后通过父类的指针或者引用调用实际子类的成员函数。这时父类的指针或引用具备了多种形态。定义虚函数:在函数声明前,加上 virtual
关键字即可。 在父类的函数上添加 virtual 关键字,可使子类的同名函数也变成虚函数。
如果基类指针指向的是一个基类对象,则基类的虚函数被调用 ,如果基类指针指向的是一个派生类对象,则派生类的虚函数被调用。
- 特点
- 多态性(Polymorphism):通过使用虚函数,可以实现运行时多态性。当基类指针或引用指向派生类对象时,调用虚函数会根据实际对象类型来确定要执行的具体函数。
- 动态绑定(Dynamic Binding):由于虚函数的动态绑定机制,在运行时才确定要调用哪个版本的虚函数。这使得程序能够根据对象类型动态地选择正确的成员函数。
- 虚函数表(Virtual function table):为了实现虚函数的多态性,编译器会在每个含有虚函数的类中创建一个虚函数表(vtable),该表存储了虚函数的地址。基类和派生类都有各自的虚函数表,虚函数的绑定是通过查找虚函数表来实现的。
4.1 工作原理
通常情况下,编译器处理虚函数的方法是: 给每一个对象添加一个隐藏指针成员,它指向一个数组,数组里面存放着对象中所有函数的地址。这个数组称之为虚函数表(virtual function table v-table)。表中存储着类对象的虚函数地址。
父类对象包含的指针,指向父类的虚函数表地址,子类对象包含的指针,指向子类的虚函数表地址。
如果子类重新定义了父类的函数,那么函数表中存放的是新的地址,如果子类没有重新定义,那么表中存放的是父类的函数地址。
若子类有自己虚函数,则只需要添加到表中即可。
4.2 构造函数可以是虚函数吗?
构造函数不能为虚函数 , 因为虚函数的调用,需要虚函数表(指针),而该指针存在于对象开辟的空间中,而对象的空间开辟依赖构造函数的执行,这就矛盾了。
cpp
#include <iostream>
using namespace std;
class father{
public:
// virtual father(){} Constructor cannot be declared 'virtual'
father(){
cout << "父类的构造..." <<endl;
}
};
class son : public father{
public:
son(){
cout << "子类的构造..." <<endl;
}
};
int main() {
son s;
return 0;
}
4.3 析构函数可以是虚函数吗?
在继承体系下, 如果父类的指针可以指向子类对象,这就导致在使用
delete
释放内存时,却是通过父类指针来释放,这会导致父类的析构函数会被执行,而子类的析构函数并不会执行,此举有可能导致程序结果并不是我们想要的。究其原因,是因为静态联编的缘故,在编译时,就知道要执行谁的析构函数。
为了解决这个问题,需要把父类的析构函数变成虚拟析构函数,也就是加上virtual
的定义。一旦父类的析构函数是虚函数,那么子类的析构函数也将自动变成虚函数。一句话概括: 继承关系下,所有人的构造都不能是虚函数,并且所有人的析构函数都必须是虚函数。
只要在父亲的析构函数加上 virtual ,那么所有的析构函数都变成 虚函数
cpp
using namespace std;
class father{
public:
father(){
cout << "父类的构造..." <<endl;
}
virtual ~father(){
cout << "父类的析构..." <<endl;
}
};
class son : public father{
public:
son(){
cout << "子类的构造..." <<endl;
}
~son(){
cout << "子类的析构..." <<endl;
}
};
int main() {
father * f = new son ();
delete f;
return 0;
}
5. 纯虚函数
纯虚函数是一种特殊的虚函数,C++中包含纯虚函数的类,被称为是"抽象类"。抽象类不能使用new出对象,只有实现了这个纯虚函数的子类才能new出对象。C++中的纯虚函数更像是"只提供声明,没有实现",是对子类的约束。
纯虚函数就是没有函数体,同时在定义的时候,其函数名后面要加上"= 0" 。
代码:
cpp
#include <iostream>
using namespace std;
class Animal{
public:
// 动物类的吃的行为,看起来更像是对子类的一种抽象,或者是看起来像是一个功能的声明。
virtual void eat() = 0 ;
/*virtual void eat(){
cout << "动物在吃..." <<endl;
}*/
};
class Bear : public Animal{
public:
void eat(){
cout << "熊吃鱼..." <<endl;
}
};
class Tiger : public Animal{
public:
void eat(){
cout << "老虎吃肉..." <<endl;
}
};
class Pangolin : public Animal{
public:
void eat(){
cout << "穿山甲吃蚂蚁..." <<endl;
}
};
class Suckler : public Animal{};
int main() {
//Animal S = new Suckler; 报错, Allocating an object of abstract class type 'Suckler'
Animal * B = new Bear;
B->eat();
Animal * T = new Tiger;
T->eat();
Animal * P = new Pangolin;
P->eat();
return 0;
}
- 注意点:
- 纯虚函数,一般会出现在父类里面,它看起来就像是一种功能的声明而已,没有具体的实现,因为每一个子类,具体功能都不太一样。
- 如果一个类含有纯虚函数,那么该类即可称之为: 抽象类。
- 抽象类禁止创建对象。因为如果能创建对象,万一调用了纯虚函数,这就有混乱了。
- 子类继承了抽象类之后,必须实现纯虚函数,如果不实现,那么子类也变成了抽象类。比如上面的 Suckler 类
- 抽象类里面,能写普通函数,虽然抽象类不能创建对象,不能调用方法,但是子类可以创建对象,可以让子类调用方法。