ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C语言函数指针入门:练习49奇偶序列求和与回调思想

C语言函数指针入门:练习49奇偶序列求和与回调思想 1. 题目还原练习49到底要求我们做什么1.1 题目原文与我的转译菜鸟教程C经典100例里练习49的题目大致是这么写的编写一个函数输入n为偶数时调用函数求1/21/4...1/n当输入n为奇数时调用函数求1/11/3...1/n利用指针函数。我第一次看到这个题目的时候注意力全被最后那句利用指针函数带跑了心里想的是什么叫指针函数跟指针有什么关系后来才搞明白题目作者真正想让你练的是函数指针也就是指向函数的指针变量。至于指针函数这个叫法很多教材里其实是混着用的严格说来它指的是返回值为指针的函数两者并不相等。这个差异我先放一放下一节再仔细掰扯。把题目翻译成人话其实就两件事。第一从键盘输入一个正整数n判断它是偶数还是奇数。第二如果n是偶数计算 1/2 1/4 1/6 ... 1/n 的值如果n是奇数计算 1/1 1/3 1/5 ... 1/n 的值。两段求和逻辑要分别封装成函数而且外层代码要通过一个函数指针来选择到底调用哪一个而不是直接用 if-else 写死。1.2 这道题到底在考哪些基本功如果你把它当成一道普通的分段求值题那确实很简单无非是条件判断 循环累加。但放在整个100例的序列里看它的位置非常微妙。前面的练习大量集中在循环、分支、递归这些基础控制结构后面的练习则开始疯狂使用指针、数组、结构体、链表。练习49正好卡在这个过渡段上它表面是在考求和实际上是通过一道足够简单的计算题逼着你接触函数指针这个很多人学了半本教材都没真正搞懂的概念。拆开来看考点至少包含四个层次奇偶判断n % 2 0是偶数否则是奇数。这个不难但它决定了后续调用哪个函数。循环边界设计偶数序列要从2开始每次加2直到不超过n奇数序列要从1开始也是每次加2。边界和步长一旦写错结果就完全不对。浮点数计算1/i和1.0/i是两个世界。整数除法直接截断小数部分写错的话不管n多大结果都惨不忍睹。函数与函数指针把求和逻辑封装成独立函数再通过指向函数的指针来间接调用。这是本题真正的魂。所以说练习49是一个一题四用的典型题目。你能把它做透就等于同时复习了条件分支、循环步长、浮点除法和函数指针四个知识点。以后学到回调函数、链表遍历、信号处理的时候你会感谢当年在这道题上多花的时间。提示如果你现在还搞不清楚指针函数和函数指针别急先往后看。等你写完代码回头再读题目很多疑问会自然消失。2. 函数指针的核心语法这一题绕不开的坎2.1 函数名本质上是一个地址想要理解函数指针得先接受一个事实C语言里函数名就像数组名一样代表着一个地址。数组名是数组首元素的地址而函数名是这段函数代码在内存中的入口地址。你可以把它理解成一个门牌号CPU执行到调用语句时会跳到这个门牌号指向的位置去执行函数体。既然函数名是一个地址那自然可以用指针变量把它存起来。这个指针变量就是函数指针。它的声明方式看起来有点别扭double (*func)(int);读法是func是一个指针这个指针指向一个参数为int、返回值为double的函数。注意(*func)两边的括号绝对不能丢。如果写成double *func(int);含义就完全变了它变成了一个函数声明func是一个接收int参数、返回double *类型的函数。一个是变量一个是函数差的只是一个括号。这个括号问题在初学阶段几乎人人踩坑我后面专门讲。2.2 为什么题目点名要用函数指针你可能觉得题目完全可以用最土的方式实现if (n % 2 0) { result sum_even(n); } else { result sum_odd(n); }这个写法没有错代码也能跑考试也能得分。那为什么题目还要强调利用指针函数因为它出现在指针练习这个区间里目的不是让你完成求和而是让你练习一种新的抽象方式把行为本身当作一个值来传递和选择。函数指针的价值在于解耦。当你有了函数指针程序可以在运行到某个时刻才决定到底调用哪个函数你可以把函数指针作为参数传给另一个函数让那个函数在合适的时机回调它你还可以用函数指针数组实现一张函数表根据输入索引直接跳转。标准的qsort排序函数最后一个参数就是一个函数指针用来比较任意两个元素的大小。Linux里的信号处理、事件回调也全是这个套路。练习49是这些复杂应用的迷你版你有两个函数根据n的奇偶性在运行时选择其中一个。用函数指针来做恰好让你提前感受选择逻辑和执行逻辑分离的滋味。2.3 用typedef把函数指针包装成类型每次都要写double (*func)(int)实在太啰嗦而且容易看花眼。实际项目里更推荐用typedef给函数指针类型起一个别名typedef double (*SumFunc)(int);这行的意思是把指向一个参数为int、返回值为double的函数的指针这个类型起名为SumFunc。之后声明变量就和声明普通变量一样SumFunc func;赋值时函数名直接赋给指针不带括号也不带参数func sum_even; // 等价于 func sum_even;调用时两种写法都合法double result func(n); double result (*func)(n);我个人的建议是初学阶段使用(*func)(n)这种显式解引用写法读起来更清楚不容易跟普通函数调用混淆。等写习惯了再改成func(n)也不迟。2.4 函数指针和普通指针的类比普通指针存的是变量的地址比如int *p a;之后*p就等价于a。函数指针本质上没差它存的是一段代码的地址之后(*func)(n)就等价于直接调用函数sum_even(n)。差别仅仅在于变量地址指向数据函数地址指向代码。数据可以被读写代码可以被调用。理解到这一层函数指针就不再神秘了它只是把原来那套指向数据的指针语法扩展到指向代码上而已。3. 完整实现可直接抄走的代码3.1 一份兼顾可读性和健壮性的答案下面这份代码我调试过gcc和MSVC都能直接编译运行。它用了typedef简化函数指针类型加了输入合法性检查求和统一使用double避免精度损失。#include stdio.h typedef double (*SumFunc)(int); static double sum_even(int n) { double sum 0.0; for (int i 2; i n; i 2) { sum 1.0 / i; } return sum; } static double sum_odd(int n) { double sum 0.0; for (int i 1; i n; i 2) { sum 1.0 / i; } return sum; } int main(void) { int n; SumFunc func NULL; printf(请输入一个正整数 n); if (scanf(%d, n) ! 1 || n 0) { printf(输入无效必须是一个正整数。\n); return 1; } if (n % 2 0) { func sum_even; } else { func sum_odd; } printf(sum %.10f\n, func(n)); return 0; }3.2 逐段解释这段代码在干什么typedef double (*SumFunc)(int);是第一处关键。它定义一个函数指针类型之后SumFunc func NULL;就能声明一个可指向接收int返回double函数的指针变量并且初始化为空指针避免使用未初始化指针的未定义行为。static double sum_even(int n)里的static表示这个函数只在当前源文件可见。练习项目里加上它可以避免多个文件编译时出现同名函数冲突。函数体用for (int i 2; i n; i 2)控制分母从2开始每次加2直到超过n为止。sum 1.0 / i;这一句是灵魂1.0是double类型除以int会先做类型转换得到浮点数结果。如果写成1 / i两个整数相除结果一律截断为整数整个求和就会错得离谱。sum_odd和sum_even几乎一样只是初始值变成i 1这样就能覆盖1、3、5、7这些奇数分母。主函数里scanf的返回值一定要检查。scanf(%d, n)返回成功读入的参数个数如果用户输入了字母或者符号返回值是0甚至EOF此时n是一个不确定的旧值。我只写了! 1的判断顺手把这种情况拦截掉。同时n 0也一并拦截因为题目说的是正整数负数没有分母倒数和的实际意义。奇偶判断完成后把对应的函数地址赋给func然后func(n)间接调用。打印时用%.10f把double的精度充分利用起来避免默认6位小数看着不够直观。4. 编译运行与边界用例验证4.1 用gcc编译并观察警告写C语言一定要开全警告编译这是养成好习惯的第一步。我的编译命令是gcc -Wall -Wextra -pedantic -o ex49 ex49.c-Wall和-Wextra开启大部分常见警告-pedantic用来检查标准兼容性。理想状态下这段代码编译后一条警告都不应该有。如果你在编译器里看到assignment to SumFunc from incompatible pointer type之类的提示说明函数指针类型不匹配十有八九是函数签名写错了。4.2 跑几个典型输入验证逻辑我实际运行了几组数据结果如下表输入n实际计算结果输出sum11/11.000000000021/20.500000000031/1 1/31.333333333341/2 1/40.750000000051/1 1/3 1/51.5333333333101/2 1/4 1/6 1/8 1/101.1416666667这组数据基本覆盖了n1的最小奇数和、n2的最小偶数和、连续奇偶切换、以及稍大一点的n。肉眼就能看出结果符合预期。4.3 float和double的精度差异实测这道题如果n比较小用float和double看起来没区别。但你把n改成100000差距就出来了。我把sum 1.0 / i里的1.0改成1.0f再把sum声明成float重新编译运行n100000的情况float版本约等于 6.5572017946但后面几位明显抖动double版本约等于 6.5662504653为什么差异这么大因为float有效精度大约7位十进制累加十万次后舍入误差会不断累积。sum的数值从1慢慢累积到6点几每次加的项越来越小当某项小于当前sum的精度刻度时加上去等于没加。而double有效精度15位以上十万次累加还在安全范围内。所以做这种带浮点累加的题除非题目明确要求float否则一律用double。这个习惯能帮你避开大量为什么我的结果差一点的困惑。提示你也可以把1.0 / i改成1.0 / (double)i强转让意图更明确。不过C语言的算术转换规则会隐式把int提升为double写不写效果一样看个人风格。5. 踩坑实录写这题时最常见的错误5.1 整数除法问题结果永远是0这大概是练习49里出现频率最高的错误。sum 1 / i; // 错误示例当i是2、3、4这些大于1的整数时1 / i是两个整数相除C语言会执行整数除法结果直接截断为0。于是整个累加过程里除了1/1能得到1其余项全是0。如果你输入的是偶数n结果很可能就是0.0输入3的话结果就是1.0肉眼可见地不对。修正方法就一个让除法至少一个操作数是浮点数sum 1.0 / i;我见过有人纠结要不要写1.0 / (double)i其实不用的C语言的隐式转换会把你照顾得很好。关键是1.0这个点不能丢。5.2 循环步长写错把奇偶序列搅在一起第二个高频错误是步长问题。求偶数序列正确的循环是for (int i 2; i n; i 2)但有人顺手写成了i结果n4时实际算的是 1/2 1/3 1/4而题目要的是 1/2 1/4。奇数序列同理如果步长写错1/1 1/2 1/3 全混进去了逻辑就崩了。我的建议是先把题目要求的序列在纸上写出来看看首项是谁、末项是谁、每次跳几格。确定了这三个要素再写循环就不容易错。这道题的偶数序列首项2末项n步长2奇数序列首项1末项n步长2。就这么简单。5.3 函数指针声明丢了括号编译器直接看不懂double *func(int); // 这是函数声明不是函数指针 double (*func)(int); // 这才是函数指针这个坑特别隐蔽因为编译器不一定报错。如果你在main里声明了double *func(int);编译器会认为你声明了一个叫func的函数然后在后面写func sum_even;时它可能报函数名不是左值或类型不兼容之类的错误。初学者看着报错信息往往一头雾水。记忆方法优先级上()比*高double *func(int)里func先跟(int)结合所以它是一个函数。加上括号(*func)后func先跟*结合才是指针。练习时多写几遍直到手比脑子快。5.4 函数指针赋值时手滑带了参数func sum_even(10); // 错误这是把返回值赋值给指针 func sum_even; // 正确这是把函数地址赋值给指针sum_even(10)是一个函数调用表达式求值结果是double数据而func需要的是一个函数指针类型对不上编译直接报错。如果你写的是func sum_even;函数名会隐式转换成函数指针没问题。想更明确一点也可以写func sum_even;取地址符号可加可不加。调用的时候同理func(n)和(*func)(n)都行。一旦你理解了函数名是地址这件事这些写法就不会再迷惑你。5.5 scanf输入漏洞用户乱输就崩忘记检查scanf返回值是很多新手做完这道题后测试正常输入没问题但一输入字母就出事的原因。scanf失败时n保持原值或不确定值程序可能用这个脏值去计算结果随机甚至进入死循环某些场景下输入缓冲区里残留的字符会反复导致读取失败。练习49的题目只要求了正确输入但作为工程习惯我建议每个用到scanf的练习都加上返回值判断。代码里那一行if (scanf(%d, n) ! 1 || n 0)如果你输入abc左边条件成立如果你输入0或-5右边条件成立。两种情况都会被拦下来程序不会继续拿错误数据算结果。6. 变体与关联做一题胜做十题6.1 不用函数指针也能做但做了等于白做如果你只是想把题目跑出来那直接用if-else调两个函数就够了。但你静下来想一想这个需求里选择哪个函数的逻辑和怎么求和的逻辑是分开的。函数指针的价值就是让调用方不关心具体实现只关心我有一组满足某种接口的函数可以根据条件选一个。这个思想在工程上叫策略模式在C语言里最朴素的实现就是函数指针。练习49用函数指针的唯一难点在于你要在头脑中把调用哪个函数和执行求和拆开。一旦拆开你会发现以后看到各种回调函数的代码思路都会清晰很多。6.2 把两个函数合并成一个通用求和函数如果你想把代码写得更精简可以把奇偶两个函数合并成一个带起始值和步长的函数static double sum_step(int start, int step, int n) { double sum 0.0; for (int i start; i n; i step) { sum 1.0 / i; } return sum; }然后在main里根据奇偶传入不同的起始值if (n % 2 0) { func(n); // 或者是直接调用 sum_step(2, 2, n) }不过这样改的话函数指针的练习意义就淡了。练习49的考点是函数指针不是代码精简所以我更推荐保留两个独立函数老老实实用函数指针把它们串联起来。6.3 扩展到三个处理函数函数指针数组如果你已经掌握了函数指针可以再进一步把多个处理函数放进数组用索引选择。比如有func_a、func_b、func_c三个函数希望根据n % 3的结果决定调用哪一个typedef double (*SumFunc)(int); SumFunc funcs[3] { func_a, func_b, func_c }; double result funcs[n % 3](n);这种写法在处理命令分发、菜单选择、协议解析时极其常见。你手边如果有输入数字1到4执行不同操作的练习题完全可以改成函数指针数组版本让代码少一堆if-else。6.4 和练习4的日期问题遥相呼应菜鸟教程100例里有一道输入某年某月某日判断这一天是这一年的第几天它和练习49在思路上其实是近亲都要先判断一个条件闰年、奇偶再根据不同情况执行不同的累加逻辑。日期题里是判断闰年然后决定二月天数这里的练习49是判断奇偶然后决定从哪个分母开始加。所以如果你以后做到日期类的题会发现这段经历派得上用场。我个人做完这道题后的感受是函数指针这个东西靠看永远看不会必须亲手写一遍写错一次再改对才算真正住进脑子里。练习49的题目本身很简单但它是通往回调、函数表、策略分发这些进阶概念的一座小桥。把这座桥走扎实了后面遇到更抽象的东西就不会慌了。
返回列表