目录
前言
一、多态的概念
二、多态的定义及实现
2.1 多态的前提
2.2 实现多态的两个重要条件
2.3 虚函数
2.4 虚函数重写/覆盖
2.4.1 虚函数重写的特例
2.4.2 协变
2.4.3 析构函数
2.5 final和override
2.6 重载/重写/隐藏的对比
三、纯虚函数和抽象类
四、多态的原理
4.1 虚函数表指针
4.2 多态的原理
4.3 静态绑定与动态绑定
4.4 虚函数表
4.5 虚函数表存储区域
补充:为什么不能是基类的对象
总结
前言
上一篇文章我们讲解了继承,那本篇文章来讲一下面向对象三大特性的最后一个,也就是多态,多态需要用到继承的知识,所以如果大家不知道继承或者说只是浅浅的知道一点,可以看看上一篇文章,先对继承有个大概的了解。
一、多态的概念
多态的概念:通俗来说,就是多种形态。多态分为编译时多态(静态多态)和运行时多态(动态多态),本篇文章重点讲的是运行时多态,编译时多态(静态多态)主要就是我们前面讲的函数重载和函数模板,他们传不同类型的参数就可以调用不同的函数,通过参数不同达到多种形态,之所以叫编译时多态,是因为他们实参传给形参的参数匹配是在编译时完成的,我们把编译时一般称为静态,运行时称为动态。运行时多态,具体点就是去完成某个行为(函数),可以传不同的对象就会完成不同的行为,就达到多种形态。比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是优惠买票(5折或75折);军人买票时是优先买票。
二、多态的定义及实现
2.1 多态的前提
多态是一个继承关系的下的类对象,去调用同⼀函数,产生了不同的行为。比如Student继承了Person。Person对象买票全价,Student对象优惠买票
2.2 实现多态的两个重要条件
- 必须是基类的指针或引用调用虚函数
- 被调用的函数必须是重写的虚函数
先来看一个多态的例子
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;
}
第一点,func函数的形参必须是父类的指针或引用是因为只有父类的指针才能做到既可以指向父类又可以指向子类,指向子类就是赋值兼容;第二点,func函数在调用BuyTicket的时候,是和普通调用不一样的,普通调用看的是类型,不管传过来什么对象,那p这个指针的类型都是父类的指针,调用的永远都是父类的BuyTicket,但是现在是多态调用,多态调用看的是指向的对象,指向父类调父类的BuyTicket,指向子类调子类的BuyTicket,所以多态才能做到,传递不同的参数,调用不同的函数,从而达到不同的行为
2.3 虚函数
class Person
{
public:virtual void BuyTicket(){cout << "买票全价" << endl;}
};
在成员函数的前面加上virtual,这个成员函数就是虚函数了,要注意的是只有成员函数才能是虚函数,非成员函数不能成为虚函数
2.4 虚函数重写/覆盖
虚函数的重写/覆盖:派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称派生类的虚函数重写了基类的虚函数。对于上面的例子来说,那派生类就重写了基类的BuyTicket,所以总结成一句话就是,重写的条件:虚函数+三同(函数名、参数、返回值)
2.4.1 虚函数重写的特例
在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,也可以构成重写,可以理解为继承后基类的虚函数被继承下来了在派生类中依旧保持虚函数属性,重写只是重写它的实现,但是这种写法不是很规范,不建议这样去写
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;
}
2.4.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* p)
{p->BuyTicket();
}int main()
{Person p;Student s;func(&p);func(&s);return 0;
}
一定要是父子类关系的指针或引用,关系必须对上,不能错开,父类返回值是指针,子类也就必须是指针;父类返回值是引用子,类也就必须是引用。而且A是父类,就只能作为Person的返回值,不能作为Student的返回值,B是子类,就只能作为Student的返回值,不能作为Person的返回值。
2.4.3 析构函数
上篇文章我们说了在派生类的析构函数中直接调用父类的析构函数会构成隐藏,因为析构函数的函数名被统一处理成了destructor,是因为多态的原因,那为什么要这么处理呢,因为要构成重写,为什么要构成重写呢,因为下面这个例子
class A
{
public:~A(){cout << "~A()" << endl;}
};class B : public A
{
public:~B(){cout << "~B()->delete:" << _p << endl;delete _p;}
protected:int* _p = new int[10];
};
int main()
{A* p1 = new A;A* p2 = new B;delete p1;delete p2;return 0;
}
一个父类指针除了会指向父类对象,还有可能会指向子类对象,析构函数不是虚函数,现在不满足多态调用,那普通调用看的是类型,p1和p2都是父类的指针,都调用父类的析构函数,子类的资源并没有释放,就会导致内存泄漏。因为这种情况的存在,就必须是多态调用才没有问题,析构函数的函数名又是类类名前加~,无论如何也做不到三同,所以编译器才特殊处理了,现在满足三同,我们只需要给基类的析构函数加上virtual变成虚函数即可,那就满足了构成多态的两个条件,基类的指针去调用析构函数,而且被调用的函数是重写的虚函数。那子类的重写的虚函数又可以不加virtual,从另一个角度来说,那个特例是为了这里而做的。
class A
{
public:// 设计成虚函数就没问题了virtual ~A(){cout << "~A()" << endl;}
};
2.5 final和override
final除了修饰一个类,该类不能被继承;还可以修饰虚函数,该虚函数不能被重写
class Person
{
public:virtual void BuyTicket() final // final修饰{cout << "买票全价" << endl;}
};class Student : public Person
{
public:// 不能重写virtual void BuyTicket(){cout << "买票半价" << endl;}
};
class Person
{
public:virtual void BuyTicket() {cout << "买票全价" << endl;}
};class Student : public Person
{
public:virtual void BuyTicket() override // override检测{cout << "买票半价" << endl;}
};
2.6 重载/重写/隐藏的对比
三、纯虚函数和抽象类
在虚函数的后面写上 =0 ,则这个函数为纯虚函数,纯虚函数不需要定义实现(语法上可以实现,但是没有意义),只要声明即可。包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。所以纯虚函数从某种程度上说间接强制了派生类重写虚函数,因为不重写实例化不出对象。
class Person
{
public:virtual void func() = 0;
};class Student : public Person
{
public:virtual void func(){cout << "xxxx" << endl;}
};int main()
{// 实例化不出对象Person p;Student s;s.func();Person* pp = new Student;pp->func();return 0;
}
通过这个例子,可以看到,Person类因为是抽象类,所以不能实例化出对象,Student类重写了这个纯虚函数,就可以实例化出对象,但是第三种写法父类指针指向子类对象也是可以的,满足多态调用,pp是基类的指针,func是被重写的虚函数,指向子类调到子类的func
四、多态的原理
4.1 虚函数表指针
我们来看一下下面的代码在32位平台下的结果是什么
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
private:int _b = 1;char _ch = 'x';
};int main()
{Base b;cout << sizeof(b) << endl;return 0;
}
按照内存对齐来说,_b是0~3,_ch是4,一共5字节,最大对齐数是4,那总大小应该是8。但是运行程序发现,结果是12,这是为什么呢?
除了_b和_ch成员,还多了一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针。一个含有虚函数的类中至少都有一个虚函数表指针,因为一个类所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表。虚表就是一个函数指针数组。
4.2 多态的原理
我们还是用这个例子,在两个类中增加一个成员变量方便观察
class Person
{
public:virtual void BuyTicket() {cout << "买票全价" << endl;}
private:int _a = 1;
};class Student : public Person
{
public:virtual void BuyTicket() {cout << "买票半价" << endl;}
private:int _b = 2;
};void func(Person* p)
{p->BuyTicket();
}int main()
{Person p;Student s;func(&p);func(&s);return 0;
}
观察p和s的虚表发现,虚表中的函数不是同一个,各自有各自的地址,这是因为派生类重写了基类的虚函数之后,就用重写的虚函数覆盖基类的虚函数,所以说重写也叫覆盖。多态调用是程序运行起来之后,到指向对象的虚表中找对应的虚函数的地址,完成调用,这样就实现了指针或引用指向父类就调用父类的虚函数,指向子就调用子类对应的虚函数。
通过下面这张表的调用逻辑也可以看的出来,跟指针p没关系,是到指向对象的虚表中去找虚函数
4.3 静态绑定与动态绑定
对不满足多态条件(指针或者引用+调用虚函数)的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址,也就做动态绑定。

