【C++】影响动态多态的静态联编与对象切割
I - 动态多态
多态就是利用虚函数和继承将多种功能实现从架构中独立出来,方便维护和修改。
多态的表现形式
- 重载:同一个类中,相同的方法名对应着不同的方法实现
- 重写:子类重写父类的方法
- 抽象类:在面向对象语言中,一个类的方法只给出了标准,而没有给出具体的方法实现,这样的类就是抽象类
- 接口:抽象类组成的集合就是接口
1.1 - 动态多态的原理
实现 C++ 的多态,利用了动态绑定的技术,该技术的核心是 虚函数表。
虚函数表 是一个指针数组,其元素是虚函数的指针,每个元素对应一个虚函数的函数指针。编译器会为每个包含虚函数的类维护一个虚函数表。普通的成员函数(非虚函数)调用不需要经过虚表,所以需表中的元素不包括普通函数的指针。
虚表是属于类的,而不是属于某个具体的对象,一个类只有一个虚表。同一个类实例化的不同对象实际上指向同一张虚表。
为了让每个包含虚表的类的对象都拥有⼀个虚表指针,编译器在类中添加了⼀个指针,*__vptr
,⽤来指向虚表。这样,当类的对象在创建时便拥有了这个指针,且这个指针的值会⾃动被设置为指向类的虚表。
所以实例化的对象,不要轻易使用
memset(&myobject, 0, sizeof(MyClass));
会将虚表指针置空,在运行时容易出现异常退出。
1.2 - 问题代码示例
看下列一段代码,补充两处空缺,使得输出为:
A::Fun
C::Do
#include <iostream>
using namespace std;
class A
{
private:
int nVal;
public:
void Fun(){
cout << "A::Fun" << endl;}
void Do(){
cout << "A::Do" << endl;}
};
class B : public A
{
public:
virtual void Do(){
cout << "B::Do" << endl;}
};
class C : public B
{
public:
void Do(){
cout << "C::Do" << endl; }
void Fun() {
cout << "C::Fun" << endl; }
};
void Call( /* 填空 1 */ )
{
p.Fun();
p.Do();
}
int main(int argc, char * argv[])
{
C c;
/* 填空 2 */;
return 0;
}
II - 题目解析
通常可能认为题目出错了 ,如下两种想法。
2.1 - 常规思路
无非就是 9 种排列组合,但是均无法获得题目中需要的结果。
// 填空 - 1
void Call(A p);
void Call(B p);
void Call(C p);
// 以及 填空 - 2
Call(c);
Call((B)c);
Call((A)c);
影响动态多态的有两种情况:静态联编 与 对象切割
静态联编 (static binding) :函数的调用,在编译阶段就可以确定函数的调用地址,并产生代码,此时就算是使用向上类型转换 (派生类的对象),受到调用的依旧是基类方法。静态联编可通过添加 virtual
关键字恢复动态多态。
静态联编问题,代码示例
class Base
{
// ...
public:
void Run() {
std::cout << "Base::Run" << std::endl; }
// ...
};
class Derivated : public Base
{
// ...
public:
void Run() {
std::cout << "Derivated::Run" << std::endl; }
// ...
}
int main(int argc, char * argv[])
{
Base* obj = new Derivated;
obj->Run(); // Base::Run
}
示例代码,需要将基类 Base 的 run 函数,声明为 virtual
即可解决问题。
强转会造成对象切割,派生类强转为基类对象时会发生对象切割,使之仍然调用基类方法。此处属于强转造成的对象切割。
C++与C相同,是静态编译型语言
在编译时,编译器自动根据指针的类型判断指向的是一个什么样的对象:
所以编译器认为基类指针指向的是基类对象。由于程序没有运行,所以不可能知道基类指针指向的具体是基类对象还是
派生类对象,从程序安全的角度,编译器假设基类指针只指向基类对象,因此编
译的结果为调用基类的成员雨数。这种特性就是静态联编。多态的发生是动态联编,在程序执行的时候判断具体基类指针应该调用的方法。
2.2 - 动态多态
或可能认为题目在考察虚函数和动态多态的问题,动态多态的调用是 基类指针指向派生类对象,这里 Call 里边的常理实现应该是使用指针
void Call(A * p)
{
p->Fun();
p->Do();
}
int main()
{
C c;
Call(&c);
}
因为在 B中 Do 函数被声明为 virtual 所以 C 中即便没有写关键字 virtual 也为虚函数,基类指针指向子类对象,就会调用 子类的实现,即 C::Do
而 Fun 函数一直都不为虚函数,则会一直调用指针对应的函数。即 A::Fun
III - 正确答案
题目没有出错,这个是在考察引用不会造成对象切割的问题。
void Call(B & p); //1
Call(c); //2
参数为父类引用,传递时不会造成对象实现被切割,虚函数指针指向无误 即 C::Do,但是造成父类函数覆盖子类函数。即 A::Fun。
这也是为何我们要求在继承标准库异常 std::exception
,实现自定义异常时, try ... catch
的参数使用异常的基类类型引用传递,而不使用基类类型值传递。
错误示例
try
{
// ...
}
catch (std::exception ex)
{
// handle exception
}
正确使用,需要使用引用,防止造成对象切割。
try
{
// ...
}
catch (std::exception & ex)
{
// handle exception
}