C++

漫谈C++——动态与静态

C++学习笔记

Posted by Felix Zhang on October 5, 2020

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类将自动继承Fatherfunc()

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()函数,查到了,则直接调用。

总结一下:

  1. 如果基类中的成员函数不是虚函数,它的函数的静态类型在编译期都已经确定了,静态绑定并不能实现多态。
  2. 所有的(指针的)虚函数调用都要等到运行时根据它实际指向的类型确定,比起静态绑定性能有损失,但是实现了动态多态。
  3. 动态绑定可以更改,静态绑定不能更改。
  4. 继承体系中只有虚函数是动态绑定,其余全是静态绑定。

虚表与虚函数的实现

上边说到,指针调用被指向对象的虚函数的时候,会根据指针的动态类型,查找到对应动态绑定的虚函数,最后调用它。好了,问题来了,查啥能查到这个信息?——查虚函数表

虚函数是通过一张虚函数表来实现的,这个表内存放的不是别的玩意儿,就是各个虚函数的地址,当我们用父类的指针来操作父类或子类的虚函数时,这个表就像一个通讯录一样,可帮助找到相应的虚函数。

下一个问题,那我的某个对象,是怎么知道虚函数表在哪的呢?答案就是,对于每个有虚函数的类的对象,虚函数表的地址始终位于每个对象的内存的最前方。这意味着,我们随便取一个对象,找到它的内存起始位置,这个地方存的一定是这个类的虚函数表地址。

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 cc的虚函数表而言,它的指向情况则变为:

多重继承虚函数表

一图抵千言,我们可以看到,每个父类都有自己的虚函数表,而具体虚函数表中的覆盖情况,由子类对虚函数的覆写相对应。关于虚函数表就先说这么多。

面试官抖个机灵,问:什么函数不能是虚函数呢?答:

  • 友元函数,这玩意儿压根就不是成员函数,更不能为虚。
  • 全局函数
  • 静态成员函数,这没有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。