C++中的动与静
问:C++面向对象的三大特性是什么?答曰:封装、继承和多态!
问:如何理解多态?答曰:静态多态指依靠函数重载和泛型编程的编译期多态,动态多态和虚函数有关。
问:讲讲动态多态?
类型、绑定
在讲虚函数之前,首先我们来谈谈类对象指针的类型和成员函数的绑定问题。
一个变量的类型在编译期就可以确定,一般都是对象的声明类型,此时称变量的类型为静态类型。相应的,在运行期被决定类型的变量,称为动态类型,不必多说,这种动态类型自然要与指针或引用挂钩。一个指针变量,可以静态类型与动态类型兼备,也就是说,当我们讨论一个指针的时候,我们可以说它的静态类型和动态类型是不同的。
class Father{
public:
void func() { std::cout << "Father" << std::endl; }
};
class Child : public Father {
void func() { std::cout << "Father" << std::endl; }
};
/***/
Child* pC = new Child();
Father* pF = new Father();
pF = pC;
Child* pNull = nullptr;
在上面的代码中:
pC
的静态类型始终是Child*
,这和它的声明类型相同,而它的动态类型则在运行时被确定为Child*
。pF
的静态类型一直为Father*
,而其动态类型在运行过程中由一开始的Father*
转变为Child*
。pNull
的静态类型是它声明的类型Child*
,但是没有动态类型,因为它指向为空。
说到绑定,我们指的是一个类的成员函数和对象类型的对应关系:对于一般的成员函数,它和(指针)对象的静态类型绑定,称为静态绑定,发生在编译期;而对于虚函数,它绑定的则是指针对象的动态类型,称为动态绑定,发生在运行期。
接着上边的代码,我做如下调用:
pC->func(); //Child::func()
pF->func(); //Father::func()
pNull->func(); //Child::func()
上方func
函数是一个非虚函数,所以有关它的调用全部都是静态绑定,也就是说它调用的是哪个类的函数,看静态类型就行,上边关于动态类型的分析统统不要。
另外可能需要稍作解释:凭啥一个空指针pNull
调用成员函数竟然不报错?我们都知道类的非静态成员函数都有一个隐藏参数this
指针,因为在上边这样一个如此简单的非空函数中,没有牵涉到类成员变量的引用,因此也就没有解引用this
指针的需求,C++作为一个保证程序运行效率的语言,在编译时已经将Child::func()
的函数地址确定下来,运行时找到了Child::func()
这个非虚成员函数的地址,直接调用,因为没有解引用所以没有运行时报错,最后完美运行。这就是静态语言的特殊之处,如果你想在Java或者Python这种动态语言中使用是不可能的。当然在C++中也并不建议这么用,上边的写法只是加深对静态绑定和动态绑定的理解。
在上边这个例子中,Child
类中的func()
实际上是Father
类中的函数重载,假如我们把Child
类中的func()
给注释掉,再次进行上边的调用,得到的结果将如下所示,Child
类将自动继承Father
的func()
。
pC->func(); //Father::func()
pF->func(); //Father::func()
pNull->func(); //Father
谈过这个小插曲后,我们把上边func()
函数改为虚函数:
class Father{
public:
virtual void func() { std::cout << "Father" << std::endl; }
};
class Child : public Father {
void func() override { std::cout << "Father" << std::endl; }
};
同样的调用,结果却大不相同:
pC->func(); //Child::func()
pF->func(); //Child::func()
pNull->func(); //Error
第一行不解释,第二行因为pF的实际动态类型是Child*
类,所以查找时先查找Child
类中的func()
函数,查到了,则直接调用。
总结一下:
- 如果基类中的成员函数不是虚函数,它的函数的静态类型在编译期都已经确定了,静态绑定并不能实现多态。
- 所有的(指针的)虚函数调用都要等到运行时根据它实际指向的类型确定,比起静态绑定性能有损失,但是实现了动态多态。
- 动态绑定可以更改,静态绑定不能更改。
- 继承体系中只有虚函数是动态绑定,其余全是静态绑定。
虚表与虚函数的实现
上边说到,指针调用被指向对象的虚函数的时候,会根据指针的动态类型,查找到对应动态绑定的虚函数,最后调用它。好了,问题来了,查啥能查到这个信息?——查虚函数表。
虚函数是通过一张虚函数表来实现的,这个表内存放的不是别的玩意儿,就是各个虚函数的地址,当我们用父类的指针来操作父类或子类的虚函数时,这个表就像一个通讯录一样,可帮助找到相应的虚函数。
下一个问题,那我的某个对象,是怎么知道虚函数表在哪的呢?答案就是,对于每个有虚函数的类的对象,虚函数表的地址始终位于每个对象的内存的最前方。这意味着,我们随便取一个对象,找到它的内存起始位置,这个地方存的一定是这个类的虚函数表地址。
Father f;
//把Father*类型强转为int*类型,得到虚函数表的地址
std::cout << "虚函数表地址" << (int*)(&f) << std::endl;
//把*(Father*)类型强转为(int*)类型,找到虚函数表中第一个虚函数的地址
std::cout << "虚函数表 第一个函数地址" << (int*)*(int*)(f&) << std::endl;
我们定义一个函数指针,它的类型和Father::func()
类型相同,然后利用这个函数指针来调用相应的成员函数
typedef void(*Func)(void);
Father f;
Func pFunc = (Func)*((int*)*(int*)(&f)); //这个过程经历了两次解引用和两次强制类型转换
pFunc(); //输出"Father"
下边这个图展示了相应的内存情况:
虚函数会按照声明顺序依次放在虚函数表中,上边虚函数表中的省略号部分其实是没有的,因为我们的Father
类就一个func()
函数,我画出来只是为了让它看起来像一张“表”。我们再考虑一个子类的对象,比如Child c;
中c
的内存结构,毫无疑问,它也有一张属于自己的虚函数表,只不过虚函数表里的内容发生了变化。
子类的虚函数表因为覆盖效果,Father::func()
被替换成了Child::func()
,这就实现了动态绑定和动态多态。
在多重继承中,假设Child
类同时继承了Father1
, Father2
和 Father3
三个类,并且对父类的虚函数覆盖情况如下:
那么同样对于Child c
的c
的虚函数表而言,它的指向情况则变为:
一图抵千言,我们可以看到,每个父类都有自己的虚函数表,而具体虚函数表中的覆盖情况,由子类对虚函数的覆写相对应。关于虚函数表就先说这么多。
面试官抖个机灵,问:什么函数不能是虚函数呢?答:
- 友元函数,这玩意儿压根就不是成员函数,更不能为虚。
- 全局函数
- 静态成员函数,这没有
this
指针做参数。 - 各种构造函数以及操作符重载一般不建议为虚函数,这也是编译期要求的。
虚函数与inline
面试官又抖个机灵,虚函数能不能是inline
呢?这得先说说inline
是咋回事儿,其实这里还是动态与静态的问题。首先说答案:不能。
inline
是为了减少函数调用时间,在编译期间把inline
函数的代码直接拷贝到调用出,这样就实现了函数的特性又减少调用函数的开销。划重点啊:在编译期间,编译期干的事儿,叫啥?肯定是静态期干的事儿。虚函数呢?是动态调用的。编译的时候压根就不知道调用的是父类的还是子类的,也就没法做代码替换和展开。不过inline
本身属于一个建议性的关键字,和constexpr
很像,在这种情况下,编译器也不会报错,直接忽略你这inline
属性,并嘲讽你一番(warning)。
dynamic_cast
讲到多态了,dynamic_cast
一般是躲不过去的。各种cast
往往是bug的高发地,这里的特殊情形比较多。
dynamic_cast < type-id > (expression)
这个玩意儿把expression
转换成type-id
类型的对象。type-id
可以是类的指针、类的引用或者void*
。如果type-id
是类指针类型,那么expression
也必须是一个指针,如果type-id
是一个引用,那么expression
也必须是一个引用。
它的作用即是将一个基类对象指针安全地cast到子类指针,即对指针做下行转换,如此一来,基类指针也能够访问被指向的子类对象的非虚成员了,这才是主要目的。dynamic_cast
会根据指针的实际指向对象类型和基类的继承关系来做相应处理,如果转换失败,对指针将会返回一个NULL
,对引用将会抛出一个异常,这就是安全性所在。
那么,什么时候dynamic_cast
会转换出错呢?
- 首先,基类必须要有虚函数,否则编译期报错。
- 基类指针指向的必须是一个子类对象,才能使用
dynamic_cast
转换成一个子类指针,否则会有不安全的风险。
另外,真正在项目中使用dynamic_cast
的实用建议:不要用!不要用!不要用!要用尽量也压到继承树的底部。
来自《Effective C++》的忠告
最后利用大杀器《Effective C++》的两条忠告结束这篇文章。
- 绝对不要重载继承而来的非虚(non-virtual)函数。(条款36)
这是把静态和动态绑定杂糅在一起的大坑,这样的重载和对象脱离了关系,很容易起到误导作用。
我们默认:如果使用non-virtual函数,这个操作在整个继承体系内应该是不变的,如果派生类希望表现出不同的行为,一定要定义成虚函数,并尽量在子类中使用override(这句话是我加上的)。
- 绝不重新定义继承而来的缺省参数值。(条款37)
上一个条款论述了non-virtual函数不应该被重新定义,那么non-virtual函数中的参数也就不存在被重新定义的机会。因此这里主要针对的是虚函数。
原因就在于,虚函数是动态绑定,而缺省参数值却是静态绑定。所以你可能调用了一个派生类的虚函数,但是使用到的缺省参数,却是基类的,就会让调用者觉得很奇怪,因为在这种情况下,虚函数重新定义的缺省值不会起作用,对调用者是一个误导。但是,即使派生类严格地遵循了基类虚函数的缺省值,也会存在问题:积累的虚函数缺省值一旦发生变化,所有派生类的也要跟着变。因此,本质在于,不应该在虚函数中使用缺省参数,如果有这样的需求,那么这种场景就应该使用虚函数的几种替代方案,详见条款35。