4.4 虚函数表
1、基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共用同一张虚表,不同类型的对象各自有独立的虚表,所以基类和派生类有各自独立的虚表。
class Base
{
public:virtual void func1(){cout << "Base::func1()" << endl;}virtual void func2(){cout << "Base::func2()" << endl;}
};class Derive : public Base
{virtual void func2(){cout << "Derive::func2()" << endl;}
};int main()
{Base b1;Base b2;Derive d;return 0;
}
b1和b2是同类型的对象,它们共用一张虚表,而d是不同类型的对象,单独有一张虚表
2、派生类由两部分构成,继承下来的基类和自己的成员,一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意的这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同一个,就像基类对象的成员和派生类对象中的基类对象成员也独立的。
class A
{
public:virtual void func1(){}
private:int _a = 1;
};class B : public A
{
public:virtual void func2(){}
private:int _b = 2;
};int main()
{A aa;B bb;return 0;
}
3、虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个0x00000000标记。(这个C++并没有进行规定,各个编译器自行定义的,vs系列编译器会再后面放个0x00000000标记,g++系列编译不会放)

4、派生类虚表生成的步骤:先把基类的虚表继承下来,如果派生类重写了基类的虚函数,就用派生类自己的虚函数的地址覆盖基类的虚函数的地址,再把派生类自己的虚函数添加到表尾
class A
{
public:virtual void func1(){cout << "A::func1()" << endl;}virtual void func2(){cout << "A::func2()" << endl;}
private:int _a = 1;
};class B : public A
{
public:virtual void func2(){cout << "B::func2()" << endl;}virtual void func3(){cout << "B::func3()" << endl;}void func4(){cout << "B::func4()" << endl;}
private:int _b = 2;
};int main()
{A aa;B bb;return 0;
}
大家有没有发现奇怪的点,bb的虚表中,func1没有变化,和基类一样,func2因为重写,所以覆盖了基类的虚函数的地址,func4不是虚函数,没有放进虚表,这些都没有问题,但是派生类的func3为什么没有呢?这里监视窗口是看不到的,我们需要用内存验证
通过vs下虚表的结果会放一个空指针的特性我们很好的在内存窗口中看到多了一个指针,0x00241244,那很明显就是派生类的func3函数了
4.5 虚函数表存储区域
关于虚表是存在哪的,C++并没有给出规定,但我们可以写一个对比程序,看看虚表的地址最接近哪个区域
我们还用上面的A类和B类,刚刚从内存中验证func3到底在不在表尾的那两个类来做这个对比程序
int main()
{int i = 0;static int k = 1;int* p1 = new int;const char* p2 = "xxxxx";printf("栈:%p\n", &i);printf("静态区:%p\n", &k);printf("堆:%p\n", p1);printf("常量区:%p\n", p2);printf("------------------------\n");A aa;B bb;A* p3 = &aa;B* p4 = &bb;printf("A虚表地址:%p\n", *(int*)p3);printf("B虚表地址:%p\n", *(int*)p4);printf("------------------------\n");printf("虚函数地址:%p\n", &A::func1);printf("普通函数地址:%p\n", &B::func3);return 0;
}
本段程序首先是先把存在四个区的变量的地址打印出来,然后把虚表的地址打印出来,看看虚表和哪个区的地址最接近,那它就是存在哪个区的,这里定义了p3指针和p4指针,因为我们上面说过,虚表指针是存在对象最前面的,而我们现在是在32位平台下,指针是4个字节,所以强转成int*的指针,然后解引用看到的就是前4个字节,也就是虚表指针,最后再打印一下普通函数和虚函数的地址,看看普通函数和虚函数是不是在一个区的,我们知道函数名就代表函数的地址,但是成员函数不是,成员函数取地址必须要加上&,还要指定类域
通过结果看到,关于虚表是存在哪的,首先排除栈区和堆区,最接近于常量区(代码段),所以我们知道了虚表是存在常量区(代码段的),而且虚函数和普通函数一样的,编译好后是一段指令,都是存在代码段的,只是虚函数的地址又存到了虚表中。
那现在大家是不是就理解了多态的条件为什么是这两个,第一:只有基类的指针或引用才能既指向父类又指向子类;第二:被调用的函数是重写的虚函数,到指向对象的虚表中去找,派生类重写了那就用自己的虚函数地址覆盖基类的,才可以做到指向谁就调用谁的。
补充:为什么不能是基类的对象
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;
}
多态的第一个条件为什么一定要是基类的指针或引用呢?基类对象也可以做到啊,传父类或者子类都可以接收。在讲赋值兼容的时候,我们说对象之间的赋值,是先拷贝再切片,但是指针和引用的赋值,是直接切片的。也就是说如果这里传子类的对象上去,那就要先拷贝,这个时候子类的虚表中放的是子类重写的虚函数的地址,有拷贝,那不仅成员要拷贝,虚表也要拷贝,那父类对象的虚表中存的就是子类的虚函数的地址了,这不就出现问题了吗,所以只能用指针或引用来接收。
总结
本篇文章讲解了多态的概念以及多态的原理是虚表,多态和继承一样,理解还是有一些难度的,而且细节比较多,也有特殊情况,会比较绕,如果大家没有特别理解的话可以再多看几遍,随着大家越用越多肯定就会更加熟练了,理解也会加深很多,那本篇文章就到这里了,如果大家觉得小编写的不错,可以给一个三连,感谢大家的支持!!!