五.很强很伟大的函数指针
我想看到这个标题中“函数指针”几个字之后,估计有一半人会选择关掉界面,因为我最开始学习C语言的时候这一章我曾无数次跳过,看到书中那些复杂的星号括号直接就崩溃了,加上老师自己本身也讲不清楚,所以学习兴趣大减。但是到后面,当我意识到函数指针的牛逼和伟大之后,我不禁开始认真的思考并学习了这部分内容,绝对受益匪浅。如果你想了解很多编程的技巧以及C++的面向对象是如何构造出来的,我建议你应该好好学习函数指针,我也会分两或者三篇来介绍这个知识,特别是在后面,我将会简单的展示下用c语言如何能做到C++多态等面向对象的特征,这样当你遇到面试时有人问:"new和malloc有什么区别"的时候,你再也不必百度出答案以后照本宣科了。
函数指针绝对是C/C++语言中比较让人恶心的东西之一,面对着眼花缭乱的*和(),很多人直接就跪了,面试的时候经常会遇到函数指针和指针函数有啥区别这样的问题,从这两个名字和中国人造词的方法就可以看出一二,函数指针本质是一个指针,指针函数本质是一个函数,虽然这句话没什么意义,但是作为一个指导思想可以让你在理解的时候可以把握一个大方向。
1.函数指针的"函数"。函数指针既然叫这个名字,那么就分别从函数和指针两个方面来介绍一下好了。为了不至于看到这里就有30%的人关掉界面,首先先从简单的开始,在c语言里面声明一个函数是很简答的一件事,你只要遵循[返回类型][函数名称][参数列表]这三大部分进行声明,你就可以声明出一个函数,比如int f()。这个函数返回类型是int ,函数名称是f,参数列表为空。为了文章能扯的下去,我们先定义一个返回值为int*的函数开始,也就是int *f()。
在这个之前,先扯点看起来稍微远一点的知识好了。在c/c++中,!运算符是一个单目运算符,就是说其所需的变量为一个,这个运算符的含义是“逻辑非”,也就是true变成false,false变成true。比如:!b,就表示对b这个变量取反,是不是感觉很弱智了?那么好,你需要理解的是在函数调用的"()"也是一个运算符,不仅仅是在四则运算中采用(),用点装逼的语句就是,这个括号要广义的理解。也就是当你调用一个函数的时候,你可以理解为这也是在做一种运算,这种运算的调用方法就是中使用()符号。再通俗一点,如果我在某一个函数中使用f()调用一个函数,这样也就是我采用这样一个运算符来进行一种计算,这种“计算”是调用函数,虽然()并不是一种单目运算符,但是为了这个问题更加简单,采用这样一种形式,目的是想强调()也是一种运算符,尽管长的很不像。
将()看作运算符的一个重要意义就是,运算符至少都是有优先级和结合性的,而更加不利于理解的一个伏笔就是()的优先级除了在[]运算符之下之外,是优先级最高的一个运算符,更加通俗的一个解释就是,如果有()运算符那么就要先计算这个运算符,这也就是为什么函数指针的声明形式看起来那么别扭的原因。比如下面这个例子int *f(),将()看作一个“函数调用”运算,那么就是先进行“函数调用”运算,然后在进行“取地址运算”,通俗的说法是,这是一个函数,返回一个int类型的指针。那么如果我采用括号改变这两个运算符的优先级,变成int (*f)(),那么这个过程就变成了,先进行“取地址运算”,再进行“函数调用”运算,也就是取出这个地址上的变量,再进行函数调用,通俗的说法就是,这是一个地址,地址上静静的矗立着一个函数。
上面的是不是有点绕?那让我们先暂时忘记上面的内容,仔细来看一下int (*f)(),这是不是一个函数?根据函数的定义,这很明显不是一个函数,因为你不能把函数的名称设置为(*f),因此,你也不能这样写int (*f)(){},因为这不是函数,没有函数体,你所做的只是声明一个指针。这里的()符号都是运算符,在这一个式子里面具有不同的意义。你可以这样来看待这样一个奇怪的结构,由于括号可以改变运算符的优先级,所以首先这是一个指针,就像(5+3)*7,首先是计算加法一样。然后将int ()看做一个部分,看做是一个"函数调用"运算,这个指针指向的是这样的一个函数调用,它具有的特点是返回值是int,无参数列表,就像int *b,这是一个指针,指向int类型的数据。这里请停下来思考1分钟,然后再试试你能不能分辨出int *b[2]和int (*b)[2]的区别,如果不能,请再思考1分钟,如此往复知道思考明白为止。
2.函数指针的"指针"。如果能看到这里,你已经战胜了至少15%的人了。既然函数指针本质是一个指针,那么就从指针的角度再来看看这玩意儿。如何在C语言里面声明一个指针,我想是任何一个看过超过50页c语言的人都能回答的问题,比如说int *f。这个概念绝大多数人都能很容易的理解,所以我们将这个概念嫁接到函数指针这个概念上,相对于整型指针,你可以把函数指针理解为指向函数的指针,就像整型指针的用法是int b=0;int *f=&b;一样,递推出声明一个函数指针以后你也可以像这样做类似的操作。你可以做这样的尝试,定义一个函数fpointed,然后类似普通指针的用法那样*f=fpointed。
你会发现,如果你运气比较好的话,可以通过编译,但是我相信绝大数情况下,你会接到报错的消息,不过至少你领悟到了一个道理,和所有整数,浮点数等等一样,函数在程序中也是有一个地址的,虽然你不知道这是怎样一个形式,但是根据指针指向的是一个地址的基本原则,你至少应该记住这一个概念才不至于太惊讶。为什么函数指针不能随便指向一个函数呢?只是因为"函数"和"整数"这两个概念是不同的,虽然都带有"数",但是就像不是所有的鸟都能飞一样,不是所有的带有数的东西都是一类。单从外形上判断,你能说int f1(int a)和int f2()是一种东西吗?先不管其他的,前面的比后面的长一大截呢,人都需要用不同的形式进行记录,更不用说编译器,所以不存在一种“通用”的函数指针能指向所有函数,这就涉及到函数指针和函数之间的关系问题。
观察一下f1和f2,对于一个函数什么最不重要?应该是名字,f1同样可以叫f2,你要是喜欢叫他f22222222都可以,决定它不同于其他某类函数的是它的返回值和它所包含的参数列表,就像人一样,你叫什么名字并不重要,重要的是你给别人表现出来的能力和你自己所本身包含的涵养,这是你区别于别人并且立于世上的基本。这种情况下,回到1里面说过的,对于一个函数指针,你可以分成两个部分来看待它,将int 和()看做指向的部分,(*f)看做指针的部分,如果你想声明指向一个“返回值为int并且带有一个int参数的函数”的指针,应该怎么做。这时候你应该大胆尝试,(*f)这个不能变,因为这已经是一个指针,只是没有明确指向什么,那么按照描述,你要写出指向的部分应该int (int a),根据函数的声明中形参的部分,你应该可以猜到这个a是可以省去的,将这两个部分拼起来,就可以得到这样一个东西int (*f)(int ),这就是指向一个“返回值为int并且带有一个int参数的函数”的指针,换句话说,你可以用指向f1的地址,也就是f=&f1,到这里,你可以认为自己已经会声明函数指针了。
好了,和上面一样,先暂停1分钟,思考一下如何声明出指向一个“返回值为int*并且带有两个int参数的函数”的指针。
既然声明好了,那么怎么使用这个东西呢?还是和上面一样,如果你定义了这样一个函数:
int fPointed(int x){ printf("pointed %d ",x); }
如果你想在main中调用该函数,你会使用fPointed(1)之类的语句去调用。那么如果声明了一个函数指针并指向它,就像下面这样,
int (*f)(int ); f=&fPointed;
怎样通过这个函数指针去调用这个函数呢?回想一下普通指针是如何使用的,比如int a=0;int *b=&a;如果你想通过b来取到a内存中所保存的数,你会采用*b这样的方式,同理,你想去的f里面所指向的函数,同理应该使用*f这样的方式,只是函数指针毕竟指向的是一个函数,你需要给编译器一个"函数调用"的运算符,并给与正确的"运算变量"(正确的参数类型及个数),所以完整的调用方式应该如下:
(*f)(2);
至此,你已经可以使用函数指针代替函数来进行调用活动了,此处,如果你是第一次看到这些东西并完成想明白上面的内容,应充满了成就感。
3.函数指针第一次应用。函数指针的应用实在是太广泛了,并且带来的方便性和巧妙性绝对是可以令人鼓掌的,和上面的方法一样,谁的第一次都不容易,所以先从简单的开始,比如,你想做一个可以进行有"加减乘除"四则运算的小程序,这个程序可以根据你输入的内容来选择不同的算法,不管你信不信,这是我研究生入学复试的第一题,当时觉得太弱智了,现在想想,就是这种题目你一样可以让别人看到你的与众不同,所以千万别小看任何一个问题。最一般的程序,也是90%的人写的程序一定是下面这样的:
float Plus (float a, float b) { return a+b; } float Minus (float a, float b) { return a-b; } float Multiply(float a, float b) { return a*b; } float Divide (float a, float b) { return a/b; } float Cal(float a, float b, char opCode) { float result; switch(opCode) { case '+' : result = Plus (a, b); break; case '-' : result = Minus (a, b); break; case '*' : result = Multiply (a, b); break; case '/' : result = Divide (a, b); break; } return result; }
然后在main里面,通过传入不同的Code来标示自己想进行的运算,比如Cal(1.0,2.0,‘+’);最后会得到3.0。这个思路就不用多介绍了吧?这还是只有四则运算,你只需写4个case语句就可以了,如果要有40个不同类型的运算怎么办?这样进行维护成本太高。而这问题使用函数指针可以很好的去掉switch从而解决这个问题。
首先根据上面的定义,并且观察这四则运算,发现返回值都是float,参数都是(float,float),所以你需要的是一个指向"返回值为float并且带有两个float参数的函数“指针,很容易写出来是float (*f)(float,float)。
接着,想想看如何替换掉这个switch语句呢?你可以顺着这条路思考,如果我能够直接传入一个函数,而不用进行判断再选择函数这样就不用switch了。如何将函数"传入"函数,这里面需要你再一次从脑海中想起函数指针是一个指针的概念,既然是一个指针,那么就可以作为一个形式参数放在一个函数的参数列表里,就像int f(int *b)一样,同样,我们可以讲函数指针作为一个函数的参数,只不过看起来更加别扭而已,不管怎么样,我们可以采用下面这样的一个函数去掉switch语句:
float Cal2(float a, float b, float (*f)(float, float)) { float result = f(a, b); return float; }
在调用的时候可以直接Cal2(1.0,2.0,&Plus),传入相应的函数地址计算结果,这样就不用维护一个庞大的switch结构,只需要在调用端传入相应的函数就可以了。当然这也有一个弊端,就是只能传入返回值为float并且带有两个float参数的函数。
4.函数指针应用one more time。这一个应用不仅仅是为了展示函数指针的用处,更是为了展示程序作为一个工具其实和数学是亲密的。很多人一看到用程序实现某某算法就头大,直接放弃的概率绝对大于50%,虽然这个例子很简单,但是我很想传达一个思想,就是计算机的本质是运算,运算绝对离不开算法,所以某种角度上说算法是程序的核心之一,也是学写程序的一个本质目标之一。
我在学高等数学的时候曾想过如何用程序写积分运算?无奈那时候水平有限,想破头也想不出来,因为积分运算参与运算的不仅有数,还有函数,那时候传数容易,怎么传函数真是蛋疼了。不过现在根据上面的思路,你可以很容易的想出解决办法,就是传入一个函数指针,如果你对积分已经忘了,你可以百度一下相关知识,我也只记得一重积分的运算方法了,所以我也只写了一个计算一重积分的例子。
稍微回顾一下一重积分的运算方法,可以想起来的是,一重积分有一个上限,一个下限,然后有一个积分变量(已经忘了是不是学名叫这个了),其几何意义就是这个积分变量的曲线,在上限和下限在曲线上的取值向x轴做垂线,然后这两条垂线,曲线以及x轴围成的面积就是这个积分的值。由于曲线不能像计算长方形面积那样长乘以宽,所以要采用特殊的方法,这个特殊并且精彩绝伦的方法就是将这个区域分成很多宽度很小的近似长方形,分别计算这些长方形的面积,然后将他们加起来,这个长方形的宽度接近无限小的时候,这个面积就是积分的值。唉,以上是我的全部记忆,如果有错误,请大声的指出来。这个思路不难,就是取两个点的函数的值,用一个很小的变量作为宽度,为了更加精确,我采用的计算梯形的面积,然后将这些面积加起来。你可以将宽度取不同的值,你会发现取的越小,最后结果越精确,但也不能太小,毕竟计算机里的数都是有精度的。我相信,如果画个图,你就会顿时明白了,这篇太长了,我下篇再稍微仔细介绍一下这段程序的思路好了,先把代码贴出来:
float Calculus(float (*f)(float),int start,int end) { float range=end-start; float delta=0.01; float loopIndex=0.0; float sum=0.0; while(range-loopIndex>0.000000001) { sum+=(f(loopIndex-delta)+f(loopIndex))*delta/2; loopIndex+=delta; } return sum; } float X2(float x) { return pow(x,2); } float X3(float x) { return pow(x,3); } int main(int argc, char *argv[]) { printf("%f ",Calculus(&X3,0,3));//计算x^3在0-3之间的积分值 return 0; }
下面super_boy的这句话我觉得对理解这个概念会有帮助,所以我附在后面了~
“函数在调用的时候,都会去维护一个栈的平衡,也就是在调用之初,进行压栈,调用结束的时候进行出栈。而由于函数的参数的不同,就导致压栈和出栈的次数不同。如果在申明的时候不把参数个数,和类型传给函数指针的话,就没法保证在运行的时候栈的平衡。程序也就崩溃了。”