写程序就像搭积木。你不会希望每次搭房子都从烧砖开始——你希望有现成的"墙"、"窗户"、"门"这些组件,拿来就能组装。在编程世界里,函数就是这样的组件:一段写好之后可以反复调用的代码。
你已经知道怎么定义变量、怎么用 printf 和 scanf 做输入输出、for 循环怎么写——这些基本功已经帮你打好了地基。加上今天要学的函数,你就能开始写真正有结构的程序了。
在 C 语言中,程序是由无数个小函数组合而成的,一个大的计算任务可以分解成若干个较小的函数来完成。同时,一个能完成某项特定任务的函数也可以被重复使用——这就是"代码复用"的本质。
函数的概念
数学里我们见过函数,比如一次函数 y = kx + b——k 和 b 是常数,给一个任意的 x,就得到一个 y。C 语言借鉴了这个思想:函数就是一段完成特定任务的小段代码,它接收输入(参数),经过内部计算,产生输出(返回值)。有些教材把它翻译成"子程序",这个译法其实更贴近本质——函数就是可以被主程序调用的子任务处理器。
C 语言中的函数分为两大类:
- 库函数:编译器厂商按照 C 语言标准给你准备好的现成函数,比如 printf、scanf、sqrt;
- 自定义函数:你自己根据需求写的函数。
有个观点我想提前强调:库函数只是帮你省时间的工具,自定义函数才是你表达逻辑的画笔。会调用库函数不等于会编程,能自己设计函数才算入门。
为什么要有函数?
把代码拆成函数至少有四个理由:
- 复用:同一段逻辑写一次,到处调用,避免复制粘贴;
- 可读性:
main函数里全是函数调用,一眼看清程序做什么("显示菜单"、"开始游戏"、"保存成绩"),细节藏在各自的函数里; - 可维护:改一个功能只动一个函数,不会牵一发而动全身;
- 可测试:小函数独立验证,比在几百行的 main 里找 bug 容易得多。
以后你写扫雷、通讯录这些大程序时会深刻体会——没有函数,程序超过 200 行就开始失控。
库函数
C 语言标准(ANSI C / ISO C)规定了一系列常用函数的功能标准,这叫"标准库"。不同的编译器厂商(微软的 MSVC、GNU 的 GCC、LLVM 的 Clang 等)各自实现这套标准。所以你写的代码只要调用的是标准库函数,在任何符合标准的编译器上都能编译通过——这就是可移植性的来源。
使用库函数有三个步骤,缺一不可:
- 查阅文档:去 cppreference.com 或 cplusplus.com 找到你要用的函数;
- 包含头文件:每个库函数都在某个特定的头文件中声明,必须
#include对应的头文件; - 正确调用:按照函数原型给出的参数类型和返回类型来使用。
学会了查文档,还要学会读文档。绝大多数库函数的文档都遵循同一套格式,看懂它你就能自学任何新函数:
- 函数原型:
double sqrt(double x);—— 函数长什么样,一眼看清返回类型和参数; - 功能介绍:用一句话说明这个函数干什么(比如 "Compute square root");
- 参数和返回类型说明:每个参数的含义、取值范围,返回值的含义;
- 代码举例:官方给出的最小可用示例;
- 代码输出:示例的运行结果,帮你对照验证;
- 相关知识链接:相关的其它函数(比如 sqrt 的兄弟函数 pow、fabs 等),方便扩展学习。
拿到一个陌生的库函数,就按这 6 个要素去读,从"背函数"变成"查函数"。
举个具体的例子——sqrt 函数用于计算平方根。它在 math.h 头文件中,函数原型是:
double sqrt(double x);
// double:返回值类型,表示计算结果是一个双精度浮点数
// sqrt: 函数名
// double x:参数列表,表示该函数接收一个 double 类型的参数完整的调用代码:
#include <stdio.h> // printf 所在的头文件
#include <math.h> // sqrt 所在的头文件,不包含这行的话编译可能出问题
int main()
{
double d = 16.0; // 待求平方根的数
double r = sqrt(d); // 调用 sqrt 函数,传入 d,返回结果存到 r
printf("%lf\n", r); // 输出结果:4.000000
return 0;
}这里有个很多人踩过的坑:某些编译器(比如老版本的 VS)在编译 math.h 相关代码时需要手动链接数学库 -lm。如果你用的是 GCC 命令行编译,记得写成 gcc test.c -lm,否则会报"undefined reference"的链接错误。换个角度看,这正好说明了库函数是独立存在的——函数声明告诉编译器"这个东西存在",但真正的实现代码在编译好的库文件里,需要链接器把它们接在一起。
常用库函数一览
| 头文件 | 代表函数 | 功能 |
|---|---|---|
<stdio.h> | printf / scanf / getchar / putchar | 输入输出 |
<stdlib.h> | rand / srand / malloc / free / exit | 随机数、内存分配、程序退出 |
<string.h> | strlen / strcpy / strcmp / strcat | 字符串操作 |
<math.h> | sqrt / pow / fabs / floor / ceil | 数学计算(可能需链接 -lm) |
<time.h> | time / clock | 时间相关 |
<ctype.h> | isdigit / isalpha / toupper / tolower | 字符分类与转换 |
这些函数不需要背,但要知道"哪个头文件里有什么",用的时候查文档。
自定义函数
库函数再好,也不可能覆盖所有需求。真正让你拥有创造力的是自定义函数。
自定义函数的语法形式和库函数完全一致:
ret_type fun_name(形式参数)
{
// 函数体
}
你可以把这个函数想象成一个小型加工厂:
- 原材料(参数):从外面送进来的数据;
- 加工车间(函数体):执行计算逻辑的地方;
- 产品(返回值):加工完毕输出的结果。
各部分的设计要点:
- ret_type:函数返回类型。如果函数完成计算后需要返回结果,就写对应的类型。如果函数只是"做一件事"而不需要返回数据(比如纯粹打印),就写
void。 - fun_name:函数名。起名字的黄金法则是——名字应该描述函数做什么,而不是怎么做。比如
sort_array比bubble_sort_array更好,因为实现细节可能改变,但功能不变。 - 形式参数:交代清楚每个参数的类型和名字。
void表示明确没有参数。C 语言中,空括号()和(void)有细微差别——前者表示"参数未知",后者表示"明确无参",建议用后者。 - 函数体:
{}包裹的逻辑块。
下面写一个加法函数:
#include <stdio.h>
// Add 函数:接收两个 int,返回它们的和
int Add(int x, int y) // x 和 y 是形式参数(形参)
{
int z = 0; // 局部变量,只在 Add 函数内有效
z = x + y; // 执行加法运算
return z; // 返回计算结果
}
// 也可以简写为:return x + y;
int main()
{
int a = 0;
int b = 0;
scanf("%d %d", &a, &b); // 从用户输入读取两个整数
int r = Add(a, b); // 调用 Add,传入 a 和 b(实参),结果存到 r
printf("%d\n", r); // 输出结果
return 0;
}设计函数时的自问清单
写任何自定义函数前,先问自己 5 个问题:
- 这个函数负责什么?(一个函数只做一件事)
- 需要从外面拿什么进来?(参数)
- 需要给外面什么结果?(返回值)
- 名字能不能让人看懂?(动词+名词,如
GetScore、PrintMenu) - 有没有更简单的写法?(比如用
return x + y;代替中间变量)
形参和实参
上面代码里,调用 Add(a, b) 时传进去的 a 和 b 叫实际参数(实参),定义函数时写的 x 和 y 叫形式参数(形参)。
一个最常见的疑问:实参和形参是同一个东西吗?
不是。 它们是两块独立的内存空间。形参在函数被调用时才会分配内存,用来存放实参传递过来的值——这个过程叫"形参的实例化"。换句话说,形参是实参的一份临时拷贝。
你可以通过调试来验证这一点。在 VS 中给 Add 函数的第一行打上断点,观察 a 和 x 的地址——它们是不同的。在函数内部修改形参,不会影响外部的实参。 C 语言默认采用"按值传递"(pass by value):
#include <stdio.h>
// 这个函数试图"交换"两个数——但它做不到!
void Swap(int x, int y)
{
int temp = x; // temp 保存 x 的值
x = y; // x 变成 y 的值
y = temp; // y 变成原来 x 的值
// 函数结束后 x 和 y 被销毁,外面的 a 和 b 纹丝不动
}
int main()
{
int a = 10;
int b = 20;
printf("交换前:a=%d, b=%d\n", a, b); // a=10, b=20
Swap(a, b); // 按值传递,形参是实参的拷贝
printf("交换后:a=%d, b=%d\n", a, b); // 依然是 a=10, b=20!
return 0;
}这个例子揭示了按值传递的核心特点。后面学到指针的时候,你会掌握"真正修改外部变量"的方法——把变量的地址传进去。现在先记住结论:形参是实参的拷贝,函数内改形参改不到实参。
传值 vs 传址——提前剧透
学指针之前,先用一个直观的方式理解这两者:
- 传值:把"值"复印一份递进去,复印件怎么改都不影响原件;
- 传址:把"原件存放的位置"告诉函数,函数按地址找到原件直接改。
scanf 就是一个典型的"传址"例子——它必须拿到 &score(地址)才能把输入写进 score。而普通的计算函数(如 Add)只需要"值",传值就够。什么时候用传址?需要修改调用者的变量时。记住这个判断标准,学指针时你会豁然开朗。
return 语句
return 是函数和外界沟通的最后一道桥梁。有几点必须记住:
- return 后面可以是一个值,也可以是一个表达式(如
return x + y;),表达式会先求值再返回; - return 后面什么也不写(
return;),仅适用于返回类型为void的函数; - return 一旦执行,函数立即返回,后面的代码不会执行——这可以用来做"提前退出";
- 如果 return 的值和函数声明的返回类型不一致,编译器会做隐式类型转换;
- 如果函数内部有 if/else 分支,每个分支都必须有 return 语句,否则可能触发编译警告甚至未定义行为;
- C89 中,如果函数不写返回类型,默认返回 int(C99 起不再允许隐式 int,建议始终显式声明);
- 函数声明了非 void 返回类型但内部没有 return 语句,返回值是不确定的(未定义行为)。
#include <stdio.h>
// 根据分数返回评级
char GetGrade(int score)
{
if (score >= 90)
return 'A'; // >=90 返回 A,函数结束
else if (score >= 80)
return 'B'; // 80-89 返回 B
else if (score >= 70)
return 'C'; // 70-79 返回 C
else if (score >= 60)
return 'D'; // 60-69 返回 D
else
return 'F'; // <60 返回 F
// 每个分支都有 return,编译器不会报警告
}
int main()
{
printf("85分评级:%c\n", GetGrade(85)); // B
printf("42分评级:%c\n", GetGrade(42)); // F
return 0;
}提前 return 的两种风格
看两种等价的写法,体会"提前 return"的威力:
/* 风格一:层层缩进(不推荐) */
void Check(int score)
{
if (score >= 60)
{
if (score >= 90)
{
printf("优秀\n");
}
else
{
printf("及格\n");
}
}
else
{
printf("不及格\n");
}
}
/* 风格二:提前 return 拉平(推荐) */
void Check(int score)
{
if (score < 60)
{
printf("不及格\n");
return; // 提前退出,后面的代码不再执行
}
if (score >= 90)
{
printf("优秀\n");
return;
}
printf("及格\n");
}"先排除异常情况,再处理正常逻辑"这种写法叫卫语句(guard clause),能让深层嵌套变平、逻辑更清晰。void 函数里 return; 就是"提前结束"的意思。
数组做函数参数
当你把数组传给函数时,事情变得有趣起来。先看一个需求:写两个函数,一个把整型数组全部置为 -1,另一个打印数组内容。
#include <stdio.h>
// set_arr:将数组的每个元素设置为 -1
// arr[]:形参,写成数组形式,大小可以省略
// sz:数组的元素个数,必须单独传递
void set_arr(int arr[], int sz)
{
int i = 0;
for (i = 0; i < sz; i++)
{
arr[i] = -1; // 通过下标访问并修改数组元素
}
}
// print_arr:遍历并打印数组
void print_arr(int arr[], int sz)
{
int i = 0;
for (i = 0; i < sz; i++)
{
printf("%d ", arr[i]); // 逐个打印,空格分隔
}
printf("\n"); // 最后换行
}
int main()
{
int arr[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int sz = sizeof(arr) / sizeof(arr[0]); // 计算数组元素个数
print_arr(arr, sz); // 打印原始数组:1 2 3 4 5 6 7 8 9 10
set_arr(arr, sz); // 将所有元素设为 -1
print_arr(arr, sz); // 打印修改后:-1 -1 -1 -1 -1 -1 -1 -1 -1 -1
return 0;
}关于数组传参,有几个必须记住的知识点:
- 一维数组作为形参时,数组大小可以省略(写成
int arr[]就行,不需要写int arr[10]); - 二维数组作为形参时,行可以省略,但列不能省略(比如
int arr[][4]); - 数组传参不会创建新的数组——形参 arr 本质上是指向原数组首元素的指针;
- 正因为如此,函数内部对数组元素的修改,会直接反映到外部的原数组上(和普通变量的按值传递完全不同!);
- 数组的长度信息在传参过程中会丢失,所以必须单独传递数组元素个数。
为什么数组传参会"特殊"?因为数组很大,如果按值复制一份传给函数,内存和时间开销都不可接受。所以 C 语言规定:数组传参时,数组名退化成指针,函数只拿到数组首元素的地址——形参和实参指向同一块内存,改数组元素自然就改到了原数组。这既是效率的胜利,也是隐患的来源(函数能"偷偷"改掉你的数据)。
你可以通过这个实验来验证最后一点:
#include <stdio.h>
void test(int arr[], int sz)
{
printf("函数内部 sizeof(arr) = %zu\n", sizeof(arr));
// 输出的是指针大小(通常 4 或 8),而不是整个数组的大小!
}
int main()
{
int arr[10] = {0};
printf("main 中 sizeof(arr) = %zu\n", sizeof(arr)); // 40(10 * 4)
test(arr, 10); // 8(64位系统指针大小)
return 0;
}sizeof 的差异就是"形参 arr 其实是指针"的铁证——同一个名字,在 main 里是数组(40 字节),进函数就成了指针(8 字节)。这也是为什么传数组时必须额外传一个长度参数——函数自己算不出来。
嵌套调用和链式访问
嵌套调用就是函数之间互相调用。每个函数就像一个乐高零件,正因为多个零件的无缝配合才能搭建出精美的作品。
注意区分"嵌套调用"和"嵌套定义"——C 语言允许嵌套调用,但绝对不允许在一个函数内部定义另一个函数。
下面的例子计算某年某月有多少天,展示了嵌套调用的实际应用:
#include <stdio.h>
// 判断闰年:能被4整除但不能被100整除,或者能被400整除
int is_leap_year(int y)
{
if (((y % 4 == 0) && (y % 100 != 0)) || (y % 400 == 0))
return 1; // 是闰年,返回1(真)
else
return 0; // 不是闰年,返回0(假)
}
// 获取某年某月的天数
// 函数内部调用了 is_leap_year 来判断2月是否需要加1天
int get_days_of_month(int y, int m)
{
// 用一个数组存储各月天数,索引0不用,方便1-12月对应
int days[] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
int day = days[m]; // 先取基础天数
if (is_leap_year(y) && m == 2) // 嵌套调用:闰年且是2月
day += 1; // 2月天数+1
return day;
}
int main()
{
int y = 0, m = 0;
scanf("%d %d", &y, &m); // 输入年份和月份
int d = get_days_of_month(y, m); // 嵌套调用链:main→get_days_of_month→is_leap_year
printf("%d年%d月有%d天\n", y, m, d); // 输出结果
return 0;
}这就是模块化设计的魅力——is_leap_year 自己只负责判断闰年这一件小事,get_days_of_month 通过调用它来完成更复杂的任务。每个函数只做一件事,组合起来却能完成很复杂的工作。
链式访问则是把一个函数的返回值直接作为另一个函数的参数,像链条一样串起来:
#include <stdio.h>
#include <string.h>
int main()
{
// 常规写法:分两步
// int len = strlen("abcdef"); // 1. 先求字符串长度
// printf("%d\n", len); // 2. 再打印
// 链式访问写法:一步搞定
printf("%d\n", strlen("abcdef")); // strlen 的返回值直接作为 printf 的参数
return 0;
}来看一个更"烧脑"的经典面试题:
#include <stdio.h>
int main()
{
printf("%d", printf("%d", printf("%d", 43)));
return 0;
}这个代码的输出是 4321。为什么?
关键知识点:printf 的返回值是成功打印在屏幕上的字符个数。
从内到外逐层分析:
- 最内层
printf("%d", 43)打印43(2个字符),返回2; - 中间层
printf("%d", 2)打印2(1个字符),返回1; - 最外层
printf("%d", 1)打印1(1个字符)。
所以屏幕上的输出按时间顺序是:4→3→2→1,最终显示为 4321。
这个题考察的就是"函数有返回值,返回值可以继续被使用"这个思维。很多初学者以为 printf 只是"打印"没有返回值——其实所有非 void 函数都有返回值,只是平时被忽略了。
函数的声明和定义
C 语言编译器是从上往下扫描源代码的。如果你在调用函数之前没有让编译器"认识"这个函数,编译器就会报错或警告。
#include <stdio.h>
int main()
{
int y = 0;
scanf("%d", &y);
int r = is_leap_year(y); // 调用了 is_leap_year,但编译器还没见到它的定义!
printf("%d\n", r);
return 0;
}
// 函数定义在调用之后
int is_leap_year(int y)
{
return ((y % 4 == 0 && y % 100 != 0) || (y % 400 == 0));
}这段代码在 VS2022 下会报出类似 "is_leap_year": 未声明的标识符 的警告。解决方法:在调用之前先声明。
#include <stdio.h>
int is_leap_year(int y); // 函数声明——只交代函数名、返回类型和参数类型
int main()
{
int y = 0;
scanf("%d", &y);
int r = is_leap_year(y); // 编译器这时已经认识 is_leap_year 了
printf("%d\n", r);
return 0;
}
int is_leap_year(int y) // 函数定义——包含完整的函数体
{
return ((y % 4 == 0 && y % 100 != 0) || (y % 400 == 0));
}规则很简单:函数调用之前必须让编译器先"认识"它。 函数定义本身也算是一种声明(定义就是带函数体的声明),所以如果你把函数定义放在调用之前,就不需要单独写声明。
声明时还有个细节:参数名可以省略。int is_leap_year(int); 也是合法声明——编译器只需要知道"参数的类型",不需要知道参数叫什么名字。但为了可读性,通常还是写上名字。
在实际项目中,我们通常不会把所有代码塞在一个 .c 文件里。标准做法是:
- 头文件(.h):放函数声明、类型定义、宏定义
- 源文件(.c):放函数的具体实现
这样组织有三个好处:接口与实现分离、方便代码复用、加速增量编译。注意尖括号 <> 和双引号 "" 的区别:<> 去系统目录找头文件(库函数的),"" 先在当前目录找,找不到再去系统目录。
// ============ add.h ============
// 函数的声明放在这里,谁想用 Add 函数就 include 这个头文件
int Add(int x, int y);
// ============ add.c ============
// 函数的实现放在这里
int Add(int x, int y)
{
return x + y;
}
// ============ test.c ============
#include <stdio.h>
#include "add.h" // 用双引号包含自己写的头文件
int main()
{
int a = 10;
int b = 20;
int c = Add(a, b);
printf("%d\n", c); // 30
return 0;
}
编译链接视角下的"声明 vs 定义"
从编译器的视角看这件事会更清晰:
- 声明是给编译器看的承诺:"这个函数存在,长这样,你先让我调用"。编译器据此检查调用是否合法(参数个数、类型对不对)。
- 定义是给链接器看的事实:"这个函数的实现在这里"。链接器据此把调用和实现接起来。
所以"只有声明没有定义"的代码能编译,但在链接阶段报错(undefined reference)。反过来,"忘了声明就调用"会在编译阶段报错。理解了这两个阶段的区别,很多报错你一眼就能定位是编译错还是链接错。
static 和 extern
最后说说 static 和 extern 这两个关键字。要理解它们,先理解两个概念:
- 作用域(scope):名字在代码中的有效范围。局部变量的作用域是它所在的
{}范围,全局变量的作用域是整个工程。 - 生命周期(lifetime):变量从创建(分配内存)到销毁(回收内存)的时间段。
static 修饰局部变量时,改变的是生命周期:
#include <stdio.h>
// 版本1:i 是普通局部变量,每次调用 test 都重新创建和初始化
void test1()
{
int i = 0; // 每次进入 test1,创建 i 并初始化为 0
i++; // i 自增
printf("%d ", i);// 打印 i
} // 离开 test1,i 被销毁
// 版本2:i 被 static 修饰
void test2()
{
static int i = 0; // static!i 只会初始化一次
i++; // 每次调用,i 在上次的值基础上自增
printf("%d ", i);
}
int main()
{
int i;
printf("test1: ");
for (i = 0; i < 5; i++)
test1(); // 输出:1 1 1 1 1
printf("\n");
printf("test2: ");
for (i = 0; i < 5; i++)
test2(); // 输出:1 2 3 4 5
printf("\n");
return 0;
}为什么会有这种差异?答案在于存储类型:普通局部变量存储在栈区(stack),函数调用时创建,返回时销毁;static 修饰的局部变量存储在静态区,生命周期与程序同寿。但作用域不变——你还是只能在定义它的函数内部访问它。
用一个表格总结 static 的三种效果:
| 修饰对象 | 作用域 | 生命周期 | 链接属性 |
|---|---|---|---|
| 局部变量(普通) | 所在 {} | 函数调用期间 | 无 |
| 局部变量(static) | 所在 {}(不变) | 整个程序运行期 | 无 |
| 全局变量(普通) | 整个工程 | 整个程序运行期 | 外部链接 |
| 全局变量(static) | 整个工程 | 整个程序运行期 | 内部链接(仅本文件) |
static 修饰全局变量或函数时,改变的是链接属性——从"外部链接"变成"内部链接",只能在定义它的源文件中使用,其他文件即使 extern 声明也访问不到。这在写辅助函数时非常有用——不想暴露给模块外部的,就加上 static。下面用多文件的例子直观对比 static 修饰函数前后的差别:
// ============ math_utils.c ============
// helper 只是内部辅助函数,加上 static,外界永远看不到、也链接不到它
static int helper(int x)
{
return x * 2;
}
// Double 是外部链接函数,main 里可以通过 extern 声明来调用
int Double(int n)
{
return helper(n); // 在同一个文件内部,可以正常调用 helper
}
// ============ main.c ============
#include <stdio.h>
extern int Double(int n); // 声明外部符号 Double,链接阶段能正确找到
// extern int helper(int n); // 如果把这一行取消注释,链接阶段会报错:
// // helper 是内部链接,虽然声明了,但外界找不到它的实现
int main()
{
printf("%d\n", Double(3)); // 输出 6,Double 调用它内部的 helper(3) 得到 2*3
return 0;
}这个例子把 static 的两种用途一起说清了:static 修饰函数(以及全局变量)的效果是"只在本文件可见",适合用来隐藏不想暴露给外部的内部实现细节。
那么反过来,extern 是干什么用的?它专门用来声明外部符号——一个符号在 A 文件中定义,想在 B 文件中使用,就用 extern 声明一下:
// ============ add.c ============
int g_val = 2018; // 全局变量定义(默认具有外部链接属性)
// ============ test.c ============
#include <stdio.h>
extern int g_val; // 声明:g_val 在别的文件中定义,我要使用它
int main()
{
printf("%d\n", g_val); // 2018,可以正常使用
return 0;
}注意:函数默认也具有外部链接属性,所以 extern int Add(int x, int y); 这种写法在别的文件中声明函数时也是合法的(只不过函数声明本身已经隐含了外部链接,extern 常可省略)。而一旦给定义处的 g_val 或 Add 加上 static,外部链接就变成了内部链接,上面 test.c 中的 extern 声明就会在链接阶段报错——这就是 static 和 extern 的分工:一个负责"锁门",一个负责"开门"。
static 局部变量的典型应用
"static 局部变量只初始化一次、跨调用保留"这个特性,最常见的应用是计数器和缓存:
#include <stdio.h>
// 统计函数被调用了多少次
int CallCount()
{
static int count = 0; // 只初始化一次,之后一直保留
count++;
return count;
}
int main()
{
printf("%d\n", CallCount()); // 1
printf("%d\n", CallCount()); // 2
printf("%d\n", CallCount()); // 3
return 0;
}再强调一遍 static 局部变量的作用域:仍然只在定义它的函数内可见。它是"生命周期变长",不是"作用域变大"——外面的函数依然访问不到它。
函数实战——把知识串起来
实战 1:用函数封装素数判断
前面流程控制篇里我们写过求 100~200 素数的代码,现在用函数把它模块化:
#include <stdio.h>
// 判断 n 是否是素数:是返回 1,不是返回 0
int IsPrime(int n)
{
int i = 0;
if (n < 2) // 0 和 1 不是素数
return 0;
for (i = 2; i * i <= n; i++) /* 只试到 sqrt(n) 即可 */
{
if (n % i == 0)
return 0; // 找到因子,不是素数,提前返回
}
return 1; // 找不到因子,是素数
}
int main()
{
int i = 0;
for (i = 100; i <= 200; i++)
{
if (IsPrime(i)) // 调用函数判断
printf("%d ", i);
}
printf("\n");
return 0;
}注意 i * i <= n 这个优化:如果 n 有因子,必有一个因子不超过 √n,所以试除到 √n 就够了。用 i * i <= n 避免了调用 sqrt 函数(更快)。IsPrime 的职责非常纯粹——只回答"是/不是",main 里只负责遍历和打印。这就是函数化的好处:逻辑清晰、可复用、好测试。
实战 2:函数求两个数的最大值
#include <stdio.h>
int Max(int a, int b)
{
return a > b ? a : b; // 用三目操作符一行搞定
}
int main()
{
int x = 0, y = 0;
scanf("%d %d", &x, &y);
printf("较大值:%d\n", Max(x, y));
return 0;
}实战 3:打印九九乘法表(函数版)
#include <stdio.h>
void PrintTable()
{
int i = 0, j = 0;
for (i = 1; i <= 9; i++)
{
for (j = 1; j <= i; j++)
printf("%d*%d=%-2d ", j, i, i * j);
printf("\n");
}
}
int main()
{
PrintTable(); // 一个调用,输出整个表
return 0;
}以后想在程序的任何地方打印乘法表,一个 PrintTable() 就够了——这就是"一次编写,处处复用"的直观体验。
函数设计与递归的预告
函数有一个"惊人"的能力:函数可以调用它自己——这叫递归。比如求阶乘:
int Fact(int n)
{
if (n <= 1)
return 1; // 终止条件:1! = 1
return n * Fact(n - 1); // 递归:n! = n × (n-1)!
}现在你不用完全理解它(下一篇文章会专门讲),但你至少要意识到:函数让程序有了"自我分解"的能力——一个问题可以被分解成"更小的同类问题"。这是很多优雅算法的基石。
本篇思考题
- 为什么
Swap(a, b)交换不了a和b?如果把参数改成数组,为什么就能改了? - 数组传参时,形参
int arr[]和int* arr有什么区别?为什么说它们本质相同? printf("%d", printf("%d", printf("%d", 43)))的输出为什么是4321?static int i = 0;放在函数里和放在函数外,效果有什么不同?- 一个函数忘了声明就调用,编译报错还是链接报错?只有声明没有定义呢?
- 用函数封装一个"求一个三位数的各位数字之和"的程序,要求输入 123 输出 6。
void函数里写return;合法吗?有什么用?
参考答案与详解
1. 为什么 Swap(a, b) 交换不了 a 和 b?改成数组为什么能改?
C 语言默认采用按值传递:调用 Swap(a, b) 时,形参 x、y 会各自拿到 a、b 的一份临时拷贝,这三组变量住在独立的内存里。函数内部交换的是 x、y 这两份拷贝,函数一返回,x、y 就被销毁,外面的 a、b 自始至终没被动过。
而数组传参完全相反——数组名会退化成指向首元素的指针,形参 arr 持有的正是实参数组那块内存的首地址。所以 arr[i] 访问的就是原数组的元素,函数内部改动直接反映到外部。一句话总结:普通变量传给函数是"复印",数组传给函数是"共享原件"。
2. 数组传参时,形参 int arr[] 和 int* arr 有什么区别?
在函数形参这个位置上,两者等价——int arr[] 会被编译器自动改写成 int* arr,都表示"一个指向 int 的指针"。区别只是"表面形式":arr[] 是给读者看的提示("这里本来应该传一个数组"),编译后和 int* arr 完全一样。证据就是函数内 sizeof(arr) 得到的是指针大小(4 或 8),而不是数组大小——说明形参确实是指针。这也是为什么传数组必须额外传长度(sz):函数内部拿不到数组的真实大小。
3. printf("%d", printf("%d", printf("%d", 43))) 的输出为什么是 4321?
因为 printf 的返回值是本次成功打印到屏幕上的字符个数。从内到外看:
- 最内层
printf("%d", 43)打印4、3两个字符 → 返回2; - 中间层
printf("%d", 2)打印2一个字符 → 返回1; - 最外层
printf("%d", 1)打印1一个字符。
四个字符按时间先后依次打出 4→3→2→1,所以最终屏幕上显示 4321。它考察的核心是"函数是有返回值的,返回值还能继续被使用"。
4. static int i = 0; 放在函数里和放在函数外,效果有什么不同?
- 放函数内:它是"静态局部变量"。改变的是生命周期——从栈区挪到静态区,只初始化一次、程序结束时才销毁,因此每次进函数会"记住"上一次的值(累加效果);但作用域不变,仍然只能在定义它的函数内部访问。
- 放函数外:它是"静态全局变量"。改变的是链接属性——从默认的"外部链接"变成"内部链接",只能在定义它的本源文件内使用,其他源文件即使
extern声明也无法链接到它(否则链接报错)。
快速记忆:函数里用 static 改生命周期,函数外用 static 改链接属性。
5. 忘了声明就调用 vs 只有声明没有定义,各报什么错?
- 忘了声明就调用 → 编译错误。编译器自上而下扫描,到调用行时还没"见过"这个函数,报"未声明的标识符",直接编译失败。
- 只有声明没有定义 → 链接错误。编译时看声明能通过(声明是给编译器的承诺,"这个函数存在"),但链接器去找函数实体时找不到,报
unresolved external symbol/LNK类错误。
这就是"声明给编译器看、定义给链接器看"的二元结构。
6. 求三位数的各位数字之和(输入 123 输出 6):
#include <stdio.h>
// 求一个整数的各位数字之和
int SumDigits(int n)
{
int sum = 0;
while (n > 0)
{
sum += n % 10; // 取最低位累加
n /= 10; // 去掉最低位
}
return sum;
}
int main()
{
int num = 0;
scanf("%d", &num);
printf("%d\n", SumDigits(num)); // 123 -> 6
return 0;
}推演 123:123%10=3(sum=3),123/10=12;12%10=2(sum=5),12/10=1;1%10=1(sum=6),1/10=0,循环结束,返回 6。技巧的核心:%10 取最低位,/10 削掉最低位,反复执行到变成 0。
7. void 函数里写 return; 合法吗?有什么用?
合法。return;(分号后不带任何值)只能用于返回类型为 void 的函数,作用就是"提前结束函数",后面的代码统统不执行。最常见的用途是卫语句(guard clause):先把异常/边界情况在前面用 return; 提前挡掉,让函数正常流程保持扁平、干净——正如正文里"提前 return 的两种风格"那段代码所示。
小结
从库函数到自定义函数,从形参实参到声明定义,函数是 C 语言程序的骨架。理解了函数,你就能开始写有结构、有组织的程序——不再是一大堆代码堆在 main 里,而是小块小块地拆分、组合、复用。
这一篇里最核心的三个认知再强调一遍:第一,形参是实参的拷贝(按值传递),普通变量在函数里改不动外面;第二,数组传参传的是地址(退化成指针),函数能直接改原数组,所以必须额外传长度;第三,声明是给编译器看的承诺,定义是给链接器看的事实,两者缺一不可。
下一阶段,我们将把函数和数组结合起来,从零构建一个完整的扫雷游戏——那时候你会体会到一个真正的 C 语言项目是什么样子。而在这之前,下一篇文章我们先解锁函数的终极形态:递归。
还没有评论 — 第一条由你来留。