编译程序时,C ++编译器对源代码中的每个语句转换为一行或多行机器语言。 我们从之前的汇编基础系列和内存管理文章中,已经知道每行机器语言都有其自己的唯一的顺序地址。 由于函数也是一个数据对象,它也将转换为机器语言并提供下一个可用地址。因此每个功能都以唯一的地址结尾。
如何理解绑定
绑定(Binding)是指将变量和函数名转换为地址的过程。
早期绑定(Early binding):绝大部分的顺序执行逻辑中函数调用或某个确定数据类型的(不存在选择性)的类类型的对象对成员调用都属于早期绑定
#include <iostream>
class Employee{
....
};
class Teamer:public Employee{
public:
size_t d_members;
std::string d_name;
Teamer(const std::string &name,size_t memeber)
:d_name(name),d_members(memeber){}
....
void show(){
std::cout<<"名称:"<<d_name<<std::endl;
std::cout<<"人数:"<<d_members<<std::endl;
}
size_t get_members(){
return d_member;
}
};
调用代码示例
int main(void){
//早期绑定
Teamer tm=Teamer{"胜利队",52};
//早期绑定
tm.get_memeber();
//早期绑定
tm.show();
}
早期绑定意味着绑定的函数或者变量,已经在编译阶段,该语句已经被编译成“call 函数地址”或"callq 函数地址"这样的汇编指令格式(如下图所示),并且这些汇编指令中的函数地址在程序编译后是固定不变的,请记住,所有函数都有唯一的地址。 因此,当编译器(或链接器)遇到函数调用时,它将用机器语言指令替换该函数调用,该指令告诉CPU跳转到该函数的地址,因此早期绑定也叫静态绑定。
动态绑定
在一些带有决策性的业务逻辑的代码中,要等到用户的反馈(通常是条件判断/参数类型判定...),直到运行时,根据决策的结果才能知道将调用哪个函数。这称为后期绑定(或动态绑定),动态绑定的技术的本源就是函数指针(也可以称为函数原型)。在C ++中运行时多态正是使用的就是函数指针。
为了简化话题的导入,我们不妨先从面向过程的动态绑定说起。这也是当作复习函数指针的另类方式。
函数指针是一种指向函数而不是变量的指针,可以通过使用指针上的函数调用运算符"()"或“(type1,type2,....)”的运算符号形式来调用函数指针指向的函数。
函数指针回顾
下面示例中5个用于计算相见多边形面积的简易函数,他们非常适合用于展示函数指针的用法,示例代码中开头全局声明了三个函数指针,分别对应如下函数原型
以下函数原型仅接受一个传入参数,匹配double (*one_pars_func)(double)
- double square(double)
- double circle(double)
以下函数原型匹配double (*two_pars_func)(double,double),接受两个传入参数;
- double retangle(double,double)
以下函数原型匹配double (*thr_pars_func)(double,double,double)接受三个传入参数
- double triangle(double,double,double)
- double trapezoid(double,double,double)
#include <iostream>
#include <sstream>
#include <math.h>
#include <string>
#include <deque>
double (*one_pars_func)(double);
double (*two_pars_func)(double,double);
double (*thr_pars_func)(double,double,double);
double square(double x){
return x*x;
}
double circle(double x){
return 3.14*x*x;
}
double triangle(double a,double b,double c){
if(a<0 || b<0 || c<0 || a+b<=c || b+c<=a || a+c<=b ){
std::cout<<"这个不是有效的三角形"<<std::endl;
return -1;
}
float k=(a+b+c)/2.0;
return sqrt(k*(k-a)*(k-b)*(k-c));
}
double trapezoid(double a,double b,double h){
return (a+b)*h/2.0;
}
double retangle(double a,double b){
return a*b;
}
//辅助函数- 提取参数
double get_data(std::deque<double> &q){
double tmp=0.0;
if(!q.empty()){
tmp=q.front();
q.pop_front();
return tmp;
}
}
上面的示例代码中的辅助函数get_data仅仅将从deque容器弹出参数,提供后期函数指针被实际调用时使用。
下面的示例代码是根据调用层的决策逻辑(main函数的业务逻辑)函数的,而有选择性地初始化我们前面声明的三个函数指针。示例中的calc_area函数有三个参数
- shapeType 是接收来自用户输入的需要计算多边形的类型的决策参数。
- 整数1表示用户需要计算正方形的面积 ,会将square函数地址赋值给one_pars_func函数指针。
- 整数2表示用户计算圆形的面积,会将circle函数地址赋值给two_pars_func函数指针。
- 整数3表示计算矩形面积,会将retangle函数地址赋值给two_pars_func函数指针。
- 整数4表示计算三角形面积,会将triangle函数地址赋值给thr_pars_func函数指针。
- 整数5表示计算梯形面积,会将triangle函数地址赋值给thr_pars_func函数指针。
通过用户输入的选项从而动态地使函数指针指向某个指定具体的函数地址,这个动作其实就是“绑定”,但需要注意的是我们还没有调用该函数,这个阶段仅仅持有该被指向的函数地址:
double calc_area(int shapeType,
std::deque<double> &q,
std::string &shapeName){
switch(shapeType){
case 1:
one_pars_func=square;
shapeName="正方形";
break;
case 2:
one_pars_func=circle;
shapeName="圆形";
break;
case 3:
two_pars_func=retangle;
shapeName="长方形";
break;
case 4:
thr_pars_func=triangle;
shapeName="三角形";
break;
case 5:
thr_pars_func=trapezoid;
shapeName="梯形";
break;
}
//根据参数的个数,判定调用那个函数原型
size_t length=q.size();
double result;
switch(length){
case 1:
result=one_pars_func(get_data(q));
break;
case 2:
result=two_pars_func(
get_data(q),
get_data(q)
);
break;
case 3:
result=thr_pars_func(
get_data(q),
get_data(q),
get_data(q)
);
break;
default:
return result;
}
}
上面示例代码的第二个switch/case控制结构,通过用户的参数个数判断调用那个函数指针,此时通过函数指针的特殊操作符"()",调用函数也称为间接函数调用.
main调用代码,我就不废唇舌,本来这个示例就要求读者对C++有中级程度的基础。
std::string prompt(const std::string &info){
std::string tmp;
std::cout<<info<<std::endl;
getline(std::cin,tmp);
return tmp;
}
int main(void){
std::string dg1,dg2,shapeName;
int choice;
std::deque<double> params;
double result;
std::cout<<"请选择的形状名称:"<<std::endl;
std::string tip1="1-正方形,2-圆形,3-长方形
,4-三角形,5-梯形";
std::string tip2="请输入计算形状面积
的参数(例如:a,b,c 以逗号隔开)";
choice=stoi(prompt(tip1));
do{
dg1=prompt(tip2);
std::stringstream istr(dg1);
//将istream读取提取子字符串并转换为double类型的数字
std::size_t offset=0;
double tmp;
while(getline(istr,dg2,',')){
tmp=stod(dg2,&offset);
params.push_back(tmp);
}
//计算形状面积
result=calc_area(choice,params,shapeName);
std::cout<<shapeName<<" 面积是"<<result<<std::endl;
choice=stoi(prompt("需要继续吗?(-1表示退出程序)"));
if(choice>0){
std::cout<<"请选择的形状名称:"<<std::endl;
choice=stoi(prompt(tip1));
}
}while(choice>=1 && choice<=5);
return 0;
}
在此示例中,我们通过用户的输入的选项,将声明函数指针指向要调用的函数,而不是直接调用这些具体的函数。 然后我们通过参数个数决定这些函数指针调用哪些函数。 编译器无法使用早期绑定来解析类似
two_pars_func(x,y),thr_pars_func(x,y,z),这样的函数调用。因为它无法告诉函数指针在编译时将指向哪个函数 !你不相信我的说法吗?😼可以去验证哈!
我们之前讨论静态绑定的时候,已经讨论过其实际上在编译时在汇编阶段在32位环境中会转换为call xxxx,在x86_64环境中会被转换为callq xxxx这样形式的指令。
反证我的说法
假设你在gdb命令可以使用disas指令反编译我们示例中calc_area函数得到反汇编代码 ,你是无法找到callq 0x401384或callq 0x40135d这类指令的,例如我写本文时,在我的机器打印的这些示例函数的地址如下。
动态绑定的性能问题
后期绑定的效率稍低,因为它涉及额外的间接访问。 通过早期绑定CPU可以直接跳转到函数的地址。 对于后期绑定,因为涉及CPU和寄存器存储/加载一系列指令,程序必须读取指针中保存的地址,然后跳转到该地址。使其速度稍慢。 后期绑定的优点是它比早期绑定更灵活,因为不需要在运行时就决定要调用什么函数。
这也是一些动态语言的卖点,例如需要使用Python程序猿在编写代码无需考虑变量和参数的类型,因为Python的解析器的底层就是用到了运行时的一系列类型检测和类型检测后的内存分配以及C的函数指针的间接调用等技术完成了对Python代码的解析和资源初始化,这一切是以低性能为代价的。而Cython的其中一项技术就是静态绑定,即是将一些程序员认为非常慢的Python业务逻辑转换可能存在大量局部变量和参数的上下文,为这些局部变量和参数指定C/C++的数据类型,实质上这些通过Cython语法修饰的变量和参数已经绕过Python解析器的类型检测,直接以C级别的静态绑定来运行。因此就从某种程度弥补了Python的性能底下的诟病。
小结
我们后续文章会继续循序渐进深入探讨一下C++面向对象版本的动态绑定话题。其实C++中动态绑定的技术牵涉好几个知识区块,很多谈及C++面向对象多态的时,尤其是运行时多态即动态绑定,到目前为止,绝大部分的牵涉动态绑定话题的文章,不是先抛出几个已经被用烂的虚函数示例代码(不是B继承A,就是D1继承D2...),然后之后千篇一律地给出结论般C风格的表达式(*(p->vptr)[n])(p),结论没错!但我想说这样抄来抄去有意思吗!?中间的牵涉的知识链条和内存状态只字不提就能够显示自己水平多么高逼格有多高~!?,老子表示对这种现象感到灰常厌恶.
因此,我这里就在自己的简书主页特意开个《C++ 多态》的文集,用来对以往的知识做个梳理和总结。
- 虚指针和虚表,虚函数
- 函数指针和间接调用
- 类型转换:这个知识区块包括用户自定义类型的向上转换和向下转换
- 更加复杂函数指针间接调用。
敬请期待,谢谢!