一.多态的概念
1.多态
多态(polymorphism)的概念:通俗的来说,就是多种形态。多态分为静态多态(编译时多态)和动态多态(运行时多态),而我们讲的多态大部分都是动态多态。
静态多态主要就是我们前面了解过的函数模板和函数重载,它们传不同的参数就可以调用不同的函数,通过参数不同达到多种形态,这所以叫编译时多态,是因为实参传递给实参的参数匹配过程是在编译时完成的,我们一般把编译时归为静态,运行时归为动态。
运行时多态,简单来说,就是对于同一个函数,不同的对象去调用的会完成不同的行为,以此来达到多种形态。比如买火车票这个行为,普通人买票就是全价;学生买票就买学生票(有优惠);军人买票可以优先买票。
这里先演示一下多态的运行结果:
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 p;student s;func(&p);func(&s);return 0;
}
对这段程序来说,person和student都有买票这个行为,但是不同的对象行为是不同的,我们用一个基类的指针去调用buyticket这个函数,其运行结果会根据对象的不同而不同,当基类指针接收的是一个基类对象的地址时,调用的就是基类的buyticket;当基类指针接受的是一个派生类的对象的地址时,调用的就是派生类的buyticket。
2.虚函数
类的成员函数前面加上virtual来修饰,那么这个成员就被称为虚函数。注意:非成员函数不能被virtual修饰。
class A
{virtual bool max(int a, int b){return a > b;}protected:int _a;int _b;
};
当该类作为基类被派生类继承后,在派生类中该虚函数依旧还是虚函数。
3.多态实现的前提
多态是在继承体系中,基类对象和派生类对象去调用同一函数,产生了不同的行为。
要实现多态的效果必须满足下面的两个条件:
1、必须是基类的指针或者引用调用该成员函数:因为基类的指针或者引用既可以表示基类对象也可以表示派生类对象。
2、被调用的成员函数必须是虚函数,且在派生类中已经完成了重写/覆盖。
3.1虚函数的重写/覆盖
派生类中有一个与基类三同(虚函数返回值、函数名、形参列表完全相同,ps:形参列表相同只要求形参的类型相同,不要求形参名相同)的虚函数,即派生类的虚函数重写了基类的虚函数。
注意:在重写基类的虚函数时,派生类的虚函数加不加virtual都可以(因为继承后基类的虚函数被派生类继承下来了,该函数在派生类中依旧保持虚函数属性),但是这样写并不规范,建议在重写时也加上virtual。
我们现在会看前面给出的那段程序,首先虚函数在派生类中完成了重写,且在调用该函数时是利用基类的指针调用的(引用也可以),不同的是基类的指针指向的对象不同,第一次指向基类对象,第二次指向派生类对象。
多态面试题1
以下程序输出的结果是什么?
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;
}
答案是:B
首先创建了一个B类对象的指针,然后调用了test函数,B类对象的指针之所以可以调用test函数,是因为B从A继承了它;然后在test函数中又调用了func函数,要注意的是,这里调用func函数时其实满足了多态的条件,因为在成员函数中,调用其他成员函数时默认有一个this指针。这个this指针就是基类对象的指针,然后func完成了重写,且这里的this指向的是一个B类对象,所以这里应该调用B类的func函数,所以应该打印B->。
那按道理来说应该是B->0,为什么是B->1呢?
注意,重写的本质是重写虚函数的函数体,所以在我们调用重写后的虚函数时,其返回值、函数名、形参表都与基类的相同,不同的只有函数体。所以这里本质上val还是1.
而当我们直接调用B类中的func时,此时与多态无关,val用的就是0.
注意:不要修改重写后的函数缺省值。
4.虚函数重写——协变
先前说,虚函数重写时要求三同。
但是其返回值可以不同,但要求返回值是具有父子关系的类型的指针/引用,这个规则称为协变。
class A{};
class B:public A{};class person
{
public://virtual person* buyticket()virtual A* buyticket(){cout << "成人票" << endl;return nullptr;}
};class student : public person
{
public://virtual student* buyticket()virtual B* buyticket(){cout << "学生票" << endl;return nullptr;}
};
我们既可以直接用person/student类的指针作为返回值,也可以用另一组具有父子关系的类作为返回值。这也是一种协变。协变也满足多态的条件,也可以实现多态。
5.析构函数的重写
基类的析构函数为虚函数,只要派生类定义了析构函数,无论是否加virtual都与基类的析构函数构成重写。虽然这两个析构函数的名字不同,但其实在编译阶段,所有的析构函数的函数名都被处理成了destructor,所以也满足了三同。
所以,只要基类的析构函数是虚函数,派生类只要显式定义析构函数,就构成了重写。
面试题2
为什么基类中的析构函数建议设计成虚函数?
为了避免基类指针指向派生类对象时,调用析构函数不能完全清理派生类对象的全部数据,造成的内存泄露问题。
我们可以借助下面这段程序来理解:
class A
{
public:virtual ~A(){cout << "~A()" << endl;}
};class B :public A
{
public:~B(){cout << "~B()" << endl;}protected:int* _ptr;
};int main()
{A* pa = new A;A* pb = new B;delete pa;delete pb;return 0;
}
当我们分别用基类的指针指向了基类对象和派生类对象时,析构pa时,就是很普通的析构,但是当析构pb时,此时满足了多态,会先调用B的析构函数,然后再调用A的析构函数(派生类析构函数调用结束时会自动调用基类的析构函数),就可以将pb指向的内容全部销毁。
但是如果没有将A类的析构函数定义为虚函数的话,此时析构pb就不会产生多态效果,直接调用了A的析构,但是pb指向的是B类对象,等于它只析构了B类对象中的A类部分,还有B类自己的部分没有析构,这就导致了内存泄漏。
6.override、final
override是C++11中新增的一个关键字,用来检测派生类中的指定函数是否完成了重写。也就是说,用override修饰的函数,必须是基类虚函数的重写,否则就会报错。
我们用override修饰了派生类的这个函数,编译阶段,编译器就会向上搜索,判断这个函数是否为基类的虚函数的重写,如果是,不报错;不是,就报错。
我们看到,这里的函数名写错了,所以不构成重写。
所以,我们可以利用这个关键字来替我们检查是否完成了重写。
如果我们不想这个虚函数被派生类重写,那么我们可以用final来修饰它。
7.重载/重写/隐藏的对比
这是三个研究的都是同名函数之间的关系。
8.纯虚函数和抽象类
当一个虚函数以=0为结尾时,这个虚函数就是纯虚函数
class A
{
public:virtual void func() = 0{}
};
纯虚函数可以有函数体,也可以没有。
包含纯虚函数的类,叫做抽象类。抽象类不能用来定义对象,但是可以作为基类
如果继承该抽象类的派生类没有重写该纯虚函数的话,该派生类也是一个抽象类。
二.多态的原理
1.虚函数表指针
下面这段程序在32位下运行结果是什么?
A.编译报错 B.运行报错 C.8 D.12
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;
}
答案是:D
答案不是8而是12的原因是,在Base类对象的头部还有一个虚函数表指针__vfptr(注意有些平台可能会放到对象的最后面,这个跟平台有关),这个指针指向一个函数指针数组,该函数指针数组里面存储的都是Base类的虚函数的指针。而在32位下,指针的大小是4字节,加上int和char,在进行内存对齐的话,刚好是12字节。
一个含有虚函数的类中至少都有一个虚函数表指针,因为一个类所有的虚函数的地址都要被放到这个类对象的虚函数表中,虚函数表也简称为虚表。
同一个类的不同对象公用同一个虚表
当派生类没有重写该虚函数时,此时派生类和基类的虚表指针的内容是一样的,但是虚函数表指针不同。
重写后,派生类就会将之前的那个地址给覆盖掉
2.多态的原理
针对下面的程序,在func中当p指向的是person类的对象时就调用的是person的buyticket,p指向的是student类的对象时就调用student的buyticket,这是为什么呢?
我们在前面说了,一个有虚函数的类的对象都会有一个虚函数表指针,里面存放着该类所有虚函数的地址。在满足多态的前提下,在运行时,当p指向的是person对象时,p就会到person类的虚表里面去找对应虚函数的地址,然后去调用;当p指向的是student对象时,p就会到student类的虚表里面去找对应虚函数的地址,然后调用。
class person
{
public:virtual void buyticket(){cout << "person" << endl;}protected:string _name;int _age;
};class student : public person
{
public:virtual void buyticket() override{cout << "student" << endl;}protected:int _id;
};void func(person* p)
{p->buyticket();
}int main()
{person p;student s;func(&p);func(&s);return 0;
}
3.动态绑定和静态绑定
对不满足多态条件(指针/引用+调用虚函数)的函数调用是在编译时绑定的,也就是在编译时确定调用函数的地址,叫做静态绑定
满足多态条件的函数调用是在运行时绑定的,也就是在运行时到指定对象的虚表中找到调用函数的地址,叫做动态绑定。
4.虚函数表
1、基类对象的虚函数表中存放基类所有的虚函数的地址。非虚函数的地址是不会存在里面的。
2、派生类由两部分构成,继承下来的基类和自己的成员,一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意的这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同一个,它们只是里面存储的地址相同。这就像基类对象和派生类对象中的基类部分。
这里调式窗口看不到B类自己的func4虚函数,可以借助内存窗口观察
3、派⽣类中重写的基类的虚函数,派⽣类的虚函数表中对应的虚函数就会被覆盖成派⽣类重写的虚函 数地址
4、派⽣类的虚函数表中包含,基类的虚函数地址,派⽣类重写的虚函数地址,派⽣类⾃⼰的虚函数地 址三个部分。
虚函数表本质是⼀个存虚函数指针的指针数组,⼀般情况这个数组最后⾯放了⼀个0x00000000标 记。(这个C++并没有进⾏规定,各个编译器⾃⾏定义的,vs系列编译器会再后⾯放个0x00000000 标记,g++系列编译不会放)
5、虚函数存在哪的?
虚函数和普通函数⼀样的,编译好后是⼀段指令,都是存在代码段的,只是虚函数的地址⼜存到了虚表中
6、虚函数表存在哪里?
为了确认,我们可以写一段程序,用来判断其在那块空间上存储
我们可以将其和每个空间上的变量的地址进行比较,如果存储在同一个空间的话,地址应该比较接近,为了获取虚表指针存储的地址,因为该指针在对象的头部,其大小是四个字节,所以我们可以先将其强转成int*,然后在对其进行解引用,只拿到前四个字节的内容,就可以拿到里面的地址。
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; }
我们对比可以发现,虚表地址和常量区的地址接近,所以可以认为,虚表就存储在常量区中。
多态部分的细节:
1、被virtual修饰的成员函数称为虚函数
2、virtual关键字只在声明时加上,在类外实现时不能加
3、static和virtual是不能同时使用的
4、实现多态是要付出代价的,如虚表,虚表指针等,所以不实现多态就不要有虚函数了
5、抽象类可以定义指针,而且经常这样做,其目的就是用父类指针指向子类从而实现多态
6、基类有几张虚表,派生类就有几张,派生类自己的虚函数不会开一个新的虚表存储自己虚函数的地址,其地址会存放到第一张虚表的末尾。
下面程序的运行结果:
答案 0 1 2;
new B时,会调用B类的构造函数,但是调用之前会先调用基类的构造函数,然后执行test(),此时多态还没有形成,所以调用的就是A类的func(),打印0;
然后到B类的构造函数,执行test(),由于基类已经创建成功,虚表已经存在了,所以此时构成了多态,调用B类的func,打印1;
最后执行p->test();满足多态,++后打印,2;
class A
{
public:A() :m_iVal(0) { test(); }virtual void func() { std::cout << m_iVal << ' '; }void test() { func(); }
public:int m_iVal;
};class B : public A
{
public:B() { test(); }virtual void func(){++m_iVal;std::cout << m_iVal << ' ';}
};int main(int argc, char* argv[])
{A* p = new B;p->test();return 0;
}
完~