前言:今天学习的内容可能是近段时间最难的一个部分的内容了,C++的多态,这部分内容博主认为难度比较大,各位一起慢慢啃下来。
💖 博主CSDN主页:卫卫卫的个人主页 💞
👉 专栏分类:高质量C++学习 👈
💯代码仓库:卫卫周大胖的学习日记💫
💪关注博主和博主一起学习!一起努力!
目录标题
- 什么是多态
- 什么是虚函数
- 多态的定义及实现
- 1.多态构成的条件
- 2. 虚函数的重写
- 3.协变
- 4.析构函数的重写
- 5. override 和 final
- 6. 重载、覆盖(重写)、隐藏(重定义)的对比
- 抽象类
- 多态的原理
- 虚函数表
- 虚表指针的内容
- 引用和指针如何实现多态
- 动态绑定与静态绑定
- 虚函数表存放位置
- 单继承和多继承中的虚拟表
什么是多态
多态的概念:多态(polymorphism)是C++中面向对象编程的一个重要概念,它指的是同一种消息(方法调用)在不同的对象上产生不同的行为。这种特性使得程序设计更加灵活,提高了代码的可扩展性和可维护性。(通俗来说,就是多种形态, 具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态)。
什么是虚函数
在C++中,虚函数是一种特殊的成员函数,用于实现多态性。通过将基类的成员函数声明为虚函数,可以在派生类中对该函数进行重写。当通过基类指针或引用调用虚函数时,实际调用的是相应派生类中的函数。
虚函数的声明和定义如下:
class Base//基类
{
public:
virtual void foo() {
// 函数实现
}
};
在基类的函数声明前加上virtual关键字,就将该函数声明为虚函数。派生类可以选择重写基类的虚函数,使用相同的函数签名来定义派生类中的函数:
class Derived : public Base//派生类
{
public:
void foo() override {
// 函数实现
}
};
注意,在派生类中重写虚函数时,可以使用override关键字显式标注,以增强代码可读性。
使用虚函数时,需要通过基类指针或引用来调用虚函数。根据指针或引用所指向的具体对象类型,调用的将是相应对象的虚函数实现。
例如:
Base* base = new Derived();
base->foo(); // 调用的是Derived的foo()函数
在上述示例中,通过基类指针base
指向Derived
对象,并调用虚函数foo()
,实际调用的是Derived
类中的foo()
函数。
总结来说,虚函数实现了在基类中声明一个函数,使其可以在派生类中被重写,并能通过基类指针或引用调用派生类对象的对应实现,从而实现多态性。
多态的定义及实现
1.多态构成的条件
C++中的多态性是指在相同的函数签名下,通过基类指针或引用调用不同的派生类对象时,能够实现不同的行为。在C++中,实现多态性需要满足以下三个条件:
- 存在继承关系:多态性需要至少有一个基类和一个或多个派生类。
- 基类函数为虚函数:基类中的函数必须声明为虚函数,以便在派生类中进行重写。子类父类都有这个虚函数 + 子类的虚函数与父类虚函数的函数名/参数/返回值 都相同 。
- 使用基类指针或引用:通过基类指针或引用来调用派生类对象的函数,实现函数的动态绑定。
下面是一个示例代码,演示了多态性的实现:
class Animal //基类
{
public:
virtual void sound()
{
cout << "Animal is making a sound." << endl;
}
};
class Cat : public Animal//派生类
{
public:
virtual void sound() {
cout << "Cat is meowing." << endl;
}
};
class Dog : public Animal//派生类
{
public:
virtual void sound() {
cout << "Dog is barking." << endl;
}
};
void func(Animal& s)//接受对象为父类的指针或者引用,你传递的是父类就调用父类的函数,传递的是子类就调用子类的函数
{
s.sound();
}
void test1()
{
Animal s1;
Cat s2;
Dog s3;
func(s1);
func(s2);
func(s3);
}
void test2()
{
Animal* animal1 = new Animal();//当基类的指针指向派生类的时候,只能操作派生类中从基类中继承过来的数因据和基类自身的数据
Animal* animal2 = new Cat();
Animal* animal3 = new Dog();
animal1->sound(); // Animal is making a sound.
animal2->sound(); // Cat is meowing.
animal3->sound(); // Dog is barking.
delete animal1;
delete animal2;
delete animal3;
}
int main()
{
test1();
test2();
return 0;
}
在上面的代码中,Animal类是基类,Cat和Dog类是派生类。基类Animal中的sound函数被声明为虚函数,而派生类Cat和Dog中重写了该函数。通过使用基类指针animal1、animal2和animal3,分别指向不同的派生类对象,实现了多态性。
注: 在test1中接受对象为父类的指针或者引用,你传递的是父类就调用父类的函数,传递的是子类就调用子类的函数。
2. 虚函数的重写
在C++中,虚函数的重写是指在派生类中对基类中已有的虚函数进行重新定义。通过重写虚函数,派生类可以改变基类中的函数实现,使其符合派生类的特定需求。
虚函数的重写要求派生类中的函数具有与基类中虚函数完全相同的函数签名(即函数名、参数列表和返回类型都一致)。可以使用override关键字显式标注派生类中的函数,以增强代码的可读性和可靠性。在刚刚的例子中我们通过派生类的Dog和Cat重写了基类中的虚函数sound
3.协变
在C++中,协变(covariant)指的是派生类中重写虚函数的返回类型与基类中的虚函数返回类型具有相关性。换句话说,派生类中重写的虚函数可以返回基类函数返回类型的派生类型。
在早期的C++标准中,派生类中重写虚函数的返回类型必须与基类函数的返回类型完全相同。但是在C++11标准引入了协变的概念,对于返回类型是指针或引用的虚函数,允许派生类中的返回类型是基类返回类型的派生类型。
注:协变是子类虚函数与父类虚函数返回值类型不同,但子类和父类的返回值类型也必须是父子关系指针和引用。
以下是一个示例:
class Animal {
public:
virtual Animal* clone()
{
cout << "Animal clone" << endl;
return new Animal;
}
};
class Dog : public Animal
{
public:
virtual Dog* clone() override
{
cout << "Dog clone" << endl;
return new Dog;
}
};
int main() {
Dog s1;
Animal* s2 = s1.clone();//基类接受派生类的虚函数的返回值构造对象
return 0;
}
在这个例子中,基类Animal有一个虚函数clone,返回一个指向基类的指针。派生类Dog重写了clone函数,并返回一个指向派生类Dog的指针。
当通过基类指针调用clone函数时,根据派生类的类型,会返回相应的指针类型。也就是说,通过协变,Dog*指针会被正确返回。
总结起来,协变允许派生类中的虚函数返回类型与基类中的虚函数返回类型具有相关性,使得处理继承关系时更加灵活和准确。
4.析构函数的重写
在C++中,析构函数(Destructor)也可以被重写。重写析构函数是为了在派生类中定义自己的清理操作。
基类的析构函数通常应该声明为虚函数,以确保正确地调用派生类的析构函数。这是因为当使用基类指针或引用指向派生类对象时,通过基类指针或引用删除对象时,应该调用派生类的析构函数来释放派生类对象的资源。
以下是一个示例:
#include <iostream>
using namespace std;
class Animal {
public:
virtual ~Animal() {
cout << "Animal destructor" << endl;
}
};
class Dog : public Animal {
public:
virtual ~Dog() override {
cout << "Dog destructor" << endl;
}
};
int main() {
Animal* animal = new Dog();//先调用子类的析构,在调用父类的析构
delete animal;
return 0;
}
在这个例子中,基类Animal
的析构函数被声明为虚函数。派生类Dog
重写了析构函数。
当通过基类指针删除派生类对象时,会正确调用派生类的析构函数。也就是说,首先调用派生类的析构函数,然后调用基类的析构函数。
输出结果为:
Dog destructor
Animal destructor
这表明析构函数按照派生类到基类的顺序被调用。
总结起来,C++中的析构函数可以被重写,在派生类中定义自己的清理操作。为了确保正确地调用派生类的析构函数,基类的析构函数通常应该声明为虚函数。
5. override 和 final
在C++11标准中,override
和final
是两个特殊的关键字,用于修饰成员函数。
override
关键字用于表示覆盖基类的虚函数。在C++中,当一个派生类的成员函数与基类的虚函数具有相同的名称和参数列表时,可以使用override
关键字显式地告诉编译器这是一个覆盖函数。这样做的好处是可以提醒开发者在派生类中是否正确地覆盖了基类的虚函数。如果函数签名不匹配,编译器会给出错误提示。例如:
class Base {
public:
virtual void foo() const;
};
class Derived : public Base {
public:
void foo() const override; // override关键字表示覆盖基类的虚函数
};
final
关键字用于表示禁止派生类进一步覆盖函数。在C++中,可以通过在基类的虚函数后面加上final
关键字来禁止派生类进一步覆盖该函数。这样做的好处是可以防止派生类无意中修改这个函数的行为。例如:
class Base {
public:
virtual void foo() const final; // final关键字表示禁止派生类进一步覆盖该虚函数
};
class Derived : public Base {
public:
// 下面的代码会导致编译错误,因为foo()函数被标记为final,无法再被派生类覆盖
// void foo() const;
};
总之,override
和final
关键字是在C++11中引入的,用于增强对虚函数覆盖的控制。override
关键字表示派生类覆盖基类的虚函数,final
关键字表示禁止派生类进一步覆盖函数。
6. 重载、覆盖(重写)、隐藏(重定义)的对比
在C++中,有三种不同的函数特性:重载、覆盖(重写)和隐藏(重定义),它们的区别如下:
- 重载(Overload):重载是指在同一个作用域内,根据函数的参数类型和/或数量的不同,可以定义多个同名函数。重载函数在调用时根据传入的参数类型和/或数量来决定使用哪个函数。重载函数可以在同一个类中或者不同的类中定义。例如:
void foo(int x);
void foo(float x);
- 覆盖(重写,Override):覆盖是指派生类中的函数覆盖了基类中的虚函数,实现了多态性。派生类中的函数必须具有和基类中虚函数相同的名称、参数列表和返回类型,而且在基类中该虚函数必须被声明为
virtual
。在运行时,根据对象的实际类型来确定使用哪个函数。例如:
class Base {
public:
virtual void foo();
};
class Derived : public Base {
public:
void foo() override;
};
- 隐藏(重定义,Hide):隐藏是指派生类中的函数屏蔽了基类中的同名函数,不具有多态性。派生类中的函数必须具有和基类中被隐藏的函数相同的名称,但是参数列表和返回类型可以不同。在编译时,根据对象的静态类型来确定使用哪个函数。例如:
class Base {
public:
void foo(int x);
};
class Derived : public Base {
public:
void foo(float x);
};
总结:
- 重载是根据函数的参数类型和/或数量来决定使用哪个函数,主要是静态多态性。
- 覆盖是指派生类中的函数覆盖了基类中的虚函数,实现了动态多态性。
- 隐藏是指派生类中的函数屏蔽了基类中的同名函数,不具有多态性。
抽象类
在C++中,抽象类是一个不能被直接实例化的类。它只能作为其他类的基类来派生出新的类。抽象类包含至少一个纯虚函数,也可以包含非纯虚函数。
纯虚函数是一个没有实现的虚函数,它通过在函数声明的末尾使用 “= 0” 来指定。纯虚函数的存在使得抽象类无法被实例化,因为任何一个派生类都必须实现所有纯虚函数,才能被实例化。
抽象类主要用于定义公共的接口,而具体的实现则由派生类来完成。它可以作为一种设计工具,用于实现多态性和封装性。在实际应用中,抽象类常常作为基类被其他具体类继承使用。
使用C++中的抽象类需要以下步骤:
- 创建一个抽象类:使用
class
关键字定义一个类,并在需要的成员函数前声明成纯虚函数。至少有一个成员函数是纯虚函数,用于使类成为抽象类。
class AbstractClass {
public:
virtual void pureVirtualFunction() = 0; // 纯虚函数
virtual void virtualFunction() { // 非纯虚函数
// 具体实现
}
};
- 派生一个具体类:从抽象类派生一个具体的子类,该子类必须实现抽象类中的所有纯虚函数。
class ConcreteClass : public AbstractClass {
public:
void pureVirtualFunction() override {
// 实现纯虚函数
}
};
- 实例化具体类:通过具体类实例化对象,可以直接调用抽象类中定义的非纯虚函数,或者通过指针或引用调用纯虚函数。
ConcreteClass obj;
obj.virtualFunction();
AbstractClass* ptr = new ConcreteClass();
ptr->pureVirtualFunction();
delete ptr;
通过使用抽象类,可以定义一个通用接口,而具体的实现则由派生类完成。这样可以提高代码的可维护性,支持多态性,并且遵循面向对象的封装性原则。
多态的原理
虚函数表
C++中的虚函数表(Virtual Function Table,简称vtable)是用于实现多态的一种机制。每个含有虚函数的类都会有一个对应的虚函数表,用于存储该类的虚函数的地址。
虚函数表是一个由函数指针组成的数组,每个函数指针指向相应的虚函数的地址。当一个对象被创建后,一个指向该对象对应的虚函数表的指针(通常称为虚表指针,vptr)会被添加到对象的内存布局中。
当通过基类指针或引用调用虚函数时,编译器会将其替换为通过虚函数表来调用相应的虚函数。具体过程如下:
- 编译器根据对象的类型找到它的虚函数表。
- 根据函数在虚函数表中的位置索引,调用相应的虚函数。
通过虚函数表,C++实现了运行时多态性,允许在运行时根据对象的实际类型来调用相应的虚函数,而不是根据指针或引用的静态类型来调用相应的函数。这为面向对象的程序设计提供了灵活性和可扩展性。
下面我看看一个面试题:
这里我们直接说结果:
为什么是8呢?因为我们刚刚提到的虚函指针在这里出现了,我们去监视窗口看看,是不是真的有这个虚函数指针的存在。
结合刚刚的例子,这里就充分解释了,为什么这对象b中会是8个字节了,当一个对象被创建后,一个指向该对象对应的虚函数表的指针(通常称为虚表指针,vptr)会被添加到对象的内存布局中。
虚表指针的内容
话不多说先看一个例子:
在下图中我们会发现,你创建的对象中,第一个存的就是虚表指针的地址,且发现我们查看虚表指针的地址会发现,虚函数的地址依次存储在该虚表中。
既然这样,我们接着去派生类中查看他的虚表指针有什么不同,如下图所示:
- 基类Animal对象s1和派生类Cat对象s2虚表是不一样的,这里我们发现sound完成了重写,所以s2的虚表中存的是重写的Animal::sound,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法
- 另外fn继承下来后是虚函数,所以放进了虚表,f1也继承下来了,但是不是虚函数,所以不会放进虚表。
- 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。
- 总结一下派生类的虚表生成:a.先将基类中的虚表内容拷贝一份到派生类虚表中 b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
- 这里还有一个童鞋们很容易混淆的问题:虚函数存在哪的?虚表存在哪的? 答:虚函数存在虚表,虚表存在对象中。注意上面的回答的错的。但是很多童鞋都是这样深以为然的。注意虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。
引用和指针如何实现多态
我们之前提到过多态,可以通过传过来的基类的对象或者派生类的对象,来调用其对应的虚函数,但是他是如何识别的呢?
注:这里我们在强调一下,你无论对基类取地址,还是父类取地址,如果有虚函数,那么这个地址都是他虚函数表指针的地址。
这里我们就可以分析,当我们传递基类的对象地址时,就会去基类的虚表指针中去调用对应的虚函数。当我们传递派生类对象的地址时,会将派生类的内容切割掉(切片),然后去调用其虚表指针的地址,然后再去调用对应的虚函数。
动态绑定与静态绑定
在C++中,动态绑定(dynamic binding)和静态绑定(static binding)是两种不同的绑定方式,它们是实现多态性的关键。
静态绑定是在编译时确定函数调用的地址。当使用对象的指针或引用调用函数时,编译器会根据指针或引用的类型来确定调用的函数。这种绑定方式是静态的,因为调用的函数在编译时就已经确定了。静态绑定通常用于非虚函数。
动态绑定是在运行时确定函数调用的地址。当使用对象的指针或引用调用虚函数时,编译器会在运行时根据对象的实际类型来确定调用的函数。这种绑定方式是动态的,因为调用的函数直到运行时才能确定。动态绑定实现了多态性,因为可以通过基类的指针或引用调用派生类的成员函数。动态绑定通常用于虚函数。
动态绑定通过虚函数表(virtual function table)来实现。每个带有虚函数的对象都有一个虚函数表,其中存储了虚函数的地址。当调用虚函数时,通过对象的指针或引用访问虚函数表,并根据对象的实际类型调用正确的函数。
总结起来,静态绑定在编译时确定函数调用的地址,而动态绑定在运行时根据对象的实际类型确定函数调用的地址,实现了多态性。在使用函数时,如果希望实现多态性,需要使用虚函数和动态绑定。
虚函数表存放位置
在 C++ 中,虚函数表的存放位置通常是在 可执行目标文件 的 只读数据段 ( .rodata )1。具体来说,虚函数表指针( vptr )存储在对象实例的内存中,而虚函数表本身则存储在可执行文件的只读数据段中。这意味着直到程序启动并加载可执行文件时,虚函数表的地址才会确定。
举个例子验证一下:
class Base {
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
private:
int a;
};
void func()
{}
int main()
{
Base b1;
Base b2;
static int a = 0;
int b = 0;
int* p1 = new int;
const char* p2 = "hello world";
printf("静态区:%p\n", &a);
printf("栈:%p\n", &b);
printf("堆:%p\n", p1);
printf("代码段:%p\n", p2);
printf("虚表:%p\n", *((int*)&b1));//虚表的地址是存放在类对象的头4个字节上因此我们对其进行强转,取地址就会得到虚表的位置了,
printf("虚函数地址:%p\n", & Base::func1);
printf("普通函数:%p\n", func);
}
如下图所示,我们会发现,虚表的地址和代码段的地址十分相近,因此我们可以得出虚表存放在代码段这个位置,和我们前面的结论相似。
单继承和多继承中的虚拟表
在C++中,每个类都有一个虚函数表(virtual function table),用于存储该类的虚函数的地址。当一个类中定义了虚函数时,编译器会为该类创建一个虚函数表,并将虚函数表的地址存储在对象的内存布局中。当通过指针或引用访问对象的虚函数时,编译器会根据对象的内存布局中存储的虚函数表的地址,找到对应的虚函数并调用。
对于单继承关系,每个类只有一个虚函数表。当子类继承父类时,子类会继承父类的虚函数表,并在自己的虚函数表中添加自己的虚函数。当通过子类的指针或引用访问虚函数时,编译器会根据子类对象的内存布局中存储的虚函数表的地址,找到对应的虚函数并调用。
对于多继承关系,每个类都有自己的虚函数表。当一个类通过多个父类进行多继承时,每个父类会有自己的虚函数表,并在子类的内存布局中存储这些虚函数表的地址。在访问虚函数时,编译器会根据对象的内存布局中存储的虚函数表的地址,找到对应的虚函数并调用。
需要注意的是,多继承中可能会出现虚函数表的冲突或者大小不一致的问题,编译器会根据不同的实现采取不同的解决方案来处理这些问题。
注:每一个虚函数都会放到虚表里面,但是有的编译器并不会显示一些虚函数!!!
好啦,今天的内容就到这里啦,下期内容预告搜索树的学习与模拟实现.
结语:进阶的内容有点繁杂,大家一起加油呐!。