从理解静态绑定 & 动态绑定开始,没想到又扯出静态类型 & 动态类型,编译期多态 & 运行期多态。但这些本来就是一体的。
静态绑定 & 动态绑定
刚开始只是看完网上的几篇帖子啰嗦几句心得,落笔时什么都没参考。重新整理笔记时硬是把以前保存在 chrome 书签中帖子给塞进来,也是够难受的。C++中的静态绑定和动态绑定
- 静态绑定(statically bound),又名前期绑定(early binding);
- 动态绑定(dynamically bound),又名延期绑定(late binding)。
ps 英文名称摘自《Effective C++》 条款37。此条款中有关于“静态类型、动态类型”的描述。
在 C 语言中并没有“静态绑定”、“动态绑定”的概念(至少我没有查到)。
我理解的 C++ 类的内存模型其实就是 C 语言中的 struct 结构体。但成员函数又是归属于类所有的,所以就存在函数到类的绑定。
- 静态绑定指的就是这种绑定关系(映射关系)是在编译期间确定的;
- 动态绑定指的就是这种绑定关系(映射关系)在编译期间确定不了,得等到程序运行、执行期间才能最终确定。
静态绑定
有这样一个现象:空指针调用【无需解引用 *this 指针的】非虚函数,是可以正确执行的。参考
对于初学者来说,可以以这个为契机去掌握“静态绑定”。参见 为什么通过空指针(NULL)可以正确调用类的部分成员函数 - csdn - 鱼思故渊的专栏,作者在文章开头言简意赅的介绍了这种现象为什么会出现,后面通过“将对象指针作为参数,以 non-member 函数的形式使用成员函数 + 汇编代码”进行了更深入的讲解,值得好好学习。
课外阅读:如果看完上一篇帖子意犹未尽,可以继续看 C++教程:指向成员函数的指针,在项目开发中基本不会用到成员函数指针,但了解这些有助于我们理解 C++ 的底层机制。阅读过程会比较吃力(关于虚函数的部分可以放到掌握继承、多态之后再学习),进度可能缓慢,但没有难点。如果寸步难行,那说明你对于 C、C++ 的底层毫无概念,真的还是个新手——很新的新手。
动态绑定
虚函数是动态绑定的基础;动态绑定是实现运行时多态的基础。五星-请阅读原文
要触发动态绑定,需满足两个条件:
- 只有虚函数才能进行动态绑定,非虚函数不进行动态绑定。
- 必须通过基类类型的引用或指针进行函数调用。
虚函数动态绑定发生在运行时,源于其设计理念、其出发点就是为了让一指针变量(或引用)根据指向的对象的实际类型(动态类型)来选择函数以实现多态性。这种选择(即动态绑定)肯定只能发生在指针变量指向某块内存(初始化)之后,同时继承体系的存在允许我们切换基类指针指向基类对象或派生类对象(赋值)。因为初始化(或者赋值)发生于运行期间,所以动态绑定也只能发生在运行期间。
反过来,如果不需要进行这种选择,使用哪个函数根据变量声明时的类型(静态类型)就能确定的话,那我们就可以把这个绑定往前放,因为静态类型在编译时是已知的了。
朴素观点
我们朴素地分析一下静态绑定、动态绑定。函数都有其地址,函数调用翻译成汇编代码其实就是直接用地址,很明显在汇编代码这个层次(甚至不用这么底层,C语言层次就行)不同的函数实现有不同的地址(使用C 语言的话,就有不同的函数名),但在 C++、Java 高级语言这个层次,不同的实现可能有相同的函数名(有很多种情况:重载、不同类里面相同名称、模板、继承体系中的重写),在 C++ 代码中出现一个函数调用(函数名),怎么正确地找到对应的实现(地址)呢?
- 重载:因为参数类型或者个数不同其实是有区分的,直接在高级语言下一个层次(比如C语言层面)使用不同的命名重新包装就可以了。调用时根据传参的情况,再映射就可以了。
- 不同的 class 里面相同名称:编译器实现这个完全可以和重载情形使用同样的方案。维护一个映射表就可以。
- 模板:暂时不了解
- 继承体系的重写:
我们都知道 override 函数时,两个函数的声明式肯定是一模一样的,如果不考虑 override 的概念(具体到代码中就是不使用 virtual
关键字),那么其场景和上述第 2 中就是一样的——如果是 base 类型(即便是指针)就调用 base class 的函数,如果是 derived 类型就调用 derived class 的函数。事实上都是编译期间根据维护的映射表“偷梁换柱”(映射是在声明的类型(函数声明式、类类型)-具体的函数实现之间),直接把对应的地址拿过来,ok,汇编代码完成了。
继承体系中允许 base 指针是可以指向 derived 对象,但编译器依旧是根据声明指针时的类型去映射具体的函数实现的,所以会出现一些变态的现象:
- class Base 无 void func(),class Derived 有 void func(),我们执行
Base *p=new Derived(); p->func();
会报错找不到 - class Base 的 void func() 打印 base,class Derived 的 void func() 打印 derived,我们执行
Base *p=new Derived(); p->func();
会打印出 base 纳尼 - class myclass 有函数 simple(),函数实现中没有对 this 解引用的操作(不管是显式的还是隐在的),我们执行
myclass *p=NULL; p->simple();
能够正确执行
so,正如我们看到的,这就是静态绑定。
随着 OO 越来与流行,为了获得多态性,我们想要打破这种规则——继承体系中允许基类指针指向派生类对象,在此基础上我们想让基类指针可以调用派生类的函数,我们要让例二 p->func()
打印 derive 怎么办?
好吧,增添新的语言特性,使用关键词 virtual
,用来表明碰到这个类的指针(或引用)调用此函数时不要根据静态类型(声明指针的类型)映射具体实现,你们要根据这个指针指向的对象的实际类型来映射具体实现(即动态绑定)。编译器说,纳尼,我靠,我哪知道啊?我只解析 declaration,只分析了变量声明的类型,内存的初始化、赋值在运行期才发生呢……好吧,编译器感觉为难做不了这个事情,只能把这个找到(绑定)函数具体实现的步骤放到运行期间了,可是效率会低一些呢。如果你要多态性,只能接受了。
具体实现中通过 virtual
关键词标记延迟绑定,然后在运行过程中,根据对象内存中的虚函数表指针获得函数地址。
如果不是通过指针或引用调用虚函数,也是在编译期间就绑定的【作业一?】;而没有 virtual
修饰的函数根据调用者的静态类型在编译期间直接绑定。
静态类型 & 动态类型
- 静态类型(static type):静态类型在编译时总是已知的,它是变量声明时的类型或表达式生成的类型。
- 动态类型(dynamic type):动态类型则是变量或表达式表示的内存中的对象的类型,动态类型直到运行时才可知。
以上概念可以去翻书《C++ Primer》第 534 页,也可以查阅《Effective C++》的条款37。
如果表达式既不是引用也不是指针,则它的动态类型永远与静态类型一致。
动态类型一如其名称所示,可在程序执行过程中改变(通常是经由赋值动作)。
动态绑定和动态类型一体两面,息息相关。在继承体系使用引用和指针,就会涉及动态类型;进一步如果继承体系中涉及虚函数(事实上必然会涉及,考虑虚析构函数),就会发生动态绑定。
编译器多态 & 运行期多态
在 C++编译期多态与运行期多态 开篇写到:
在面向对象C++编程中,多态是OO三大特性之一,这种多态称为运行期多态,也称为动态多态;在泛型编程中,多态基于template(模板)的具现化与函数的重载解析,这种多态在编译期进行,因此称为编译期多态或静态多态。
进入正文作者总结到:
运行期多态的设计思想要归结到类继承体系的设计上去。运行期多态的实现依赖于虚函数机制。
对模板参数而言,多态是通过模板具现化和函数重载解析实现的。以不同的模板参数具现化导致调用不同的函数,这就是所谓的编译期多态。
在 多态性之编译期多态和运行期多态(C++版) 则重点强调了
需要注意的是函数重载和多态无关,很多地方把函数重载也误认为是编译期多态,这是错误的。
当说到多态性的时候一般都默认指运行期多态。
作为小人物,真心不想扯“函数重载到底算不算编译器多态?”这种问题。
编译器 & 运行期
为什么变量的初始化(或者赋值)只能发生在运行时,而不能在编译时呢?因为分配内存发生在运行时,包括 const 常量(下文详细描述)。
此处有一个容易混淆的概念,我们经常区分“编译器分配内存”和“动态分配内存”,但“编译器分配内存”不是“编译期分配内存”。编译器分配内存,是说内存的分配和回收的指令(命令、函数或操作符等)不用我们这些 coder 去手工写明,当数据类型(非容器)确定时,编译器能够为我们做这些繁琐、重复且容易出错的内存分配工作,它会在汇编代码中生成内存分配和回收的命令。但编译器也只能做“确定性”的工作,不确定的或者会动态改变的情形还是需要我们 coder 使用高级语言“下指令”(动态分配内存),好在可以封装,比如 STL 的容器类,shared_pointer 智能指针等等。
内存的管理工作编译器承担了大部分,我们得以从“埋头和机器交流”中解放出来,有更多的时间关注业务。但无论是否需要我们手工“下指令”,程序向系统要内存(获取/分配),用完了把内存还给系统(释放/回收)都是发生在运行期间。
编译期分配内存并不是说在编译期就把程序所需要的空间在内存里面分配好,而是说在程序生成的代码里面产生一些指令,由这些指令控制程序在运行的时候把内存分配好。不过分配的大小在编译的时候就是知道的,并且这些存贮单元的位置也是知道的。
而运行期分配内存则是说在运行期确定分配的大小,存放的位置也是在运行期才知道的。引用来源
直观感受和理性分析
logical,就是因为变量需要内存,分配内存发生于运行时。
如果我们把普通 const
常量认为和宏一样(事实上只限于 C++编译器,且要求是直接定义的 const
常量),那么其编译过程我们可以这样理解:const
属性的变量在编译时就确定,即编译器会将这个变量出现的所有位置直接替换成值,在编译器生成的汇编代码中不会出现这个变量名称。
很显然 non-const 变量不可以这么做,因为它在后面的逻辑中会有更改,甚至于其初始化(或者赋值)完全依赖运行过程中的用户输入 int j = atoi(argv[1])
。所以在编译器生成的汇编代码中必须保留有这个变量。
1 | // Base 类有虚函数 func() |
再来回顾一下上述代码,我们觉得一目了然的事情,比如编译器汇编时直接把 p->func()
调用换成 derived::func(p)
不就好了吗,实现起来很难吗?事实上编译器是卡在它只知道 p 是 base *
类型,它并不知晓 p 被初始化(赋值)了什么,它只生成“得到一个地址,把这个地址赋给 base 指针 p;根据 p 指明的地址调用 func()” 的指令,至于前一条“分配内存,初始化 derived 对象”的指令,现在是前后相邻紧挨着,其他场景可能这两条指令相差十万八千里呢。事实上我们只会在测试时写 Base *p=new Derived(); p->func();
这样的例子,在真实的业务场景中为了效率至少应该写成 Derived derived; derived.func();
,在能够确定类型的时候使用静态绑定效率更高。实际上真实的业务场景多是
1 | func(base *p) |
正如这段代码,跨越上下文的事情,我们能轻易理解是发生在运行期,是动态绑定。我们只会困惑于上下文紧邻的情形,比如例二,比如 const int i=1; const int j = i+1;
后者编译器可以(但不是必须的,术语称为“优化”)帮忙处理,但例二就不行。编译器会优化吗?或许应该做个测试。
普通 const 常量分不分配内存?
帖子 关于C++ const 的全面总结 正如其标题一样,总结得非常全。其中提到一句:
const定义常量从汇编的角度来看,只是给出了对应的内存地址,而不是象#define一样给出的是立即数,所以,const定义的常量在程序运行过程中只有一份拷贝,而#define定义的常量在内存中有若干个拷贝。
编译器通常不为普通const常量分配存储空间,而是将它们保存在符号表中,这使得它成为一个编译期间的常量,没有了存储与读内存的操作,使得它的效率也很高。
刨根问底,那什么时候会分配内存呢?在 关于Const常量内存使用 一文中可以找到答案。
C++编译器对除了直接定义的Const常量外,都是分配内存的。
也就是声明和定义(初始化分配内存)分开的 const 常量,这种情况下编译器无法获知常量的值。关于“const 修饰类内成员变量”,除了遵循这个原则外,还有更复杂的使用限制,详情请翻书《Effective C++》条款2、条款3 和条款4。
如果成员变量是 const 或 reference,它们就一定需要初值,不能被赋值。
写代码注意事项
不要重新定义继承而来的非虚函数。参考《Effective C++》条款36
如果基类中某个函数没有被声明为虚函数,派生类中也定义了同原型的函数的话,通过基类指针访问派生类定义的对象,调用的函数是基类中的定义,而不是派生类中的定义。
这也是增加一个 virtual 关键字的意义。
一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。摘抄自《C++ Primer》第547页。
不要重新定义继承而来的虚函数的缺省参数值,缺省参数值是静态绑定的。参考《Effective C++》条款37
(成员函数的)重载、覆盖与隐藏
覆盖,和隐藏是很不一样的两个概念,要重点区分。五星-请阅读原文
重载(overload):同名函数变量,但属于不同的函数类型。发生在同一个作用域内,即在同一个类中。如果跨类,在父类、子类当中存在同名的函数变量(即便是不同的函数类型),如果是 virtual 性质的,就是重载(也叫继承,函数层面的继承),如果是 non-virtual 性质的,(只要子类中有一个同名函数变量没有用 virtual 修饰)就会发生隐藏——这个坑应该绕道走。
覆盖(override):派生类函数覆盖基类函数,必须有 virtual 关键字修饰,且函数类型相同,函数变量命名相同。
隐藏(hide):有些笔记中会写作“overwrite”,但这个词不是 C++ 中的术语,是不规范的写法。在代码层面上,隐藏很容易让人困扰,但其本质上只是嵌套作用域中命名隐藏问题。和以下代码同理。
ps 即便函数变量的类型不同,但编译器只关注变量的命名。如果还是似懂非懂,感到困惑,可以去翻书《C++ Primer》第 547 页。
一如往常,名字查找先于类型检查。
1 | void Print(int var) |
在《Effective C++》一书条款 36 提到“绝不重新定义继承而来的 non-virtual 函数”,就是为了避免隐藏带来的问题。
参考书籍:《C++ Primer》、《Effective C++》
—–以下暂时弃用
[08]:http://blog.csdn.net/ygzhong000/article/details/42034279
[09]:http://blog.csdn.net/dongpy/article/details/1118658
[07]:http://www.cnblogs.com/ztteng/p/3419843.html