[C++]多态
文章目录
- [C++]多态
- 一、什么是多态
- 1.概念
- 2.虚函数 virtual
- (1)概念
- (2)重写/覆盖
- (3)特殊情况
- (4)总结
- 3.多态的定义和实现
- 4.析构函数的重写
- 5.final/override
- 6.重载/隐藏/重写
- 二、抽象类
- 1.概念
- 2.接口继承和实现继承
- 三、原理
- 1.虚函数表
- 2.多态原理
- 3.动态绑定和静态绑定
- 四、单继承和多继承的虚表
- 1.单继承的虚表
- 2.多继承的虚表
- 3.菱形继承和菱形虚拟继承的虚表
- 五、几道经典题目
一、什么是多态
1.概念
通俗来说,多态就是指就是多种形态,具体点就是对于完成某个行为,当不同的对象去完成时会产生出不同的状态。
比如领外卖优惠券,假如你是新用户那你领到的优惠券额度就比较大,假如你是老用户(长期使用该APP)那你领到的优惠券额度就比较小,或者是长期没有使用的人领到的额度也会比较大。
2.虚函数 virtual
(1)概念
被virtual
修饰的成员函数我们称为虚函数。
class Person
{
public:virtual void BuyFood() {cout << "0优惠减免" << endl;}
};
(2)重写/覆盖
在继承关系中,如果子类中有一个跟父类完全相同的虚函数 : 函数名、函数参数、函数返回值都相同 (简称三同),则称子类的虚函数重写或者覆盖
了父类的虚函数。
class Person
{
public:virtual void BuyFood() {cout << "0优惠减免" << endl;}
};
class NewUser : public Person
{
public:virtual void BuyFood() { cout << "满20减15" << endl; }
};
class OldUser : public Person
{
public:virtual void BuyFood() { cout << "满20减5" << endl; }
};
(3)特殊情况
- 子类的virtual可以省略,但是父类的不能省略。
虚函数的继承是接口继承,子类和父类的虚函数接口是完全相同的,子类对虚函数进行重写,仅仅重写了函数实现并不是改变函数接口。所以子类不加virtual
的函数类型和父类也是一样的(同为虚函数类型)。
虽然可以不用加virtual
,但是我们建议都加上,方便我们理解代码。
- 协作: 子类虚函数和父类虚函数的返回值可以不同,但必须是一个子类类型或父类类型的指针或者引用。
当前父子类:
class Person
{
public:virtual Person* BuyFood() { cout << "0优惠减免" << endl; return this; }
};
class NewUser : public Person
{
public:virtual NewUser* BuyFood() { cout << "满20减15" << endl; return this; }
};
class OldUser : public Person
{
public:virtual OldUser* BuyFood() { cout << "满20减5" << endl; return this; }
};
其他父子类:
class A{};
class B : public A{};
class Person
{
public:virtual A* BuyFood() { cout << "0优惠减免" << endl; return nullptr; }
};
class NewUser : public Person
{
public:virtual B* BuyFood() { cout << "满20减15" << endl; return nullptr; }
};
class OldUser : public Person
{
public:virtual B* BuyFood() { cout << "满20减5" << endl; return nullptr; }
};
(4)总结
1.如果父类和子类的函数不满足虚函数和三同(函数名、参数类型、返回值,协同除外),那一般他们仅仅构成隐藏(函数名相同)。
2.构成重写的条件:虚函数+三同(或特殊情况)。
3.多态的定义和实现
class Person
{
public:virtual void BuyFood() { cout << "0优惠减免" << endl; }
};
class NewUser : public Person
{
public:virtual void BuyFood() { cout << "满20减15" << endl; }
};
class OldUser : public Person
{
public:virtual void BuyFood() { cout << "满20减5" << endl; }
};
void Func(Person& fp)
{fp.BuyFood();
}
我们通过传不同的类的对象参数调用Func函数,产生了不同的效果。这就是我们之前所说的不同对象进行同一种行为产生不同的状态,我们称它为多态。
- 多态调用
刚刚我们的操作就是我们所说的多态调用,多态调用必须满足的前提是:继承+虚函数重写、通过父类的指针/引用调用虚函数。
- 普通调用
与多态调用相反的调用方式,我们称为普通调用,即不满足多态调用的前提。
void Func(Person fp)
{fp.BuyFood();
}int main()
{Person p;NewUser nu;OldUser ou;Func(p);Func(nu);Func(ou);return 0;
}
仅仅是去掉了引用,传入后fp的类型都是Person,所以都调用的Person对象的BuyFood()函数。
不满足多态调用的通过父类的指针/引用调用虚函数
条件。
4.析构函数的重写
一般来说正常的析构是没有问题的,但是有一种特殊情况,如果我们不把析构函数进行重写,那就会造成内存泄漏。
class Person
{
public:~Person() { cout << "Person::delete" << endl; }
};
class NewUser : public Person
{
public:~NewUser() { cout << "NewUser::delete" << endl; }
};
class OldUser : public Person
{
public:~OldUser() { cout << "OldUser::delete" << endl; }
};int main()
{Person* p1 = new Person;Person* p2 = new NewUser;Person* p3 = new OldUser;delete p1;delete p2;delete p3;return 0;
}
delete会默认去调用类的析构函数operator delete(),由于p1,p2,p3都是Person类对象,所以都去调用了Person类的析构函数。这就是普通调用,什么对象调用什么函数。
Person指针不仅指向父类对象,还指向子类对象。但是由于子类没有多态实现析构,所以都去调用了父类Person的析构函数。
在我们实现多态调用(子类虚函数重写 + 父类的指针/引用调用虚函数)后就不会出现上述问题。
class Person
{
public:virtual ~Person() { cout << "Person::delete" << endl; }
};
class NewUser : public Person
{
public:virtual ~NewUser() { cout << "NewUser::delete" << endl; }
};
class OldUser : public Person
{
public:virtual ~OldUser() { cout << "OldUser::delete" << endl; }
};int main()
{Person* p1 = new Person;Person* p2 = new NewUser;Person* p3 = new OldUser;delete p1;cout << endl;delete p2;cout << endl;delete p3;cout << endl;return 0;
}
虽然析构函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。
所以实现父类的时候,我们可以每次都给析构函数加上virtual。
5.final/override
1.final:修饰虚函数,表示该虚函数不能再被重写
2.override: 检查子类虚函数是否重写了父类某个虚函数,如果没有重写编译报错。
6.重载/隐藏/重写
- 重载 : 两个函数要在同一个作用域之中,函数名相同,参数不同,与返回值无关!
- 重定义(隐藏):两个函数分别在子类和父类的作用域,函数名相同,如果两个基类和派生类的同名函数不构成重写那就是重定义!
- 重写(覆盖) : 两个函数分别在子类和父类的作用域,虚函数+三同(函数名、参数、返回值),除了两种特殊情况(协作/省略
virtual
)。
二、抽象类
1.概念
在虚函数的后面写上 =0 ,则这个函数为纯虚函数,包含纯虚函数的类叫做抽象类(也叫接口类)。
特点:
- 抽象类不能实例化出对象
- 如果一个子类的父类是抽象类,该子类也不能实例化出对象(除非重写虚函数)
纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
class Person//纯虚类
{
public:virtual void BuyFood() = 0;//纯虚函数
};
class NewUser : public Person
{
public:virtual void BuyFood() { cout << "--- 满20减15 ---" << endl; }
};
class OldUser : public Person
{
public:virtual void BuyFood() { cout << "--- 满20减5 ---" << endl; }
};int main()
{Person* pnu = new NewUser;pnu->BuyFood();Person* pou = new OldUser;pou->BuyFood();return 0;
}
纯虚函数强制我们实现多态。
override 和 纯虚函数的区别:
- override 是检查重写,没重写直接编译报错;
- 纯虚函数是强制我们重写,因为不重写就不能实例化出对象,但是不会不实例化对象也可以不重写。
2.接口继承和实现继承
-
普通函数的继承是一种实现继承,子类继承了父类函数,可以使用函数,继承的是函数的实现。
-
虚函数的继承是一种接口继承,子类继承的是父类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
三、原理
1.虚函数表
我们每次实现多态后,进入调试窗口发现多了一个_vfptr。
这个东西叫做虚函数表指针,它指向一个虚函数表(又称虚表,与之前的虚基表不是同一个),其中存放的是虚函数的地址。(我们可以看到普通函数func3() 没有进到虚函数表中)
class A
{
public:virtual void func1(){cout << "A::func1()" << endl;}virtual void func2() {cout << "A::func2" << endl;}void func3() {cout << "A::func3" << endl;}
protected:int _a;
};
class B : public A
{
public:virtual void func1(){cout << "B::func1()" << endl;}
protected:int _b;
};int main()
{A a;B b;return 0;
}
我们再构造一个B对象继承A对象再重写func1(),来看看B对象虚函数表中是什么样的。
我们可以得到以下结论:
1.子类对象 b 由两部分构成(父类成员+本身);同时我们发现,子类对象中也有虚表指针,并且该虚表指针是存在于子类对象中父类那一部分中的,说明子类对象的虚基表是从父类继承/拷贝过来的。
2.我们发现,虽然子类并没有对 func2 进行重写,但是虚表中仍然有 func2 函数的地址,这是因为子类会继承父类的 func2;
3.同时,还可以发现子类虚表指针指向的虚表和父类虚表指针指向的虚表的内容是不同的,并且不同的那部分内容刚好是子类中进行虚函数重写的那部分内容;
所以子类虚表是通过拷贝父类虚表,然后将子类进行重写得到的新的虚函数地址覆盖掉虚表中该需函数原来的地址得到的。这就是说为什么要把重写叫做覆盖。
4.其实func3()也被继承了下来,只是它并不是虚函数,没有进虚表而已。
那虚表的位置到底是存在哪里呢?
我们通过一段代码进行推理一下。
int main()
{int a;cout << "栈 " << &a << endl;int* p = new int;cout << "堆 " << p << endl;const char* str = "hello world";cout << "代码段/常量区 " << (void*)str << endl;static int b;cout << "静态区/数据段 " << &b << endl;A aa;cout << "虚表 " << (void*)*((int*)&aa) << endl;return 0;
}
不用我多说,大家也能猜出在哪了吧。(代码段/常量区)
通过对比我们发现,虚表和代码段的地址十分接近,这说明它就在代码段中。
为什么这么写呢?因为32位平台下指针的大小是4字节,所以我们用int*
来强转取出vfptr虚表的前4字节,再进行解引用即可获得vfptr的地址。由于得到的是整形,不转换为(void*)不容易和其他地址对比,所以我们再强转为void*
类型(指针类型)。
- 由于虚表存储在代码段中,所以同一类型的虚表是共享的。
- 子类的虚表是先拷贝父类虚表,然后进行覆盖,覆盖完毕后存储到代码段中。
2.多态原理
class A
{
public:virtual void func1(){cout << "A::func1()" << endl;}virtual void func2(){cout << "A::func2" << endl;}void func3(){cout << "A::func3()" << endl;}
protected:int _a;
};
class B : public A
{
public:virtual void func1(){cout << "B::func1()" << endl;}void func3(){cout << "B::func3()" << endl;}
protected:int _b;
};int main()
{A aa;B bb;A* p = &aa;p->func1();p = &bb;p->func1();p = &aa;p->func3();p = &bb;p->func3();return 0;
}
- 调用func1()时,由于func1是一个多态调用(虚函数+三同:函数名、参数、返回值),此时与p指针指向的对象有关。
指向父类aa时,调用aa::func1()。指向子类bb时,调用bb::func1()。
- 调用func3()时,func3是一个普通函数,此时调用只与指针类型有关。
指针类型都为A*,无论指向什么对象类型都会去调用父类的func3()。
我们通过反汇编再进行理解。
多态调用
普通调用
3.动态绑定和静态绑定
-
静态绑定(又称编译时绑定): 它在程序编译时就确定程序的行为,也叫静态多态。例如,函数重载就是静态绑定。
-
动态绑定(又称运行时绑定): 它在程序运行时,根据具体拿到的类确定程序的具体行为,也叫动态多态。
-
反汇编中就很好理解这两种绑定,一个是运行直接call函数的地址,另一个运行等取到地址后再去那个地址进行调用。
为什么父类对象不能实现多态,且必须是父类的指针/引用?
因为子类对象赋值给父类对象时会切片赋值,父类对象中不会有子类的虚表指针,无法进行动态绑定(也就是无法取到该函数地址)。父类只会去调用已有的虚表指针(也就是自己的虚表指针),从而实现静态绑定。
四、单继承和多继承的虚表
1.单继承的虚表
class A
{
public:virtual void func1() { cout << "A::func1()" << endl; }virtual void func2() { cout << "A::func2" << endl; }
protected:int _a;
};
class B : public A
{
public:virtual void func1() { cout << "B::func1()" << endl; }virtual void func2() { cout << "B::func2()" << endl; }virtual void func3() { cout << "B::func3()" << endl; }virtual void func4() { cout << "B::func4()" << endl; }virtual void func5() { cout << "B::func5()" << endl; }
protected:int _b;
};int main()
{A aa;B bb;return 0;
}
明明对象B bb中,有四个虚函数而调试窗口中只看到了2个,而不是4个,这是因为编译器(VS2019)对这里进行了优化(继承来的A中只有两个虚函数,这里就不能显示其他的虚函数了),隐藏了另外两个虚函数。
其实他们两个一直是存在这个虚表指针中的,我们写一个打印代码验证一下。
//将返回值为void,参数为void的函数指针重命名为 VFPTR
typedef void(*VFPTR)();void PrintVTable(VFPTR vTable[])
{cout << " 虚表地址->" << vTable << endl;for (int i = 0; vTable[i] != nullptr; ++i){printf(" [%d]:%p,->", i, vTable[i]);vTable[i]();}cout << endl;
}
-
函数指针的 typedef 与其他类型的 typedef 不同,重命名的名字要放在括号里面作为函数名 (函数名就是函数指针);
-
在其他平台下虚表最后一个元素不一定是 nullptr(Linux下必须指明size;使用函数指针调用函数是不必解引用,因为函数名就是函数指针(可以用
[]
访问内容)。
2.多继承的虚表
class A
{
public:virtual void func1() { cout << "A::func1()" << endl; }virtual void func2() { cout << "A::func2()" << endl; }protected:int _a;
};
class B : public A
{
public:virtual void func1() { cout << "B::func1()" << endl; }virtual void func2() { cout << "B::func2()" << endl; }//virtual void func3() { cout << "B::func3()" << endl; }//virtual void func4() { cout << "B::func4()" << endl; }//virtual void func5() { cout << "B::func5()" << endl; }protected:int _b;
};
class C : public A, public B
{
public:virtual void func1() { cout << "C::func1()" << endl; }virtual void func3() { cout << "C::func3()" << endl; }
};//将返回值为void,参数为void的函数指针重命名为 VFPTR
typedef void(*VFPTR)();void PrintVTable(VFPTR vTable[])
{cout << " 虚表地址->" << vTable << endl;for (int i = 0; vTable[i] != nullptr; ++i){printf(" [%d]:%p,->", i, vTable[i]);vTable[i]();}cout << endl;
}int main()
{A aa;PrintVTable((VFPTR*)*((int*)&aa));B bb;PrintVTable((VFPTR*)*((int*)&bb));C cc;PrintVTable((VFPTR*)*((int*)&cc));B* pcc = &cc;PrintVTable((VFPTR*)*((int*)pcc));return 0;
}
-
在C类中,有两个继承下来的虚表指针(分别继承A/B), 其中C类只是重写了func1()。
-
所以在C类继承的A的虚表指针中有重写的func1()和继承来的func2(),还有一个自身的func3()。(子类将自身特有的虚函数会存到最先继承的父类虚表的最后)
-
第二次打印为了访问C类中B类的虚表指针,所以用B类指针切片C取到B类虚表指针开始的位置(4字节)即可访问第二个虚表(B类虚表)。
3.菱形继承和菱形虚拟继承的虚表
他们没有太多的实际意义,不建议设计出来。
如果很好奇,大家可以自行查阅相关专业的资料。
五、几道经典题目
1.下列代码类的初始化顺序是什么?
#include<iostream>
using namespace std;
class A {
public:A(char* s) { cout << s << endl; }~A() {}
};
class B :virtual public A
{
public:B(char* s1, char* s2) :A(s1) { cout << s2 << endl; }
};
class C :virtual public A
{
public:C(char* s1, char* s2) :A(s1) { cout << s2 << endl; }
};
class D :public B, public C
{
public:D(char* s1, char* s2, char* s3, char* s4) :C(s1, s3), B(s1, s2), A(s1){cout << s4 << endl;}
};
int main() {D* p = new D("class A", "class B", "class C", "class D");delete p;return 0;
}
ABCD为菱形虚拟继承关系,其中A只有一份,所以A只会调用一次构造函数,且变量初始化的顺序与变量在初始化列表出现的顺序无关,只与变量的声明顺序有关(即先被继承,先完成初始化)。
答案:ABCD
我们略微修改代码,这下初始化的顺序又是什么?
#include<iostream>
using namespace std;
class A {
public:A(char* s) { cout << s << endl; }~A() {}
};
class B : public A
{
public:B(char* s1, char* s2) :A(s1) { cout << s2 << endl; }
};
class C : public A
{
public:C(char* s1, char* s2) :A(s1) { cout << s2 << endl; }
};
class D :public B, public C
{
public:D(char* s1, char* s2, char* s3, char* s4) :C(s1, s3), B(s1, s2), A(s1){cout << s4 << endl;}
};
int main() {D* p = new D("class A", "class B", "class C", "class D");delete p;return 0;
}
A B C D 为菱形继承,所以 D 对象中的属于 B 和 属于 C 的部分各有一个 A,即 A 应该调用两次构造;同时,变量初始化 的顺序与变量在初始化列表出现的顺序无关,而与变量的声明顺序有关,对应到继承关系中,就是先被继承的先完成初始化。
所以,输出顺序是:ABACD
2.下列说法正确的是()
A:p1 == p2 == p3 B:p1 < p2 < p3 C:p1 == p3 != p2 D:p1 != p2 != p3
class Base1 { public: int _b1; };
class Base2 { public: int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };
int main() {Derive d;Base1* p1 = &d;Base2* p2 = &d;Derive* p3 = &d;return 0;
}
子类对象模型中先被继承的父类,其父类模型会被放在子类对象模型的上方
-
栈的使用规则是先使用高地址,再使用低地址。
-
对象模型内部是先使用低地址,再使用高地址,即先继承的父类其对象模型在子类模型的低地址处。
即为:p1 == p3 != p2
我们再略微修改代码:
class Base1 { public: int _b1; };
class Base2 { public: int _b2; };
class Derive : public Base2, public Base1 { public: int _d; };
int main() {Derive d;Base1* p1 = &d;Base2* p2 = &d;Derive* p3 = &d;return 0;
}
子类对象模型中先被继承的父类,其父类模型会被放在子类对象模型的上方;
对象模型内部是先使用低地址,再使用高地址,即先继承的父类其对象模型在子类模型的低地址处。
即为:p1 > p2 == p3
3.以下程序输出结果是什么()
A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错
F: 以上都不正确
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) { std::cout << "B->" << val << std::endl; }
};
int main(int argc, char* argv[])
{B* p = new B;p->test();return 0;
}
- func()是多态调用,一种接口继承。多态调用看调用对象指向的类型,this指向B类型,所以调用B重写后的func()。
- 接口继承,只是对{}中内容进行重写,而缺省值还是用的A中继承下来的val = 1。(test()函数的指针this是A*类型,访问时p->this->func())。