第4章 函数与程序结构
代码的组织与复用
一、导读
1.1 本章学习目标
第4章是C语言从"写小程序"到"构建大系统"的关键转折点。前面三章我们学习了数据类型、运算符和控制流——这些是编写单个函数内部逻辑的工具。而本章将教你如何将程序分解为多个函数,如何在文件之间组织代码,以及如何管理变量的作用域和生命周期。
本章的核心学习目标包括:
- 掌握函数的定义和声明:返回类型、参数列表、函数体的完整语法。
- 理解值传递机制:C语言中参数是如何传递给函数的?为什么C语言只有值传递?
- 掌握函数原型:声明与定义分离的意义,以及头文件在其中的作用。
- 理解作用域规则:局部变量、全局变量、
static变量各自的生命周期和可见范围。 - 掌握递归:函数如何调用自身?递归的工作原理是什么?
- 学会组织多文件程序:头文件、源文件、编译单元之间的关系。
1.2 为什么本章如此重要
函数是C程序的基本构建块。一个大型C程序可能包含数千个函数,它们分布在数十个源文件中。如何设计函数的接口(参数和返回值),如何管理函数之间的数据流,如何组织文件的依赖关系——这些决定了程序的可维护性和可扩展性。
K&R 在第四章展示了一种优雅的编程哲学:将复杂的程序分解为小的、功能单一的函数,每个函数做好一件事。这种"分而治之"的策略是软件工程的基础。
更重要的是,本章引入了作用域和存储类的概念。理解一个变量在哪里可见、在哪里不可见、何时创建、何时销毁,是避免bug的关键。C语言的作用域规则比大多数现代语言更复杂,static、extern、register 等关键字的用法需要仔细掌握。
1.3 核心问题引导
在阅读本章时,请带着以下问题思考:
C语言的参数传递是"值传递"还是"引用传递"?如何通过指针实现类似"引用传递"的效果?
函数原型声明的意义是什么?为什么现代C语言要求函数在使用前必须声明?
static 关键字在局部变量和全局变量/函数上的含义有什么不同?
递归函数的两个基本要素是什么?如何保证递归一定会终止?
头文件应该包含什么?不应该包含什么?为什么?
如何避免多文件程序中的"重复定义"错误?
二、核心概念详解
2.1 函数的基本语法
return_type function_name(parameter_list) {
// 函数体
return value;
}// 一个完整的函数示例
int add(int a, int b) {
return a + b;
}
// 无返回值的函数
void print_greeting(const char *name) {
printf("Hello, %s!\n", name);
}
// 无参数的函数
int get_random_seed(void) {
return time(NULL);
}2.1.1 函数名与参数
函数名遵循与变量相同的命名规则。参数列表中的每个参数都必须声明类型:
// 正确
int max(int a, int b) { return (a > b) ? a : b; }
// 错误(K&R C风格,现代C不推荐)
int max(a, b)
int a, b;
{ return (a > b) ? a : b; }2.1.2 return 语句
return 语句终止函数的执行并返回一个值给调用者:
int absolute(int x) {
if (x < 0)
return -x; // 提前返回
return x;
}
// void函数可以省略return,或使用不带值的return
void process(int x) {
if (x < 0)
return; // 提前退出
// ... 处理x ...
}注意:如果函数声明了非 void 的返回类型,但没有 return 语句(或执行到了函数末尾),则返回值是未定义的。唯一的例外是 main 函数——如果 main 没有 return,C99标准规定返回0。
2.2 参数传递:值传递
C语言的参数传递始终是值传递(pass by value)。函数接收到的是参数的副本,对参数的修改不会影响原始变量。
void swap_wrong(int a, int b) {
int temp = a;
a = b;
b = temp;
// 无效!a和b是局部副本,不影响调用者的变量
}
int x = 10, y = 20;
swap_wrong(x, y);
// x仍然是10,y仍然是20通过指针实现"引用传递"效果:
void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
int x = 10, y = 20;
swap(&x, &y);
// x现在是20,y现在是10虽然C语言只有值传递,但通过传递指针的值(即地址),函数可以间接修改调用者的变量。这是C语言中实现"输出参数"的标准方式。
// 返回多个值的常见模式
int divide(int a, int b, int *quotient, int *remainder) {
if (b == 0) return -1; // 错误码
*quotient = a / b;
*remainder = a % b;
return 0; // 成功
}
int q, r;
if (divide(17, 5, &q, &r) == 0) {
printf("17 / 5 = %d remainder %d\n", q, r);
}2.3 函数原型
函数原型(function prototype)声明了函数的返回类型和参数类型,但不包含函数体:
// 函数原型
int add(int a, int b);
// 函数定义
int add(int a, int b) {
return a + b;
}为什么需要函数原型?
类型检查:编译器可以在函数调用处检查参数类型是否匹配。
声明顺序无关:有了原型,函数可以在定义之前被调用。
模块化:原型通常放在头文件中,让其他文件知道函数的接口。
// 没有原型时,编译器可能无法发现错误
// file1.c
int main() {
double x = sqrt(2); // 如果sqrt没有原型,编译器假设返回int
// x的值可能是错误的
}
// 有原型时
#include <math.h> // 包含 double sqrt(double); 的声明
double x = sqrt(2); // 编译器知道sqrt接受double,返回double参数名在原型中可以省略:
int add(int, int); // 合法,参数名省略
int calculate(int a, int); // 部分省略也合法2.4 作用域规则
2.4.1 局部变量
在函数内部或代码块中声明的变量是局部变量,只在声明它的代码块内可见:
void func(void) {
int x = 10; // x的作用域从声明到函数结束
{
int y = 20; // y的作用域从这个花括号到对应的闭花括号
printf("%d %d\n", x, y); // 可以访问x和y
}
// printf("%d\n", y); // 错误!y在这里不可见
}变量遮蔽(Shadowing):内层代码块中的变量可以遮蔽外层同名变量:
int x = 100; // 全局变量
void func(void) {
int x = 200; // 遮蔽全局变量
{
int x = 300; // 遮蔽外层局部变量
printf("%d\n", x); // 输出300
}
printf("%d\n", x); // 输出200
}2.4.2 全局变量
在所有函数外部声明的变量是全局变量,从声明位置开始到文件结束都可见:
int global_counter = 0; // 全局变量
void increment(void) {
global_counter++;
}
void print_counter(void) {
printf("Counter: %d\n", global_counter);
}全局变量在整个程序运行期间都存在,但它们有以下缺点:
- 任何函数都可以修改它们,难以追踪状态变化
- 增加了函数之间的耦合
- 降低了代码的可测试性和可复用性
建议:尽量避免使用全局变量。如果必须使用,用 static 限制其作用域为当前文件。
2.4.3 static 存储类
static 关键字在不同上下文中有不同的含义:
局部 `static` 变量:在函数调用之间保持值不变:
void counter(void) {
static int count = 0; // 只初始化一次
count++;
printf("Called %d times\n", count);
}
int main(void) {
counter(); // Called 1 times
counter(); // Called 2 times
counter(); // Called 3 times
return 0;
}局部 static 变量的特点:
- 在程序启动时初始化一次(默认为0)
- 在函数调用之间保持值不变
- 作用域仍然局限于声明它的函数内部
- 存储在数据段(而不是栈上)
全局 `static` 变量/函数:限制作用域为当前文件(内部链接):
// file1.c
static int file_local_var = 42; // 只在file1.c中可见
static void helper(void) { ... } // 只在file1.c中可调用
// file2.c
extern int file_local_var; // 错误!file_local_var是static的,不可见这种用法在多文件程序中非常重要——它允许你在一个文件中使用全局变量和辅助函数,而不影响其他文件。
2.4.4 extern 存储类
extern 声明一个在其他文件中定义的全局变量:
// file1.c
int shared_data = 100; // 定义(分配内存)
// file2.c
extern int shared_data; // 声明(不分配内存,引用file1.c中的定义)
void use_shared(void) {
printf("%d\n", shared_data);
}规则:全局变量只能有一个定义(One Definition Rule),但可以有多个声明。extern 声明不分配内存。
2.4.5 register 存储类
register 建议编译器将变量存储在寄存器中以提高访问速度:
void fast_loop(void) {
register int i;
for (i = 0; i < 1000000; i++) {
// i被频繁访问,放在寄存器中更快
}
}现代编译器通常能自动做出这种优化,register 关键字的实际效果有限。但 register 变量不能使用 & 运算符取地址。
2.5 递归
递归是函数调用自身的编程技术。每个递归函数都需要:
基本情况(base case):终止递归的条件
递归步骤(recursive step):将问题分解为更小的子问题
// 阶乘
int factorial(int n) {
if (n <= 1) return 1; // 基本情况
return n * factorial(n - 1); // 递归步骤
}
// 斐波那契数列
int fibonacci(int n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
// 二分查找
int binary_search(int arr[], int left, int right, int target) {
if (left > right) return -1; // 基本情况:未找到
int mid = left + (right - left) / 2;
if (arr[mid] == target) return mid;
if (arr[mid] > target)
return binary_search(arr, left, mid - 1, target);
else
return binary_search(arr, mid + 1, right, target);
}2.5.1 递归的工作原理
每次函数调用时,系统会在调用栈(call stack) 上分配一个栈帧(stack frame),用于存储:
- 函数的局部变量
- 函数的参数
- 返回地址(调用结束后回到哪里)
递归调用时,每次调用都有自己的栈帧。当递归深度过大时,可能导致栈溢出(stack overflow)。
// 危险的递归:没有基本情况
void infinite_recursion(void) {
infinite_recursion(); // 栈溢出!
}
// 效率低下的递归:指数级复杂度
int fib_slow(int n) {
if (n <= 1) return n;
return fib_slow(n - 1) + fib_slow(n - 2);
// fib_slow(50) 可能需要数小时!
}
// 更好的方法:迭代
int fib_fast(int n) {
if (n <= 1) return n;
int a = 0, b = 1;
for (int i = 2; i <= n; i++) {
int temp = a + b;
a = b;
b = temp;
}
return b;
}2.6 多文件程序组织
2.6.1 头文件和源文件
一个典型的C程序由多个源文件(.c)和头文件(.h)组成:
project/
├── main.c // 主程序
├── utils.c // 工具函数实现
├── utils.h // 工具函数声明
├── data.c // 数据处理实现
├── data.h // 数据处理声明
└── Makefile // 构建脚本头文件(`.h`)应该包含:
- 函数原型声明
- 宏定义(
#define) - 类型定义(
typedef、struct) extern变量声明
头文件不应该包含:
- 函数定义(实现)
- 全局变量定义(会违反One Definition Rule)
static变量或函数
2.6.2 头文件保护(Include Guard)
防止头文件被多次包含:
#ifndef UTILS_H
#define UTILS_H
// 头文件内容
int add(int a, int b);
void sort(int arr[], int n);
#endif // UTILS_H或使用非标准的 #pragma once:
#pragma once
// 头文件内容2.6.3 编译多文件程序
# 分别编译每个源文件
gcc -c main.c -o main.o
gcc -c utils.c -o utils.o
gcc -c data.c -o data.o
# 链接所有目标文件
gcc main.o utils.o data.o -o program
# 或者一步完成
gcc main.c utils.c data.c -o program2.6.4 完整示例
// utils.h
#ifndef UTILS_H
#define UTILS_H
int max(int a, int b);
int min(int a, int b);
void swap(int *a, int *b);
#endif
// utils.c
#include "utils.h"
int max(int a, int b) {
return (a > b) ? a : b;
}
int min(int a, int b) {
return (a < b) ? a : b;
}
void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
// main.c
#include <stdio.h>
#include "utils.h"
int main(void) {
int x = 10, y = 20;
printf("max(%d, %d) = %d\n", x, y, max(x, y));
printf("min(%d, %d) = %d\n", x, y, min(x, y));
swap(&x, &y);
printf("After swap: x=%d, y=%d\n", x, y);
return 0;
}三、实战案例
3.1 案例一:递归实现字符串反转
#include <stdio.h>
#include <string.h>
void reverse_string(char *str, int left, int right) {
if (left >= right)
return;
char temp = str[left];
str[left] = str[right];
str[right] = temp;
reverse_string(str, left + 1, right - 1);
}
int main(void) {
char str[] = "Hello, World!";
printf("Original: %s\n", str);
reverse_string(str, 0, strlen(str) - 1);
printf("Reversed: %s\n", str);
return 0;
}运行结果:
Original: Hello, World!
Reversed: !dlroW ,olleH3.2 案例二:简易学生成绩管理系统
#include <stdio.h>
#include <string.h>
#define MAX_STUDENTS 100
#define MAX_NAME_LEN 50
typedef struct {
char name[MAX_NAME_LEN];
int scores[3];
float average;
} Student;
static Student students[MAX_STUDENTS];
static int student_count = 0;
void add_student(const char *name, int s1, int s2, int s3) {
if (student_count >= MAX_STUDENTS) {
printf("Error: student list full\n");
return;
}
Student *s = &students[student_count++];
strncpy(s->name, name, MAX_NAME_LEN - 1);
s->name[MAX_NAME_LEN - 1] = '\0';
s->scores[0] = s1;
s->scores[1] = s2;
s->scores[2] = s3;
s->average = (s1 + s2 + s3) / 3.0f;
}
void print_all_students(void) {
printf("%-20s %5s %5s %5s %7s\n",
"Name", "S1", "S2", "S3", "Average");
printf("----------------------------------------------\n");
for (int i = 0; i < student_count; i++) {
Student *s = &students[i];
printf("%-20s %5d %5d %5d %7.1f\n",
s->name, s->scores[0], s->scores[1],
s->scores[2], s->average);
}
}
float class_average(void) {
if (student_count == 0) return 0;
float total = 0;
for (int i = 0; i < student_count; i++) {
total += students[i].average;
}
return total / student_count;
}
int main(void) {
add_student("Alice", 90, 85, 92);
add_student("Bob", 78, 82, 80);
add_student("Charlie", 95, 91, 88);
print_all_students();
printf("\nClass Average: %.1f\n", class_average());
return 0;
}3.3 案例三:汉诺塔问题
#include <stdio.h>
void hanoi(int n, char from, char to, char aux) {
if (n == 1) {
printf("Move disk 1 from %c to %c\n", from, to);
return;
}
hanoi(n - 1, from, aux, to);
printf("Move disk %d from %c to %c\n", n, from, to);
hanoi(n - 1, aux, to, from);
}
int main(void) {
int n = 4;
printf("Solving Tower of Hanoi with %d disks:\n\n", n);
hanoi(n, 'A', 'C', 'B');
printf("\nTotal moves: %d\n", (1 << n) - 1);
return 0;
}运行结果:
Solving Tower of Hanoi with 4 disks:
Move disk 1 from A to B
Move disk 2 from A to C
Move disk 1 from B to C
Move disk 3 from A to B
Move disk 1 from A to C
Move disk 2 from A to B
Move disk 1 from C to B
Move disk 4 from A to C
Move disk 1 from B to A
Move disk 2 from B to C
Move disk 1 from A to C
Move disk 3 from B to C
Move disk 1 from A to B
Move disk 2 from A to C
Move disk 1 from B to C
Total moves: 15四、本章小结
第4章系统讲解了C语言的函数机制和程序组织方法:
函数基础:函数由返回类型、函数名、参数列表和函数体组成。C语言只有值传递,通过指针可以实现类似"引用传递"的效果。
函数原型:声明与定义分离是模块化编程的基础。原型放在头文件中,让编译器在调用处进行类型检查。
作用域规则:局部变量只在声明它的代码块内可见;全局变量从声明位置到文件结束可见;static 可以限制全局变量/函数的作用域为当前文件;extern 用于引用其他文件中的全局变量。
递归:递归函数需要基本情况(终止条件)和递归步骤(问题分解)。注意递归深度过大可能导致栈溢出。
多文件组织:头文件包含声明,源文件包含实现。使用include guard防止重复包含。
函数是C程序的灵魂。设计良好的函数接口——参数清晰、职责单一、行为可预测——是编写可维护代码的关键。下一章将进入C语言最核心的领域:指针与数组。
五、思考与练习
思考题
为什么C语言只有值传递?与C++的引用传递相比有什么优劣?
static 关键字在局部变量和全局变量上的含义有何不同?它们的存储位置分别在哪里?
递归和迭代在性能上有什么区别?所有的递归都可以转化为迭代吗?
为什么头文件中不应该包含函数定义或全局变量定义?
extern 声明和定义的区别是什么?一个变量可以有多少个 extern 声明?多少个定义?
编程练习
实现一个递归的二分查找函数:在有序数组中查找目标值,返回其索引或-1。
实现一个简单的栈数据结构:使用数组和函数提供 push、pop、is_empty、is_full 操作。
编写一个程序:将一个多文件程序拆分为头文件和源文件,正确管理函数的声明和定义。
实现一个递归的幂函数 power(base, exp):计算 base 的 exp 次方,使用快速幂算法(当 exp 为偶数时,base^exp = (base^2)^(exp/2))。