ARTICLE DETAIL

资讯详情

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

C语言函数详解:声明、定义、参数传递与递归实战

C语言函数详解:声明、定义、参数传递与递归实战 函数这块儿是很多C语言学习者的一道坎儿。前面几章写写变量、搞搞循环还能靠感觉硬扛等学到函数代码突然从自上而下一行行读变成了跳来跳去调用不少人就是在这儿被劝退的。这篇是系列的第7篇咱们把函数从头到尾捋一遍搞清楚它到底解决了什么问题、声明定义调用怎么配合、参数传递的真面目是什么再把递归、函数指针这类进阶玩法捎带手讲了。写到哪儿算哪儿都是我实际讲课、刷题、写代码时趟出来的经验。不管你是刚看完循环语句、准备进军函数的新手还是已经被指针和参数绕晕、回头补基础的老哥这篇文章应该都能帮上忙。C语言函数看着规矩多其实核心就三件事怎么写、怎么调、参数怎么传。把这三件事想透了后面学指针、学结构体、学链表路都会顺很多。1. 为什么要写函数先想清楚它解决什么问题1.1 函数就是C语言的积木块我见过太多学生写完一个五百行的main函数int main()打开所有逻辑全堆在里面循环套循环、判断套判断最后自己调试的时候都找不到某个变量是在哪儿被改的。这就是典型的不会用函数。函数说到底就是C语言提供的一个积木块机制。你先把一段逻辑做成一坨积木给它起个名字定好它要接收什么输入、输出什么结果然后可以在任何需要的地方反复调用它。这个过程大家其实早就在用printf、scanf就是别人写好的积木块你直接拿过来用而已。自己写函数本质上就是在做同样的事情。那为什么要拆积木直接按顺序写不行吗我总结下来有这几个直接收益消灭重复代码。同样的输出逻辑、同样的计算过程如果复制粘贴三遍以上后面需求一改你就得改三处漏一处就会出bug。写成函数只改一处。大幅提升可读性。main函数变得像一份目录每行都是一次函数调用看名字就知道程序在做什么不用一头扎进细节里。模块化测试。单独把某一功能写成函数就可以单独验证它是否正确出问题定位也快。一个生活化的类比你去餐厅吃饭不会要求厨师在你面前现场洗菜、切菜、配菜再下锅你只看最后端上来的菜就行。函数就等于把洗切炒的过程封装起来main函数只负责点菜。1.2 什么样的代码该拆成函数这其实是新手最容易问的问题我这个代码到底需不需要写函数我的标准很简单三条——有重复、有关键逻辑需要单独测试、代码块超过20行满足其中任意一条就值得拆。先说有重复。写一段打印成绩等级的逻辑A是优秀、B是良好、C是及格如果main里用三次if判断分别打印三遍就属于纯粹浪费。封装成void print_level(int score)三处调用即可。再说关键逻辑。比如你写一个银行利息计算器本金、利率、年限都有折算公式就那么几行但很容易算错。把它拆成double calc_interest(double principal, double rate, int years)你就可以单独在main里测试各种边界值不用搭整个程序框架。最后说代码行数。经验之谈一个函数看下来如果超过一屏大约40~50行阅读成本就开始飙升了。这时候哪怕逻辑是一个完整的整体也应该想想能不能拆成几个步骤函数让每个函数各管一段。1.3 函数设计的第一原则一个函数只做一件事我在给初学者改作业的时候发现一个非常普遍的问题一个函数里又做了输入校验、又做了格式转换、又做了计算、又做了输出美其名曰一个函数搞定所有事。这种函数写出来就是给自己埋雷。好的函数设计应该像流水线上的工位。每个工位只管一道工序。一个函数只做一件事通常意味着函数名能准确描述它的功能比如is_leap_year、get_max、bubble_sort函数的输入输出是清晰的它能接收什么、返回什么看名字和参数列表就明白函数内部不牵扯非本职责的内容比如计算函数就不应该顺手打一段乱七八糟的日志到屏幕上。这条原则看着简单实践中非常考验设计意识。我自己的习惯是写之前先想好名字和注释再动手填充代码。名字都起不顺溜说明这块逻辑本身就没想清楚。2. 函数的三个基本动作声明、定义、调用2.1 先区分声明和定义一个是打招呼一个是真干活函数声明和函数定义是C语言新手最先晕的地方。很多教程讲得模糊导致很多学生写了int add(int a, int b);却不知道这句是干嘛用的或者干脆漏掉最后编译报隐式声明错误。我用大白话解释一下函数定义Function Definition就是函数完整的实现包括返回类型、函数名、参数列表和函数体花括号里的代码。这是真正干活的部分。函数声明Function Declaration也叫函数原型只写返回类型、函数名和参数列表后面跟一个分号没有函数体。这是打个招呼告诉编译器注意了后面会有一个长得这样的函数。为什么要打招呼C语言编译器是自上而下处理代码的。如果在main函数里调用add(3, 5)而add的定义写在main后面编译器当时根本不知道add是什么东西就会报错或者猜测性地生成一个隐式声明。解决办法有两种第一种把函数的定义写在main函数之前。这样编译器先看到了定义后面再调用就没问题了。第二种在main函数前面写函数声明把定义放在main后面。这样声明在先编译器提前知道了函数的长相返回类型、参数类型至于函数体在哪儿定义编译后链接阶段再找。很多项目代码里函数声明会被统一放进.h头文件里而函数定义放在.c文件里。比如你建一个calc.h写声明再建一个calc.c写实现main里#include calc.h就能调用。这就是头文件的本质作用。从实际经验来说我建议初学者先养成写完函数定义之后再写main或者开头集中声明的习惯任选一种。等到程序变大、多个文件分工时自然就会切换到头文件方案。2.2 具体语法照着这个框架写不会错一个标准函数定义语法长这样返回类型 函数名(参数类型 参数名1, 参数类型 参数名2, ...) { // 函数体 return 返回值; // 如果返回类型不是void // 如果返回类型是void可以不写return或者写 return; }举个例子判断一个整数是否为奇数#include stdio.h // 函数声明 int is_odd(int n); int is_odd(int n) // 函数定义 { if (n % 2 ! 0) return 1; else return 0; } int main() { int x 7; if (is_odd(x)) { printf(%d 是奇数\n, x); } return 0; }这里int is_odd(int n);是声明int is_odd(int n) { ... }是定义main里面is_odd(x)是调用。三个动作一目了然。需要特别注意的是声明两边的类型必须一致。声明里参数写int定义里写成double编译器直接报错。还有一种常见错误是声明和定义参数个数对不上这种也要靠编译器抓。2.3 return到底是干嘛的返回值和void函数return的作用有两个一是结束函数的执行二是一个值返回给调用者。新手很容易搞混一件事return到底是在返回给谁答案很清楚——返回给调用者。main调用is_odd(x)is_odd里return 1这个1就回到了main里main继续往下执行。如果你不想返回任何值就把函数返回类型写成void。这类函数往往是为了执行一段操作而存在的比如打印一条欢迎信息void print_welcome() { printf(欢迎使用本系统\n); }注意void函数里写了return;是允许的作用是提前结束函数。但void函数里写return 某个值;就是语法错误编译器会直接崩掉。很多学生还会犯一个迷糊为什么有的函数结尾不写return也能编译过比如int类型的函数最后没有return语句。这个属于未定义行为——编译器可能给警告程序运行时返回的值是不确定的有可能是垃圾值。正确做法是声明了什么返回类型函数所有分支都要确保返回对应的值。比如is_odd里理论上只有奇数返回1如果n是偶数虽然逻辑上走到了else分支返回0但条件表达式写得不完整时就可能出现走到函数末尾还没返回值的情况。编译器对这种代码往往很宽容运行时错误却很隐蔽。我的建议是宁可写完整也别依赖默认返回。2.4 和Python定义函数做个对比语法不同思维相通因为不少同学也在学Python我顺带对比一下加深理解。Python里定义函数def is_odd(n): return n % 2 ! 0C语言里int is_odd(int n) { return (n % 2 ! 0); }两边的核心思路是一模一样的封装逻辑、接收参数、返回结果。区别主要在语法上Python不需要显式写返回类型C必须写清int、double、void等Python用缩进表示函数体C用花括号Python的参数天然支持多种类型C必须提前声明类型这也是C严谨或者说麻烦的地方。理解了这层关系学习重点其实不在语法而在传参方式。Python里的参数传递规则和C不完全一样C的值传递和地址传递问题是下个板块的重头戏。3. 参数传递是C语言函数的灵魂值传递与地址传递3.1 你有没有问过函数里改了参数外面为什么没变这个疑惑特别典型。写这样一个代码#include stdio.h void change(int x) { x 100; } int main() { int a 10; change(a); printf(a %d\n, a); // 你会以为打印100实际打印10 return 0; }为什么change(a)之后a还是10这就是C语言函数参数传递的核心特性值传递pass by value。C语言里调用函数时传给函数的是实参的一个拷贝。change(a)把a的值10复制了一份交给了形参x。函数里所有对x的修改改的都是那个拷贝而main里的a从头到尾跟函数内没有任何关系。函数结束拷贝销毁a还是原来的10。这里我感觉用发快递来类比很贴切你把一个物品拍张照片发给商家商家对着照片做修改改完把照片删了你手里的实物当然不会被改动。C语言默认传参就是发照片。很多同学学到这里会不服气那我就是想在函数里修改外面的变量怎么办那就需要用到下面说的地址传递。3.2 地址传递把变量的地址钥匙传进去C语言解决函数内部改外部变量的办法是把变量的地址传进去。指针在这里就派上用场了。#include stdio.h void change(int *p) { *p 100; // 通过指针间接修改p指向的那个变量 } int main() { int a 10; change(a); // a是a的地址 printf(a %d\n, a); // 现在打印100 return 0; }这次传给change的不是a的值而是a的地址a相当于把仓库的钥匙给了函数。函数拿到钥匙就能打开a那个房间把里面的数字改成100。注意这里的int *p表示p是一个指向整型变量的指针*p表示p指向的那个变量。这里就有一个非常重要的结论C语言其实没有真正的按引用传递唯一的传参方式就是值传递。地址传参本质上传进去的地址本身也是一个值只不过这个值是变量的地址。理解这个逻辑就不会被引用传递这个概念绕晕。3.3 数组参数的本质传进去的其实是地址数组和函数搭配是新手重灾区。很多学生以为void print_array(int arr[], int n)是把整个数组复制一份传进了函数。错数组名本质上就是指向数组首元素的指针或者说地址。所以你在函数里修改了arr[0]外面的原数组也会被改。#include stdio.h void set_first(int arr[]) { arr[0] 99; } int main() { int nums[5] {1, 2, 3, 4, 5}; set_first(nums); printf(%d\n, nums[0]); // 打印99 return 0; }因为数组传参传的是地址所以函数里对数组元素的修改会反映到外面。这个特性和普通变量完全不同用的时候要心里有数。一个实际中的坑新手写一个初始化数组的函数希望函数内部生成新数组并传给main。比如void create_array(int arr[], int n) { arr (int*)malloc(n * sizeof(int)); // 想要分配新内存给arr }这样写是无效的因为arr参数只是地址的一个拷贝函数内部修改arr本身不会影响main里的数组变量。如果真想让函数内部给指针分配内存需要传指针的指针void create_array(int **arr, int n) { *arr (int*)malloc(n * sizeof(int)); }这个模式在写链表、树这类高级数据结构时非常常见。3.4 参数传递的查错技巧打印地址是最快的验证方式如果你不确定一个参数到底传的是值还是地址最直接的办法是在函数里和外面分别打印变量和地址printf(in function: x %d, x %p\n, x, x); printf(in main: a %d, a %p\n, a, a);如果两个地址相同说明传的是地址如果不同说明只是值拷贝。这个方法我调试时用过无数次比空想快得多。4. 函数进阶三件套递归、可变参数、函数指针4.1 递归自己调用自己的函数要注意的两条铁律递归是C语言函数学习中最有魔法感的部分。函数可以直接或间接地调用自己一层一层往里钻再一层一层往回返。经典例子阶乘#include stdio.h long factorial(int n) { if (n 1) return 1; // 终止条件 return n * factorial(n - 1); // 递归调用 } int main() { printf(%ld\n, factorial(5)); // 120 return 0; }这段代码执行过程是factorial(5)调用factorial(4)再调用factorial(3)直到factorial(1)返回1然后一路乘回去。很像俄罗斯套娃最里层的最小号套娃先有结果然后一层层往外带出来。写递归最容易犯的两个错误忘了终止条件。没有if (n 1) return 1;这个出口函数永远停不下来最后栈溢出崩溃。递归深度太深。10000的阶乘如果递归算栈帧会堆到爆炸。递归适合解决的问题是有小而同类结构的比如树的遍历、快速排序深度通常是可控的。另外新手写递归时经常犯一个逻辑错误以为return fact(n - 1);和return n * fact(n - 1);没区别。前者直接把内层结果返回出去不乘n后者才是真正的阶乘逻辑。这种细节只有一步步画调用栈才能真正搞懂。我的建议是遇到递归先在纸上画三层调用关系再写代码。4.2 可变参数printf背后是怎么做到想传几个传几个你用过printf无数遍了有没有想过它凭什么能接收任意多个参数比如printf(%d %d %d, a, b, c)和printf(%d, a)都能用这就涉及C语言的可变参数机制。C语言中printf的原型是int printf(const char *format, ...);这里的...就是可变参数列表。要想读取这些参数需要用到stdarg.h头文件里提供的va_list、va_start、va_arg、va_end这几个宏。举个最简单的自定义可变形参函数例子#include stdarg.h #include stdio.h double average(int count, ...) { va_list args; double sum 0; va_start(args, count); for (int i 0; i count; i) { sum va_arg(args, double); // 按double类型取值 } va_end(args); return count 0 ? sum / count : 0; } int main() { printf(%.2f\n, average(3, 2.5, 3.5, 4.0)); return 0; }注意几个要点可变参数至少先要有一个固定参数比如这里的count用来告诉函数后面到底有几个参数。va_arg每次调用要写明期望取出的类型比如double。类型写错取出来的值就是垃圾数据这是可变参数最容易翻车的地方。va_start和va_end必须成对出现函数退出前都要调。可变参数在很多库函数里用得很广泛。但我个人建议写自己的代码时尽量避免设计可变参数因为类型安全和安全性都差调试难度高容易踩内存越界。能用一个固定数组参数解决的就用数组。顺带提一句上述代码用可变参数时为什么传2.5, 3.5, 4.0而不是2.5, 3.5, 4因为4是int类型但va_arg按double取类型不匹配会出诡异结果。这种类型一致性的要求对新手极不友好也是这个知识点在面试里被问得多、在实际代码里用得少的原因。4.3 函数指针与回调函数函数也能当参数传递函数指针就是指向函数的指针。没错C语言里函数本身也有地址你可以把函数名赋值给一个指针变量然后通过指针来调用函数。#include stdio.h int add(int a, int b){ return a b; } int sub(int a, int b){ return a - b; } int main() { int (*op)(int, int); // 声明一个函数指针 op add; // 指向add函数 printf(%d\n, op(3, 4)); // 7 op sub; printf(%d\n, op(3, 4)); // -1 return 0; }函数指针最典型的应用场景就是回调函数。所谓回调就是把一个函数作为参数传给另一个函数让它在合适的时机调用你传进去的函数。很多库函数都这么设计比如C标准库里的qsort排序函数就需要你传入一个比较函数。#include stdio.h #include stdlib.h int compare_int(const void *a, const void *b) { int ia *(const int*)a; int ib *(const int*)b; return ia - ib; } int main() { int arr[] {5, 2, 8, 1, 9, 3}; qsort(arr, 6, sizeof(int), compare_int); for (int i 0; i 6; i) { printf(%d , arr[i]); } return 0; }这里compare_int就是回调函数。qsort不知道你要按什么规则排你通过函数指针告诉它根据这个规则来比较两个元素。以后如果你要按降序排就改回调函数的逻辑即可不用改qsort本身。函数指针的声明看起来反人类int (*op)(int, int)其实拆解很简单——op先和*结合表示op是一个指针然后括号外的int (int, int)表示它指向一个接收两个int、返回int的函数。括号不能省否则int *op(int, int)就变成一个返回int*的函数了。这种反人类声明我印象里会劝退不少人但一旦理解指针声明时的从内往外读法则就没什么好怕的。4.4 标准库函数你不用全背但要会用热搜词里c语言库函数大全这种词一直是高频。这背后是很多新手的一种执念我是不是要把所有库函数都背下来我可以负责任地说完全不需要。C语言标准库分成几大类stdio.h输入输出、stdlib.h内存分配、随机数、转换、string.h字符串处理、math.h数学函数、ctype.h字符判断、time.h时间处理等。你不需要背全但应该知道有这个函数。比如string.h里的strcmp、strcpy、strlenstdlib.h里的malloc、free、atoi、qsort这些是高频中的高频早晚要熟。遇到不确定的函数我的方法就一句话查文档比瞎猜快。直接用搜索引擎搜C标准库 函数名或者打开本地的man手册。写代码的重点是思路背函数名是搜索引擎的事。5. 典型练习逐个拆解作业题里怎么用好函数5.1 九九乘法表拆成打印函数很自然九九乘法表这个题目几乎每个初学者都写过。很多人的第一版是全部堆在main里。更好的练习方式是拆出函数#include stdio.h void print_line(int n) { for (int i 1; i n; i) { printf(%d*%d%-2d , i, n, i * n); } printf(\n); } int main() { for (int n 1; n 9; n) { print_line(n); } return 0; }这个版本里print_line负责打印第n行的乘法表main只需要控制行数。这么做的好处是你想改格式比如改成%2d只改一处你想只打印某一行直接调用print_line(7)不用跑整个程序。这就是拆分函数带来的灵活性。5.2 冒泡排序封装成函数并理解数组传参冒泡排序应该算C语言里最经典的数组加工练习。直接看函数封装版本的代码#include stdio.h void bubble_sort(int arr[], int n) { for (int i 0; i n - 1; i) { int swapped 0; for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int tmp arr[j]; arr[j] arr[j 1]; arr[j 1] tmp; swapped 1; } } if (!swapped) break; // 本轮没有交换说明已经有序 } } void print_array(int arr[], int n) { for (int i 0; i n; i) { printf(%d , arr[i]); } printf(\n); } int main() { int nums[] {64, 34, 25, 12, 22, 11, 90}; int n sizeof(nums) / sizeof(nums[0]); print_array(nums, n); bubble_sort(nums, n); print_array(nums, n); return 0; }这里面藏着几个新手特别容易踩的坑**sizeof(nums) / sizeof(nums[0])**这个写法只能用在数组本尊上不能用在函数参数里。因为函数参数里的数组名退化成指针sizeof(arr)得到的只是一个指针的大小通常是8字节不是整个数组的字节数。所以要额外传n进去。这一点几乎每次都会被学生问属于经典陷阱。冒泡排序里j n - 2 - i还是j n - 1 - i一不小心就数组越界。核心逻辑是每一轮比较相邻元素用小于号做条件才能保证j1不越界。我加的swapped优化很简单但很有效如果某一轮循环没有发生任何交换说明数组已经有序直接跳出复杂度从最好情况的O(n²)降到O(n)。5.3 5×5鞍点用函数拆解别把所有判断堆一起鞍点是矩阵中一个特殊位置的值它在所在行上是最大的在所在列上是最小的。一个5×5的矩阵可能没有鞍点也可能有多个。这个题我看到热搜词里专门有人问用stdio.h和limits.h解决确实有些细节值得一说。思路拆成函数会清楚得多#include stdio.h #include limits.h #define ROWS 5 #define COLS 5 // 找到第r行的最大值及其列坐标 int find_row_max(int matrix[ROWS][COLS], int r, int *col) { int max_val INT_MIN; for (int j 0; j COLS; j) { if (matrix[r][j] max_val) { max_val matrix[r][j]; *col j; } } return max_val; } int main() { int matrix[ROWS][COLS]; printf(请输入5x5矩阵\n); for (int i 0; i ROWS; i) { for (int j 0; j COLS; j) { scanf(%d, matrix[i][j]); } } int found 0; for (int i 0; i ROWS; i) { int col; int max_val find_row_max(matrix, i, col); // 检查这个位置在列上是否最小 int is_min_in_col 1; for (int k 0; k ROWS; k) { if (matrix[k][col] max_val) { is_min_in_col 0; break; } } if (is_min_in_col) { printf(鞍点: matrix[%d][%d] %d\n, i, col, max_val); found 1; } } if (!found) { printf(没有鞍点\n); } return 0; }这里用limits.h的意思是找一个行的最大值时初始值设为INT_MIN整型能表示的最小值这样第一轮比较必然能更新。如果不设初始值有的编译器可能默认0当矩阵整行全是负数时max_val会错误地保持为0。这个边界案例就是很多人代码看起来对却提交时WAWrong Answer的原因。另一个细节是二维数组传参时必须指定除第一维外的维度比如int matrix[ROWS][COLS]因为C语言需要知道每一行有多少列才能正确计算元素地址偏移。这也是初学者很容易忽略的语法点。5.4 字符串逆序注意修改字符串常量会崩溃字符串逆序c语言pta这个高频搜索词背后也是一个大坑。先看一个常见的错误写法#include stdio.h #include string.h int main() { char *s hello; // 注意这是字符串常量 int len strlen(s); for (int i 0; i len / 2; i) { char tmp s[i]; s[i] s[len - 1 - i]; s[len - 1 - i] tmp; } printf(%s\n, s); // 运行到这里可能会崩溃 return 0; }问题在于char *s hello定义的是一个指向字符串常量的指针而字符串常量在绝大多数系统上位于只读内存区对它的任何修改都是未定义行为程序很可能会段错误。正确的做法是用字符数组#include stdio.h #include string.h void reverse_string(char str[]) { int len strlen(str); for (int i 0; i len / 2; i) { char tmp str[i]; str[i] str[len - 1 - i]; str[len - 1 - i] tmp; } } int main() { char s[] hello; // 字符数组可以修改 reverse_string(s); printf(%s\n, s); return 0; }把字符串逆序封装成reverse_string函数传数组进去因为数组传参传的是地址所以外面s的内容会被真正修改。这里的char s[]和char *s的区别是C语言里非常经典的一个易错点值得所有初学者反复理解。5.5 翁恺老师和PTA练习怎么刷题效率最高热搜词里翁恺c语言练习题和浙江大学c语言基础编程题目及答案都出现了说明很多人在跟着中国大学MOOC上翁恺老师的课程走。翁恺老师的课我推荐过给很多学生确实讲得清楚。但刷题这件事我想给几个曾经踩过坑的建议先想再写。拿到题目先在纸上画流程确定函数的输入输出再动手敲键盘。直接上手敲改着改着就会把代码拆乱。一次只改一个地方。程序报错了不要同时改三处再运行绝大多数情况下你根本不知道是哪处生效的。注释掉不确定的部分分段验证。自己造边界数据。PTA的测试用例是隐藏的但你可以自己补。比如排序题先测一个空数组、一个只有1个元素、一个已经有序的数组、一个有重复元素的数组。看题解不丢人但看完要自己重写。我见过太多学生把答案看了一遍就觉得我会了一上机就卡壳。看题解一定要关掉答案从头默写一遍才算真正掌握。6. 新手高频报错与调试实战环境配置、编译错误、逻辑陷阱6.1 开发环境的两个高频坑PowerShell报错和VSCode跳转失灵热搜词里那串无法将pnpm项识别为 cmdlet、函数、脚本文件或可运行程序的名称其实不只是pnpm很多人第一次装C语言环境就卡在这。这个报错的本质是你在Windows的PowerShell里执行一个命令但系统在PATH环境变量里找不到这个程序。解决办法就一条把程序所在的文件夹路径加进PATH环境变量。以VSCode配置C语言环境为例你需要安装MinGW-w64或者Visual Studio Build Tools然后把gcc.exe所在目录比如C:\mingw64\bin加入系统的Path。加完之后一定要重新打开终端否则PATH刷新不生效。测试方法是在终端敲gcc --version如果能显示版本号那环境就通了。还有一个VSCode里高频报错vscode c所有的函数 变量 都没办法跳转。这通常是因为VSCode没有正确配置C/C扩展的IntelliSense或者你打开的不是整个项目文件夹。解决办法安装Microsoft的C/C扩展用文件 - 打开文件夹打开项目根目录不要直接打开单个.c文件如果还不行在项目根目录建一个.vscode/c_cpp_properties.json把includePath指到你的MinGW include目录。环境配置这种问题网上教程一搜一大把但很多教程细节过时了。我的建议是以官方文档为准。比如MinGW的配置直接看VSCode官方文档里的C/C configuration一节或者Microsoft C/C扩展的README比到处找博客靠谱得多。6.2 编译链接阶段的报错声明、定义和链接三个环节C语言的报错通常会出现在编译阶段或链接阶段。新手经常分不清我这里列几个特别常见的undefined reference toxxx链接错误意思是编译器找到了函数的调用但在所有编译单元里都没找到这个函数的定义。常见原因函数只声明了没实现或者你在.c文件里定义的函数名拼写和调用时不一致。conflicting types for xxx声明和定义的类型冲突。比如声明是int foo(double)定义却是int foo(int)或者同一个函数声明了两次但参数不一样。implicit declaration of function xxx调用函数前没有声明也没有定义。C99起这是个警告但在某些编译器配置下可能升级为错误。调试这种问题的通用思路是先看报错文件是哪个、行号是多少绝大多数错误有具体位置。不要看到一屏红字就慌张大部分报错里真正有用的信息就前两行。6.3 运行时的逻辑错误打印是最好用的调试工具运行时不报错但结果不对这种调试最费时间。我的经验是在关键位置打印变量的值比如函数入口、return之前、循环内每一轮结束时。打印时带上上下文标签比如printf(debug: i%d, arr[i]%d\n, i, arr[i]);这样看得清楚。怀疑某段逻辑时可以用二分法注释代码。把一半代码注释掉如果程序行为变了说明问题在这半段再细分直到定位。有些同学追求用调试器断点当然好。但我个人觉得对新手来说printf调试法往往更快直观因为你不用理解断点、单步、监视变量这些概念。等你敲的代码多了再上调试器也不迟。还有一个隐藏得很深的坑scanf格式字符串和变量类型不匹配。比如double d; scanf(%f, d);%f对应float如果变量是double应该用%lf。这种错编译不报但结果完全不对非常折磨人。遇到scanf读完输入不对的情况先检查格式串。6.4 把调试经验变成编码习惯踩坑多了之后我发现自己写代码的习惯也在变函数开头先把参数打印一遍确认输入没问题写完函数先单独测试一次再往主程序里接循环边界一律先画下标的范围避免差一错误能不写全局变量就不写全局变量函数之间的数据传递用参数和返回值这样每个函数都可以独立验证。这些习惯看着简单但能把调试时间压缩到原来的三分之一。这也是我一直跟初学者强调的写代码时就把可调试性考虑进去比事后追bug效率高得多。结尾函数这章到这儿就聊完了。我个人在实际教学中最大的体会是函数并不是C语言里最难的知识点但它是一个分水岭。能在自己写的代码里自觉拆函数、琢磨参数怎么传、理解了数组和指针在函数里的行为说明你已经从照着课本敲代码进化到按设计写代码了。如果这一章你暂时还没吃透别急正常的。函数和指针结合起来学的阶段几乎每个人都会有一个懵的时期。我的建议是手边准备一个草稿本遇到想不明白的函数调用把函数名、参数、返回值画出来标清谁是拷贝、谁是真身多画几道就会豁然开朗。最后再分享一个小技巧学到函数之后试着返回去把自己之前写的上百行程序按功能拆成函数重新组织一遍。这个重构练习比多做十道新题都管用。拆的时候你会发现原来很多逻辑其实是重复的很多变量的生命周期其实根本不需要拉那么长。等你能把一个混乱的main拆得清清爽爽函数的掌握程度才算真正到位。
返回列表