本篇文章会对C++中的多态进行详解。希望本篇文章会对你有所帮助。
文章目录
一、多态的定义及实现
1、1 多态的概念
1、2 多态的构成条件
1、2、1 虚函数
1、2、2 虚函数的重写
1、2、3 析构函数构成重写特例原因
1、3 多态的实例练习
1、3、1 例1
1、3、2 例2
1、3、3 例3
1、4 C++11 override 和 fifinal
1、5 重载、覆盖(重写)、隐藏(重定义)的对比
二、多态的原理
2、1 虚函数表
2、2 多态的原理
2、3 静态绑定与动态绑定
三、抽象类
四、单继承和多继承的虚函数表
4、1 单继承的虚函数表
4、2 多继承的虚函数表
4、3 多继承中同一虚函数地址不同的问题原因
4、3 虚表存储的位置
五、总结
♂️ 作者:@Ggggggtm ♂️
专栏:C++
标题:C++ 多态
❣️ 寄语:与其忙着诉苦,不如低头赶路,奋路前行,终将遇到一番好风景 ❣️
C++多态性(Polymorphism)是面向对象编程(OOP)的一个重要特性之一,它允许我们使用统一的接口来处理不同类型的对象。多态性使得程序更加灵活、可扩展并且易于维护。
通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。Person对象买票全价,Student对象买票半价。在继承中要构成多态还有两个条件:
- 必须通过基类的指针或者引用调用虚函数。
- 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。
上述构成多态的条件中提到了虚函数,所谓的虚函数,就是被virtual修饰的类成员函数。具体如下:
class Person { public: virtual void BuyTicket() { cout << "买票-全价" << endl; } };
上述的代码中,成员函数 BuyTicket() 即为虚函数。
虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。我们看如下例子:
class Person { public: virtual void BuyTicket() { cout << "买票-全价" << endl; } }; class Student : public Person { public: virtual void BuyTicket() { cout << "买票-半价" << endl; } /*void BuyTicket() { cout << "买票-半价" << endl; }*/ };
上面的派生类Student 的 BuyTicket() 与Person 的 BuyTicket() 构成了重写。注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用。
但是,虚函数重写也有两个例外:
协变(基类与派生类虚函数返回值类型不同)派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。class A {}; class B : public A {}; class Person { public: virtual A* f() { return new A; } }; class Student : public Person { public: virtual B* f() { return new B; } };
析构函数的重写(基类与派生类析构函数的名字不同)如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。class Person { public: virtual ~Person() { cout << "~Person()" << endl; } }; class Student : public Person { public: virtual ~Student() { cout << "~Student()" << endl; } };
我们在上述中了解到了析构函数不同名也够构成重写的特例。这个其实是有原因的。我们先看如下代码:
class Person { public: ~Person() { cout << "~Person()" << endl; } }; class Student : public Person { public: ~Student() { cout << "~Student()" << endl; } }; int main() { Person* p1 = new Person; Person* p2 = new Student; delete p1; delete p2; return 0; }
上述代码动态申请了 Person 和 Student 对象,然后再去释放掉动态申请的空间。我们看运行结果:
好像并不是我们想的那样。为什么会出现上图的结果呢?别忘记了,子类的指针赋给父类指针时,会发生切割。p2指针只会指向属于父类的那一部分。所以时调用了父类的析构函数。 并不能正确的释放掉动态开辟的空间。
针对上述的问题,我们发现多态的调用就可以很好的解决。为了构成多态,编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。 我们可看如下例子:
class Person { public: virtual ~Person() { cout << "~Person()" << endl; } }; class Student : public Person { public: virtual ~Student() { cout << "~Student()" << endl; } }; int main() { Person* p1 = new Person; Person* p2 = new Student; delete p1; delete p2; return 0; }
上述的析构函数构成的重写,p2对析构函数的调用构成了多态调用。运行结果如下:
我们上面了解了多态的概念后,接下来结合几个例子,看看自己到底掌握的怎么样。我们先看如下代码:
class Person { public: virtual void BuyTicket() { cout << "买票-全价" << endl; } }; class Student : public Person { public: virtual void BuyTicket() { cout << "买票-半价" << endl; } }; void Func(Person& p) { p.BuyTicket(); } int main() { Person ps; Student st; Func(ps); Func(st); return 0; }
上述代码构成了多态吗?首先是虚函数,其次完成了重写。别忘记了还有一个条件是通过基类的指针或者引用调用虚函数。Func()函数正是用的基类的指针去调用虚函数。我们再来看一下运行结果:
确实是使用统一的接口来处理不同类型的对象,结果也是不同的。p.BuyTicket() 到底是调用谁的 BuyTicket() 呢?关键在于我们所传的对象了。我们看 p 是引用的那个对象,引用的那个对象就会调用那个对象的 BuyTicket()。指针也是如此,指向的是那个对象,调用的就是所指向对象的 BuyTicket()。
我们这里再练习一道题,代码如下:
class A { public: virtual void func(int val) { cout << "A->" << val << std::endl; } }; class B : public A { public: virtual void func(int val) { cout << "B->" << val << std::endl; } }; int main() { A* p = new B; p->func(1); return 0; }
上述的代码调用构成多态吗?上述的代码运行结果是什么呢?答案是:构成多态。运行结果如下:
我们再来看一道终极练习题,代码如下:
class A { public: virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; } virtual void test() { func(); } }; class B : public A { public: void func(int val = 0) { cout << "B->" << val << std::endl; } }; int main(int argc, char* argv[]) { B*p = new B; p->test(); return 0; }
问上述的代码调用构成多态吗?上述的代码运行结果是什么呢?
首先调用是构成多态调用的。为什么呢?派生类 B 继承了 基类 A的test()成员函数。同时func()函数构成重写。但是不要忽略了this指针。test()函数中本身就有一个基类 A的this指针。同时调用的是构成重写的虚函数。所以构成多态调用。
当我们p->test(),传过去的this指针是 B*,所以调用的是派生类 B中的func()函数。那我们来看看运行结果是否是这样的。如下:
运行结果不应该是 B->0 吗?这里就涉及到了另一个知识了:虚函数的重写是接口继承,实现重写。 所以才会导致运行结果是 B->1。
从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮助用户检测是否重写。
- final:修饰虚函数,表示该虚函数不能再被重写。
class Car { public: virtual void Drive() final {} }; class Benz :public Car { public: virtual void Drive() { cout << "Benz-舒适" << endl; } };
- override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
class Car { public: virtual void Drive() {} }; class Benz :public Car { public: virtual void Drive() override { cout << "Benz-舒适" << endl; } };
重载(Overload):
- 定义:重载是指在同一作用域内,通过改变函数或方法的参数列表来定义多个具有相同名称但是不同参数的函数或方法。
- 特点:
- 参数列表必须不同,可以是参数个数不同、参数类型不同、参数顺序不同,但不能只有返回值不同。
- 重载实现了多态的一种形式,编译器根据调用时提供的参数列表的不同来选择调用对应的函数或方法。
覆盖(Override):
- 定义:覆盖是指在派生类中重新定义基类中已经存在的虚函数,使用相同的函数(函数名称、参数列表和返回类型)来实现新的功能。
- 特点:
- 被重写的函数必须是虚函数,即在基类中使用"virtual"关键字声明。
- 覆盖是实现继承和多态的一种重要方式。
- 子类中的函数与基类中的函数具有相同的名称和参数列表,但是功能实现可以完全不同。
- 在运行时,通过基类指针或引用来调用该函数时,根据实际对象的类型来确定调用的是基类还是派生类中的函数。
隐藏(Redefinition):
- 定义:隐藏是指在派生类中定义了与基类中同名函数,隐藏了基类中的同名函数。这种情况下,派生类对象调用该函数时,默认调用到自己所定义的同名函数,调用基类中的同名函数可以使用 基类::基类成员 显示访问。
- 特点:
- 通过派生类对象直接调用同名函数时,会屏蔽掉基类中的同名函数,即使基类中的同名函数声明为虚函数也无法实现动态绑定。
- 对于隐藏函数而言,它们只在静态类型上起作用,不涉及运行时的多态性。
上述中提到去多新概念:静态绑定、动态绑定等。下面我们讲解多态实现原理时会一一讲到。
我们先看如下代码:
class Base { public: virtual void Func1() { cout << "Func1()" << endl; } private: int _b = 1; }; int main() { Base a; cout << sizeof(a) << endl; return 0; }
上述代码的运行结果是什么呢?也就是对象 a 的大小。我们看一下运行结果:
不就是只有一个 _b 变量吗?然后内存对齐应该是四个字节啊,怎么不是呢?我们不妨通过调试观察一下,对象 a 中到底有哪些变量。如下图:
我们看到,除了_b成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。那么派生类中这个表放了些什么呢?我们接着往下分析。
针对上面的代码我们做出以下改造:
- 我们增加一个派生类Derive去继承Base。
- Derive中重写Func1。
- Base再增加一个虚函数Func2和一个普通函数Fun。
具体代码如下:
class Base { public: virtual void Func1() { cout << "Base::Func1()" << endl; } virtual void Func2() { cout << "Base::Func2()" << endl; } void Func3() { cout << "Base::Func3()" << endl; } private: int _b = 1; }; class Derive : public Base { public: virtual void Func1() { cout << "Derive::Func1()" << endl; } private: int _d = 2; }; int main() { Base b; Derive d; return 0; }
我们再次通过调试观察一下,如下图:
通过观察和测试,我们发现了以下几点问题:
- 派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在部分的另一部分是自己的成员。
- 基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
- 另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函数,所以不会放进虚表。
- 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。
- 总结一下派生类的虚表生成:a.先将基类中的虚表内容拷贝一份到派生类虚表中 b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
- 这里还有一个童鞋们很容易混淆的问题:虚函数存在哪的?虚表存在哪的? 答:虚函数存在虚表,虚表存在对象中。注意上面的回答的错的。但是很多童鞋都是这样深以为然的。注意虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那虚表存在哪里了呢?后面会验证虚表到底存储在哪里的。
上面分析了这个半天了那么多态的原理到底是什么?还记得这里Func函数传Person调用的
Person::BuyTicket,传Student调用的是Student::BuyTicket,代码如下:class Person { public: virtual void BuyTicket() { cout << "买票-全价" << endl; } }; class Student : public Person { public: virtual void BuyTicket() { cout << "买票-半价" << endl; } }; void Func(Person* p) { p.BuyTicket(); } int main() { Person Mike; Func(&Mike); Student Johnson; Func(&Johnson); return 0; }
通过上面对虚表的学习,我们也大概清楚了每个对象都有属于自己的虚表。而自己的虚表中存储的是自己的虚函数。在调用时,会到指针所指向的对象的虚表中找到对应的虚函数进行调用。具体我们可看下图:
我们不妨通过汇编进行观察一下:
call eax中存虚函数的指针。这里可以看出满足多态的调用,不是在编译时确定的,是运行起来以后到指向的对象的虚表中去找对应的虚函数。
我们再来看,当不满足多态调用普通函数调用,汇编代码是什么样子的。如下图:
我们看到,普通函数的调用是在编译时已经从符号表确认了函数的地址,直接call 地址普通函数的调用。这就与静态绑定和动态绑定有关了。
在C++中,动态绑定(dynamic binding)和静态绑定(static binding)是与多态性相关的两个概念。
- 静态绑定: 静态绑定是在编译时确定调用的函数或方法,它是通过函数或方法的名称、参数数量、类型和顺序来匹配确定的。对于非虚拟函数和静态成员函数,默认情况下都是静态绑定。例如,在以下代码中:
class Base { public: void display() { std::cout << "Base class" << std::endl; } }; class Derived : public Base { public: void display() { std::cout << "Derived class" << std::endl; } }; int main() { Base baseObj; Derived derivedObj; baseObj.display(); // 静态绑定,输出 "Base class" derivedObj.display(); // 静态绑定,输出 "Derived class" }
- 动态绑定: 动态绑定是指在运行时确定调用的函数或方法,它是通过虚拟函数和指针/引用来实现的。虚拟函数是在基类中声明为虚拟的成员函数,在派生类中进行重写。通过使用基类的指针或引用调用虚拟函数时,实际调用的是派生类中重写的函数。例如,在以下代码中:
class Base { public: virtual void display() { std::cout << "Base class" << std::endl; } }; class Derived : public Base { public: void display() { std::cout << "Derived class" << std::endl; } }; int main() { Base* basePtr; Derived derivedObj; basePtr = &derivedObj; basePtr->display(); // 动态绑定,输出 "Derived class" }
通过将
derivedObj
的地址赋给basePtr
,然后使用basePtr->display()
调用虚拟函数,实际上执行的是派生类Derived
中的重写函数,这是因为动态绑定在运行时根据对象的实际类型确定调用的函数。
所谓的抽象类,在虚函数的后面写上 =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() { cout << "BMW-操控" << endl; } }; void Test() { Car* pBenz = new Benz; pBenz->Drive(); Car* pBMW = new BMW; pBMW->Drive(); }
我们通过如下代码观察单继承的虚函数表:
class Base { public: virtual void func1() { cout << "Base::func1" << endl; } virtual void func2() { cout << "Base::func2" << endl; } private: int a; }; class Derive :public Base { public: virtual void func1() { cout << "Derive::func1" << endl; } virtual void func3() { cout << "Derive::func3" << endl; } virtual void func4() { cout << "Derive::func4" << endl; } private: int b; }; int main() { Base b; Derive d; return 0; }
通过调试观察,如下图:
观察上图中的监视窗口中我们发现看不见func3和func4。这里是编译器的监视窗口故意隐藏了这两个函数,也可以认为是他的一个小bug。那么我们如何查看d的虚表呢?下面我们使用代码打印出虚表中的函数。
typedef void(*VFPTR) (); void PrintVTable(VFPTR vTable[]) { // 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数 cout << " 虚表地址>" << vTable << endl; for (int i = 0; vTable[i] != nullptr; ++i) { printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]); VFPTR f = vTable[i]; f(); } cout << endl; } int main() { Base b; Derive d; VFPTR* vTableb = (VFPTR*)(*(int*)&b); PrintVTable(vTableb); VFPTR* vTabled = (VFPTR*)(*(int*)&d); PrintVTable(vTabled); return 0; }
解释一下上述代码的思路:
取出b、d对象的头4bytes,就是虚表的指针,前面我们说了虚函数表本质是一个存虚函数指针的指针数组,这个数组最后面放了一个nullptr。 先取b的地址,强转成一个int*的指针(为了取对象的头4bytes)。 指针再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的。 再强转成VFPTR*,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组。 虚表指针传递给PrintVTable进行打印虚表。 需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后面没有放nullptr,导致越界,这是编译器的问题。我们只需要点目录栏的-生成-清理解决方案,再编译就好了。我们再来看一下运行结果:
我们通过如下代码观察一下多继承的虚函数表,代码如下:
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; }; class Derive : public Base1, public Base2 { public: virtual void func1() { cout << "Derive::func1" << endl; } virtual void func3() { cout << "Derive::func3" << endl; } private: int d1; }; typedef void(*VFPTR) (); void PrintVTable(VFPTR vTable[]) { cout << " 虚表地址>" << vTable << endl; for (int i = 0; vTable[i] != nullptr; ++i) { printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]); VFPTR f = vTable[i]; f(); } cout << endl; } int main() { Derive d; VFPTR* vTableb1 = (VFPTR*)(*(int*)&d); PrintVTable(vTableb1); VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1))); PrintVTable(vTableb2); return 0; }
我们看一下运行结果,如下图:
其实我们也不难发现,在多继承中对象 d 中,是有两个虚函数表的。观察上图可以看出:多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中
细心的同学可能已经发现,上述多继承的虚函数表中的重写后的同一个函数的地址竟然不同。我们也可看如下代码的运行结果:
#include
#include using namespace std; class Base1 { public: virtual void func1() { cout << "Base1::func1" << endl; } virtual void func2() { cout << "Base1::func2" << endl; } private: int b1 = 1; }; class Base2 { public: virtual void func1() { cout << "Base2::func1" << endl; } virtual void func2() { cout << "Base2::func2" << endl; } private: int b2 = 2; }; class Derive : public Base1, public Base2 { public: virtual void func1() { cout << "Derive::func1" << endl; } virtual void func3() { cout << "Derive::func3" << endl; } private: int d1 = 3; }; typedef void(*VFPTR) (); void PrintVTable(VFPTR vTable[]) { cout << " 虚表地址>" << vTable << endl; for (int i = 0; vTable[i] != nullptr; ++i) { printf(" 第%d个虚函数地址 :%p,->", i, vTable[i]); VFPTR f = vTable[i]; f(); } cout << endl; } int main() { Derive d; VFPTR* vTableb1 = (VFPTR*)(*(int*)&d); PrintVTable(vTableb1); VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1))); PrintVTable(vTableb2); printf("%p\n", &Derive::func1); d.func1(); Base1* ptr1 = &d; ptr1->func1(); Base2* ptr2 = &d; ptr2->func1(); return 0; } 运行结果如下:
我们发现,同一个函数打印出来三个不同的地址!其实我们通过汇编看其实如何调用的就可清楚啦。如下图:
d.func1() 是直接调用,编译时就确定了所要调用的函数地址。
ptr1->func1() 是多态调用,是在运行时找所指向对象虚表中找到对应的虚函数。只不过是中间跳转了一下,我们所看到的地址是寄存器中所保存的跳转指令的地址。
ptr2->func1()是多态调用。但我们发现,中间跳转了两次。发现对ecx中的地址减去了8。为什么呢?首先ecx中存储的是this指针。那就明白了。其实就是对this指针减去了8。原因是我们所指向的对象是Derive类的 d 对象,我们要只想整个 d 对象。而 ptr2 = &d 会发生切割,使得ptr2指向的是属于自己的那一部分。相当于就是对 ptr2 中的地址减去了8。下图是对象 d 的对象模型,和ptr1、ptr2所指向的位置:
通过上面的学习,我们对虚表会有一个新的认知。虚表存储在每个类的对象实例中。具体来说,虚表是一个指向虚函数的指针数组,它被存储在对象的内存布局的开头或结尾的某个位置。通常情况下,虚表位于对象内存布局的最前面,以便可以通过对象指针直接访问虚表。
重要的是要注意,虚表对于每个类只有一个实例,并且所有该类的对象共享同一个虚表。这是因为虚表包含的是对于特定类的虚函数的地址,而不是具体对象的成员函数。
最后,我们再来验证一下,共享的虚表到底存储在哪里。可以通过如下代码进行验证:
class Base { public: virtual void Func1() { cout << "Base::Func1()" << endl; } virtual void Func2() { cout << "Base::Func2()" << endl; } void Func3() { cout << "Base::Func3()" << endl; } private: int _b = 1; }; class Derive : public Base { public: virtual void Func1() { cout << "Derive::Func1()" << endl; } private: int _d = 2; }; int main() { Base a1; Base a2; Derive b; printf("虚表:%p\n", *((int*)&b)); static int x = 0; printf("static变量:%p\n", &x); const char* ptr = "hello world"; printf("常量:%p\n", ptr); return 0; }
运行结果如下图:
我们就可判断出续编所存储的位置了。其实虚表是存储在常量区的,也就是上图的正文代码区。注意,并不是静态区。
多态的细节较多,理解起来也相对不容易。其中有构成重写的条件,构成多态的条件、虚表、多态原理等等很多重点都是需要我们掌握的。多态也是C++面向多象的重要特征之一。我们也应该熟练掌握。本篇文章的讲解就到这里,感谢阅读ovo~