一.认识多态
1. 多态分类
-
静态多态
在程序编译期间就确定了,也称静态绑定或前期绑定。比如:函数重载
-
动态多态
动态多态是c++面向对象部分对于继承体系的使用。用基类的指针或引用指向不同的派生类,当该指针或引用调用某个方法时,产生的结果会根据其指向的派生类不同而不同。
也称动态绑定或后期绑定,是程序运行期间,根据具体拿到的类型确定具体的行为。
本篇博客主要讨论动态多态
2. 虚函数
a. 介绍
被virtual
关键字所修饰声明的类成员函数称之为虚函数
class A
{
public:
virtual void show();
};
成员函数show()是A类的虚函数
b. 虚函数的重写
当派生类中有和基类同型(函数的返回值类型、函数名、参数列表完全相同)的虚函数时,派生类的虚函数重写了基类的虚函数。或称为覆盖
class B :public A
{
public:
virtual void show();
};
B类的虚函数重写了A类的虚函数show()
-
B类中的show()函数如果没加virtual修饰也是虚函数
因为对于虚函数是接口继承,基类的虚函数被继承下来在派生类中保持着虚函数的属性。但是还是建议显示加上修饰,保持代码可读性
c. 协变
基类与派生类虚函数返回值类型可以不同,但是有约束条件
当基类虚函数返回类型为某基类对象的指针或者引用时,派生类虚函数返回类型需要是对应派生类对象的指针或者引用。
协变需要是函数的返回值是,对应的基类和派生类。并且必须是其指针或引用
d. 析构函数
对于派生类和其基类,(为了方便多态的实现),编译器对于这两个类的析构函数进行了特殊处理:编译后析构函数名统一处理成destructor()。
析构派生类对象时,编译器会自己处理,先调用派生类的析构函数,然后调用其基类部分的析构函数。
class A
{
public:
virtual ~A();
};
class B :public A
{
public:
virtual ~B();
};
B类的析构函数,重写了A类的析构函数,
因为处理后都是destructor(),并且是虚函数(virtual),符合虚函数重写的条件
3. 多态构成条件
- 派生类对虚函数进行了重写
- 通过基类的指针或引用去调用虚函数
下图将分别展示虚函数的多态使用
a. 虚函数调用多态
通过多态调用,基类的指针或引用可以调用到派生类重写后的虚函数
b. 析构函数多态
对应delete的操作,可以理解成,调用到的是B类重写后的析构函数。
(对于派生类对象中基类部分析构函数的调用,其实是编译器在编译阶段自动处理了,当执行完派生类的析构函数后,就会调用其基类部分的析构函数。)
对于析构函数非多态而误用的操作,会导致B对象未被释放完全,有内存泄漏的危害。
- 对于带多态性质的基类中,应该声明一个virtual析构函数。即如果class有任何virtual函数,它就应该拥有一个virtual析构函数。
4. C++11新特性
a. override
检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错
b. final
修饰虚函数,表示该虚函数不能再被重写
5. 重载、重写(覆盖)、重定义(隐藏)
三种关系都是在继承体系中,对于派生类与基类中同名(型)成员间的关系
二. 抽象类
1.介绍
虚函数的后面写上
=0
,则这个函数声明为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类)。
抽象类不能实例化出对象,派生类继承后只有重写纯虚函数,派生类才能实例化出对象(否则,派生类也为抽象类)。纯虚函数规范了派生类必须重写(否则无法实例化对象),另外纯虚函数更体现出了接口继承
2. 接口继承
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。
虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。
上述代码执行输出的结果是B->1,由于虚函数使用的是接口继承,通过基类A的指针来调用虚函数时,是通过A的func(int val = 1)的接口调用的。
三. 多态原理
是否会疑惑,为什么使用基类的指针能调用到派生类重写的虚函数,虚函数同普通函数的差异是什么?
下面将通过vs环境下,来剖析虚函数以及多态实现的原理
1. 虚函数表
为什么有virtual函数的A类会比没有virtual函数的大4个字节呢?
通过监视窗口,我们发现拥有virtual函数的A类中,额外增加了一个void型的成员 _vfptr,是虚函数表指针**,其指向的内容存是一个指针数组,存储的是虚函数的地址。
含有virtual函数的类中都(至少)有一个虚函数表指针,该指针指向虚函数表,又称为虚表(本质是一个函数指针数组),虚表中存放的是虚函数的地址。
2. 打印虚函数表
编译器对于虚函数的调用,会去对象的虚函数表中找到对应的地址然后调用
通过打印结构显示,虚表中存放的确实是A类中虚函数的地址
typedef void(*_vfptr)();
void PrintVFTable(_vfptr* table, size_t n)
//void PrintVFTable(_vfptr* table)
{
for (size_t i = 0; i < n; ++i)
//for (size_t i = 0; table[i] != nullptr; ++i)
{
printf("[%d]:%p -> ", i, table[i]);
table[i]();
}
cout << endl;
}
vs编译器对于虚表,大多数情况下会以nullptr(00 00 00 00)作为虚表最后一个尾元素,因此有时也可以省略size_t n这个参数。(有时结尾可能是一个非法地址,可以重新生成解决访问来尝试解决)
3. 单继承关系
派生类会继承基类的虚函数表,如果派生类中有函数重写则在虚函数表对应的位置覆盖新的地址,如果派生类中有非重写的虚函数会将函数地址新增到虚表中。
4. 多态的原理
用B对象b传给A类指针p时,发生切片,p会指向b中A类的部分。由于访问的show是虚函数,因此编译器会去虚函数表中通过地址来调用该函数
由于B类中虚函数重写,使虚表中的存放的内容覆盖成B类的show函数地址,因此访问的也将会是B中的show了
派生类的对象赋值给基类的指针或引用时,发生的切片行为,会让基类指针或者引用指向派生类中基类的部分。
但是如果是派生类的对象赋值给基类的对象时,发生的切片只会拷贝赋值基类部分的成员变量,不会将虚表指针(_vfptr)拷贝过去,基类对象会创建自己的。
6. 多继承关系
可以发现在C类对象c中,有两个虚基表指针(_vptr),分别来着其所继承的A、B类。
如果构成重写条件,会对其两个基类的show都进行重写,并在虚表中覆盖新的地址。
在两个虚表中存放的C类的show函数地址并不相同([0]: … ),是因为编译器会做一些跳转处理,但是最终访问到的还是相同的C类重写函数。
并且对于派生类C类中新增的virtual函数,可以发现编译器将其放在第一个虚表中了(在vs下,第一个虚表地址是存储于对象空间的前4个字节中)
四. 其他
1.程序分析
one
class A
{
public:
void test()
{
show();
}
virtual void show()
{
cout << "A::show" << endl;
}
};
class B :public A
{
public:
virtual void show()
{
cout << "B::show" << endl;
}
};
int main()
{
B b;
b.test();
return 0;
}
程序运行的结果是:B::show
b.test(),会到A类中的void test()函数,在函数中的show(),是通过**this->show()**而调用的。
此时我们如果理解this是什么就可以知道为什么会调到B类的Show()。首先通过b.test()时,会自动传入**&b给test()参数列表中的A* const this**,此时发生切片行为,this是指向派生类b中基类A部分的,其中虚表的对于地址已经被覆盖了,所以会调用到B::show()
two
class A
{
public:
virtual void show()
{
cout << "A::show ";
}
void run()
{
cout << "A::run ";
}
};
class B :public A
{
public:
virtual void show()
{
cout << "B::show ";
}
void run()
{
cout << "B::run ";
}
};
int main()
{
B b;
A& a = b;
a.show();
a.run();
return 0;
}
程序运行的结果是:B::show A::run
因为void run(),非虚函数,不会构成多态的条件,编译器在编译阶段就已经处理完成a.run()会调用A类的run。
而对于virtual void show(),(动态)多态,是程序运行期间,根据虚表中的地址来调用的。
three
class A
{
public:
A()
{
log();
}
virtual void log()
{
time = 0;
}
int time;
};
class B :public A
{
public:
virtual void log()
{
time = 1;
}
};
int main()
{
B b;
cout << "time: " << b.time << endl;
return 0;
}
程序运行的结果是:time: 0
值得一提的是,该例与第一个示例a中不同的是,virtual void log()的调用是在A类的构造函数中。
是的,在派生类对象中的基类成分会在派生类自身成员被构造之前先构造完成,也可以理解成,在派生类对象b的基类构造期间,对象的类型是基类,而不是派生类。此时调用的虚函数log()还是A类的版本。
析构函数同理,因为析派生类的析构函数会先执行,执行后才执行基类部分的析构函数。
- 在构造和析构期间不要调用virtual函数,因为这类函数不会下降到派生类
2. 常见问题
a. 构造函数能否成为virtual函数
不能,编译时会报错。
因为对象中的虚函数表指针会在构造函数初始化列表阶段进行初始化的,virtual的(构造)函数地址无法放在虚表中了
b. static成员函数可以是virtual吗
不能,编译时会报错。
因为static成员函数没有this指针,并且可以使用类型::成员函数的方式调用。而virtual函数需要通过虚函数表指针找到虚表的对于位置地址来访问。
c. inline函数可以是virtual吗?
可以,但是没意义。
成员函数如果在类中定义,编译器可能将其当做inline函数处理。当然如果显示的加上inline修饰,对于编译器而言那也只是一种建议,编译器会判断是否忽略掉inline特性。
如果有virtual,则编译器会忽略掉inline特性,因为对于inline的处理是在编译阶段到调用地方进行展开,而virtual函数这是在运行时根据虚表中所存的地址来调用。
d.虚函数表是在什么阶段生成的,存放在哪?
虚函数表在编译阶段就生成了,一般存放在代码段(常量区)。将虚函数表地址(虚函数表指针)给对象,在初始化列表阶段。
🦀🦀观看~~