写程序就像搭积木。你不会希望每次搭房子都从烧砖开始——你希望有现成的"墙"、"窗户"、"门"这些组件,拿来就能组装。在编程世界里,函数就是这样的组件:一段写好之后可以反复调用的代码。

你已经知道怎么定义变量、怎么用 printf 和 scanf 做输入输出、for 循环怎么写——这些基本功已经帮你打好了地基。加上今天要学的函数,你就能开始写真正有结构的程序了。

在 C 语言中,程序是由无数个小函数组合而成的,一个大的计算任务可以分解成若干个较小的函数来完成。同时,一个能完成某项特定任务的函数也可以被重复使用——这就是"代码复用"的本质。

函数的概念

数学里我们见过函数,比如一次函数 y = kx + b——k 和 b 是常数,给一个任意的 x,就得到一个 y。C 语言借鉴了这个思想:函数就是一段完成特定任务的小段代码,它接收输入(参数),经过内部计算,产生输出(返回值)。有些教材把它翻译成"子程序",这个译法其实更贴近本质——函数就是可以被主程序调用的子任务处理器。

C 语言中的函数分为两大类:

  1. 库函数:编译器厂商按照 C 语言标准给你准备好的现成函数,比如 printf、scanf、sqrt;
  2. 自定义函数:你自己根据需求写的函数。

有个观点我想提前强调:库函数只是帮你省时间的工具,自定义函数才是你表达逻辑的画笔。会调用库函数不等于会编程,能自己设计函数才算入门。

为什么要有函数?

把代码拆成函数至少有四个理由:

  1. 复用:同一段逻辑写一次,到处调用,避免复制粘贴;
  2. 可读性:main 函数里全是函数调用,一眼看清程序做什么("显示菜单"、"开始游戏"、"保存成绩"),细节藏在各自的函数里;
  3. 可维护:改一个功能只动一个函数,不会牵一发而动全身;
  4. 可测试:小函数独立验证,比在几百行的 main 里找 bug 容易得多。

以后你写扫雷、通讯录这些大程序时会深刻体会——没有函数,程序超过 200 行就开始失控。

库函数

C 语言标准(ANSI C / ISO C)规定了一系列常用函数的功能标准,这叫"标准库"。不同的编译器厂商(微软的 MSVC、GNU 的 GCC、LLVM 的 Clang 等)各自实现这套标准。所以你写的代码只要调用的是标准库函数,在任何符合标准的编译器上都能编译通过——这就是可移植性的来源。

使用库函数有三个步骤,缺一不可:

  1. 查阅文档:去 cppreference.com 或 cplusplus.com 找到你要用的函数;
  2. 包含头文件:每个库函数都在某个特定的头文件中声明,必须 #include 对应的头文件;
  3. 正确调用:按照函数原型给出的参数类型和返回类型来使用。

学会了查文档,还要学会读文档。绝大多数库函数的文档都遵循同一套格式,看懂它你就能自学任何新函数:

  1. 函数原型:double sqrt(double x); —— 函数长什么样,一眼看清返回类型和参数;
  2. 功能介绍:用一句话说明这个函数干什么(比如 "Compute square root");
  3. 参数和返回类型说明:每个参数的含义、取值范围,返回值的含义;
  4. 代码举例:官方给出的最小可用示例;
  5. 代码输出:示例的运行结果,帮你对照验证;
  6. 相关知识链接:相关的其它函数(比如 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 个问题:

  1. 这个函数负责什么?(一个函数只做一件事)
  2. 需要从外面拿什么进来?(参数)
  3. 需要给外面什么结果?(返回值)
  4. 名字能不能让人看懂?(动词+名词,如 GetScore、PrintMenu)
  5. 有没有更简单的写法?(比如用 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)!
}

现在你不用完全理解它(下一篇文章会专门讲),但你至少要意识到:函数让程序有了"自我分解"的能力——一个问题可以被分解成"更小的同类问题"。这是很多优雅算法的基石。


本篇思考题

  1. 为什么 Swap(a, b) 交换不了 a 和 b?如果把参数改成数组,为什么就能改了?
  2. 数组传参时,形参 int arr[] 和 int* arr 有什么区别?为什么说它们本质相同?
  3. printf("%d", printf("%d", printf("%d", 43))) 的输出为什么是 4321?
  4. static int i = 0; 放在函数里和放在函数外,效果有什么不同?
  5. 一个函数忘了声明就调用,编译报错还是链接报错?只有声明没有定义呢?
  6. 用函数封装一个"求一个三位数的各位数字之和"的程序,要求输入 123 输出 6。
  7. 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 语言项目是什么样子。而在这之前,下一篇文章我们先解锁函数的终极形态:递归。