函数这个东西,是C语言学习路上绕不过去的第一道坎,也是从"能看懂代码"到"能自己组织代码"的分水岭。很多初学者在控制台里把变量、循环、分支都折腾明白了,但一碰到自己写函数就卡壳:不知道什么时候该把代码拆成函数,搞不清楚声明和定义到底啥区别,数组传进去怎么就变了样。这篇文章就把函数这件事从头到尾捋一遍,包括声明、定义、调用的边界,参数传递的底层机制,指针和数组在函数里的真实行为,以及库函数、递归、回调这些进阶用法。适合正在学C语言基础、准备期末或者刚接触项目开发的读者,看完能自己动手拆代码、写函数,遇到报错也知道往哪个方向排查。
1. 为什么非要用函数:从一段没有函数的代码说起
1.1 先看一段"能跑但很痛苦"的代码
假设你接到一个需求:读入三组坐标,每组两个整数,分别计算每组坐标到原点的距离并输出。新手最常见的写法是这样:
#include <stdio.h> #include <math.h> int main() { int x1 = 3, y1 = 4; double d1 = sqrt(x1 * x1 + y1 * y1); printf("点(%d,%d)到原点距离: %.2f\n", x1, y1, d1); int x2 = 6, y2 = 8; double d2 = sqrt(x2 * x2 + y2 * y2); printf("点(%d,%d)到原点距离: %.2f\n", x2, y2, d2); int x3 = 5, y3 = 12; double d3 = sqrt(x3 * x3 + y3 * y3); printf("点(%d,%d)到原点距离: %.2f\n", x3, y3, d3); return 0; }这段代码有很明显的问题:三个坐标的计算逻辑完全重复,只是数字不一样。如果以后要改成计算到某个固定点的距离,你得改三处;如果计算方式从"距离"变成"欧氏距离再取对数",又是三处。代码一多,这种复制粘贴的写法就是灾难。
1.2 函数真正解决的是"组织问题"
有人可能会说,三组数据用数组加循环不就行了,为什么要用函数?这个观点有一定道理——数据量小的时候循环确实够了。但函数解决的不仅是重复代码问题,还有三个更本质的东西:
第一,是抽象能力。你把"计算两点距离"这个动作封装成一个函数后,调用方只需要关心"输入两个点,得到一个距离",不需要关心内部用了几次乘法、开了几次方。这就是把实现细节藏起来。
第二,是命名能力。distance()这个函数名本身就在描述意图,比一坨sqrt(x*x+y*y)更有语义。读代码的人不用去"翻译"数学公式,直接看函数名就知道这段在做什么。
第三,是隔离调试能力。如果距离算错了,你只需要检查distance()函数内部,而不是在一大段main函数里翻来翻去找错误。函数把"哪里可能出错"的范围缩小了。
所以函数的本质不是"消除重复代码",而是"把问题拆成可独立理解的小块"。这才是模块化编程的起点。
1.3 改造成函数版本后对比
把上面那段改成函数版本:
#include <stdio.h> #include <math.h> double distance(int x, int y) { return sqrt(x * x + y * y); } int main() { int points[3][2] = {{3, 4}, {6, 8}, {5, 12}}; for (int i = 0; i < 3; i++) { double d = distance(points[i][0], points[i][1]); printf("点(%d,%d)到原点距离: %.2f\n", points[i][0], points[i][1], d); } return 0; }改动之后,主函数立刻清爽了很多。后面如果距离公式要变,只动distance一个地方就够了。这就是函数最基本的价值——不是"炫技",是让自己和别人都能少受苦。
提示:判断一个代码段该不该拆成函数,有个很朴素的标准——如果你发现自己复制粘贴了一段代码并且改了参数,那就该写成函数了。三个地方重复就拆,五个地方重复还不拆的,后面改需求时一定会后悔。
2. 声明、定义与调用:三者的边界与配合
2.1 先分清三个容易混淆的概念
热搜词里"函数声明"被搜得很多,说明这确实是新手容易困惑的点。声明、定义、调用,三个词看着像,实际各管一段:
- 函数定义:写出函数完整的实现,包括返回类型、函数名、参数列表、函数体。比如
double distance(int x, int y) { return sqrt(x*x + y*y); }。一个函数在同一个程序里只能定义一次,否则编译器会报"重复定义"。 - 函数声明:只告诉编译器"有这么个函数,参数是什么,返回什么",不写函数体。比如
double distance(int x, int y);。声明可以出现多次,但位置有讲究。 - 函数调用:在代码里实际使用这个函数,传入实参,拿返回值。
为什么要单独搞个声明?因为C语言的编译器是从上往下处理的。如果你在main函数里调用了distance,但distance的定义写在main后面,编译器在编译到调用点时根本不知道distance是什么,就会报隐式声明错误或类型不匹配错误。
2.2 最常见的解决方法:定义顺序问题
解决方式有三种:
第一种,把被调函数定义放在调用函数之前。简单,但代码一多,main 函数就得挪到最底下,可读性很差。
第二种,在文件开头加声明。这是最规范的做法:
#include <stdio.h> #include <math.h> // 函数声明 double distance(int x, int y); int main() { // 调用 printf("%.2f\n", distance(3, 4)); return 0; } // 函数定义在调用之后 double distance(int x, int y) { return sqrt(x * x + y * y); }第三种,把声明写到头文件里,在源文件里#include。这是多文件项目里的标准做法,把"接口"和"实现"分开,别人只需要看头文件就知道这个模块提供了哪些能力,不需要去看.c文件的具体实现。
2.3 参数列表里必须包含类型
新手写声明容易犯的一个错误是只写参数名不写参数类型,比如double distance(int, int);和double distance(int x, int y);都是合法的,但double distance(x, y);是非法的。声明里必须包含完整的参数类型信息,因为编译器要用它来检查调用时传参是否匹配。
我见过不少初学者在声明里写int distance(x, y)后编译报错,怎么都看不明白。其实很简单——C语言从语法层面就要求参数必须带类型。
注意:函数声明末尾必须加分号。这个分号代表"我声明完了,定义在别处"。删了分号编译器会把声明当成定义来解析,接着就是一堆莫名其妙的语法错误。排查这类问题的时候,先检查声明行末尾的分号还在不在。
2.4 返回类型的隐式规则
C语言标准有个历史遗留问题:如果没有明确指定返回类型,老标准会默认返回int。现代编译器基本都会给警告,但最好的习惯是永远显式写出返回类型,哪怕返回的是void。这个习惯能避免很多奇怪的类型错误,尤其是当你写了一个本该返回指针或double的函数却忘记写返回类型的时候。
3. 参数传递的真相:值传递、指针与数组参数的降级
3.1 值传递:函数里改不了外面的变量
C语言的参数传递默认是值传递。意思是调用函数时,实参的值被复制一份给形参,函数内部操作的其实是那个副本,外面的变量不受影响。
看这个例子:
#include <stdio.h> void add_one(int a) { a = a + 1; } int main() { int num = 10; add_one(num); printf("num = %d\n", num); // 输出 10,不是 11 return 0; }很多初学者第一次跑这段代码,看到输出还是10,就以为函数坏了。其实函数没坏,是"值传递"这个机制决定了它改不了num本身。可以这样理解:你把身份证复印件交给了办事窗口,窗口在复印件上改了名字,原件不会变。
3.2 指针参数:把"原件地址"传进去
如果想在函数里修改外面的变量,就得传地址,也就是指针:
void add_one(int *a) { *a = *a + 1; } int main() { int num = 10; add_one(&num); printf("num = %d\n", num); // 输出 11 return 0; }这里传的是num的地址,函数通过解引用*a拿到了原件本身,所以修改生效了。这也是C语言里"通过函数交换两个变量"的经典写法的原理:
void swap(int *x, int *y) { int temp = *x; *x = *y; *y = temp; }注意,如果你写成void swap(int x, int y),那交换的只是副本,主函数里的变量纹丝不动。面试题和期末考试里经常拿这个考人。
3.3 数组作为参数:不是复制,是退化
数组在C语言里不能直接按值传递。当你写int sum_array(int arr[], int n)时,编译器实际把arr[]当成int *arr来处理。也就是说,数组传进函数,传的是首元素的地址,而不是整个数组的副本。
这个特性带来的结果有好有坏:
好的方面是,函数可以直接修改传入数组的元素,不需要返回一个新数组。比如写冒泡排序:
void bubble_sort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; } } } }坏的地方在于,因为传的是地址不是数组本体,函数内部无法通过sizeof(arr)知道数组长度。sizeof(arr)在函数里拿到的是指针的大小(8字节或者4字节,取决于平台),不是整个数组的字节数。所以C语言里数组参数必须额外传一个长度参数n,这是一个新手经常踩的坑——在函数里用sizeof(arr)/sizeof(arr[0])计算长度,结果永远不对。
还有一个隐含的坑:因为传的是地址,函数内部对数组的修改会直接影响调用方的数组。如果你只想在函数内部做临时处理而不改变原数组,得自己手动复制一份,或者用const修饰参数,让编译器帮你拦住意外修改。
void print_array(const int arr[], int n) { for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } }加上const后,如果函数体里出现arr[i] = 0;这类赋值,编译器会直接报错,从语法层面防止误改。
3.4 多维数组做参数时的"列数必须写"问题
二维数组作为参数时,第一维(行数)可以省略,但第二维(列数)必须明确写出。原因同样和内存布局有关——编译器需要知道一行有多少个元素,才能计算arr[i][j]的真实偏移地址。
void print_matrix(int matrix[][4], int rows) { for (int i = 0; i < rows; i++) { for (int j = 0; j < 4; j++) { printf("%2d ", matrix[i][j]); } printf("\n"); } }如果写成int matrix[][]或者int matrix[3][],编译器都没法计算地址偏移,会直接报错。这个知识点在热搜词里"计算5*5鞍点问题"这类二维数组题目中会反复用到,最好一次弄明白。
4. 库函数、递归与回调:函数在不同层次的应用
4.1 库函数:站在别人肩膀上
C语言本身提供了一套标准库函数,检索词里可以看到sqrt、abs、select、stdout系列等搜索。这些函数不是语言关键字,但几乎所有C编译器都会带一套实现,比如:
- 数学函数:
sqrt(平方根)、pow(幂)、fabs(浮点绝对值),用之前#include <math.h>,在gcc下链接时可能要加-lm。 - 字符串函数:
strlen(长度)、strcpy、strcmp,用之前#include <string.h>。 - 输入输出:
printf、scanf、fopen,用之前#include <stdio.h>。 - 内存操作:
malloc、free、memset,用之前#include <stdlib.h>和#include <string.h>。
我见过不少初学者写了自己的"求字符串长度"函数,然后兴奋地发现系统自带strlen。学习阶段自己造一遍轮子是好事,能加深理解,但项目开发中优先用标准库是基本素养——标准库经过了大量测试,性能和正确性都有保障。
提示:重名问题。如果你自己定义了一个函数叫
sqrt,它会和标准库里的sqrt冲突。不要在项目里故意跟标准库函数重名,这种问题排查起来非常痛苦,编译器报的错还往往看不懂。
4.2 递归:函数调用自己,但要小心栈
递归是C语言函数里最"神奇"也最容易翻车的部分。简单说,递归就是函数直接或间接调用自己。经典例子:阶乘。
long long factorial(int n) { if (n <= 1) { return 1; } return n * factorial(n - 1); }递归的写法很简洁,但有两个必要条件:
第一,必须有终止条件。上面代码里的if (n <= 1) return 1;就是终止条件。没有终止条件的递归会无限调用下去,直到栈空间耗尽,程序崩溃或挂起。
第二,每次递归调用必须让问题规模缩小。factorial(n - 1)比factorial(n)接近终止条件一点,这样才能保证有限次调用后停下来。
递归的代价是栈空间。每次函数调用都会在栈上分配一块空间存放局部变量和返回地址,递归调用太多层会撑爆栈。我之前跑过一个递归算斐波那契数列的程序,n到45左右已经明显变慢,因为重复计算太多;如果做个n = 10000的递归,大概率段错误。所以递归适合层级不深、逻辑天然递归的场景(比如遍历目录、汉诺塔、二叉树),不适合大量的性能敏感计算。
4.3 回调函数:把函数当参数传
热搜词里"回调函数"搜得挺多,这是函数指针的高级应用。C语言里函数名本身就是函数地址,可以像变量一样传来传去。回调函数的典型场景是:你写了一个通用工具,它不知道具体要做什么操作,由调用方把操作函数传进来。
一个经典例子——自己实现一个简单的map函数,对数组每个元素做某种变换:
#include <stdio.h> int double_value(int x) { return x * 2; } int square_value(int x) { return x * x; } void map_array(int arr[], int n, int (*func)(int)) { for (int i = 0; i < n; i++) { arr[i] = func(arr[i]); } } int main() { int numbers[] = {1, 2, 3, 4, 5}; int n = sizeof(numbers) / sizeof(numbers[0]); map_array(numbers, n, double_value); printf("每个元素翻倍后: "); for (int i = 0; i < n; i++) { printf("%d ", numbers[i]); } printf("\n"); map_array(numbers, n, square_value); printf("每个元素平方后: "); for (int i = 0; i < n; i++) { printf("%d ", numbers[i]); } printf("\n"); return 0; }注意int (*func)(int)这个声明——它表示func是一个指向"返回int、带一个int参数"的函数的指针。调用的时候写func(arr[i])即可。这里double_value和square_value两个函数做的事情不同,但形状完全一样,所以能装进同一个函数指针里。
回调函数的威力在于"策略解耦"。排序算法不知道你的比较规则是什么,你传一个比较函数进去,它就能按你的规则排序;定时器不知道你要做什么,你传一个回调,时间到了它就调用。C语言标准库的qsort就是这种设计,你只要写一个compare函数传给qsort,它就能排序任意类型的数据。
4.4 函数指针数组:批量处理的高阶玩法
更进一步,把函数指针放进数组里,就能实现"按编号选择操作"。比如一个简单的菜单系统:
#include <stdio.h> void action_open() { printf("执行打开文件操作\n"); } void action_save() { printf("执行保存文件操作\n"); } void action_exit() { printf("执行退出程序操作\n"); } int main() { void (*menu[])(void) = {action_open, action_save, action_exit}; int choice; while (1) { printf("0:打开 1:保存 2:退出\n"); printf("请选择: "); scanf("%d", &choice); if (choice >= 0 && choice <= 2) { menu[choice](); // 根据编号调用对应函数 } else { printf("无效选择\n"); } } return 0; }这种写法把"多个 if/else"变成"表驱动",在写解释器、状态机、命令分发器的时候特别有用。虽然初学者平时用不上,但理解了这个思路,后续看开源代码会顺畅很多。
5. 独立函数实现与多文件组织的工程化思路
5.1 什么时候该拆成单独的文件
上面聊的都是单文件内的函数,但真实项目不会只有一个.c文件。当你的程序包含几百个函数、几万行代码时,全堆在 main 所在的文件里,编译慢、找代码难、多人协作更是灾难。这时候就得按"模块"拆文件。拆分的粒度一般看两个维度:
一是高内聚:一个文件里的函数应该围绕同一类职责。比如math_utils.c放所有数学工具函数,string_utils.c放所有字符串处理函数,file_io.c放所有文件读写函数。
二是低耦合:文件之间尽量通过函数接口交流,不直接访问对方的全局变量。这要求你在设计头文件时,只暴露必要的函数声明,内部辅助函数用static修饰限制在本文件可见。
5.2 一个最小可用的多文件示例
以"字符串工具"为例,目录结构如下:
project/ ├── main.c ├── string_utils.h ├── string_utils.cstring_utils.h内容:
#ifndef STRING_UTILS_H #define STRING_UTILS_H int string_length(const char *s); void string_reverse(char *s); int string_contains(const char *s, char c); #endifstring_utils.c内容:
#include "string_utils.h" int string_length(const char *s) { int len = 0; while (s[len] != '\0') { len++; } return len; } void string_reverse(char *s) { int len = string_length(s); for (int i = 0; i < len / 2; i++) { char temp = s[i]; s[i] = s[len - 1 - i]; s[len - 1 - i] = temp; } } int string_contains(const char *s, char c) { for (int i = 0; s[i] != '\0'; i++) { if (s[i] == c) { return 1; } } return 0; }main.c内容:
#include <stdio.h> #include "string_utils.h" int main() { char str[] = "hello"; string_reverse(str); printf("反转后: %s\n", str); printf("是否包含字母o: %d\n", string_contains(str, 'o')); return 0; }头文件里#ifndef STRING_UTILS_H这段叫头文件保护,作用是防止头文件被重复包含时出现重复定义问题。正规的头文件都会有这个,学的时候最好直接养成习惯。
编译时要把两个.c文件一起编译:
gcc main.c string_utils.c -o app5.3 static 在模块化中的角色
static有三个常见用途,这里重点说和函数相关的两个:
第一个是static修饰函数,让函数仅在当前文件可见。比如string_utils.c里有个内部辅助函数to_lowercase,你不希望外部调用它,就写成:
static char to_lowercase(char c) { if (c >= 'A' && c <= 'Z') { return c + ('a' - 'A'); } return c; }加上static后,即使别的文件里声明了这个函数,链接时也找不到——这能有效避免多文件项目里的命名冲突。
第二个是static修饰函数内的局部变量,让它变成"静态局部变量",生命周期延长到整个程序运行期间,但只能在函数内部访问。典型的应用是计数器:
int next_id(void) { static int counter = 0; counter++; return counter; }每次调用next_id,counter的值都会保留,从1开始递增。如果没有static,counter每次进函数都会重置为0,这个函数就没用了。
提示:搜索热词里那个"无法将 'pnpm' 项识别为 cmdlet、函数..."的报错,本质上和函数没关系,那是Windows的PowerShell找不到对应命令。但如果你自己写的函数名和系统命令重名,或者编译出的可执行文件名与已有命令冲突,也会出现类似"调用到错误的东西"的问题。写C语言程序时,给函数起名前先搜一下,避免和标准库或常用工具的函数/命令重名,能省很多排错时间。
6. 从报错反推问题:常见编译错误的排查链路
6.1 错误一:隐式函数声明
典型的报错长这样:
warning: implicit declaration of function 'foo' [-Wimplicit-function-declaration]这个错误的意思是:编译器在某个调用点遇到了foo,但之前没有见过它的声明或定义。排查思路:
- 检查
foo是否在调用点之前有过声明(头文件包含是否正确、声明语句是否在调用之前)。 - 检查函数名是否拼写错误,比如
string_lenght拼成了string_length。 - 检查是不是忘了
#include对应的头文件。用了sqrt没#include <math.h>,用了malloc没#include <stdlib.h>,都会出这个错。
6.2 错误二:重复定义
报错一般像:
duplicate symbol 'foo' in: main.o utils.o这个说明foo函数被定义了两遍。常见原因是:
- 函数定义写进了头文件,而头文件被两个
.c文件包含,导致每个.c文件都生成了一个定义。正确做法是头文件只放声明。 - 同一函数在两个
.c文件里分别写了一遍,但没有用static限制可见性。 - 没有头文件保护,同一个头文件被间接包含了两次,里面的普通函数定义被重复处理。
排查链路:先在项目里搜索函数名,看定义出现的位置;再检查它是不是在头文件里;最后确认头文件有没有加#ifndef保护。
6.3 错误三:参数类型不匹配
比如:
conflicting types for 'foo' previous declaration of 'foo' was here这种往往是声明与定义参数不一致,或者声明在前面但定义在后且签名不同。排查时把声明行和定义行放在一起对比:
// 声明 double distance(int x, int y); // 定义写成了 double distance(int x, double y) { return sqrt(x * x + y * y); }编译器会认为声明和定义是同一个函数,但签名冲突,于是报错。解决方式很简单——保证声明和定义的返回类型、参数类型、参数个数完全一致。
6.4 错误四:间接引用的含义理解偏差
初学者经常在scanf("%d", &num)里故意写scanf("%d", num)或者反过来在函数调用时把int *p传成p而不是&p。这类问题本质是对"指针参数需要地址"理解不到位。建议用一句话自检:如果要修改调用方的变量,就传地址;如果只是读取数据,可以传值;如果数据本身很大(比如结构体),优先传指针避免复制开销。
6.5 环境配置类的连锁问题
热搜词里还出现了很多"无法将 xxx 项识别为 cmdlet、函数"之类的报错,这在配置C语言开发环境时也会遇到——比如你在PowerShell里输入gcc提示找不到命令,很多人会怀疑自己写的代码有问题,其实只是环境变量没配好,编译器都没被系统找到。排查顺序是:先在终端敲gcc --version,看有没有输出;没有的话检查编译器是否安装、是否配置了环境变量;代码层面的问题不要在这个时候折腾。
7. 函数使用时的高频误区与实用自检清单
7.1 误区:在函数里用 sizeof 求解数组长度
前面提过,数组作为参数会退化成指针。在函数内部写sizeof(arr) / sizeof(arr[0])算出来的是指针大小除以元素大小,结果基本是1或者2。正确做法是:调用函数时传入数组长度,或者用宏定义固定长度。我自己的习惯是写数组处理函数时,第一个参数放数组,第二个参数放长度,这两个永远绑定在一起,很少出问题。
7.2 误区:忘了返回值
有的函数明明声明返回类型是int,但函数体内有个分支没有写return,导致返回值不确定。C语言对"函数结尾不返回"是未定义行为,表现可能很随机——有时碰巧对,有时完全乱掉。编译器一般会警告,但初学者经常忽略警告。建议:所有有返回类型的函数,每个分支最后都要有return;不确定返回值的时候,用一个明确的默认值兜底。
7.3 误区:函数写太长
一个函数动辄几百行,这不算语法错误,但维护起来非常痛苦。实用性建议是:一个函数最好只做一件事,代码行数控制在50行以内,超出就考虑拆分。拆分的信号包括:函数名已经很难起——因为职责太杂了;函数里有多个明显无关的代码段——比如既做数学计算又做文件写入;函数里的局部变量非常多——说明它承载了太多逻辑。
7.4 误区:滥用全局变量代替参数
全局变量确实能让函数少传几个参数,但副作用很大——函数不再"纯净",输出不仅依赖入参还依赖全局状态,排查bug时你不知道是哪个调用点改了全局量。除非特殊情况(比如全局配置项、只读常量表),否则优先通过参数传递数据。如果发现很多函数都在读写同一个全局变量,说明这个变量该被封装进结构体,或者被限制到某个模块内部。
7.5 实用自检清单
每次写完一个函数,过一遍这些检查项:
- 返回类型是否与函数体实际返回的类型一致。
- 参数是否需要加
const修饰(防止误改传入的数据)。 - 数组参数是否配套传了长度。
- 每个可能的执行路径是否都有明确的
return。 - 函数名是否能准确表达"这个函数做了什么"而不是"这个函数怎么做的"。
- 是否有重复代码可以抽取成子函数复用。
8. 一个完整案例:字符串逆序功能从需求到实现
8.1 需求拆解与函数设计
热搜词里"字符串逆序 c语言"是常考题。假设需求是:给定一个字符串,原地逆序,不允许使用额外数组。这句话里包含几个关键信息:
- "字符串"在C语言里是以
\0结尾的字符数组。 - "原地"意味着不能开一个新数组再拷贝回去,必须直接在原字符数组上交换位置。
- 目标是逆序,就是第一个字符和最后一个字符交换,第二个和倒数第二个交换,以此类推。
核心函数设计如下:
void reverse_string(char *str) { int len = 0; while (str[len] != '\0') { len++; } for (int i = 0; i < len / 2; i++) { char temp = str[i]; str[i] = str[len - 1 - i]; str[len - 1 - i] = temp; } }这个函数的输入参数是char *str,本质上和char str[]等价。因为传入的是数组首地址,函数内部的交换操作会直接修改原有的字符数组,正好满足"原地"要求。
8.2 边界条件的处理
写这个函数时最容易考到的边界条件是空字符串和单字符字符串。空字符串("",长度0)不会进入循环,函数直接返回,无操作;单字符字符串(如"a",长度1)也不会进入循环,返回后还是"a"。这两类情况天然安全,不需要额外判断。
另一个容易忽视的是中文字符串。char在C语言里占一个字节,而中文字符在UTF-8编码下占三个字节,所以"你好"实际存储为\xE4\xBD\xA0\xE5\xA5\xBD六个字节。用上面这个函数逆序后,字节顺序会反转,中文字符就变成乱码了。如果需求确实要处理中文,得用宽字符wchar_t或者按多字节字符边界处理,复杂度会高不少。这个点算是面试加分项,但日常练习不需要过度纠结。
8.3 完整测试代码
#include <stdio.h> void reverse_string(char *str) { int len = 0; while (str[len] != '\0') { len++; } for (int i = 0; i < len / 2; i++) { char temp = str[i]; str[i] = str[len - 1 - i]; str[len - 1 - i] = temp; } } int main() { char s1[] = "hello"; char s2[] = ""; char s3[] = "a"; reverse_string(s1); reverse_string(s2); reverse_string(s3); printf("s1: %s\n", s1); // olleh printf("s2: %s\n", s2); // (空) printf("s3: %s\n", s3); // a return 0; }注意s1必须是字符数组而不能是字符串常量。如果写成char *s1 = "hello";然后调用reverse_string(s1),运行时大概率段错误,因为字符串常量通常存放在只读内存段,写入会触发保护。这个坑我在学生时代踩过,提醒你也注意——需要修改的字符串,必须用可写的字符数组来存放。
9. 函数学习的进阶路径:从基础到项目实战
9.1 阶段一:把常见算法用函数重写
如果你正在练习冒泡排序c语言、九九乘法表c语言、字符串逆序c语言这类基础题,建议在写对之后多做一个动作:把核心逻辑封装成函数。比如九九乘法表,不要只在main里用两层循环输出,试着写一个void print_multiplication_table(int n),把规模n作为参数,这样改需求时只需要换参数。
9.2 阶段二:练习自顶向下的拆分
给自己一个稍微复杂的任务,比如"读入学生成绩,计算平均分、最高分、最低分,并输出不及格人数"。不要一口气在main里写完,先列出需要哪些功能,再逐个实现:
double compute_average(int scores[], int n)int find_max(int scores[], int n)int find_min(int scores[], int n)int count_failed(int scores[], int n)- 然后把
main变成"数据输入 + 依次调用上述函数"的流程
这种训练的价值在于,你逐渐学会把大问题切成可管理的小块,每一块都可以单独测试、单独验证。
9.3 阶段三:接触指针与函数的组合
学完指针之后,回头再看函数传参,很多之前模糊的东西会变得清晰。比如你要写一个函数返回一个数组,但C语言函数不能返回局部数组名(因为局部数组在函数结束时就销毁了)。这时候你有几个选择:
- 让调用方分配数组,通过参数传入,函数负责填充。
- 用
malloc在堆上分配内存,返回指针,然后调用方负责free。 - 返回一个静态局部数组的指针,但也意味着每次调用返回的都可能是同一块内存。
这三种方式各有优劣,理解了函数生命周期和内存分布之后,才算真正掌握了C语言的函数。
9.4 阶段四:阅读别人写的函数
多读开源项目或优秀库的代码,尤其是头文件。注意看别人怎么组织函数声明、怎么用const修饰参数、怎么命名函数、怎么设计参数顺序。比如标准库的qsort函数签名是:
void qsort(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *));这里的void *是为了接收任意类型的数据指针,size_t是为了表示元素个数和元素大小,函数指针compar表示比较规则。你能把函数签名看懂,就说明这一阶段学得相当扎实了。
提示:学习函数的核心不是背语法,而是养成"用函数组织代码"的直觉。刚开始写的时候可能会觉得拆函数麻烦,但等代码量上来,你会感谢当初拆得够细的自己。每次写完一段代码,回头看一眼——如果
main函数超过几十行,就该拆一拆了。
最后说一个我在实际教学中反复强调的小技巧:写好一个函数后,立刻写个小的测试代码单独验证它。比如你写了reverse_string,就在main里塞两三个不同情况的测试用例——普通字符串、单字符、空字符串——跑一遍,确认输出正确。这样后面拼进大项目时,出问题的概率会小很多。先保证每一个函数正确,再组合起来,这个就叫"单元测试"思维,是写任何规模代码都适用的基本功。