C++数据结构分析多态的实现与原理及抽象类
作者:呆呆兽学编程
⭐️上一篇博客我和大家聊了聊关于继承的内容,继承是C++的三大特性之一,今天要和大家聊一聊有关C++的三大特性中的最后一个——多态。 ⭐️博客代码已上传至gitee:https://gitee.com/byte-binxin/cpp-class-code
🌏多态的
🍯概念
多态: 从字面意思来看,就是事物的多种形态。用C++的语言说就是不同的对象去完成同一个行为会产生不同的效果。
🍯虚函数
虚函数: 被virtual关键字修饰的类成员函数叫做虚函数。
实例演示: 看一下代码,其中BuyTicket成员函数被virtual关键字修饰
class Person { public: // 虚函数 virtual void BuyTicket() { cout << "买票全价" << endl; } };
🍯多态构成的条件
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。
继承中构成多态有两个条件:
- 必须有基类的指针或引用调用
- 被调用的函数必须是虚函数,其派生类必须对基类的虚函数进行重写
虚函数的重写是什么?
虚函数的重写(覆盖): 派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。(重写是对函数体进行重写)
实例演示:
class Person { public: virtual void BuyTicket() { cout << "买票全价" << endl; } }; class Student : public Person { public: virtual void BuyTicket() // 这里也可以不写virtual,因为基类的虚函数属性已经被保留下来了,这里只是完成虚函数的重写 { cout << "买票半价" << endl; } };
虚函数重写的两个例外:
1.协变:基类和派生类的虚函数的返回类型不同
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。(也就是基类虚函数的返回类型和派生类的虚函数的返回类型是父子类型的指针或引用)
// 协变 返回值类型不同,但它们之间是父子或父父关系 返回类型是指针或者引用 // 基类虚函数 返回类型 是 基类的指针或者引用 // 派生类虚函数 返回类型 是 基类或派生类的返回类型是基类的指针或引用 class A {}; class B : public A {}; class Person { public: virtual A* f() { return new A; } }; class Student : public Person { public: virtual A* f() { return new B; } };
2.析构函数的重写 基类与派生类的析构函数的函数名不同
我在上一篇博客中说到过,基类和派生类的析构函数的函数名会被编译器统一处理成destructor,所以只要基类的析构函数加了关键字virtual,就会和派生类的析构函数构成重写。
我们再回到多态构成的两个条件中,完成基类虚函数的重写我已经介绍了,还有一个必须由基类的指针或引用调用的条件,这个应该很好理解吧。下面举个例子: 实例演示:
class Person { public: virtual void BuyTicket() { cout << "买票全价" << endl; } }; class Student : public Person { public: virtual void BuyTicket() // 这里也可以不写virtual,因为基类的虚函数属性已经被保留下来了,这里只是完成虚函数的重写 { cout << "买票半价" << endl; } }; void Func1(Person& p) { p.BuyTicket(); } void Func2(Person* p) { p->BuyTicket(); } void Func3(Person p) { p.BuyTicket(); } int main() { Person p; Student s; // 满足多态的条件:与类型无关,父类指针指向的是谁就调用谁的成员函数 // 不满足多态的条件:与类型有关,类型是谁就调用谁的成员函数 cout << "基类的引用调用:" << endl; Func1(p); Func1(s); cout << "基类的指针调用:" << endl; Func2(&p); Func2(&s); cout << "基类的对象调用:" << endl; Func3(p); Func3(s); return 0; }
代码运行结果:
总结:
- 满足多态的条件:成员函数调用与对象类型无关,指向那个对象就调用哪个的虚函数
- 不满足多态的条件:成员函数的调用与对象类型有关,是哪个对象类型就调用哪个对象的虚函数。
思考: 析构函数是否要加virtual? 答案是需要的。先给大家看一个例子:
class Person { public: /*virtual*/ ~Person() { cout << "~Person()" << endl; } }; class Student: public Person { public: ~Student() { cout << "~Student()" << endl; } }; int main() { Person* p = new Person; Person* ps = new Student;// 不加virtual,不构成多态,父类指针只会根据类型去调用对于的析构函数 // 加了virtual,构成多态,父类指针会根据指向的对象去调用他的析构函数 delete p; delete ps; return 0; }
下面分别是基类析构函数不加virtual和加virtual的代码运行结果:
可以看出,不加virtual关键字时,第二个对象delete时没有调用子类的析构函数清理释放空间。为什么呢?因为不加virtual关键字时,两个析构函数不构成多态,所以调用析构函数时是与类型有关的,因为都是都是父类类型,所以只会调用父类的析构函数。加了virtual关键字时,因为两个析构函数被编译器处理成同名函数了,所以完成了虚函数的重写,且是父类指针调用,所以此时两个析构函数构成多态,所以调用析构函数时是与类型无关的,因为父类指针指向的是子类对象,所以会调用子类的析构函数,子类调用完自己的析构函数又会自动调用父类的析构函数来完成对父类资源的清理。 所以总的来看,基类的析构函数是要加virtual的。
🍯C++11override和final
final: 修饰虚函数,表示该虚函数不可以被重写(还可以修饰类,表示该类不可以被继承)
实例演示:
class Car { public: // final 表示该虚函数不能被重写 也可以修饰类,表示该类不可以被继承 virtual void Drive() final {} }; class Benz :public Car { public: virtual void Drive() { cout << "Benz-舒适" << endl; } };
编译器检查结果: 由于dirve字母编写错误,所以编译器检查出没有重写基类的虚函数
2.overide: 检查派生类虚函数是否重写了基类的某个虚函数 实例演示:
class Car { public: // final 表示该虚函数不能被重写 也可以修饰类,表示该类不可以被继承 virtual void Drive() final {} }; class Benz :public Car { public: virtual void Drive() { cout << "Benz-舒适" << endl; } };
编译器检查结果:
🍯重载、重写和重定义(隐藏)
名称 | 作用域 | 函数名 | 其他 |
---|---|---|---|
重载 | 两个函数在同一作用域 | 相同 | 参数类型不同 |
重写 | 两个函数分别再基类和派生类的作用域 | 相同 | 函数返回类型和参数类型一样 |
重定义(隐藏) | 两个函数分别再基类和派生类的作用域 | 相同 | 两个基类和派生类的同名函数不是构成重写就是重定义 |
🌏抽象类
概念: 在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化象纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
总结出几个特点:
- 虚函数后面加上=0
- 不能实例化出对象
- 派生类如果不重写基类的纯虚函数那么它也是抽象类,不能实例化出对象
- 抽象类严格限制派生类必须重写基类的纯虚函数
- 体现了接口继承
实例演示:
class Car { public: virtual void Drive() = 0; }; class Benz : public Car { public: virtual void Drive() { cout << "Benz" << endl; } }; class BMW : public Car { public: virtual void Drive () override { cout << "BMW" << endl; } }; int main() { Car* pBenZ = new Benz; pBenZ->Drive(); Car* pBMW = new BMW; pBMW->Drive(); delete pBenZ; delete pBMW; return 0; }
代码运行结果:
抽象类的意义?
- 强制子类完成父类虚函数的重写
- 表示该类是抽象类,没有实体(例如:花、车和人等)
接口继承和实现继承
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
🌏多态的原理
🌲虚函数表
概念: 一个含有虚函数的类中至少有一个虚函数指针,这个指针指向了一张表——虚函数表(简称虚表),这张表中存放了这个类中所有的虚函数的地址。
计算一下下面这个类的大小:
class Base { public: virtual void func1() {} virtual void func2() {} public: int _a; }; int main() { cout << sizeof(Base) << endl; return 0; }
代码运行结果如下:
这个类中存放了一个虚表指针和一个成员变量,所以总大小就是8。给大家看一下它的类对象模型:
实例演示:
class Person { public: virtual void BuyTicket() { cout << "买票全价" << endl; } virtual void func() { cout << "func()" << endl; } int _p = 1; }; class Student : public Person { public: virtual void BuyTicket() // 这里也可以不写virtual,因为基类的虚函数属性已经被保留下来了,这里只是完成虚函数的重写 { cout << "买票半价" << endl; } int _s = 1; }; int main() { Person p; Student s; return 0; }
类对象模型如下:
可以看出,两个虚函数地址是不一样的,其实子类会先把父类的虚表拷贝一份下来,如果子类重写了虚函数,那么子类的虚函数的地址将会覆盖虚表中的地址,如果没有重写,那么将不覆盖。
总结几点:
- 子类对象由两部分构成,一部分是父类继承下来的成员,虚表指针指向的虚表有父类的虚函数,也有子类新增的虚函数
- 子类完成父类虚函数的重写其实是对继承下来的虚表的中重写了的虚函数进行覆盖,把地址更换了,语法层是称为覆盖
- 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr
- 虚表生成的过程:先将基类中的虚表内容拷贝一份到派生类虚表中 b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后
下面我们来讨论一下虚表存放的位置和虚表指针存放的位置
虚表指针肯定是存在类中的,从上面的类对象模型中可以看出。其次虚表存放的是虚函数的地址,这些虚函数和普通函数一样,都会被编译器编译成指令,然后放进代码段。虚表也是存在代码段的,因为同类型的对象共用一张虚表。下面带大家验证一下(环境:vs2019)
验证代码:
class Base { public: virtual void func1() { cout << "Base::func1" << endl; } virtual void func2() { cout << "Base::func2" << endl; } virtual void func3() { cout << "Base::func3" << endl; } void func() {} int b = 0; }; class Derive :public Base { public: virtual void func1() { cout << "Derive::func1" << endl; } virtual void func2() { cout << "Derive::func2" << endl; } virtual void func4() { cout << "Derive::func4" << endl; } virtual void func5() { cout << "Derive::func5" << endl; } int d = 0; }; void func() {} int globalVar = 10; int main() { Base b; Derive d; const char* pChar = "hello"; int c = 1; static int s = 20; int* p = new int; const int i = 10; printf("栈变量:%p\n", &c); printf("虚表指针:%p\n", (int*)&b); printf("对象成员:%p\n", ((int*)&b + 1)); printf("堆变量:%p\n", p); printf("代码段常量:%p\n", pChar); printf("普通函数地址:%p\n", func); printf("成员函数地址:%p\n", &Base::func); printf("虚函数:%p\n", &Base::func1); printf("虚函数表:%p\n", *(int*)&b); printf("数据段:%p\n", &s); printf("数据段:%p\n", &globalVar); delete p; return 0; }
代码运行结果如下:
容易看出,代码段常量存放的地址和虚表存放的地址很接近,和数据段的地址也很接近,所以可以猜测虚表存放在数据段或代码段,更可能是在代码段。
🌲原理
多态是在运行时到指向的对象中的虚表中查找要调用的虚函数的地址,然后进行调用。
总结:
- 多态满足的两个条件:一个是虚函数的覆盖,一个是对象的指针和引用调用
- 满足多态后,函数的调用不是编译时确认的,而是在运行时确认的。
动态绑定和静态绑定
- 静态绑定: 发生在编译时,也就是早期绑定,就是我们之前说过的函数重载就是属于静态绑定,也称静态多态。
- 动图绑定: 发生在运行时,也就是后期绑定,多态就是发生在运行时,也称动态多态。
🌏单继承和多继承的虚表
🍍单继承的虚表
先看下面的代码(单继承)
class Base { public: virtual void func1() { cout << "Base::func1" << endl; } virtual void func2() { cout << "Base::func2" << endl; } virtual void func3() { cout << "Base::func3" << endl; } void func() {} int b = 0; }; class Derive :public Base { public: virtual void func1() { cout << "Derive::func1" << endl; } virtual void func2() { cout << "Derive::func2" << endl; } virtual void func4() { cout << "Derive::func4" << endl; } virtual void func5() { cout << "Derive::func5" << endl; } int d = 0; };
观察它的类对象模型:
在上面的类对象模型中,派生类中只可以看见func1和func2,后面两个函数看不见,这是因为编译器把这两个新增的虚函数给隐藏了,为了我们能够更好的观察,我们可以通过写代码来看。 先定义一个函数指针:
typedef void(*VF_PTR)(); // 给函数指针typedef
下面是打印虚表的代码:
void PrintVFTable(VF_PTR* pTable) { for (size_t i = 0; pTable[i] != nullptr; ++i) { printf("vfTable[%d]:%p->", i, pTable[i]); VF_PTR f = pTable[i]; f();// 通过函数地址调用函数 } cout << endl; }
下面我们只需要通过传虚表地址的方式来调用函数打印虚表,虚表地址如何获取呢?从上面的类对象模型可以知道,类对象的前四个地址存放的是虚表指针,虚表指针也就是虚表的指针,所以我们要获取类对象的前四个字节。下面是获取方法:
(VF_PTR*)*(int*)&b;
先将类对象的地址取出,然后强转为整形,解引用就会按照四个字节来获取内容,这四个字节的内容是虚表指针,其实也是虚表的地址,我们可以把这个整形强转为函数地址的类型就可以了。
打印虚表:
int main() { Base b; Derive d; PrintVFTable((VF_PTR*)*(int*)&b); PrintVFTable((VF_PTR*)*(int*)&d); return 0; }
打印结果如下:
可以看出派生类对象中新增的虚函数会按照虚函数函数次序声明放在虚表的最后。
🍍多继承的虚函数表
看下面代码(多继承)
class Base1 { public: virtual void func1() { cout << "Base1::func1" << endl; } virtual void func2() { cout << "Base1::func2" << endl; } private: int b1; }; class Base2 { public: virtual void func1() { cout << "Base2::func1" << endl; } virtual void func2() { cout << "Base2::func2" << endl; } private: int b2 = 1; }; class Derive : public Base1 , public Base2 { public: virtual void func1() { cout << "Derive::func1" << endl; } virtual void func3() { cout << "Derive::func3" << endl; } private: int d1 = 1; };
类对象模型如下:
为了更好地观察,我们还是通过打印虚表来观察:
int main() { Derive d; cout << sizeof(Derive) << endl; cout << "Base1的虚表:" << endl; PrintVFTable((VF_PTR*)*(int*)&d); cout << "Base2的虚表:" << endl; PrintVFTable((VF_PTR*)*(int*)((char*)&d+sizeof(Base1))); cout << "Derive的成员变量d:" << endl; //PrintVFTable((VF_PTR*)*(int*)((char*)&d + sizeof(Base1) + sizeof(Base2))); cout << *(int*)((char*)&d + sizeof(Base1) + sizeof(Base2)) << endl; return 0; }
打印结果如下:
可以看出,派生类新增的虚函数放在了第一个继承的对象的虚表中最后了。
🌏几个值得思考的问题
- 内敛函数可以是虚函数吗?
答:不可以,内联(inline)函数没有地址,因为虚函数要把地址放到虚表中去。
- 构造函数可以是虚函数吗?
答:不可以,因为对象中虚函数指针是在构造函数初始化列表阶段才初始化的。
- 析构函数可以是虚函数吗?
答:可以,且建议设计成虚函数,具体原因前面说了。
- 对象访问普通函数快还是虚函数更快?
答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
- 虚函数表是在什么阶段生成的?
答:在编译阶段生成的,存在于代码段。
- 什么是抽象类?有什么意义?
答:前面介绍过了,可以参考前文。
🌐总结
多态也是C++的三大特性之一,之前也介绍过两个,就是封装和继承。多态也是十分的重要,我们要理清其中一些改了,更好地理解这些。今天的内容就到这里了,喜欢的话,欢迎点赞支持和关注~
到此这篇关于C++数据结构分析多态的实现与原理及抽象类的文章就介绍到这了,更多相关C++ 多态内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!