文章目录
- 一、概念
- 二、定义和实现
- 1. 多态的构成条件
- 2. 虚函数
- 2.1 虚函数的重写/覆盖
- 2.2 虚函数重写的两个例外
- 3. override 和 final关键字
- 4. 重载/重写/隐藏的对比
- 5. 例题
- 三、纯虚函数和抽象类
- 四、多态的原理
- 1. 虚函数表
- 2. 实现原理
- 3. 动态绑定和静态绑定
- 总结
一、概念
多态(polymorphism)的概念:通俗来说,就是多种形态。多态分为编译时多态(静态多态)和运行时多态(动态多态),这里我们重点讲运行时多态。
编译时多态(静态多态)主要就是我们前面讲的函数重载和函数模板,他们传不同类型的参数就可以调用不同的函数,通过参数不同达到多种形态,之所以叫编译时多态,是因为他们实参传给形参的参数匹配是在编译时完成的,我们把编译时⼀般归为静态,运⾏时归为动态。
运行时多态,具体点就是去完成某个行为(函数),可以传不同的对象就会完成不同的行为,就达到多种形态。比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是优惠买票(5折或75折);军人买票时是优先买票。再比如,同样是动物叫的⼀个行为(函数),传猫对象过去,就是"喵喵",传狗对象过去,就是"汪汪"。
二、定义和实现
1. 多态的构成条件
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了
Person。Person对象买票全价,Student对象买票半价。
那么在继承中要构成多态还有两个条件:
必须通过基类的指针或者引用调用虚函数
- 因为只有基类的指针或引⽤才能既指向基类对象⼜指向派⽣类对象
被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
- 派⽣类必须对基类的虚函数完成重写/覆盖,重写或者覆盖了,基类和派⽣类之间才能有不同的函数,多态的不同形态效果才能达到。
- 派生类重写虚函数时,不加
virtual
修饰也会被认为是基类虚函数的重写,虽然这是不规范的写法,但是确实存在这个效果
2. 虚函数
类成员函数前面加virtual修饰,那么这个成员函数被称为虚函数。注意非成员函数不能加virtual修饰。
class Person
{
public:virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
2.1 虚函数的重写/覆盖
派生类中有⼀个跟基类完全相同的虚函数(三同:返回值类型、函数名、参数列表) ,称派⽣类的虚函数重写了基类的虚函数。
- 注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用,不过在考试选择题中,经常会故意埋这个坑,让你判断是否构成多态。
class Person {
public:virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:virtual void BuyTicket() { cout << "买票-打折" << endl; }
};void Func(Person* ptr)
{ptr->BuyTicket(); // 传参为基类对象指针,则调用基类的虚函数// 传参为派生类对象指针切割后 转换的基类对象指针,则调用派生类虚函数
}
int main()
{Person ps;Student st;Func(&ps);Func(&st);return 0;
}
2.2 虚函数重写的两个例外
- 协变
派生类重写基类虚函数时,与基类虚函数返回值类型可以不同,但返回值类型必须为各自的指针或引用类型。
即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。协变的实际意义并不大,所以我们了解⼀下即可。
class A {};
class B : public A {};class Person {
public:virtual A* BuyTicket() //基类虚函数返回基类对象指针或引用{cout << "买票-全价" << endl;return nullptr;}
};
class Student : public Person {
public:virtual B* BuyTicket() //派生类虚函数返回派生类对象指针或引用{cout << "买票-打折" << endl;return nullptr;}
};void Func(Person* ptr)
{ptr->BuyTicket();
}
int main()
{Person ps;Student st;Func(&ps);Func(&st); return 0;
}
- 析构函数的重写
基类的析构函数一般需要修饰为虚函数,构成多态,否则使用切割 即子类对象指向父类指针/引用时,会去调用父类的析构函数,如果子类对象中有申请资源,就会造成内存泄漏。注意:我们使用切割时,把子类中的父类部分切给父类指针/引用后,剩余的子类部分是没人要的,但是不能没人管,最终必须要对其资源回收,所以必须要构成多态,用子类自己的析构函数去释放资源。
构成多态的条件是派生类重写基类的虚函数,基类和派生类的析构函数名都不一样,怎么重写?
虽然基类与派⽣类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统⼀处理成destructor,所以满足同名。 所以基类的析构函数加了vialtual修饰,派生类的析构函数就构成重写。
下面的代码我们可以看到,如果~A(),不加virtual,那么delete p2时只调用的A的析构函数,没有调用B的析构函数,就会导致内存泄漏问题,没有释放子类切割后剩余的部分(如果没有子类剩余部分中没有进行资源申请,那无所谓,如果有就会内存泄漏)
class A
{
public:virtual ~A(){cout << "~A()" << endl;}
};
class B : public A {
public:~B(){cout << "~B()->delete:" << _p << endl;delete _p;}
protected:int* _p = new int[10]; //子类独有部分进行了资源申请,且使用了切割时,必须使用多态,否则内存泄漏
};
// 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函数,
// 才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
int main()
{A* p1 = new A;A* p2 = new B;delete p1;delete p2;return 0;
}
3. override 和 final关键字
从上面可以看出,C++对虚函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写错参数写错等导致无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此C++11提供了override,可以帮助用户检测是否重写。如果我们不想让派生类重写这个虚函数,那么可以用final去修饰。
class Car {
public:virtual void Dirve(){}
};
class Benz :public Car {
public:// 报错:使用“override”声明的成员函数不能重写基类成员virtual void Drive() override { cout << "Benz-舒适" << endl; }
};
int main()
{return 0;
}
class Car
{
public:virtual void Drive() final {}
};
class Benz :public Car
{
public:// 无法重写“final”函数 "Car::Drive" (已声明 所在行数:xxx)virtual void Drive() { cout << "Benz-舒适" << endl; }
};
int main()
{return 0;
}
4. 重载/重写/隐藏的对比
同一作用域
重载 (同名,不同参)
不同作用域
重写 (三同:同名,同参,同返回,协变例外)
隐藏 (同名,包含重写)
5. 例题
-
以下程序输出结果是什么()
A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确
B->1
基类指针调用基类成员函数
p->test();
this指针为基类指针,所以构成多态,调用派生类虚函数
this.func();
重写的“大坑”
- 缺省值不同仍视为参数相同,所以
func()
构成重写- 重写的是实现(函数体),相当于使用基类的参数 + 派生类的函数体, 所以使用基类参数的缺省值
class A
{
public:virtual void func(int val = 1) // 3. 基类的缺省值参数{ std::cout << "A->" << val << std::endl;}virtual void test(){ func(); //2. this指针为基类指针,所以构成多态,调用派生类虚函数}
};class B : public A
{
public:void func(int val = 0) { // 3. 派生类的函数体std::cout << "B->" << val << std::endl;}
};int main(int argc, char* argv[])
{B* p = new B;p->test(); // 1.基类指针调用基类成员函数return 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(){cout << "BMW-操控" << endl;}
};
int main()
{// 编译报错:error C2259: “Car”: 无法实例化抽象类Car car;Car* pBenz = new Benz;pBenz->Drive();Car* pBMW = new BMW;pBMW->Drive();return 0;
}
四、多态的原理
1. 虚函数表
虚函数表本质是一个存虚函数指针的指针数组,只要类中声明了虚函数就会生成该类独有的虚函数表,将类中声明的虚函数地址都放入虚表。
派生类会继承基类的虚函数,所以也会生成自己的虚函数表
- 先将基类中的虚表内容拷贝一份到派生类虚表中
- 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
- 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
类对象中并不直接存储虚表,而是存储虚表指针
下方代码运⾏结果12bytes(32位),除了_b和_ch成员,还多⼀个__vfptr放在对象的偏移量为0的区域(不同平台可能放置位置不同),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。
class Base{ public:virtual void Func1(){cout << "Func1()" << endl;} protected:int _b = 1;char _ch = 'x'; }; int main() {Base b;cout << sizeof(b) << endl;return 0; }
虚函数和虚函数表存在哪?
虚函数和普通函数一样,存储在代码段中
虚函数表存储的位置C++标准并没有规定,我们写下⾯的代码可以对⽐验证⼀下。vs下是
存在代码段的(常量区)。
class Base {
public:virtual void func1() { cout << "Base::func1" << endl; }virtual void func2() { cout << "Base::func2" << endl; }void func5() { cout << "Base::func5" << endl; }
protected:int a = 1;
};
class Derive : public Base
{
public:// 重写基类的func1virtual void func1() { cout << "Derive::func1" << endl; }virtual void func3() { cout << "Derive::func1" << endl; }void func4() { cout << "Derive::func4" << endl; }
protected:int b = 2;
};int main()
{int i = 0;static int j = 1;int* p1 = new int;const char* p2 = "xxxxxxxx";printf("栈:%p\n", &i);printf("静态区:%p\n", &j);printf("堆:%p\n", p1);printf("常量区:%p\n", p2);Base b;Derive d;Base* p3 = &b;Derive* p4 = &d;printf("Person虚表地址:%p\n", *(int*)p3);printf("Student虚表地址:%p\n", *(int*)p4);printf("虚函数地址:%p\n", &Base::func1);printf("普通函数地址:%p\n", &Base::func5);return 0;
}
2. 实现原理
class Person {
public:virtual void BuyTicket() { cout << "买票-全价" << endl; }
private:string _name;
};
class Student : public Person {
public:virtual void BuyTicket() { cout << "买票-打折" << endl; }
private:string _id;
};
class Soldier : public Person {
public:virtual void BuyTicket() { cout << "买票-优先" << endl; }
private:string _codename;
};void Func(Person* ptr)
{// 这里可以看到虽然都是Person指针Ptr在调用BuyTicket// 但是跟ptr没关系,而是由ptr指向的对象决定的。ptr->BuyTicket();
}
int main()
{// 其次多态不仅仅发生在派生类对象之间,多个派生类继承基类,重写虚函数后// 多态也会发生在多个派生类之间。Person ps;Student st;Soldier sr;Func(&ps);Func(&st);Func(&sr);return 0;
}
从底层的角度Func函数中
ptr->BuyTicket()
,是如何根据传参指针的不同 指向不同类的虚函数的呢?
- 先回顾一下切割的定义,基类指针指向派生类对象,实际上是将派生类对象的父类部分切割给基类指针,而上文我们验证过虚函数表指针
__vptr
会放置在指定偏移量的区域,这样基类对象指针可以通过偏移量找到派生类对象的这个虚函数表指针- 通过下图我们可以看到,满足多态条件后,底层不再是编译时通过调用对象确定函数的地址,而是运行时到指向的对象的虚函数表中确定对应的虚函数的地址,这样就实现了指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类对应的虚函数。
第⼀张图,ptr指向的Person对象,调用的是Person的虚函数;第⼆张图,ptr指向的Student对象,调用的是Student的虚函数
3. 动态绑定和静态绑定
对不满足多态条件(指针或者引用+调用虚函数)的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。
满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址,也就做动态绑定。
// ptr是指针+BuyTicket是虚函数满⾜多态条件。
// 这里就是动态绑定,编译在运⾏时到ptr指向对象的虚函数表中确定调用函数地址
ptr->BuyTicket();
00EF2001 mov eax, dword ptr[ptr]
00EF2004 mov edx, dword ptr[eax]
00EF2006 mov esi, esp
00EF2008 mov ecx, dword ptr[ptr]
00EF200B mov eax, dword ptr[edx]
00EF200D call eax
// BuyTicket不是虚函数,不满足多态条件。
// 这里就是静态绑定,编译器直接确定调用函数地址
ptr->BuyTicket();
00EA2C91 mov ecx, dword ptr[ptr]
00EA2C94 call Student::Student(0EA153Ch)
总结
本文讲解了C++多态相关知识,尤其是虚函数和虚函数表部分涉及类对象的内存布局,比较深入底层。
尽管文章修正了多次,但由于水平有限,难免有不足甚至错误之处,敬请各位读者来评论区批评指正。