4C程序设计语言(第2版)

函数与程序结构

代码的组织与复用

函数定义参数传递作用域递归

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第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 函数的基本语法

c
return_type function_name(parameter_list) {
    // 函数体
    return value;
}
c
// 一个完整的函数示例
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 函数名与参数

函数名遵循与变量相同的命名规则。参数列表中的每个参数都必须声明类型:

c
// 正确
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 语句终止函数的执行并返回一个值给调用者:

c
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)。函数接收到的是参数的副本,对参数的修改不会影响原始变量。

c
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

通过指针实现"引用传递"效果:

c
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语言中实现"输出参数"的标准方式。

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)声明了函数的返回类型和参数类型,但不包含函数体:

c
// 函数原型
int add(int a, int b);

// 函数定义
int add(int a, int b) {
    return a + b;
}

为什么需要函数原型?

类型检查:编译器可以在函数调用处检查参数类型是否匹配。

声明顺序无关:有了原型,函数可以在定义之前被调用。

模块化:原型通常放在头文件中,让其他文件知道函数的接口。

c
// 没有原型时,编译器可能无法发现错误
// file1.c
int main() {
    double x = sqrt(2);  // 如果sqrt没有原型,编译器假设返回int
    // x的值可能是错误的
}

// 有原型时
#include <math.h>  // 包含 double sqrt(double); 的声明
double x = sqrt(2);  // 编译器知道sqrt接受double,返回double

参数名在原型中可以省略:

c
int add(int, int);           // 合法,参数名省略
int calculate(int a, int);   // 部分省略也合法

2.4 作用域规则

2.4.1 局部变量

在函数内部或代码块中声明的变量是局部变量,只在声明它的代码块内可见:

c
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):内层代码块中的变量可以遮蔽外层同名变量:

c
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 全局变量

在所有函数外部声明的变量是全局变量,从声明位置开始到文件结束都可见:

c
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` 变量:在函数调用之间保持值不变:

c
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` 变量/函数:限制作用域为当前文件(内部链接):

c
// 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 声明一个在其他文件中定义的全局变量:

c
// 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 建议编译器将变量存储在寄存器中以提高访问速度:

c
void fast_loop(void) {
    register int i;
    for (i = 0; i < 1000000; i++) {
        // i被频繁访问,放在寄存器中更快
    }
}

现代编译器通常能自动做出这种优化,register 关键字的实际效果有限。但 register 变量不能使用 & 运算符取地址。

2.5 递归

递归是函数调用自身的编程技术。每个递归函数都需要:

基本情况(base case):终止递归的条件

递归步骤(recursive step):将问题分解为更小的子问题

c
// 阶乘
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)。

c
// 危险的递归:没有基本情况
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)

防止头文件被多次包含:

c
#ifndef UTILS_H
#define UTILS_H

// 头文件内容
int add(int a, int b);
void sort(int arr[], int n);

#endif  // UTILS_H

或使用非标准的 #pragma once:

c
#pragma once
// 头文件内容

2.6.3 编译多文件程序

bash
# 分别编译每个源文件
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 program

2.6.4 完整示例

c
// 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 案例一:递归实现字符串反转

c
#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 ,olleH

3.2 案例二:简易学生成绩管理系统

c
#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 案例三:汉诺塔问题

c
#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))。

4/8函数与程
0%