
我刷同济大学CMOOC的时候第五、六讲是第一个真正让我停下来反复琢磨的地方。前面几讲学数据类型、学循环代码行数少逻辑也直白到了第五讲“函数”和第六讲“指针与引用”画风突然就变了——你要从“写代码”切换到“设计代码”从“用变量”切换到“搞懂变量在内存里到底怎么待着”。说实话很多同学就是从这里开始掉队的。这篇内容不是复述课件而是把这两讲的核心考点、常见作业题的可运行参考实现、易错点以及我当年一遍遍踩坑总结出来的经验整理出来。适合正跟着同济这门课刷题的学生也适合刚入门C、想在函数和指针这两个坎上真正站稳的初学者。1. 这两讲在整门课里的“分水岭”位置1.1 为什么说第五讲是结构化编程的起点在第五讲之前你写的代码基本是“顺序执行分支循环”的流水账。一个问题来了你就把流程从头到尾铺开变量满天飞逻辑稍微复杂一点main函数就膨胀得没法看。这一讲引入函数本质上是在教你一件事把一个大问题拆成多个小问题每个小问题用一段独立代码解决最后组合起来。同济MOOC在这一讲反复强调的核心其实是“模块化”三个字。函数声明和定义分开、参数按值传递和按引用传递的结果差异、递归需要终止条件这些知识点表面上是在讲语法实际上是在训练你脑子里那根“函数调用栈”的弦。很多同学写递归写不明白不是不会写终止条件而是没理解每次递归调用都是一次新的函数栈帧局部变量互不干扰。这一讲的作业通常集中在判断质数、求最大公约数、递归实现数列或汉诺塔、用函数封装排序逻辑等。题目本身不难但如果你不理解参数传递方式极容易写出“看起来对但结果全错”的程序。1.2 第六讲指针从“用变量”到“理解内存”第六讲是整门课第一个真正劝退大量学生的关卡。指针这个概念听起来玄乎其实剥开来看就一句话变量是内存空间的别名而指针是一个变量它存的不是普通值而是另一个变量的地址。你用一个变量去存别的东西的地址然后通过这个地址间接访问那个东西这门手艺叫指针。同济这门课在这一讲里把指针和数组绑在一起讲是有道理的。数组名在很多场景下会“退化”成指向首元素的指针arr[i]本质上就是*(arr i)。很多初学者理解不了为什么int arr[10]作为函数参数时会变成int* arr就是因为没搞明白“数组名会退化为指针”这个底层逻辑。这一讲还会讲到引用。引用是C相对C语言增加的重要特性它本质上是指针的“语法糖”但规则比指针更严格必须初始化、不能重新指向别的变量。作业里经常出现的题目包括用指针遍历数组、动态内存分配建立二维数组、用函数指针实现简易计算器、以及对比引用和指针作为函数参数的区别。每一道题都指向同一个核心能力内存模型的理解。2. 第五讲“函数”从声明到递归的典型题目参考答案2.1 函数声明与定义最常见的编译错误来自这里很多同学第一次写函数时会遇到这样一个编译错误xxx was not declared in this scope。原因很简单——C编译器从上往下读代码调用函数时如果前面还没出现过这个函数的名字编译器就不知道怎么处理。解决办法有两个要么把函数定义写在调用之前要么在调用之前先写函数声明也叫函数原型。比如判断质数的标准写法我建议你按“声明定义主函数”的结构来组织代码#include iostream #include cmath using namespace std; // 函数声明告诉编译器我有这个函数参数是int返回值是bool bool isPrime(int n); int main() { for (int i 1; i 100; i) { if (isPrime(i)) { cout i ; } } cout endl; return 0; } // 函数定义具体实现 bool isPrime(int n) { if (n 2) return false; for (int i 2; i * i n; i) { if (n % i 0) return false; } return true; }判断质数的条件i * i n代替i sqrt(n)有两个好处一是避免调用数学库函数效率更高二是i * i是整型运算不会出现浮点精度问题。边界条件n 2必须放在最前面因为1不是质数0和负数更不是。这道题看着简单但很多人第一次写都会漏掉n 1的情况导致输出结果多一个1。这种“声明放前面、定义放后面”的写法在只有一个文件的作业里可能看不出优势但等你以后做多文件项目就知道了。头文件里放的都是声明.cpp文件里放定义编译才能通过。现在养成习惯后面省心很多。2.2 参数传递的三种方式值传递、指针、引用作业里有一个经典题目写一个函数交换两个变量的值。我第一次写的时候用值传递代码长这样#include iostream using namespace std; void swapValue(int a, int b) { int temp a; a b; b temp; } int main() { int x 3, y 5; swapValue(x, y); cout x x , y y endl; // 输出还是 x3, y5 return 0; }输出结果一点没变原因就在值传递上swapValue(x, y)把x和y的值拷贝了一份给参数a和b函数内部交换的是局部变量a和b和原来的x、y没有任何关系。函数调用结束后a和b随之销毁x和y依然故我。这就是我一直强调的“函数栈帧”概念参数传的是复制品不是本尊。要真正改变实参有两种方式。一种是用指针把地址传进去通过地址操作原变量void swapPointer(int* a, int* b) { int temp *a; *a *b; *b temp; } int main() { int x 3, y 5; swapPointer(x, y); cout x x , y y endl; // x5, y3 return 0; }另一种是用引用这是C推荐的方式void swapReference(int a, int b) { int temp a; a b; b temp; }引用和指针都能改实参区别在写法上指针要写*a解引用取地址要写x引用直接就是别名参数类型int a表示a是实参的另一个名字不需要额外符号。写起来更自然也更安全——引用不可能为空指针理论上可以传nullptr函数里如果不判空运行时就可能崩。2.3 递归题的通用拆法以汉诺塔为例递归是第五讲的压轴内容。很多人觉得递归难其实难的不是语法而是思维方式。我刚学的时候也死磕了很久后来总结出一个万能的拆法先写终止条件再写“当前这一步做什么”最后写“递归调用把剩下的问题交给函数自己”。这三步的顺序不能乱。以汉诺塔为例题目是有三根柱子A、B、CA柱上有n个盘子要求全部移到C柱一次只能移动一个盘子大盘子不能压在小盘子上。递归的思路是两个步骤先把上面n-1个盘子从A通过C移到B再把第n个盘子从A移到C最后把B上的n-1个盘子通过A移到C。#include iostream using namespace std; void hanoi(int n, char from, char via, char to) { // 终止条件只剩一个盘子直接从from移到to if (n 1) { cout Move disk 1 from from to to endl; return; } // 第一步把上面n-1个盘子从from经to移到via hanoi(n - 1, from, to, via); // 第二步把第n个盘子从from移到to cout Move disk n from from to to endl; // 第三步把n-1个盘子从via经from移到to hanoi(n - 1, via, from, to); } int main() { hanoi(3, A, B, C); return 0; }递归最怕“想一层套一层的细节”越套越晕。正确做法是信任函数本身——你设计好了“hanoi(n, from, via, to)能够把n个盘子从from移到to”这个契约那么在中间步骤里调用hanoi(n - 1, from, to, via)就把n-1个盘子这个子问题交给契约本身去完成。这就是我后来才想明白的递归不是“一步步追踪调用过程”而是“定义清楚子问题再组合子问题的解”。2.4 函数重载、默认参数与内联函数的选择第五讲里函数重载和默认参数是容易被忽略但作业里常考的点。重载的意思是函数名相同参数列表不同类型、个数、顺序至少有一个不同编译器根据实参自动匹配。比如你可以同时定义int abs(int x)和double abs(double x)int abs(int x) { return x 0 ? -x : x; } double abs(double x) { return x 0 ? -x : x; }调用abs(-5)会匹配int版本调用abs(-3.14)会匹配double版本。这里需要注意不能用返回值类型区分重载。两个函数只有返回值不同、参数列表完全相同编译器会直接报错因为它无法确定你到底想调哪一个。默认参数的意思是给参数一个预设值调用时可以省略。规则有一条必须记住默认参数必须从右往左连续设置。void printStars(int n 5, char ch *) { for (int i 0; i n; i) { cout ch; } cout endl; } int main() { printStars(); // 输出 ***** printStars(3); // 输出 *** printStars(3, #); // 输出 ### return 0; }printStars(3, #)合法因为两个参数都给了printStars(#)是非法调用因为char会尝试匹配第一个int参数把#的ASCII码当作n传给函数编译不一定会报错但结果完全不是你想要的。这种题同济作业里出过不少专门考你对默认参数位置的判断。内联函数用inline关键字声明意思是建议编译器把函数体直接嵌入调用处省去函数调用时的压栈和跳转开销。注意是“建议”超大的函数编译器通常会忽略这个请求。内联适合那种函数体只有几行的场景比如上面那个abs加上inline就挺合适那种几十行的复杂函数写成内联反而导致代码膨胀。3. 第六讲“指针与引用”把内存模型彻底想明白3.1 数组名是指针吗从底层逻辑理解数组与指针第六讲里我踩过的最大一个坑是把数组名和指针完全画等号。它们是两个不同的概念只是在很多场景下可以互换使用。数组名的本质是“一个表示整个数组首地址的常量”它不能自增自减不能整体指向别的内存。指针是变量可以重新赋值可以移动。看这段代码#include iostream using namespace std; int main() { int arr[] {10, 20, 30, 40}; int* p arr; // 数组名退化为指向首元素的指针 cout *p endl; // 10 cout *(p 1) endl; // 20 cout p[1] endl; // 20方括号运算符本质就是*(p 1) // arr 是编译错误数组名不是变量 p; // 合法p现在指向arr[1] cout *p endl; // 20 return 0; }p加1之后指向的不是“arr地址加1个字节”而是“加1个int所占的字节数”。这就是指针运算的底层规则p n实际上移动n * sizeof(指针指向的类型)个字节。很多人在这个地方犯糊涂是因为以为指针加1就是地址值加1。不是的指针运算的刻度是它所指向的类型的大小。这个规则记牢了指针遍历数组、指针遍历字符串就都不会写错。当数组作为函数参数时比如void printArr(int a[])编译器看到这个参数实际上把它当成了int* a。所以下面两种写法完全等价void printArr(int a[], int n) { for (int i 0; i n; i) { cout a[i] ; } } void printArr2(int* a, int n) { for (int i 0; i n; i) { cout *(a i) ; } }这也是为什么在函数里用sizeof(a) / sizeof(a[0])算不出数组长度的原因——a已经是指针了sizeof(a)得到的是指针的大小64位系统上通常8字节而不是整个数组的字节数。这个题在第六讲的作业里几乎必考我当年就栽过。3.2 动态二维数组new/delete的正确配对C语言的malloc和free到了C里有了更顺手的选择new和delete。唯二要注意的点是new和delete必须成对出现new[]和delete[]必须成对出现。交叉使用属于未定义行为程序可能看着正常但内存管理已经被你搞坏了。动态二维数组的创建思路是“先建行指针数组再给每一行分配连续内存”#include iostream using namespace std; int main() { int rows 3, cols 4; // 1. 分配行指针数组 int** matrix new int*[rows]; // 2. 给每一行分配列内存 for (int i 0; i rows; i) { matrix[i] new int[cols]; } // 3. 填充数据 for (int i 0; i rows; i) { for (int j 0; j cols; j) { matrix[i][j] i * cols j; } } // 4. 打印验证 for (int i 0; i rows; i) { for (int j 0; j cols; j) { cout matrix[i][j] \t; } cout endl; } // 5. 释放先释放每一行再释放行指针数组 for (int i 0; i rows; i) { delete[] matrix[i]; } delete[] matrix; return 0; }释放顺序是反着来的先回收内层的列内存再回收外层的行指针数组。如果把顺序写反先把matrix释放了那每一行的地址就全丢了程序会爆内存泄漏。我在作业里做过一次错序释放运行时没报错但用内存检测工具一查泄漏量非常可观。这类错误编译期不会提示运行期也不一定崩溃最容易“莫名其妙”。养成“new[]配delete[]、先内后外”的习惯能省很多排查时间。3.3 函数指针把函数当数据传递第六讲里有一个我觉得最“科幻”的知识点函数指针。函数的代码也是放在内存里的有起始地址所以也可以用指针指向它然后通过指针去调用。这种能力的价值在于你可以把函数本身作为参数传给另一个函数实现“回调”机制。经典作业题是用函数指针实现一个简易计算器#include iostream using namespace std; int add(int a, int b) { return a b; } int subtract(int a, int b) { return a - b; } int multiply(int a, int b) { return a * b; } int divide(int a, int b) { return b 0 ? 0 : a / b; } int main() { // 定义一个函数指针数组元素类型是指向返回值为int、参数为两个int的函数的指针 int (*ops[4])(int, int) {add, subtract, multiply, divide}; int a 12, b 3; for (int i 0; i 4; i) { cout Result of op[ i ]: ops[i](a, b) endl; } return 0; }int (*ops[4])(int, int)这个声明看起来很吓人拆开读就简单了ops先是一个括号里面是*ops[4]表示ops是一个长度为4的数组数组里存的是指针指针指向的类型是后面的int(int, int)也就是一个返回int、接收两个int参数的函数。函数名add、subtract在表达式中会隐式转换为函数指针所以直接赋值给数组元素没问题。通过函数指针调用函数既可以用(*ops[i])(a, b)这种传统写法也可以直接ops[i](a, b)。现代C推荐后面这种和普通函数调用写法一致可读性更好。函数指针非常重要后面学类、学多态、学STL里的排序算法自定义比较规则时到处都会用到。3.4 引用与const引用现代C更偏爱的传参方式引用是C给C语言打的“补丁”。它本质上是“另一个变量的别名”底层实现是指针但从语法和使用体验来看它就是一个已经帮你解引用的指针。引用的规则很简单定义时必须初始化初始化后不能改变绑定对象不存在“空引用”。作业里最常见的一个应用是用const引用传参避免不必要的拷贝。比如有一个大的结构体或者字符串如果按值传递函数参数会完整拷贝一份开销很大用const string传参既不复制又保证了函数内部不能修改原对象#include iostream using namespace std; struct Student { int id; double score; }; void printStudent(const Student s) { cout id s.id , score s.score endl; // 如果不加const函数里有可能不小心写 s.id 100改了外部数据 } int main() { Student stu {1001, 88.5}; printStudent(stu); return 0; }这里的Student s不会复制结构体而是直接绑定到stu上const修饰保证函数体内只能读不能写。这个模式在之后所有课程项目里几乎天天用等学到类的时候拷贝构造、运算符重载、移动语义你会发现引用无处不在。第六讲现在把这个底子打牢后面你写代码会顺畅很多。引用和指针还有一个细节值得注意引用作为函数参数时实参不需要写函数内部不需要写*所以swapReference(x, y)这样调用特别自然。这也是为什么现代C的编码规范包括Google C Style Guide在需要修改实参时优先推荐引用而不是指针因为指针需要担心空指针判断而引用天然安全。4. 刷作业时最常踩的坑和排查思路4.1 编译期错误声明缺失、默认参数顺序、重载歧义我在这两讲见过的编译错误大概七八成是以下三类。第一类是“函数没声明”。调用的函数定义在main函数之后前面又没有写函数原型编译器报was not declared in this scope。解决办法无非两个把定义挪到调用之前或者在文件开头把所有函数的声明写一遍。第二类是默认参数位置错误。比如你这样写void func(int a 1, int b); // 错误默认参数必须从右往左连续这里把有默认值的参数放在了无默认值参数的左边编译直接失败。正确写法是void func(int a, int b 1);或者两个参数都设默认值。规则一句话所有带默认值的参数必须排在参数列表的末尾。第三类是函数重载的歧义调用。比如你重载了void show(int)和void show(double)然后调用show(3)编译器能正确匹配int版本但如果调用show(3.14f)float类型编译器就犯难了——float转int可以、转double也可以两个版本匹配程度相当直接报ambiguous call to overloaded function。遇到这种错误别怀疑编译器是你给的实参类型没法唯一确定匹配哪个重载版本自己加上显式类型转换就行。4.2 运行期崩溃野指针、悬垂指针、数组越界运行期崩溃是所有指针类作业最让人头疼的问题。最经典的场景是“函数返回局部变量的地址”int* getValue() { int x 42; return x; // 错误x是局部变量函数结束后内存就被回收了 } int main() { int* p getValue(); cout *p endl; // 未定义行为可能输出42也可能是垃圾值还可能直接崩溃 return 0; }x在函数返回后离开作用域它的栈上空间已经不属于你。指针p仍然保存着那个地址但地址上的内容随时可能被其他调用改写。这就是悬垂指针dangling pointer。你第一次访问它时可能运气好值还在多调用几次别的函数值就变了。这种“时好时坏”的程序才是最恶心的它让你误以为代码没问题。排查这类问题的思路我建议分三步先看指针指向的内存是不是自己用new申请的再看申请的内存是不是在函数里被释放了最后看这段内存在使用期间有没有别的指针把它覆盖了。排查时可以用调试器设置内存断点不过作业阶段更有效的方法是“代码走查”而不是调式——因为问题往往不难发现难的是你愿意承认自己写了返回局部地址这种代码。数组越界是另一大崩溃来源。int arr[5]你访问arr[5]编译不会报错运行可能也不报错但它已经踩到了数组后面不属于你的内存。这个行为叫未定义行为undefined behavior意思是程序可能正常可能输出垃圾值可能崩溃全看运气。规范的做法是始终用循环边界约束访问范围同时如果需要动态大小直接用vector#include vector vectorint arr(10);4.3 内存泄漏的检测与避免内存泄漏不是“程序直接崩”而是“程序跑着跑着内存一直涨”最终把系统资源耗光。初学者容易觉得它不严重但作业里的动态数组题目如果漏写delete代码必然占用内存而不归还。内存泄漏最常见的两种情况一是只用new分配了内存但分支提前return时漏掉了后面的delete二是二维数组的释放顺序写反只释放了行指针数组而忘了释放每一行的列内存。我之前就用过一个笨办法排查在程序里写计数器new一个就countdelete一个就count--程序结束时检查计数是否为0。这个方法非常朴素但对作业级别的代码量够用了。不要用工具暴力检测就结束真正的习惯是“写分配代码的同时就把释放代码写掉”。我现在的习惯是写完new立刻写对应的delete然后再回头填中间的代码。这样就不会出现“写完了业务逻辑忘了释放内存”的情况。5. 参考答案的正确用法从“看懂”到“自己写出来”5.1 拿到题目先拆解不要急着看答案我知道很多人搜参考答案是想赶紧把作业交掉但如果你真的想把这门课学好拿到题目后应该先自己拆一遍再对照参考。拆题的方法是把题目要求转化成输入、输出、约束三条信息。以判断质数为例——输入是一个整数n输出是true或false约束是n的范围比如1到100。然后把实现拆成两步先处理边界情况n 2再对2到根号n之间的数做整除判断。哪怕你最后写出来的代码和参考答案不一样只要思路是一致的这个“拆”的过程才真正帮你建立了程序思维。我刷MOOC的习惯是把每道作业题当成两个阶段先自己写一版写不出来再看答案看完答案之后关掉答案重新写一版。第一次写卡住说明这个知识点还有盲区看答案能懂说明盲区不大关掉答案能复现说明这个知识才真正变成自己的。5.2 对照答案时要问的三个问题看到参考答案很多人的反应是“哦这么写也行”然后抄完交作业什么都没留下。我建议每次对照答案时都问自己三个问题。第一问这个答案和我自己的写法差别在哪如果答案用了引用传参而你用了指针想想为什么引用更简洁如果答案在边界条件上比你多判断了n 2想想你漏掉这个条件会导致什么后果。第二问这个答案的写法有什么缺点参考答案也是人写的也可能有局限性比如递归深度大时可能溢出、没有做输入校验等你能看出缺点说明你已经有了批判性的阅读能力。第三问如果题目条件改变这个答案还适用吗比如把“判断1到100的质数”改成“判断用户任意输入的n是否是质数”函数是否需要改动这三个问题问完你对一道题的理解深度远超单纯抄一遍答案。这也是我刷完同济MOOC第五、六讲后最大的收获参考答案不是终点是起点。5.3 把一道题改三个版本是练习函数和指针最有效的方法函数和指针的练习我见过最有效的方法不是刷更多新题而是把一道题反复改写。比如“用函数交换两个变量”这道题你可以写值传递版本观察为什么失败、指针版本、引用版本三个通过对比理解三种参数传递方式的本质差异。同样把“用指针遍历数组”这道题改写三个版本用下标arr[i]、用指针*(p i)、用移动指针*p。三个版本运行结果一样但写法不同你对数组、指针、运算符优先级都会有更深的体会。我建议你就拿作业里的两三道题做这种“一题多写”的练习每写一个版本在心里讲一遍它的内存变化过程。两讲学完你再看后面的类与对象、字符串与流会觉得底子厚实很多。等到学STL容器的时候你会发现迭代器本质上就是一个更聪明的指针。那时候回头再看第六讲你会感谢自己在这里没有糊弄过去。