c++多态机制解析
提到”多态机制“这个概念,背过面经的同学都应该毫不犹豫地说出:**“多态分为静态多态和动态多态:静态多态机制依靠函数重载实现,动态多态依靠继承体系虚函数实现!”**这句话是面试的“金玉良言”,可一定要记住咯!那么我们分别从这两方面深入探讨一下其中的机制吧:
- “静态多态”的实现——name mangling(函数重载,overload)
- “动态多态”的实现——继承体系中的虚函数表(函数重写,override)
一、“静态多态”的实现——name mangling
name mangling,这词啥意思呢?mangling,日常用途解释是:碾碎;冶金领域、交通领域的专业解释是:矫平、矫直;在计算机领域的解释是:识别编码。所以,字面意思就是:(给)函数名编码。
c++函数:
void func(const int*){}
int func(int*){}
解析出的符号:
zkcc@LAPTOP-OHBI7I8S:~/mytest$ g++ -g -std=c++11 const_test.cc -o const_test
zkcc@LAPTOP-OHBI7I8S:~/mytest$ readelf const_test -a |grep func
60: 0000000000001198 15 FUNC GLOBAL DEFAULT 16 _Z5funcPi
72: 0000000000001189 15 FUNC GLOBAL DEFAULT 16 _Z5funcPKi
zkcc@LAPTOP-OHBI7I8S:~/mytest$ c++filt _Z4funcPi
func(int*)
zkcc@LAPTOP-OHBI7I8S:~/mytest$ c++filt _Z4funcPKi
func(int const*)
c++编译器具体的name mangling规则我们不需了解,需要掌握的是:c++为函数编码成符号到底用到了哪些信息?通过c++filt工具的帮助,可以看到,一个**c++函数的符号编码至少用到了:函数名、函数参数列表(常量参数信息也作为区分),而没有返回值!**这也是c++函数符号编码和c语言最大的不同,c语言为一个函数编码为符号,并不需要参数列表信息而仅有函数名,这也是为什么c语言不支持重载,而c++却可以的本质原因!。没错,c++ name mangling机制理解到这句话就足够了!不过,本段加粗的文字,你真的理解到家了吗???我相信很多初学者看到这里八成会漏掉一种情况!
举个栗子:
class A{
public:
void func(int)const{}
void func(int){}
static int func(int){}
};
你说,编译器处理这个类,会报错吗?(假设类内静态函数的声明处理无误,这不是我们要讨论的问题)
上述代码能不能通过编译在于:**类内函数的重载,支不支持函数的const、static饰词?**不过,这个问题具有误导性,如果你真的是这么思考的,就说明你还没有完全理解上一段加粗的文字!
应该怎么理解呢?"const与constexpr"中也提到过,GNU实现的类成员函数中,第一个参数是一个该类指针(this),其实函数的饰词,也就是this指针的饰词!
因此,上面的代码当然会通过编译,可以完成重载,编译器将它们三个视作不同的函数,相当于:
{
void func(const A* this, int){}
void func(A* this, int){}
int func(int){}
}
那你说,上面3个函数作为全局函数能否通过编译?显然可以吧,因为参数列表都不一样,函数符号肯定也不一样。
所以到现在,你对函数重载(也就是“静态多态”)应该有了一个比较深入的认识;在自己的c++工程代码中,你也应该知道如何利用函数重载机制,组织便于他人使用的函数重载体系了。
二、“动态多态”的实现——继承体系中的虚函数表
说到这里,按照我个人目前的理解,我不太喜欢“动态多态”、“静态多态”的说法。(同理还有函数的“动态绑定”和“静态绑定”)因为我发现,由于“动态”、“静态”的说法,导致很多c++入门者(更不要说初学者了。。)会以为“动态”绑定、“动态”多态是在运行时才确定执行哪个函数调用的!(说的我好像挺nb一样。。其实我也是菜鸡入门者哈哈哈)
其实,不管动态多态还是静态多态,有一点毋庸置疑:执行哪个函数,都是“静态”确定的(在编译期就确定的)!
可别忘了,c++不是一门脚本语言(python,js,shell…),**c++是一门静态语言!**啥意思呢?就是说,c++通过编译链接等工作处理源文件之后,生成一个可执行文件,机器可以直接加载运行这个可执行文件,而完全不需要再回过头来看看源文件(或者中间生成的什么文件)辅助运行。这就是说,即便是动态多态,它的运行行为也是在编译时就早早确定好了,完全没有很多初学者想象的那么高端:到运行时才能确定执行哪个函数。。。编译器又不是傻子。。是吧!
那么,所谓“动态多态”是怎么做的呢?为什么我们把它称作“动态”呢?(尽管不是真的运行时绑定,但你写程序时看起来像是运行才时绑定的)
概括地说,动态多态是由继承体系中的虚函数、虚函数表指针、虚函数表实现的,通过这三者的配合,可以让你在写程序时感觉貌似编译器是在执行时动态匹配到具体函数的。
初学者的话,十分推荐认真观看侯捷老师的课程,侯捷老师在多态机制这的讲义非常精彩,这里我借用一下他的讲义: ? 图1 侯捷老师讲义中关于动态多态的刨析
可以看到,当一个类中有虚函数时,类的内存将增加4/8字节,类分配内存的首地址是一个虚函数表指针(vptr),指向当前类的虚函数表(虚表,vtbl)。而虚表里存放的都是一些函数指针,指向当前类对其基类虚函数的不同实现函数。需要注意的是,如果子类并没有重写父类的虚函数,那子类的虚表里该函数的函数指针就是父类虚函数实现的函数指针。毕竟子类拥有父类所有的非private访问级别的资源,包括非静态函数和非静态变量。
当动态多态机制触发时,即父类指针/引用指向子类对象,如下代码:
int main(){
A* a1=new B;
A* a2=new C;
B& b=new C;
a1->vfunc1();
}
会发生什么呢?我们从头到尾捋顺一下:
- 父类对象(c++中的指针/引用可以理解为java中的对象语义)将获得子类对象的地址(指针就是存储着地址的变量嘛!引用也一个道理)。
- 而子类对象的首地址就是虚表指针,即父类对象通过子类地址拿到了子类的虚表指针。
- 虚表指针指向虚表,当父类对象调用虚函数时,会通过虚函数查找子类的虚表。
- 当然这里还需要知道虚函数的编号,即调用的虚函数在虚表里的索引(编译时按照虚函数在类中声明的顺序构建索引下标)。通过索引来调用对应函数指针,over!
相信你看到这里,应该可以清晰地知道动态是怎么触发的了。最后,还有2个小问题必须搞清楚:
-
从汇编角度看,为什么虚函数的这种机制叫做“动态多态“呢? class A{
public:
virtual void vfunc1(){}
virtual void vfunc2(){}
void func1(){}
void func2(){}
};
class B:public A{
public:
void vfunc1()override{}
};
int main(){
A* a=new B;
a->vfunc1();
a->func1();
B b;
A* a1=&b;
A& a2=b;
}
上面的代码在GNU下汇编可得到汇编代码,截取了部分能说明问题的汇编代码如下: main:
# call a->vfunc1();
call _Znwm@PLT
movq %rax, %rbx
movq %rbx, %rdi
call _ZN1BC1Ev
movq %rbx, -24(%rbp)
movq -24(%rbp), %rax
movq (%rax), %rax
movq (%rax), %rdx
movq -24(%rbp), %rax
movq %rax, %rdi
call *%rdx # 特别是这里!并不是直接call一个符号,而是call一个指针!
# call a->func1();
movq -24(%rbp), %rax
movq %rax, %rdi
call _ZN1A5func1Ev # 不是虚函数,直接call一个符号
可见,对虚函数的调用处理比正常函数繁琐一些。最重要的区别就是:虚函数最终的调用语句call了一个指针,而这个指针通过前面的汇编分析就是:vtbl[n] (虚表是指针的指针,每一个表项都是指针)。而这种调用指针而非直接调用符号的形式就叫做所谓的”动态绑定“。 而非虚函数的调用,则是直接call了一个函数符号(上面讲的),稀松平常。 所以到现在,我相信你冥冥之中应该有所感觉:c++这门语言,对所谓**”动态行为“就是利用堆区空间做事情**,而**”静态行为“就是在栈上相关的事情**。类似的概念还有c++98风格的”动态数组“。 也许你现在还不懂栈上动作的机理和汇编语言的对应关系,没关系,我之后的文章会深入解析用汇编来实现c/c++的调用栈逻辑。而我认为,作为一个合格的c++入门者,用汇编干别的事情可以暂时不了解,但汇编实现调用栈这个话题是必须要熟悉掌握的! -
很多c++初学者会问这样的问题:为什么 子类值语义赋值给父类值语义变量 不能触发多态呢??
B b;
A a=b;
a.vfunc1();
我认为,想入门c++最重要的是:**一定要设法搞懂你写的语句到底会导致编译器怎样的行为?**如果你写每一行代码都在思考这个问题,并真正去设法弄懂它,相信你可以很快入门! 那么,值语义赋值和对象语义指向,二者有何区别呢?其实也不难理解:对象语义的指向意味着父类对象取得了子类对象的地址;而值语义直接赋值则发生了上行转换! 而在上行转换中,子类首地址中存储的虚表指针将被丢弃掉(别问为啥,语言的规范就是这样处理虚表指针的),这导致父类调用虚函数时,拿到的是自己的虚表指针、调用的是自己的虚函数! 下面是在GNU下的测试代码: #include"iostream"
using namespace std;
class A{
public:
virtual void vfunc1(){cout<<"this is A!"<<endl;}
virtual void vfunc2(){}
void func1(){}
void func2(){}
};
class B:public A{
public:
void vfunc1()override{cout<<"this is B!"<<endl;}
};
int main(){
B b;
A a=b;
a.vfunc1();
}
执行结果: zkcc@LAPTOP-OHBI7I8S:~/mytest$ g++ test_virtual.cc -o test_virtual && ./test_virtual
this is A!
你看,它调用的真的是父类A的虚函数实现版本! 为了验证我上面说的,再来看一下这样编译出的相关汇编代码: main:
# 做了一些省略
movq %rax, %rdi
call _ZN1A6vfunc1Ev
噢。。编译器做的更直接了,都根本没有“动态绑定”的过程了,它看到没有得到子类的虚表指针,直接用“静态绑定“的方式直接调用vfunc1()了! 可见,多态的触发条件不止一个,但它们有一个共性:父类能拿到子类的虚表指针,即可触发多态!
最后的最后,简单聊聊在实际c++工程中,多态的使用建议吧:
- 对于函数重载,如果你认为你设计的代码中含有功能类似、但使用方式不完全一样的函数,那么用函数重载是完全鼓励的,但这些重载的返回值最好一样。如果返回值都不一样,那最好不要用重载机制了,干脆起不同的函数名吧。。以免造成意想不到的冲突困惑。
- 对于继承体系,在实际工程中的原则是:能不用就不用,用了的话最好只是在一定范围几个类中使用。而且你最好确保”继承逻辑“真的存在:子类真的需要父类的所有对象和方法。如果不是这样,建议你不要使用继承多态体系。
- 如果有充分的理由使用继承多态,那么你重写虚函数时一定加上
override 关键字(就像上面的示例代码一样)。对于你不想让子类重写的虚函数,一定加上final 关键字。这会让编译器在编译期提醒你可能出错的情况。一定不要用多继承(子类继承多个基类),一旦形成菱形继承体系,就需要用”虚继承“处理中间层的继承体系,避免出现继承末端类中的变量出现二义性。标准库中那样用看看就得了,在实际工程中用很容易出问题的!还是那句话,就算你能100%确定这种技术你不会用错,但你也不敢保证组内其他人也能如此。所以对于不推荐的c++技术,能不用就不用。这也是java为什么不支持多继承的原因!
|