多态是C++面向对象三大特性之一,即具有多种形态。可以理解为,同一种行为会产生不同的具体状态/操作。具体到C++中,就是函数调用的多种形态。
多态分为两类
静态多态: 函数重载 和 运算符重载属于静态多态,复用函数名
动态多态: 派生类和虚函数实现运行时多态,主要指父类的指针或引用调用、重写虚函数。(如果父类的指针或引用指向父类,就调用父类的虚函数;如果父类的指针或引用指向某个子类,就调用那个子类的虚函数)
静态多态和动态多态区别:
静态多态的函数地址早绑定 - 编译阶段确定函数地址
动态多态的函数地址晚绑定 - 运行阶段确定函数地址
1 多态的基本概念
1.1 多态的构成条件
多态是指不同继承关系的类对象,调用同一函数时产生了不同的行为。
在继承中要构成多态需满足两个条件:
① 必须通过父类的指针或者引用调用虚函数
② 被调用的函数必须是虚函数,且派生类必须对父类的虚函数进行重写
1.2 静态多态
静态多态:主要指函数重载。
class Animal
{
public:
void Speak()
{
cout << "父类 --- 动物类在说话" << endl;
}
};
class Cat : public Animal
{
public:
void Speak() //这里是函数重写:函数返回值相同、函数名相同、参数列表相同
{
cout << "子类--- 猫类在说话 --- 静态多态" << endl;
}
};
void DoSpeak(Animal & animal)
{
animal.speak();
}
void polymorphism_basic()
{
Cat cat1;
doSpeak(cat1);
// 在该函数中, Animal &animal = cat1, 输出为"父类 --- 动物类在说话"
// 是因为doSpeak()函数属于静态多态的函数,地址早绑定 - 编译阶段确定函数地址,因此,不管传什么参数,始终走得就是“父类”
}
注:在doSpeak(cat1)中:Animal &animal = cat1, 输出为"父类 — 动物类在说话"。是因为doSpeak()函数属于静态多态的函数,地址早绑定 - 编译阶段确定函数地址,因此,不管传什么参数,始终走得就是“父类”
1.3 动态多态
class Animal
{
public:
//函数前面加上virtual关键字,变成虚函数
virtual void Speak()
{
cout << "父类 --- 动物类在说话 --- 动态多态" << endl;
}
};
class Dog : public Animal
{
public:
/*
*/
virtual void Speak()
{
cout << "子类--- 狗类在说话 --- 动态多态" << endl;
}
};
void doSpeak(Animal &Animal)
{
Animal.Speak();
}
void polymorphism_basic()
{
Dog dog1;
doSpeak(dog1);
}
多态的两个条件:
①virtual关键字和虚函数:在父类成员函数Speak() 前加virtual关键字后,该函数就变为虚函数;
② 在子类Dog中重写父类虚函数(子类重写时,virtual关键字可加可不加)
注意:
① 只有类的非静态成员函数才可以成为虚函数。
② **虚函数和虚继承都用到了virtual关键字,但二者之间没有任何关系。**虚函数使用virtual是为了实现多态,而虚继承是为了解决菱形继承中产生的数据冗余和二义性问题,它们之间没有关联。
③ 虚函数重写
派生类中有一个跟基类完全相同的虚函数,即派生类虚函数与基类虚函数的返回值类型、函数名、参数列表完全相同,这时称子类的虚函数重写了基类的虚函数。
1.4 虚函数重写注意事项
class Animal
{
public:
virtual void Speak()
{
cout << "父类 --- 动物类在说话" << endl;
}
};
class Dog : public Animal
{
public:
virtual void Speak() //虚函数重写
{
cout << "子类 --- 狗狗类在说话" << endl;
}
};
void DoSpeak(Animal &animal)
{
animal.Speak(); // 调用"同一个"成员函数(实际不是同一个)
}
int test06()
{
Animal a;
Dog d;
//父类的引用接收不同类型的对象,实现了多态
DoSpeak(a);
DoSpeak(d);
return 0;
}
上面代码中DoSpeak()函数的参数a是父类的引用,接收不同类型的对象时调用虚函数产生了不同的结果,如果将参数改为父类的指针也可以实现同样的效果,但是如果是父类的对象则不可以。
class Animal
{
public:
virtual void Speak()
{
cout << "父类 --- 动物类在说话" << endl;
}
};
class Dog : public Animal
{
public:
virtual void Speak() //虚函数重写
{
cout << "子类 --- 狗狗类在说话" << endl;
}
};
void func1(Animal &animal) //父类引用
{
animal.Speak(); // 调用"同一个"成员函数(实际不是同一个)
}
void func2(Animal *animal) //父类指针
{
animal->Speak();
}
void func3(Animal animal) //父类对象
{
animal.Speak();
}
int test06()
{
Animal a;
Dog d;
//父类的引用接收不同类型的对象,实现了多态
func1(a);
func1(d);
cout << endl;
//父类的指针接收不同类型的对象,实现了多态
func2(&a);
func2(&d);
cout << endl;
//父类的对象接收不同类型的对象,无法实现多态
func3(a);
func3(d);
return 0;
}
很显然以父类的对象为参数无法实现多态,这也印证了最开始提到的,只有父类的引用或指针才可以实现多态。
1.5 总结
1.5.1 多态满足条件
① 有继承关系
② 子类重写父类中的虚函数
1.5.2 多态使用条件
父类指针或引用 指向子类对象
1.5.3 重写
函数返回值类型 函数名 参数列表 完全一致称为重写
2 纯虚函数和抽象类
在多态中,通常父类中虚函数的实现是毫无意义的,主要都是调用子类重写的内容,因此可以将虚函数改为纯虚函数
2.1 纯虚函数
语法: virtual 返回值类型 函数名 (参数列表)= 0 ;
当类中有了纯虚函数,这个类也称为抽象类
2.2 抽象类特点
(1)无法实例化对象
(2)子类必须重写抽象类中的纯虚函数,否则也属于抽象类
class Base //抽象类
{
public:
virtual void func() = 0; //纯虚函数
};
class Son :public Base
{
public:
virtual void func()
{
cout << "子类重写父类中的纯虚函数" << endl;
}
};
void polymorphism_basic()
{
//利用多态调用
Base *base = new Son;
base->func();
delete base;
}
2.2.1 抽象类特点(1)无法实例化对象
void polymorphism_basic()
{
//抽象类特点:(1)无法实例化对象
Base b1; //ERR
Base * base = new Base; // 错误,抽象类无法实例化对象
}
2.2.2 抽象类特点(2)子类必须重写抽象类中的纯虚函数,否则也属于抽象类
void polymorphism_basic()
{
//抽象类特点:(2)子类必须重写抽象类中的纯虚函数,否则也属于抽象类
Son s1;
s1.func();
}
2.3 接口继承和实现继承
普通函数的继承是一种实现继承,子类继承了父类函数,继承的是函数的实现。
虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态。尤其是抽象类的产生,更是强制子类重写父类(否则子类也无法实例化出对象,类的功能大打折扣)。
3虚析构和纯虚析构
问题:多态使用时,如果子类中有属性开辟到堆区,那么父类指针在释放时无法调用到子类的析构代码
解决方式:将父类中的析构函数改为虚析构或者纯虚析构
虚析构语法:
virtual ~类名(){}
纯虚析构语法:
virtual ~类名() = 0;
类名::~类名(){}
3.1 普通析构函数
class MyAnimal
{
public:
MyAnimal()
{
cout << "MyAnimal 构造函数调用" << endl;
}
virtual void Speak() = 0;
~MyAnimal() //利用虚析构可以解决父类指针释放子类对象时,释放不干净的问题
{
cout << "MyAnimal 析构函数调用" << endl;
}
};
class MyCat : public MyAnimal
{
public:
MyCat(string name)
{
cout << "MyCat 构造函数调用" << endl;
m_Name = new string(name);
}
virtual void Speak()
{
cout << *m_Name << "小猫在说话" << endl;
}
~MyCat()
{
if (m_Name != NULL)
{
cout << "MyCat 析构函数调用" << endl;
delete m_Name;
m_Name = NULL;
}
}
string *m_Name;
};
void test06()
{
MyAnimal *animal = new MyCat("Tom");
animal->Speak();
delete animal;
}
从打印结果来看,animal指针在delete时仅调用了父类的析构函数,而它在new时申请的是Cat的空间,这样就可能造成内存泄漏。
问题:通过父类指针去释放,会导致子类对象可能清理不干净,造成内存泄漏
解决方式:给基类增加一个虚析构函数/纯虚析构函数
虚析构函数就是用来解决通过父类指针释放子类对象
3.2 给基类增加虚析构函数
class MyAnimal
{
public:
MyAnimal()
{
cout << "MyAnimal 构造函数调用" << endl;
}
virtual void Speak() = 0;
virtual ~MyAnimal() //利用虚析构可以解决父类指针释放子类对象时,释放不干净的问题
{
cout << "MyAnimal 析构函数调用" << endl;
}
};
3.3 给基类增加纯虚析构函数
注意:纯虚析构 — 需要声明,也需要类外实现;有纯虚析构时,这个类也属于抽象类(无法实例化对象)
class MyAnimal
{
public:
MyAnimal()
{
cout << "MyAnimal 构造函数调用" << endl;
}
virtual void Speak() = 0;
virtual ~MyAnimal() = 0;
};
MyAnimal:: ~MyAnimal()
{
cout << "MyAnimal 纯虚析构函数调用" << endl;
}
3.4 虚析构和纯虚析构共性&区别
虚析构和纯虚析构共性:
① 可以解决父类指针释放子类对象
② 都需要有具体的函数实现
虚析构和纯虚析构区别:
如果是纯虚析构,该类属于抽象类,无法实例化对象
4 多态原理
在虚函数实现时,内存空间会有一个虚函数表(vftable)和虚函数表指针(vfptr)
4.1 虚函数表指针
class A
{
public:
virtual void Func()
{
cout << "virtual void Func()" << endl;
}
private:
int _b = 0;
char _ch = '\0';
};
int main()
{
A a;
cout << sizeof(a) << endl;
return 0;
}
根据结构体 中内存对齐的规则可得该类定义出的对象的大小为8字节,但下面的运行结果是12字节。
通过调试看到对象a中不仅仅有类中定义的两个变量,还有一个void类型的指针,vfptr指针是虚函数表指针,对象a大小为12字节正是因为多了这一个指针变量。
只要类中有虚函数,就一定会存在虚函数表指针,它就是用来实现多态的。
4.2 虚函数表
一个含有虚函数的类中都至少有一个虚函数表指针,因为虚函数的地址要被放到虚函数表(本质是一个数组)中,虚表指针指向的就是这个数组的起始地址。
class A
{
public:
virtual void Func() //虚函数
{
cout << "virtual void Func()" << endl;
}
virtual void Func2() //虚函数
{}
void Func3() //普通函数
{}
private:
int _b = 0;
char _ch = '\0';
};
int main()
{
A a;
cout << sizeof(a) << endl;
return 0;
}
可以看到,类中定义了两个虚函数,则对象中含有虚表指针指向的数组中有两个元素,而普通函数则没有对应的虚表指针。
4.3 多态原理
① 父类的虚函数表内记录虚函数的地址;
② 当不同子类重写父类的虚函数时,各个子类的虚函数表内会替换成各个子类的虚函数的地址;
③ 当父类指针或引用指向子类对象的时候,发生多态
结论:
通过调试可以看到,animal1和animal2中的虚表指针指向的地址不同,也就是说两个类中定义的speak()和析构函数不是同一个,所以在调用时调用各自定义的函数,实现了多态。
总结:
1、满足多态条件、构成多态后,父类的指针或引用调用哪个虚函数,不是在编译时确定的,而是运行到指定位置、需要从指针或引用指向的对象的虚表中找虚函数地址时才确定的。所以如果指向的是父类对象,则调用父类的虚函数;指向的是子类对象,则调用子类的虚函数。
2、如果不满足多态条件,那么调用哪个函数是在编译是就可以确定的,调用函数的对象是什么类型,就调用哪个类型定义的虚函数,与传什么类型的参数无关。
5 静态绑定和动态绑定
静态绑定又称为前期绑定(早绑定),即在程序编译期间就确定了程序的行为,也称为静态多态,比如本文最开始提到的函数重载。
动态绑定又称后期绑定(晚绑定),即在程序运行期间根据具体的类型来确定程序的具体行为,并确定具体调用哪个函数,也称为动态多态。