前言
本文将会对多态进行讲解,多态是面向对象3大特性之一,多态在继承的基础上增强了代码的复用性和灵活性,这也是C++中特别重要的一个知识点。多态可以分为动态多态和静态多态的,这里是主要对动态多态进行讲解。
1.多态的概念相关概念(静态多态与动态多态)
1.多态的实现
多态的概念:多态的概念是指多种形态,即不同的对象去完成某个行为时会产生出不同的状态。
举个例子:动物都会有叫声,狗和猫都是动物,但是它们的叫声完全不一样。关于多态其实分为动态多态和静态多态的,这里主要是围绕动态多态进行介绍。
静态多态:
静态多态是指在编译时期就确定了调用的函数(接口),静态多态的实现方式有函数重载、运算符重载和模板。
也被称为静态绑定;动态多态:动态多态是指在运行时期才确定了调用的函数,也叫运行时多态或覆盖多态。动态多态的实现方式有虚函数和纯虚函数。
也被称为动态绑定。
多态的构成必须满足3个条件
继承 重写 父类引用或者指针调用。
这里重写是指子类重新定义父类中有相同名称和参数返回值的虚函数
,函数重写也叫覆盖,它是一种实现多态的方式。
代码示例
#include<iostream>
using namespace std;
class A
{
public:
virtual void Print()
{
cout << "A" << endl;
}
};
class B:public A
{
public:
virtual void Print()
{
cout << "B" << endl;
}
};
void F(A& a)
{
a.Print();
}
int main()
{
A a;
B b;
F(a);
F(b);
}
上述代码就构成多态,这个Print函数在B类被重写了。调用重写函数的时候必须是父类的指针或者引用,
当然我们也可以直接使用子类对象或者指针引用直接调用,这样就是普通的类对象调用成员函数的方式。也就谈不上所谓的多态了。
这个父类指针或者引用去调用被重写的函数的时候会根据接收到的类型选择调用对应类中的函数。比如当我们传入A类型的时候回调用该类型的打印成员函数,当我们传入B类型的时候回调用该类型的打印成员函数。
虚函数的定义是:即被virtual修饰的类成员函数称为虚函数。函数重写的两个例外:
1.子类继承父类虚函数后,如果对虚函数进行重写,子类的虚函数就不用加virtual关键字。
其实这里也很好理解,子类继承了父类的成员函数,父类某个成员函数如果是虚函数,那么子类继承下的函数肯定也是虚函数。但是为了规范不建议省略不写。
这里有个特别重要的点就是编译器在处理析构函数的时候析构函数函数名会被统一成destructor,
这个时候如果基类的的析构函数是虚函数,这里子类的析构函数也会构成重写。1. 协变(基类与派生类虚函数返回值类型不同)派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指 针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
协变也构成重写。
接口继承和实现继承
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。
所以如果不实现多态,不要把函数定义成虚函数。
这里我们看一道笔试编程选择题
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进行了重写。最后就是父类指针或者引用调用,我们看到A类中test调用了func函数,但是这里是谁调用的呢?这里是A类的this指针也就说这个test函数隐藏了一个参数就是this,this指针是A*类型的,虽然这个test函数也被B类继承下来了,但是test的形参依旧是A*类型的,也就是说当我们传入B类型指针的时候就会发生多态。这个时候就会调用func函数。
这里重点来了,我们前说了虚函数是一种接口继承,所谓接口继承就是继承函数声明,也就说B的函数应该是是void func(int val=1)后面的实现是B类自己的实现。那么打印结果也就是B->1。
这道题的坑点真的是特别特别多,能做对真的不容易。
注意事项
如果用父类对象调用虚函数,编译器在编译阶段就能确定调用的是哪一个类的虚函数,所以属于静态关联
。而多态是要求在运行时,根据对象的实际类型来确定调用的是哪一个类的虚函数,所以需要动态关联。只有通过父类的指针或者引用来调用虚函数,才能实现动态关联,因为这时编译器无法从语句本身确定调用哪一个类的虚函数,只有在运行时,指针或者引用指向某一具体对象后,才能确定调用的是哪一个类的虚函数。
2. C++11 override 和 final
从上面看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮助用户检测是否重写。
final:
修饰虚函数,表示该虚函数不能再被重写.
但是这个关键字有点鸡肋,如果我写了虚函数那肯定是为了构成多态而准备的,那么势必会对虚函数进行重写。上面也说了如果不想构成多态就不要定义虚函数。那这个关键字不让虚函数重写,这显得没啥用。
override:
检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
3.抽象类
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承
#include<iostream>
using namespace std;
class A
{
public:
virtual void func() = 0;
};
class B :public A
{
public:
};
int main()
{
A a;
B b;
}
这里就是抽象类不能实例化出对象,同样的如果继承了抽象类没有重写虚函数,这里也不能实例化出对象。
可能会有人觉得这个抽象类莫名其妙,如果学过设计模式就知道这样一个概念
依赖倒置原则
(Dependence Inversion Principle,DIP)是指设计代码结构时,高层模块不应该依赖低层模块,二者都应该依赖其抽象。抽象不应该依赖细节,细节应该依赖抽象。
通过依赖倒置,可以减少类与类之间的耦合性,提高系统的稳定性,提高代码的可读性和可维护性,并且能够降低修改程序所造成的风险。这个时候抽象类就派上用场了。
2.多态的原理
现在我们来探究一下多态的原理。
#include<iostream>
using namespace std;
class A
{
public:
virtual void func()
{
;
}
int _a;
};
int main()
{
cout << sizeof(A) << endl;
}
我们看到这个类的大小是8字节,这个A类只有做一个整形成员变量,那么多出来的4字节是哪里来的呢?
其实这个4字节是A类中的虚函数指针。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。
1.多态实现的原理
虚函数表和虚函数指针是虚函数重写实现多态的关键。虚函数表是一个函数指针数组,每个元素对应一个虚函数的函数地址。虚表指针是一个隐藏的成员变量,指向类的虚函数表。
当子类继承父类的时候会把父类的虚函数指针也继承下来,当我们重写子类虚函数的时候,子类的虚函数表的地址中就会存放子类虚函数的地址,如果没有重写,子类虚函数表中存放的还是父类虚函数地址。当通过基类指针或引用调用一个虚函数时,会根据指针或引用所指向的对象的实际类型,从其虚函数表中找到对应的虚函数地址,然后调用该函数。
多态的函数调用是发生在运行时,根据对象的实际类型来确定调用的是哪一个类的虚函数。
2.子类的虚函数表是什么时候创建的呢?虚函数指针什么时候初始化的?
子类的虚函数表是在编译期间创建的,由编译器根据子类的虚函数和继承关系来确定。
虚函数表指针是在对象构造时初始化的,由编译器自动设置,指向对象所属类的虚函数表。如果对象有继承关系,那么虚函数指针是在父类构造完之后,子类初始化列表之前被设置的。虚函数指针不会经过子类初始化列表。
3.虚函数表存放在哪里呢?
#include<iostream>
using namespace std;
class Base
{
public:
virtual void fun()
{
cout << "Base fun" << endl;
}
};
class D :public Base
{
public:
void fun()
{
cout << "Base fun" << endl;
}
};
int main()
{
Base b;
D d;
int x = 0;
static int y = 2;
int* z = new int;
const char* p = "ppppp";
printf("栈对象地址:%p\n", &x);
printf("堆对象地址:%p\n", z);
printf("静态区对象地址:%p\n", &y);
printf("常量区对象地址:%p\n", p);
printf("b对象虚表地址:%p\n", *((int*)&b));
printf("d对象虚表地址:%p\n", *((int*)&d));
return 0;
}
这段代码是32平台下的,指针是4字节,类中的虚表指针存放在类对象的起始位置,我们通过int指针拿到这个虚表指针,虚表指针存放的是虚表地址,我们打印出这个虚表地址,发现它和常量区的地址非常接近。由此可以推倒出虚表存放在常量区
虚函数表是全局共享的,虚函数表通常被放置常量区也就是代码段。一个类只需要一个虚函数表但是在多重继承中子类有多张虚函数表。虚函数表指针是在对象的内存布局中的一般是在最前面。
这里注意一下虚函数和普通函数一样是存在代码段。
反过来思考我们要达到多态,继承关系的前提下有两个条件,一个是虚函重写,一个是父类的指针或引用调虚函数。反思一下为什么?
虚函数重写是指派生类中重新定义了与基类中同名、同参数的虚函数,从而覆盖了基类中的虚函数。这样做的目的是为了让派生类表现出自己的特性,而不是完全继承基类的行为。如果没有虚函数重写,那么派生类就无法实现多态,因为它只能调用基类中定义的虚函数,而不能根据自己的需要修改或扩展虚函数的功能。
对象的指针或引用调用虚函数是指通过基类类型的指针或引用来调用虚函数,而不是通过对象本身来调用。这样做的目的是为了实现动态绑定,即在运行时根据指针或引用所指向的对象的实际类型来确定调用哪个类中的虚函数。如果没有对象的指针或引用调用虚函数,那么就只能实现静态绑定,即在编译时就确定调用哪个类中的虚函数,而不能根据运行时的情况来变化。
3.如果子类没有重写虚函数,那么子类有虚函表吗
如果子类没有重写虚函数,那么子类虽然有自己的虚函数表,但是虚函数表中的内容和父类的虚函数表是一样的,都指向父类的虚函数。这样可以保证子类通过基类指针调用虚函数时,能够正确地找到父类的虚函数实现
3.单继承多继承关系的虚函数表
1.单继承的虚函数表
其实通过上述的分析我们就知道了子类的会继承父类的虚函数指针,然后编译器会为子类分配虚函数表,如果子类重写了虚函数,子类虚函数表中的父类虚函数就会被覆盖成子类的虚函数。
由此我们也可以推断出,如果子类继承两个父类,这个两个父类都有虚函数,那么也就是说子类也会有两张虚函数表。
2.打印虚函数表
我们可以用代码程序打印一出虚表中每个虚函数的地址
#include<iostream>
using namespace std;
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);
Base2* ptr2 = &d;
PrintVTable((VFPTR*)(*(int*)(ptr2)));
return 0;
}
这里为了方便我们将虚函数定义为void(),然后定义一个对应的函数指针类型 VFPTR,这虚表指针是指向虚表的,虚表就是一个函数指针数组,那么也就是说这个虚函数指针指向函数指针数组首元素地址,这个数组存放的元素都是地址,指向元素地址那么这个Print打印函数的形参就得是个二级指针了。然后遍历这个数组打印数组元素就是虚函数地址。这虚函指针放在对象内存布局的最前面,我们先拿到第一张虚函数表,之后我偏移Base1字节大小就跳过了Base1的部分来到了Base2的部分在转成4字节,但是我们需要得到这个元素的地址,就得对这个虚函数指针解引用一次,然后传参打印。
ptr2和vTable2是一样的,都是Base2的虚函数表的地址。
我们看一下打印结果。
我们可以通过上述打印现象来分析一些东西。
1.如果两个父类都有虚函数,子类定义一个属于自己的另外的虚函数,这个子类中行产生的虚函数地址会放在哪里?
通过打印结果我们发现这个新生成的虚函数地址会被放在放在最先继承的父类的虚函数表中,为什么呢?
这是因为编译器在生成虚函数表的时候,会按照继承的顺序来复制父类的虚函数表,并在其中替换或添加子类的虚函数地址。这样做是为了保证子类的第一个虚函数表中包含了所有父类和子类的虚函数的地址.
2.为啥第二个虚函数表没有子类自己的虚函数地址呢,为啥不保证第二个虚函数表中包含了所有父类和子类的虚函数?
这是因为编译器在生成虚函数表的时候,会优先考虑第一个虚函数表的完整性,因为第一个虚函数表是子类对象的默认虚函数表。如果子类自己的虚函数也被添加到第二个虚函数表中,那么就会造成冗余和浪费。
而且,如果子类对象通过第二个父类的指针或引用来调用虚函数,那么也不会调用到子类自己的虚函数,因为子类自己的虚函数没有覆盖第二个父类的虚函数。所以,编译器只会把子类自己的虚函数添加到第一个虚函数表中,这样既可以保证多态和动态绑定,又可以避免冗余和浪费。
3.子类和两个父类中都有虚函数func1,并且子类重写了func1,为啥子类的两个虚函数表中指向子类重写func1函数的地址不一样呢?
子类重写func1函数的地址不一样,是因为
子类的两个虚函数表是分别从两个父类复制过来的,而每个父类的虚函数表都有自己独立的地址空间,因此每个父类的虚函数表中的fun1函数的地址都是不同的,,当子类重写fun1函数时,它会在两个虚函数表地址中分别替换掉父类的func1函数,所以就会造成这样的现象。
也就是存放地址的地址虽然是不同的,但是地址指向的内容是相同的都是指向子类的func1。同时如果子类没有重写这个func1这样也能保证准确调用不同父类的func1,避免混淆。
4.零碎相关问题汇总
1.nline函数可以是虚函数吗?
答案是可以的,
但是编译器会忽略掉这个nilne特性,我们知道inline函数是在编译期间将函数体复制到调用处,省去了函数调用的开销,但是也会导致代码膨胀和内存消耗。虚函数是为多态作准备的,在运行期间通过虚函数表来动态绑定的,编译器无法知道运行期间调用哪个函数,因此不能进行内联展开。
2.静态成员可以是虚函数吗?
不能,静态成员函数属于类,不属于对象本身,没有this指针,也没有虚函数表指针。
使用类型::成员函数的调用方式无法访问虚表指针也就无法访问虚函数表。静态成员函数和虚函数的语义和目的是不一致的,静态成员函数是为了提供类相关的功能,而虚函数是为了提供对象相关的多态性。
3.构造函数可以是虚函数吗
不可以,
虚函数需要一个指向虚函数表的指针,这个指针是存储在对象的内存空间的。但是在构造函数调用时,对象还没有实例化,也就没有内存空间,没有存储空间哪有虚表指针,所以无法找到虚函数表,无法找到虚函数表就无法调用虚函数,这就自相矛盾了。
从实现上看,虚函数表在构造函数调用后才建立,因此构造函数不可能成为虚函数。而且构造函数的作用是提供初始化,在对象生命周期仅仅运行一次,不是对象的动态行为,也没有必要成为虚函数。同样的拷贝构造也不能是虚函数因为拷贝构造函数是在创建对象时调用的,此时对象的虚函数表还没有建立,虚表指针也没有初始化。
4.析构函数可以是虚函数吗
析构函数常常是虚函数,
为了防止内存泄漏。
如果派生类中申请了内存空间,并在其析构函数中对这些内存空间进行释放。如果基类中采用的是非虚析构函数,当删除基类指针指向的派生类对象时就不会触发动态绑定,因而只会调用基类的析构函数,而不会调用派生类的析构函数。那么在这种情况下,派生类中申请的空间就得不到释放从而产生内存泄漏。
5.赋值运算符可以是以虚函数吗
可以 为复制操作符的重载函数往往要求形参与类本身的类型一致才能实现函数功能,故形参类型往往是基类的类型,因此即使声明为虚函数,也把虚函数当普通基类普通函数使用。因此不建议将赋值运算符定义虚函数。
6.友元函数可以是虚函数吗
友元函数不是类的成员函数,也不会被继承。对于没有继承特性的函数,没有虚函数的说法。同样的友元函数和静态函数一样也没有this指针,无法访问虚函数表。
7.函数重载和重写(覆盖)以及重定义(隐藏)
重载是在同一作用域下,函数名相同,函数参数个数不同或者函数参数类型不同构成函数重载。函数重写是,子类继承父类的虚函数后对函数重新定义实现,这里要求该函数必须和父类保持一致,比如函数名,函数参数,函数返回值。
但是有个例外基类虚函数返回基类对象的指 针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
协变也构成重写。函数重定义是子类新产生的成员函数和父类成员函数名相同构成隐藏。