很多人学 C 的时候函数与预处理这一章是最容易“飘过去”的感觉函数嘛就是定义、调用、返回值预处理不就是 #define 和 #include有啥难的可真到了做第四章习题的时候基本全军覆没。我自己当年也是自认为听懂了拿起笔一写全是问题参数传引用还是传值搞不清楚、递归边界写错、带参宏展开结果和预期完全不一样。这本经典教材的第四章习题我一直建议认真做一遍但说实话教材配套的参考答案有不少地方只是“能跑”谈不上好甚至有些写法放到现在会被レビュー直接打回。所以我这次把第四章函数与预处理部分习题的答案重新梳理了一遍不是简单抄一遍而是把原答案里的问题找出来给出改进后的写法和思路。这篇文章适合正在啃这本书、准备考试或者想老老实实把 C 基础打扎实的人。1. 第四章到底讲什么函数与预处理的完整知识骨架1.1 函数部分的核心命题点这一章的题目基本都围绕函数的定义与调用、参数传递值传递、地址传递、引用传递、函数重载、内联函数、默认参数、递归以及变量存储类别这几个方向出。看起来每个知识点都很独立实际上它们经常组合在一道题里出现尤其是后面几道综合题往往既考递归又考全局变量和局部变量的作用域还顺手考一下你写函数声明的时候有没有偷懒。我建议复习时抓住一条主线一个 C 程序由若干函数组成而每个函数解决一个明确的小任务。判断一个函数写得好不好不只是看它能否算出正确结果还要看它是否容易被其他地方调用、是否依赖全局变量、是否对非法输入有防御。很多教材配套答案的问题恰恰出在这里函数内部直接用 cout 输出结果返回值却写成 void这样一来这个函数根本无法被复用只能当作一个“一次性片段”。1.2 预处理部分的核心命题点预处理部分主要集中在宏定义、文件包含和条件编译。宏定义里面最经典的是带参宏题目一定会让你写一个类似“求两个数的最大值”“求圆的面积”的宏然后问你某个表达式的展开结果是什么。这类题表面上考宏实际上考的是括号意识。至于条件编译和文件包含更多是概念题和判断题比如头文件为什么要防止重复包含、#ifndef 和 #pragma once 的区别是什么。很多初学者容易把宏函数和真正的函数混为一谈。宏在预处理阶段就完成文本替换不进行类型检查也不存在运行时的函数调用开销但它的毛病也在这里——没有类型、没有作用域、参数被多次求值时会产生副作用。这一章习题里凡是涉及宏的基本都在挖这个坑。1.3 课后习题的分布与复习优先级我按题型把这一章常见习题大致分了一下题型常见题目特征复习优先级基础函数设计与调用求最大公约数、最小公倍数、素数判断、水仙花数高递归题汉诺塔、猴子吃桃、年龄递归、字符串逆序高传参与数组题两数交换、数组元素排序、矩阵转置高重载与默认参数同一函数名处理 int/double、不同参数个数中宏定义题求最大值、求平方、面积公式高条件编译与头文件判断输出结果、防止重复包含中我的建议是先踏实做基础函数题这部分是后面所有题型的“地基”递归题必须自己画调用栈靠背代码肯定翻车宏定义题不需要死记答案核心是学会“手动展开”宏。2. 函数基础题从“能跑”到“写得专业”2.1 经典题回归最大公约数与最小公倍数最大公约数这道题教材配套答案大概率是辗转相除法用递归或者 while 循环写。原答案的问题不在算法本身而在函数设计。常见写法是这样的int gcd(int a, int b) { int r; while (b ! 0) { r a % b; a b; b r; } return a; }这个函数本身没有大问题但如果你把 a 和 b 传入负数呢C 里 -6 和 4 的 % 运算会得到一个负数导致 final 结果可能出现负数。更严谨的写法是先在函数入口把参数取绝对值保证任何输入都得到非负公约数。我自己的改进版是int gcd(int a, int b) { a std::abs(a); b std::abs(b); while (b ! 0) { int r a % b; a b; b r; } return a; }最小公倍数通常会用到公式lcm(a, b) a / gcd(a, b) * b。注意这里要先除再乘如果先乘 a * b 再除两个 int 相乘可能溢出尤其在一些竞赛题的数据范围里很危险。原答案里如果直接写 a * b / gcd(a, b)实际是埋了个雷。2.2 素数判断与因数类题目判断素数是这一章的“标配题”原答案通常写int isPrime(int n) { for (int i 2; i n; i) { if (n % i 0) return 0; } return 1; }这段代码的问题有两点一是循环到 n 效率太低实际上只需要循环到 sqrt(n)二是函数返回 int调用者还得自己判断 0 和 1 分别代表什么。改进以后是这样的bool isPrime(int n) { if (n 2) return false; if (n 2) return true; if (n % 2 0) return false; for (int i 3; i * i n; i 2) { if (n % i 0) return false; } return true; }这里我特意加了 n 2 的判断是因为 0、1、负数都不是素数。用 bool 而不是 int让函数意图更清晰。循环用 i * i n 等价于 i sqrt(n)避免了每次循环都调 sqrt 的开销也避开了浮点数精度问题。2.3 为什么我坚持给函数加 const 和注释我做这一章习题时有一个习惯每个函数都问自己三个问题。第一这个函数要不要修改参数如果不需要就把参数声明为 const 引用或按值传递第二函数返回值是否表达清楚能用 bool 就不用 int 表示真假第三函数边界情况处理了吗比如空数组、负数、零、极大值。这些习惯看起来啰嗦但能显著降低你后续调试成本。有一个很有意思的现象原答案的某些函数喜欢在函数内部直接打印结果而不是把结果返回给调用者。比如判断完素数直接在函数里 printf。这样做在“交作业”的场景下很方便但函数失去了复用价值。我改进时统一改成“计算和输出分离”函数只负责算main 函数负责输出。这样同一套逻辑既能在控制台程序里用将来也能迁移到 GUI 或其他程序里不用改函数体。3. 值传递陷阱与递归很多翻车都在这里3.1 swap 为什么不能传值第四章必有一道“交换两个数”的题目。原始答案可能是用指针写的也可能是用引用写的。很多人在这一题上第一次感受到 C 和 C 的区别。先看最经典的错误版本void swap(int a, int b) { int temp a; a b; b temp; }调用完 swap(x, y) 后x 和 y 完全没变。原因是 C 默认按值传递函数内部交换的是形参的拷贝。这个时候有两种修正思路。第一种是传地址void swap(int* a, int* b) { int temp *a; *a *b; *b temp; }调用时写成 swap(x, y)。第二种是传引用void swap(int a, int b) { int temp a; a b; b temp; }调用时直接 swap(x, y)。这是我的推荐写法原因很简单引用让调用代码更自然不需要取地址也不容易漏写星号。实际上现代 C 的标准库已经提供了 std::swap做习题当然要自己实现一遍加深理解但实际工程里请直接使用标准库函数。3.2 递归题的通用写法从数学归纳到递归边界递归题是第四章的“分水岭”。教材里有经典的猴子吃桃、汉诺塔、年龄推测这类题。很多人背了标准答案过两天又忘了因为没理解递归的“数学归纳”本质。拿汉诺塔来举例原答案通常是void hanoi(int n, char A, char B, char C) { if (n 1) { cout A - C endl; } else { hanoi(n - 1, A, C, B); cout A - C endl; hanoi(n - 1, B, A, C); } }这个写法本身没问题但初学者经常抄错字母顺序因为没有理解三个参数的含义递归函数 hanoi(n, A, B, C) 表示把 n 个盘子从 A 借助 B 移到 C。当 n 大于 1 时先借助 C 把上面 n-1 个盘子从 A 移到 B再把最下面的盘子从 A 移到 C最后借助 A 把 n-1 个盘子从 B 移到 C。每一步都能用同一个函数描述这就是递归。我改进这类答案时主要做了两件事一是把参数命名成 from、helper、to让三个角色一目了然二是增加 n 0 的非法输入保护。比如void hanoi(int n, char from, char helper, char to) { if (n 0) return; if (n 1) { cout from - to endl; return; } hanoi(n - 1, from, to, helper); cout from - to endl; hanoi(n - 1, helper, from, to); }参数命名这种修改看似“无所谓”但实际调试时一个叫 from 的参数比一个叫 A 的参数省太多脑细胞。3.3 数组传参的退化问题第四章有几道题涉及数组操作比如数组逆序输出、数组排序。原答案经常出现把数组声明为全局变量、函数直接操作全局数组的写法。比如int arr[100]; void reverseArr() { // 直接操作 arr }这种写法对交作业来说很省事但工程上是大忌全局变量让函数之间产生隐式耦合而且数组长度写死成 100换个数据范围就不能用了。我会改成这样void reverseArr(int arr[], int n) { for (int i 0; i n / 2; i) { std::swap(arr[i], arr[n - 1 - i]); } }注意数组在传给函数的时候会自动退化成指针所以 int arr[] 等价于 int* arr。如果你只想读数组内容应该加上 constconst int arr[]。数组退化成指针还带来一个问题在函数里用 sizeof(arr) 得不到数组总字节数只能得到指针大小。这也是为什么我建议把数组长度 n 显式传进来不要依赖 sizeof 这种“看起来能行”的写法。4. 重载、内联、默认参数容易“二义性”的组合4.1 重载匹配规则与一个经典错误函数重载的规则很简单函数名相同参数列表不同类型、个数、顺序至少一个不同。但很多习题会让初学者误以为返回值不同也能重载。这是不行的因为单独调用一个函数时编译器无法仅凭返回值类型决定调用哪一个版本。另外一个经典考点是参数类型转换导致的二义性。比如同时定义了int max(int a, int b); double max(double a, double b);然后调用 max(1, 2) 会自然匹配 int 版本。但如果你定义的是int max(int a, int b); long max(long a, long b);调用 max(1, 2.5)编译器就不知所措了int 可以转成 longdouble 也可以转成 int两者都不是精确匹配属于同一优先级于是报二义性错误。做这类题的时候不要凭感觉猜结果先看参数类型再看有没有“可转换”的路径两条路径优先级冲突就会编译失败。4.2 内联函数适不适合所有小函数教材在讲内联函数时会说“适用于函数体小、调用频繁的函数”但原答案里经常把递归函数也加上 inline这是错误示范。编译器对递归函数通常不会真正内联因为内联本质上是把函数体复制到调用处而递归函数无法在编译期确定复制次数。另外函数体很大或者包含循环的内联也会被编译器忽略。我自己的经验是不要手动大面积加 inline。内联关键词对编译器只是一个建议现代编译器在优化阶段的判断往往比人更准确。习题里如果要求写内联函数找那种函数体只有一两行、没有循环没有递归的函数就够了比如求绝对值、判断奇偶这类。4.3 默认参数与函数重载同时使用造成冲突默认参数单独用很容易理解给参数一个默认值调用时可不传。但这一章很多习题把默认参数和重载放在一起考稍不留神就写出二义性代码。比如void show(int a) { } void show(int a, int b 0) { }当调用 show(1) 时第一个版本完全匹配第二个版本通过默认参数也能匹配编译器不知道该选哪个直接报错。所以默认参数和重载的组合必须非常克制如果两个函数在省略某些参数之后长得一模一样那设计上就有问题。我的一般原则是同一组重载函数要避免让“参数更少的版本”和“参数更多的版本带默认值”同时存在尽量让默认参数只在单一函数版本里出现。5. 预处理指令第四章习题里的隐形扣分点5.1 带参宏的括号问题带参宏几乎是每一届学生都要错一遍的题。教材配套答案最常见的例子是#define S(r) PI * r * r如果你调用 S(1 2)宏展开后变成 PI * 1 2 * 1 2先乘除后加减结果完全不对。正确写法#define S(r) PI * (r) * (r)但即使加了括号也要注意另一类问题参数被多次求值。比如#define MAX(a, b) ((a) (b) ? (a) : (b))调用 MAX(x, y) 时x 可能被执行两次行为不可预测。这正是我在前面强烈建议用内联函数或普通函数替代带参宏的原因。原答案里如果只是让你“写出输出结果”你必须要手动展开宏不要试图心算最终结果老老实实把文本展开完再算。5.2 头文件卫士与条件编译第四章习题里有一类是比较常见的判断下面代码会不会重复包含或者说一个头文件被两个源文件包含会怎样。教材配套答案一般会给出 #ifndef 的写法#ifndef MY_HEADER_H #define MY_HEADER_H // 头文件内容 #endif这种头文件卫士能防止同一个头文件被重复包含后产生重复定义错误。另一个思路是 #pragma once但教材出的题一般不会考 #pragma once因为它是非标准扩展虽然实际编译器基本都支持。做习题时我建议优先用 #ifndef 方案它的可移植性更好而且这一套机制本身就是“预处理指令”的最佳练习题。5.3 宏定义与 const/constexpr 的选择这一章的习题里如果出现定义常量比如圆周率原答案大概率是#define PI 3.14159在 C 里这种写法属于历史遗留风格。宏常量没有类型编译器不做类型检查而且不遵守作用域规则。更合适的写法是constexpr double PI 3.14159;constexpr 在编译期求值既有类型又能真正作为常量使用。现在很多公司老项目里还留着大量 #define 常量但新代码里普遍已经迁移到 const 和 constexpr 了。做习题时如果题目明确要求用宏那就按宏写如果只是让你定义一个常量我建议交作业时用 constexpr 并提供注释说明这是一种更现代的写法。6. 答案改进的具体示范两份代码对比6.1 原答案常见写法我把这一章里一道典型综合题的“原答案风格”概括一下用两个全局变量保存结果函数内部直接打印参数不检查变量命名是 a、b、c 满天飞。比如实现一个函数求三个整数中的最大值原答案可能长这样int max; void f(int a, int b, int c) { if (a b) max a; else max b; if (c max) max c; printf(max%d\n, max); }这段代码的致命问题是全局变量 max 可能和别的地方冲突函数没有返回值导致无法在其他地方复用打印逻辑和计算逻辑耦合在一起。6.2 改进版写法我会改成int getMax(int a, int b, int c) { int m a; if (b m) m b; if (c m) m c; return m; } int main() { int result getMax(3, 7, 5); cout max result endl; return 0; }改进思路很明确getMax 只负责计算main 负责输出函数是独立的、可测试的变量 m 的作用域控制在函数内部。如果将来需要比较四个数只需要再加一个参数或者用 std::max 嵌套。6.3 改进前后对照表维度原答案常见风格改进后写法全局变量用全局变量传递结果用返回值或引用参数传递输入输出函数内部直接打印计算与输出分离边界判断很少考虑负数、零、空数组在入口处处理非法输入参数传递基本靠值传递偶尔用指针优先引用、const 引用类型语义int 表示真假用 bool 表示真假可重用性写完只能在 main 里调用一次任何地方都能安全调用这一章的每一道题其实都可以按这个思路做一遍“重构”。不用刻意追求代码的复杂度重点是让每个函数从“能用”变成“可靠”。7. 做完这一章习题后我踩过哪些坑7.1 常见编译错误速查编译错误提示常见原因解决方向redefinition of ...头文件被重复包含加头文件卫士call of overloaded ... is ambiguous默认参数与重载冲突去掉多余重载版本expected primary-expression before ...宏展开缺少括号给宏参数和整体加括号invalid conversion from ... to ...传值/传引用类型不匹配检查函数声明与调用control reaches end of non-void function非 void 函数缺 return为所有分支补 return7.2 调试技巧我在做递归题的时候几乎必踩爆栈的坑。比如猴子吃桃如果你递归写错了边界哪怕 n 只大一点调用层数也会很恐怖。一个简单的排查思路是在递归函数开头加一行调试输出打印当前参数和调用深度。写完删除即可。另外宏定义的行为很难用断点调试所以遇到宏相关题目我的习惯是先手动展开写成普通表达式再验证。比如 #define SQUARE(x) (x) * (x)遇到 SQUARE(a b)先在草稿纸上把它展开成 (a b) * (a b)再考虑运算符优先级。7.3 关于习题答案的个人体会我个人觉得这本教材第四章的习题要至少做两遍。第一遍合上书按自己的理解写能跑就行第二遍对照参考答案再把每个函数按我上面说的标准改一遍。第二遍你会发现大部分时间其实不是花在算法上而是花在思考“这个函数应该接收什么参数、返回什么值、要不要加 const、要不要加边界保护”上面。原答案更偏向告诉你“结果是什么”而我希望这篇文章能帮你看到“为什么这样设计更好”。函数与预处理往小说是课后习题往大说其实是代码组织能力和工程习惯的起点。把这一章吃透后面学到类、模板、STL 时你会明显轻松很多。