2026年9月27日

C 语言从零到进阶:写给零基础的完整学习指南

一份写给零基础读者的 C 语言完整指南:从安装编译器讲起,覆盖指针、内存管理、链表、文件与多文件编程,最终独立完成一个学生成绩管理系统。

从安装编译器开始,一步步走到独立完成一个"学生成绩管理系统"。全文共 20 章(第 0~19 章)和 6 个附录,第 1~17 章末尾都配有练习,参考答案集中放在附录 A。

阅读指南

这份指南适合谁

  • 从未写过代码、想把 C 作为第一门编程语言的人;
  • 学过一点 C,但对指针、内存、文件等内容一知半解的人;
  • 即将学习数据结构、操作系统、嵌入式开发,或正在准备计算机考研,需要把 C 基础打扎实的人。

怎么读效果最好

  1. 按顺序读。 章节由浅入深,后面的内容依赖前面的基础。标注"选读"的小节,第一遍可以跳过。
  2. 每个示例都亲手敲一遍。 不要复制粘贴——手指敲过的代码才会留在脑子里。敲完后试着改一改,观察结果如何变化。
  3. 先做练习,再看答案。 卡住半小时以上再去看附录 A,看懂之后合上答案,自己重写一遍。
  4. 遇到指针和内存,就画图。 用纸笔画出变量、地址和箭头,比盯着代码看十遍都管用。

建议进度(每天 1.5~2 小时)

阶段章节核心内容建议用时
入门篇第 0~6 章环境、数据类型、输入输出、运算符、分支、循环约 2 周
核心篇第 7~10 章函数、数组、字符串、指针约 2~3 周
进阶篇第 11~17 章结构体、动态内存、链表、文件、多文件编程、位运算、调试约 2~3 周
实战篇第 18~19 章综合项目、后续学习路线约 1 周

本文约定

  • 代码遵循 C11 标准,同时兼容 C99、C17 和 C23。所有完整示例程序都已用 GCC 和 Clang 实际编译运行过。
  • 以 $ 开头的行表示在终端(命令行)里输入的命令,$ 本身不用输入。
  • "运行结果"里,用户从键盘输入的内容和程序的输出会一起显示,就像你在屏幕上看到的那样。
  • 文中的提示框含义如下:提示是实用技巧或补充说明;注意是容易出错的地方;常见错误是新手的高频踩坑点。

第一部分 入门篇

本部分的目标:搭好开发环境,掌握 C 语言最基本的"语法零件"——数据类型、变量、输入输出、运算符,以及控制程序走向的分支和循环。学完之后,你就能写出计算器、猜数字游戏这样的小程序了。

第 0 章 认识 C 语言

0.1 计算机只懂 0 和 1

计算机的"大脑"——CPU(中央处理器)——只能执行由 0 和 1 组成的机器指令。比如在常见的 x86 处理器上,"把数字 5 放进寄存器 eax"这条指令长这样:

text
10111000 00000101 00000000 00000000 00000000

最早的程序员就是这样一位一位地写程序的,既痛苦又容易出错。于是人们发明了汇编语言,用英文缩写代替 0 和 1:

text
mov eax, 5

汇编语言好读多了,但它和具体的 CPU 绑定:换一种 CPU,程序就得重写。后来人们又发明了高级语言,写法更接近人类的思维方式,而且与具体硬件无关:

c
int a = 5;

但 CPU 依然只认识机器指令,所以需要一个"翻译"把高级语言转换成机器指令。翻译的方式有两种:

  • 编译型:像把一整本书翻译好再出版,读者直接读译本。翻译(编译)一次,之后可以反复运行,速度快。C、C++、Go、Rust 都属于这一类。
  • 解释型:像同声传译,边读边翻译。灵活,但运行时需要一直有"翻译"在场,速度相对慢。Python、JavaScript 是典型代表。

C 语言是典型的编译型语言。负责翻译的程序叫作编译器(compiler)。

0.2 C 语言从哪里来

1969 年起,美国贝尔实验室的 Ken Thompson 和 Dennis Ritchie 开始开发 Unix 操作系统。Thompson 设计了一门叫 B 的语言,Ritchie 在 B 的基础上加入了数据类型等特性,于 1972 年前后形成了 C 语言。1973 年,Unix 的内核用 C 重写,这使 Unix 成为最早主要用高级语言写成的操作系统之一。从此 Unix 可以相对容易地移植到不同的计算机上,C 语言也随之风靡世界。

1978 年,Brian Kernighan 和 Dennis Ritchie 合著了《The C Programming Language》(中译本《C 程序设计语言》),人们亲切地称之为 K&R,它在很长一段时间里就是 C 语言事实上的标准。此后,C 语言经历了多次标准化:

标准发布时间主要变化
C89 / C901989 / 1990第一个官方标准(常称 ANSI C),统一了各家编译器的差异
C991999// 单行注释、long long 类型、布尔类型、变长数组、可以在代码中间声明变量、for (int i = 0; ...) 写法
C112011多线程支持、_Generic、_Static_assert、匿名结构体;删除了危险的 gets 函数
C172018只修正缺陷,没有新特性(也常被称为 C18)
C232024bool/true/false 成为关键字,新增 nullptr、constexpr、typeof、二进制字面量 0b1010、数字分隔符 1'000'000 等

本文的代码以 C11 为准,这些代码在更新的标准下同样可以编译。GCC 从 15 版开始默认采用 C23 标准,你在较新的编译器上运行本文代码也不会有问题。

0.3 为什么今天还要学 C

C 语言已经五十多岁了,为什么还值得学?

  1. 它无处不在。 Linux、Windows、macOS 的内核主要用 C 写成;家电、汽车电子、单片机等嵌入式设备大量使用 C;SQLite、Redis、PostgreSQL 等数据库,Nginx 服务器,Git 版本控制工具,以及 Python 的官方解释器(CPython)都是用 C 写的。
  2. 它让你真正理解计算机。 内存、地址、指针、编译与链接——这些在 Python、Java 中被隐藏起来的概念,在 C 里一览无余。学透 C,再学任何语言都会快很多。
  3. 它是许多语言的"祖先"。 C++、Java、C#、JavaScript、Go 的语法都深受 C 影响:if、for、while、花括号……学会了 C,再看这些语言会觉得非常眼熟。
  4. 学业和职业的需要。 它是大多数高校理工科专业的第一门编程课;计算机考研中,数据结构的算法题通常用 C 或 C++ 作答;嵌入式、驱动、操作系统、高性能计算等岗位都把 C 作为必备技能。

C 语言的特点可以概括为:

优点缺点
运行速度快,占用资源少需要手动管理内存
贴近硬件,可以直接操作内存几乎没有安全检查,数组越界、野指针等错误不会被自动发现
可移植性好,几乎所有平台都有 C 编译器标准库比较精简,很多功能需要自己实现
语法简洁,关键字少某些语法(如复杂的指针声明)不够直观

一句话:C 给了你极大的自由,也要求你承担相应的责任。

0.4 如何学好 C 语言

  1. 动手第一。 编程是一门手艺,只看不练等于没学。本文的每段示例代码都请亲手敲一遍并运行。
  2. 学会阅读报错信息。 编译器的报错是你最好的老师。报错大多是英文,但常见的就那么几十条(附录 F 列出了最常见的错误信息),看多了自然就熟悉了。
  3. 理解而非背诵。 不要死记语法规则,要理解"为什么这样设计"。理解了内存模型,指针就不再可怕。
  4. 勤画图。 遇到数组、指针、链表时,在纸上画出内存格子和箭头,问题往往就迎刃而解。
  5. 小步前进。 写几行就编译运行一次,不要写了几百行才第一次编译——到时候几十个错误会让你无从下手。
  6. 学会查文档。 想知道某个函数怎么用,就去查 cppreference(en.cppreference.com,其中有 C 语言部分,也有中文版)。
  7. 合理使用 AI 工具。 让 AI 解释报错、讲解概念、检查你写好的代码,都是很好的学习方式;但如果让 AI 替你写完所有练习,就像把健身卡借给别人去跑步——卡是用了,肌肉却没长在你身上。

第 1 章 搭建开发环境,写出第一个程序

1.1 写程序需要哪些工具

写 C 程序需要两样东西:

  • 编辑器:用来编写源代码。理论上记事本就够用,但专业的编辑器提供语法高亮、自动补全、错误提示,能极大提升效率。
  • 编译器:把 C 源代码翻译成可以运行的程序。主流的 C 编译器有三个:GCC(GNU 编译器套件,Linux 上的标准配置)、Clang(macOS 的默认编译器)和 MSVC(微软的编译器,随 Visual Studio 一起安装)。

把编辑器、编译器和调试器打包在一起的软件叫作 IDE(集成开发环境),装好一个 IDE 就能一站式完成"编写—编译—运行—调试"。

整个流程是这样的:

text
  编写源代码               编译                     运行
 hello.c(文本文件) ────────────▶ hello.exe(可执行程序) ────────▶ 屏幕上显示结果

1.2 安装开发环境

根据你的操作系统,选择一种方案即可。

Windows 方案一:Visual Studio Community(最省心)

  1. 在微软官网下载 Visual Studio Community(社区版,个人学习免费)。
  2. 安装时勾选工作负载 "使用 C++ 的桌面开发"(它同时包含 C 编译器)。
  3. 新建项目时选择 "空项目",然后在"源文件"上右键 → 添加 → 新建项,文件名写 main.c。后缀一定要是 `.c`,如果是 .cpp,代码会被当作 C++ 编译。
  4. 写好代码后,按 Ctrl + F5(开始执行,不调试)运行程序。
注意
在 Visual Studio 中使用 scanf 会报 C4996 错误,解决方法见 3.2 节。

Windows 方案二:VS Code + MinGW-w64(轻量、通用)

  1. 在 MSYS2 官网(msys2.org)下载并安装 MSYS2,默认安装位置为 C:\msys64。
  2. 安装结束后会打开一个 "MSYS2 UCRT64" 终端窗口,在其中输入以下命令安装 GCC 工具链。遇到选择提示直接按回车(表示全部安装),再输入 Y 确认:
bash
   pacman -S --needed base-devel mingw-w64-ucrt-x86_64-toolchain
  1. 把 C:\msys64\ucrt64\bin 添加到系统环境变量 Path 中(设置 → 系统 → 系统信息 → 高级系统设置 → 环境变量)。
  2. 打开一个新的命令提示符窗口,输入 gcc --version,能看到版本信息就说明安装成功了。
  3. 安装 VS Code,在扩展商店中搜索并安装微软官方的 C/C++ 扩展。

Windows 方案三:小熊猫 C++ / Dev-C++(开箱即用)

这类轻量 IDE 自带编译器,安装完就能写代码,很多学校的课堂和信息学竞赛都在用。功能相对简单,非常适合入门阶段。

macOS

打开"终端",输入:

bash
xcode-select --install

在弹出的窗口中点击安装,即可获得 Apple 的命令行开发工具(包含 Clang 编译器)。安装完成后输入 clang --version 验证。在 macOS 上输入 gcc 实际调用的也是 Clang,用法完全相同。编辑器推荐 VS Code 或 CLion。

Linux

大多数发行版一条命令就能装好:

bash
# Ubuntu / Debian
sudo apt update
sudo apt install build-essential gdb

# Fedora
sudo dnf install gcc gdb

# Arch Linux
sudo pacman -S base-devel gdb

安装后用 gcc --version 验证。

其他选择

  • CLion:JetBrains 出品的专业 C/C++ IDE,自 2025 年起对非商业用途(包括个人学习)免费。
  • 在线编译器:如 Compiler Explorer(godbolt.org)、OnlineGDB(onlinegdb.com),打开网页就能写代码运行,适合临时试验。但长期学习还是建议在自己电脑上搭建环境。
提示
不必在工具的选择上纠结太久。对初学者来说,这些编译器之间的差异几乎没有影响。选一个能跑起来的,尽快开始学习才是正事。

1.3 第一个程序:Hello, World!

按照编程界的传统(这个传统因 K&R 的《C 程序设计语言》而流行),第一个程序是让计算机向世界问好。新建一个文件 hello.c,输入以下代码:

c
#include <stdio.h>

int main(void)
{
    printf("Hello, World!\n");
    return 0;
}

运行结果:

text
Hello, World!

短短 7 行,包含了 C 程序的基本骨架。我们逐行来看:

代码含义
#include <stdio.h>引入标准输入输出头文件(standard input/output)。printf 函数就声明在这个文件里,不引入它,编译器就不认识 printf
int main(void)定义 main 函数。main 是程序的入口,程序总是从这里开始执行,每个程序有且只有一个 main。int 表示它结束时会返回一个整数给操作系统,void 表示它不接收参数
{ 和 }花括号括起来的部分是函数体,也就是函数要执行的语句
printf("Hello, World!\n");调用 printf 函数在屏幕上输出文字。双引号中的内容叫字符串;\n 表示换行;末尾的分号 ; 表示一条语句结束
return 0;结束 main 函数,并向操作系统返回 0,表示"程序正常结束"

几个必须记住的规则:

  • C 语言区分大小写。 printf 不能写成 Printf,main 不能写成 Main。
  • 声明和普通语句以英文分号结尾。 像 printf(...);、return 0; 这样的语句,以及第 2 章要学的变量定义(如 int a = 5;)等声明,末尾都要加分号,分号相当于中文里的句号。但以 # 开头的预处理指令、函数头 int main(void),以及包围代码块的花括号 {、} 后面都不加分号。(花括号属于某个声明时则不同,例如第 8 章的数组初始化 int a[5] = {1, 2, 3, 4, 5}; 和第 11 章的结构体定义,这时声明结尾的分号仍要写在 } 后面。)
  • 代码中的符号全部使用英文半角字符。 中文的分号 ;、引号 “”、括号 () 都会导致编译错误。字符串的双引号里面可以写中文。
注意
一些老教材把主函数写成 void main(),这不是标准 C 的写法,部分编译器会报错或警告。请始终使用 int main(void),或者第 10 章介绍的带参数版本 int main(int argc, char *argv[])。

1.4 编译与运行

如果你使用 IDE,点击"运行"按钮即可。但了解命令行的方式非常重要——IDE 的按钮背后做的正是这些事情。打开终端,进入 hello.c 所在的文件夹,输入:

bash
$ gcc hello.c -o hello
$ ./hello
Hello, World!
  • 第一条命令调用 GCC 编译 hello.c,-o hello 指定生成的程序名为 hello。如果不写 -o,Linux/macOS 上默认生成 a.out,Windows 上默认生成 a.exe。
  • 第二条命令运行程序。Windows 的命令提示符中输入 hello(或 hello.exe)即可,PowerShell 中输入 .\hello.exe。

在日常学习中,建议使用下面这条"加强版"编译命令:

bash
$ gcc -Wall -Wextra -std=c11 -g hello.c -o hello
选项作用
-Wall -Wextra开启常用警告和额外警告。很多隐藏的错误会以警告的形式被提前发现
-std=c11按 C11 标准编译
-g在程序中加入调试信息,方便用调试器排查问题(第 17 章)
-o hello指定生成的程序名
提示
把编译器的警告当成错误来对待,努力让自己的程序"零警告"。这是一个会让你少走很多弯路的好习惯。

1.5 编译背后发生了什么

gcc hello.c -o hello 这一条命令,实际上依次完成了四个步骤:

text
hello.c ──预处理──▶ hello.i ──编译──▶ hello.s ──汇编──▶ hello.o ──链接──▶ hello
 源代码            展开后的源代码       汇编代码           目标文件           可执行程序
  1. 预处理(Preprocessing):处理以 # 开头的指令。比如 #include <stdio.h> 会被替换成 stdio.h 文件的全部内容,所以预处理后的文件往往有几百上千行。
  2. 编译(Compilation):把 C 代码翻译成汇编代码,语法错误就是在这一步被发现的。
  3. 汇编(Assembly):把汇编代码翻译成机器指令,生成目标文件(.o 文件)。
  4. 链接(Linking):printf 函数的机器代码并不在你的 hello.o 里,而是在 C 标准库中。链接器把你的目标文件和标准库"拼接"起来,生成最终的可执行程序。

你可以用下面的命令分步观察每个阶段的产物:

bash
$ gcc -E hello.c -o hello.i    # 只预处理
$ gcc -S hello.i -o hello.s    # 编译成汇编代码
$ gcc -c hello.s -o hello.o    # 汇编成目标文件
$ gcc hello.o -o hello         # 链接成可执行程序

打开 hello.i 和 hello.s 看一看(hello.o 是二进制文件,用文本编辑器打开会是乱码)。现在看不懂没关系,重要的是建立一个印象:从源代码到程序,要经过预处理、编译、汇编、链接四步。 以后遇到报错,你就能判断它发生在哪一步了。

1.6 注释

注释是写给人看的说明文字,编译器会完全忽略它。C 语言有两种注释:

c
// 单行注释:从 // 开始,直到这一行结束

/* 多行注释:从斜杠星号开始,
   到星号斜杠结束,
   中间可以跨越多行 */

注意多行注释不能嵌套:/* 外层 /* 内层 */ 外层 */ 中,第一个 */ 就会结束注释,剩下的"外层 */"会导致编译错误。

好的注释解释"为什么这样做",而不是重复代码"在做什么":

c
i = i + 1;   // i 加 1                 ← 废话,代码本身已经说明了
i = i + 1;   // 跳过第一行的表头        ← 有用,说明了意图
提示
调试时,可以用注释临时"关掉"一段代码。大多数编辑器中,选中代码后按 Ctrl + /(macOS 上是 Cmd + /)即可快速注释或取消注释。

1.7 新手最常见的编译错误

写代码出错是常态,关键是能看懂报错信息。GCC 的报错格式是:

text
文件名:行号:列号: error: 错误描述

下面是新手最常遇到的几种错误(以 GCC 13 的输出为例,不同版本的措辞可能略有差异):

错误写法编译器提示原因与解决
printf("Hello\n") 后面漏了分号error: expected ';' before 'return'缺少分号。编译器是读到下一行的 return 才发现上一句没有结束的,GCC 会把位置标在漏写分号那一行的末尾;但在另一些情况下(比如变量定义后漏了分号),报告的行号会是下一行
使用了中文分号 ; 或中文引号 “”error: stray '\342' in program 或 expected ';' before '\U0000ff1b'代码中混入了中文标点,改成英文半角符号。输入代码时记得切换到英文输入法
print("Hello\n");implicit declaration of function 'print'; did you mean 'printf'? 以及 undefined reference to 'print'函数名拼写错误,编译器找不到叫 print 的函数
int mian(void)undefined reference to 'main'main 拼写错误,链接器找不到程序入口
忘记写 #include <stdio.h>implicit declaration of function 'printf' 和 include '<stdio.h>' or provide a declaration of 'printf'使用了没有声明的函数。较新的编译器(如 GCC 14 起)会直接将其视为错误
printf("Hello\n);error: missing terminating " character字符串的引号没有配对
结尾少了 }error: expected declaration or statement at end of input花括号不配对。养成写 { 就立刻写 } 的习惯
printf("%d\n", cout);(本想写 count)error: 'cout' undeclared (first use in this function); did you mean 'count'?变量名拼写错误或变量未定义

处理报错的两条原则:

  1. 从第一条错误开始修。 一个错误常常引发一连串"连锁报错",修好第一个,后面的可能就全消失了。
  2. 看行号,但也看看上一行。 像漏分号、括号不配对这类错误,真正的问题有时出在报告位置的前面。

1.8 常见问题

问题 1:输出的中文变成了乱码。

这通常发生在 Windows 上:命令行窗口默认使用 GBK 编码显示文字,而 VS Code 等编辑器默认把源文件保存为 UTF-8 编码,两者对不上就会乱码。解决方法任选其一:

  • 运行程序前,在命令行窗口执行 chcp 65001,把窗口切换为 UTF-8 编码;
  • 编译时加上选项 -fexec-charset=GBK,让程序以 GBK 编码输出中文:gcc hello.c -o hello -fexec-charset=GBK;
  • 在 Windows 的"区域设置"中勾选"使用 Unicode UTF-8 提供全球语言支持"(这会影响整个系统,部分老软件可能出现乱码,请谨慎选择)。

在 Visual Studio 中直接新建的源文件,一般不会遇到这个问题。

问题 2:程序窗口一闪而过。

直接双击运行 .exe 文件时,程序执行完毕窗口就会立即关闭。解决方法:在命令行窗口中运行程序;在 Visual Studio 中用 Ctrl + F5 运行(程序结束后窗口会停住);或者临时在 return 0; 前面加一句 getchar();,让程序等待你按下回车。

问题 3:提示"'gcc' 不是内部或外部命令"。

说明系统找不到 GCC。请检查是否已把编译器所在目录(如 C:\msys64\ucrt64\bin)加入环境变量 Path,并且重新打开命令行窗口(已打开的窗口不会自动读取新的环境变量)。

本章练习

  1. 个人名片。 编写程序,分多行输出你的姓名、专业(或职业)和一句座右铭,上下各用一行 ==================== 围起来。
  2. 故意犯错。 在 Hello World 程序中分别制造以下错误:删掉分号、把 printf 写成 Printf、删掉右花括号 }、使用中文的双引号。每次只制造一个错误,编译后记录报错信息,体会错误写法和报错之间的对应关系。
  3. 星号三角形。 用 printf 输出下面的图案(只用 printf,不用循环):
text
      *
     ***
    *****
   *******

第 2 章 数据类型、变量与常量

程序的本质是处理数据:成绩、价格、姓名、坐标……这一章我们来学习 C 语言如何表示和存储各种数据。

2.1 计算机如何存储数据

计算机内部只有 0 和 1。一个 0 或 1 称为一个位(bit),这是最小的存储单位;8 个位组成一个字节(byte),这是最常用的存储单位。在计算机领域,人们习惯说 1 KB = 1024 字节、1 MB = 1024 KB,以此类推。(严格地说,按国际标准,1024 字节应写作 1 KiB,1024 KiB 应写作 1 MiB;而按国际单位制,1 kB 是 1000 字节,硬盘厂商标注容量时用的就是这种十进制算法。)

我们可以把内存想象成一排整齐的储物柜,每个柜子能放 1 个字节,并且都有一个唯一的编号,这个编号就叫作地址:

text
地址      0x1000   0x1001   0x1002   0x1003   0x1004  ...
        +--------+--------+--------+--------+--------+
内容    |01001000|01101001|00000000|11111111|00101010| ...
        +--------+--------+--------+--------+--------+

地址通常用十六进制书写(0x 开头)。你需要熟悉三种进制:

  • 十进制:日常使用,逢十进一,用 0~9 表示。
  • 二进制:计算机使用,逢二进一,只用 0 和 1。
  • 十六进制:二进制的"简写",逢十六进一,用 0~9 和 A~F(表示 10~15)。1 位十六进制数正好对应 4 位二进制数,所以书写内存内容时非常方便。
十进制二进制十六进制十进制二进制十六进制
000000810008
100011910019
200102101010A
300113111011B
401004121100C
501015131101D
601106141110E
701117151111F

十进制转二进制用"除 2 取余,倒序排列"。以 13 为例:13 ÷ 2 = 6 余 1,6 ÷ 2 = 3 余 0,3 ÷ 2 = 1 余 1,1 ÷ 2 = 0 余 1,把余数倒过来读,得到 1101。

二进制转十进制用"按权相加":1101 = 1×2³ + 1×2² + 0×2¹ + 1×2⁰ = 8 + 4 + 0 + 1 = 13。

二进制转十六进制只需从右往左每 4 位一组查表:1010 1111 → AF,即 0xAF。

2.2 变量:有名字的储物柜

变量就是一块有名字的内存空间,用来存放可以变化的数据。使用变量之前必须先定义它,告诉编译器变量的类型和名字:

c
#include <stdio.h>

int main(void)
{
    int age = 18;            // 定义整数变量 age,并初始化为 18
    double height = 1.75;    // 定义小数变量 height
    char grade = 'A';        // 定义字符变量 grade

    printf("年龄:%d\n", age);
    printf("身高:%.2f 米\n", height);
    printf("等级:%c\n", grade);

    age = 19;                // 给变量赋一个新值
    printf("明年的年龄:%d\n", age);
    return 0;
}

运行结果:

text
年龄:18
身高:1.75 米
等级:A
明年的年龄:19

printf 中的 %d、%f、%c 是格式说明符,表示"在这里填入一个整数/小数/字符",第 3 章会详细讲解。

关于变量,有几个要点:

1. 初始化与赋值。 定义变量时给它一个值叫初始化(int age = 18;),定义之后再修改它的值叫赋值(age = 19;)。可以一次定义多个同类型的变量:int a = 1, b = 2, c;。

2. 一定要初始化。 函数内部定义的变量如果没有初始化,它的值是不确定的——储物柜里还留着上一个使用者的东西,俗称"垃圾值"。使用未初始化的变量是一种常见且隐蔽的错误:

c
int sum;          // 没有初始化,sum 的值不确定
sum = sum + 10;   // 结果不可预测!

3. 命名规则。 变量名(以及后面要学的函数名等,统称标识符)必须遵守以下规则:

  • 只能由字母、数字和下划线 _ 组成;
  • 不能以数字开头;
  • 区分大小写,score 和 Score 是两个不同的变量;
  • 不能使用 C 语言的关键字,如 int、return、if 等(完整列表见附录 E)。
名字是否合法说明
score合法
total_score合法用下划线分隔单词,推荐
score2合法数字不在开头即可
2score不合法以数字开头
total-score不合法含有减号
my score不合法含有空格
return不合法是关键字

4. 好名字胜过注释。 名字要"见名知意":student_count 远比 sc 或 a1 清楚。C 语言社区习惯用小写字母加下划线的风格(如 max_value),本文也采用这种风格。

提示
在 C89 标准中,变量必须定义在代码块的开头;从 C99 开始,变量可以在需要的地方随时定义。建议在"第一次用到时"再定义变量,并且立即初始化。

2.3 基本数据类型

不同的数据需要不同大小的"储物柜"。C 语言的基本数据类型如下("典型大小"指在目前主流的 64 位电脑上的情况):

类型典型大小典型取值范围用途printf 格式
char1 字节-128~127字符、很小的整数%c / %d
short2 字节-32768~32767较小的整数%hd
int4 字节约 ±21 亿最常用的整数类型%d
long4 或 8 字节因平台而异较大的整数%ld
long long8 字节约 ±9.2×10¹⁸很大的整数%lld
float4 字节约 6~7 位有效数字单精度小数%f
double8 字节约 15~16 位有效数字双精度小数,最常用%f
long double8~16 字节因平台而异更高精度的小数%Lf

C 标准并没有规定每种类型的确切大小,只规定了最小范围和大小关系(short ≤ int ≤ long ≤ long long)。例如 long 在 64 位 Windows 上是 4 字节,在 64 位 Linux 和 macOS 上是 8 字节。用 sizeof 运算符可以查看某个类型在你电脑上的实际大小:

c
#include <stdio.h>

int main(void)
{
    printf("char        : %zu 字节\n", sizeof(char));
    printf("short       : %zu 字节\n", sizeof(short));
    printf("int         : %zu 字节\n", sizeof(int));
    printf("long        : %zu 字节\n", sizeof(long));
    printf("long long   : %zu 字节\n", sizeof(long long));
    printf("float       : %zu 字节\n", sizeof(float));
    printf("double      : %zu 字节\n", sizeof(double));
    printf("long double : %zu 字节\n", sizeof(long double));
    return 0;
}

在 64 位 Linux 上的运行结果:

text
char        : 1 字节
short       : 2 字节
int         : 4 字节
long        : 8 字节
long long   : 8 字节
float       : 4 字节
double      : 8 字节
long double : 16 字节

sizeof 的结果是 size_t 类型(一种无符号整数),对应的格式说明符是 %zu。

有符号与无符号。 整数类型默认是有符号的(signed),可以表示负数。在类型前加 unsigned 就变成无符号类型,只能表示非负数,但正数范围扩大了一倍。例如 unsigned int 的范围是 0~4294967295,格式说明符是 %u。

注意
char 默认是有符号还是无符号,由编译器和平台决定(x86 电脑上通常是有符号的,某些 ARM 平台上是无符号的)。如果要用 char 存放小整数,最好明确写成 signed char 或 unsigned char。

头文件 <limits.h> 和 <float.h> 中定义了各种类型的取值范围:

c
#include <stdio.h>
#include <limits.h>
#include <float.h>

int main(void)
{
    printf("int 的范围:%d ~ %d\n", INT_MIN, INT_MAX);
    printf("unsigned int 的最大值:%u\n", UINT_MAX);
    printf("long long 的范围:%lld ~ %lld\n", LLONG_MIN, LLONG_MAX);
    printf("float 的有效数字:%d 位\n", FLT_DIG);
    printf("double 的有效数字:%d 位\n", DBL_DIG);
    return 0;
}

运行结果:

text
int 的范围:-2147483648 ~ 2147483647
unsigned int 的最大值:4294967295
long long 的范围:-9223372036854775808 ~ 9223372036854775807
float 的有效数字:6 位
double 的有效数字:15 位

溢出。 如果运算结果超出了类型的表示范围,就会发生溢出。无符号整数溢出时会"绕回":UINT_MAX + 1 的结果是 0,就像汽车里程表从 999999 走到 000000。而有符号整数溢出是未定义行为——C 标准不规定会发生什么,结果通常是一个奇怪的负数,但也可能出现更离奇的现象。所以在选择类型时,要预估数据的范围:比如计算 20 的阶乘(约 2.4×10¹⁸),int 装不下,必须用 long long。

提示
未定义行为(Undefined Behavior,简称 UB)是 C 语言中一个非常重要的概念,指 C 标准对某种操作的结果不作任何规定。程序可能崩溃,可能输出奇怪的结果,也可能"看起来一切正常"——最后这种最危险。我们会在学习过程中陆续遇到各种未定义行为,第 17 章会对它们做一个汇总。

2.4 整数在内存中的样子:补码(选读)

计算机如何表示负数?最直观的想法是用最高位表示符号(0 为正,1 为负),这叫原码。以 8 位为例:

text
+5 的原码:0000 0101
-5 的原码:1000 0101

但原码有两个问题:0 有两种表示(0000 0000 和 1000 0000),而且加减法电路设计复杂。所以现代计算机普遍使用补码:

  • 正数的补码就是它本身;
  • 负数的补码 = 对应正数的二进制按位取反,再加 1。
text
+5:           0000 0101
按位取反:      1111 1010
再加 1:        1111 1011    ← 这就是 -5 的补码

验证一下:0000 0101(5)+ 1111 1011(-5)= 1 0000 0000,最高位的进位超出 8 位被丢弃,结果正好是 0000 0000。补码让减法可以直接用加法电路完成,0 也只有一种表示。

一个有趣的推论:-1 的补码是全 1(1111 1111)。如果把这 8 位当作无符号数来解读,就是 255。这就解释了为什么把 -1 赋给 unsigned int 变量会得到 4294967295(32 个 1)。

提示
C23 标准明确规定有符号整数必须使用补码表示。实际上,几十年来几乎所有计算机都是这样做的。

2.5 浮点数

带小数点的数叫浮点数,C 语言中有 float 和 double 两种常用类型。浮点数有两种写法:

c
double a = 3.14;       // 普通写法
double b = 1.5e3;      // 科学计数法:1.5 × 10³ = 1500.0
double c = 2.5E-3;     // 2.5 × 10⁻³ = 0.0025
float  d = 3.14f;      // 带 f 后缀表示 float 类型

代码中直接写出的小数(如 3.14)默认是 double 类型。

浮点数是有误差的。 就像十进制无法精确表示 1/3(0.3333……)一样,二进制也无法精确表示 0.1 这样的数,只能存一个非常接近的近似值。请看:

c
#include <stdio.h>
#include <math.h>

int main(void)
{
    double a = 0.1 + 0.2;
    printf("0.1 + 0.2 = %.17f\n", a);
    printf("a == 0.3 的结果:%d\n", a == 0.3);
    printf("误差足够小吗:%d\n", fabs(a - 0.3) < 1e-9);

    float f = 1.0f / 3;
    double d = 1.0 / 3;
    printf("float  的 1/3:%.20f\n", f);
    printf("double 的 1/3:%.20f\n", d);
    return 0;
}

运行结果:

text
0.1 + 0.2 = 0.30000000000000004
a == 0.3 的结果:0
误差足够小吗:1
float  的 1/3:0.33333334326744079590
double 的 1/3:0.33333333333333331483

== 用来判断两边是否相等,成立时结果为 1,不成立时为 0(第 4 章详细介绍)。fabs 是求绝对值的数学函数,声明在 <math.h> 中。从结果可以得出三条经验:

  1. 不要用 `==` 直接比较两个浮点数,而是判断它们的差的绝对值是否小于一个很小的数(如 1e-9)。
  2. 优先使用 `double`。float 只有 6~7 位有效数字,误差很快就会累积起来;double 精度高得多,而在现代电脑上速度几乎没有差别。
  3. 涉及金额的计算不要用浮点数,可以用整数以"分"为单位存储。
提示
在 Linux 上用 GCC 编译使用了 <math.h> 中函数的程序时,可能需要在命令末尾加上 -lm 选项来链接数学库,例如 gcc demo.c -o demo -lm。

2.6 字符与 ASCII 码

char 类型用来存储单个字符,字符要用单引号括起来:'A'、'7'、'#'。

计算机只能存储数字,那字符是怎么存的呢?答案是给每个字符编一个号。最基础的编码表叫 ASCII 码,它给 128 个常用字符(英文字母、数字、标点和控制字符)各分配了一个 0~127 的编号。`char` 变量里存的其实就是这个编号,所以 char 本质上是一个很小的整数(附录 B 列出了常用的 ASCII 码)。

需要记住的几个编号:'0' 是 48,'A' 是 65,'a' 是 97。并且数字、大写字母、小写字母各自都是连续编号的。

c
#include <stdio.h>

int main(void)
{
    char c = 'A';
    printf("字符 %c 的 ASCII 码是 %d\n", c, c);
    printf("ASCII 码 97 对应的字符是 %c\n", 97);
    printf("'A' + 1 得到 %c\n", c + 1);
    printf("转换成小写:%c\n", c + 32);      // 'a' - 'A' 等于 32

    char digit = '7';
    printf("字符 '7' 对应的数值:%d\n", digit - '0');
    return 0;
}

运行结果:

text
字符 A 的 ASCII 码是 65
ASCII 码 97 对应的字符是 a
'A' + 1 得到 B
转换成小写:a
字符 '7' 对应的数值:7

同一个值,用 %c 输出就显示为字符,用 %d 输出就显示为编号。digit - '0' 是一个非常常用的技巧:数字字符 '0'~'9' 的编号是连续的,减去 '0' 就得到对应的数值。

常见错误
'A' 和 "A" 完全不同。单引号是字符,存入 char 变量占 1 个字节;双引号是字符串,末尾还藏着一个结束标记,占 2 个字节(第 9 章详述)。(一个小细节:在 C 语言中,字符常量 'A' 本身的类型其实是 int,所以 sizeof('A') 等于 sizeof(int),在常见平台上是 4 而不是 1,这一点与 C++ 不同。)另外,一个汉字在 UTF-8 编码下通常占 3 个字节,无法放进一个 char 变量中。

2.7 布尔类型

布尔类型只有"真"和"假"两个值。早期的 C 语言没有布尔类型,而是约定:0 表示假,任何非 0 的值都表示真。这个约定至今仍然有效。

C99 引入了 <stdbool.h> 头文件,可以使用 bool、true、false:

c
#include <stdio.h>
#include <stdbool.h>

int main(void)
{
    bool is_passed = true;
    bool is_excellent = false;
    printf("%d %d\n", is_passed, is_excellent);   // 输出:1 0
    return 0;
}

在 C23 中,bool、true、false 已经成为关键字,不包含头文件也能直接使用;包含了也没有任何坏处,因此为了兼容性,本文始终包含 <stdbool.h>。

2.8 常量

程序运行过程中不会改变的量叫常量。

1. 字面常量:直接写在代码里的值。

写法含义
42十进制整数
052八进制整数(以 0 开头),等于十进制的 42
0x2A十六进制整数,等于十进制的 42
0b101010二进制整数(C23 标准,GCC 和 Clang 早已支持)
42u、42L、42LL分别表示 unsigned int、long、long long 类型
3.14、3.14f、1.5e3浮点数(默认 double,加 f 为 float)
'A'字符
"Hello"字符串
常见错误
不要为了"对齐"而在整数前补 0!int x = 010; 中 x 的值是 8,而不是 10,因为以 0 开头的整数是八进制。

2. `const` 常量:在变量定义前加 const,它的值就不能再被修改。

3. 宏常量:用 #define 定义,预处理时会把代码中所有的宏名替换为对应的值。注意语法:没有等号,末尾也没有分号。

c
#include <stdio.h>

#define PI 3.14159265358979     // 宏常量

int main(void)
{
    const int DAYS_PER_WEEK = 7;   // const 常量
    double r = 2.0;

    printf("半径为 %.1f 的圆面积:%.2f\n", r, PI * r * r);
    printf("一年大约有 %d 周\n", 365 / DAYS_PER_WEEK);
    // DAYS_PER_WEEK = 8;          // 编译错误:不能修改 const 变量
    return 0;
}

运行结果:

text
半径为 2.0 的圆面积:12.57
一年大约有 52 周

用有名字的常量代替直接写出的数字,有两个好处:一是可读性强,DAYS_PER_WEEK 比 7 含义明确得多;二是便于修改,需要改动时只改一处即可。像这样突然出现、不知含义的数字,被戏称为"魔法数字",应当尽量避免。常量名习惯上全部大写。

2.9 类型转换

当不同类型的数据混在一起运算或赋值时,就会发生类型转换。

1. 赋值时的转换:值会被转换成左边变量的类型。把小数赋给整数变量时,小数部分会被直接截断(不是四舍五入)。

2. 运算时的自动转换:

  • char、short 等比 int 小的类型,参与运算前会先被提升为 int(称为整型提升;严格地说,如果 int 无法表示原类型的全部取值,就会提升为 unsigned int,但在常见平台上,它们都会被提升为 int);
  • 两个不同类型的操作数运算时,"较小"的类型会被转换为"较大"的类型,例如 int 与 double 运算,int 先被转换为 double。

3. 强制类型转换:用 (类型) 手动转换,例如 (double)sum。

c
#include <stdio.h>

int main(void)
{
    int n = 3.99;                  // 小数部分被截断,n 的值是 3
    double avg1 = 7 / 2;           // 7 / 2 是整数除法,结果为 3,再转换成 3.0
    double avg2 = 7.0 / 2;         // 7.0 是 double,2 被转换为 2.0,结果为 3.5
    double avg3 = (double)7 / 2;   // 强制转换,结果同样为 3.5

    printf("n = %d\n", n);
    printf("avg1 = %.1f, avg2 = %.1f, avg3 = %.1f\n", avg1, avg2, avg3);
    return 0;
}

运行结果:

text
n = 3
avg1 = 3.0, avg2 = 3.5, avg3 = 3.5

对于第一行,有的编译器(如 Clang)会给出警告,提醒你小数部分会被丢弃。avg1 则是一个经典的坑:两个整数相除,结果仍然是整数,小数部分在除法时就已经丢失了,之后再转换成 double 也于事无补。计算平均值时,一定要让至少一个操作数是浮点数。

有符号与无符号混用的陷阱:

c
#include <stdio.h>

int main(void)
{
    int a = -1;
    unsigned int b = 1;
    printf("a < b 的结果:%d\n", a < b);   // 输出 0,也就是"假"!
    return 0;
}

-1 小于 1,结果却是"假"?原因是 int 和 unsigned int 比较时,a 被转换成了无符号数,而 -1 的补码是 32 个 1,作为无符号数就是 4294967295,自然不小于 1。开启 -Wextra 后,GCC 会对此给出警告:comparison of integer expressions of different signedness。尽量避免有符号数和无符号数混合运算,这也是要认真对待编译器警告的原因之一。

本章练习

  1. 用 sizeof 输出 char、short、int、long、long long、float、double、long double 在你电脑上的大小,并与本章表格对比。
  2. 判断下列标识符是否合法,不合法的说明原因:_count、2nd_place、my-name、int、Int、total_sum、a b、score3。
  3. 已知 char c = 'm';,不查 ASCII 表,分别写出表达式求:c 对应的大写字母、c 在字母表中的序号(a 为 1)、c 后面第 3 个字母。用 printf 输出验证。
  4. 不运行程序,先写出下面代码的输出,再运行验证:
c
   int a = 7 / 2;
   double b = 7 / 2;
   double c = 7.0 / 2;
   int d = (int)3.99;
   unsigned int e = -1;
   printf("%d %.1f %.1f %d %u\n", a, b, c, d, e);

第 3 章 输入与输出

程序需要与用户交流:从键盘读取数据(输入),把结果显示在屏幕上(输出)。C 语言中最常用的输入输出函数是 scanf 和 printf,它们都声明在 <stdio.h> 中。

3.1 printf:格式化输出

printf 的基本形式是:

c
printf("格式字符串", 参数1, 参数2, ...);

格式字符串中的普通文字会原样输出,而以 % 开头的格式说明符会依次被后面的参数替换。

常用格式说明符:

说明符含义示例输出
%d十进制整数(int)printf("%d", 42)42
%u无符号十进制整数printf("%u", 42u)42
%ld / %lldlong / long long 整数printf("%lld", 10LL)10
%f小数形式,默认 6 位小数printf("%f", 3.14)3.140000
%e科学计数法printf("%e", 1500.0)1.500000e+03
%g默认保留 6 位有效数字,根据数值大小自动采用 %f 或 %e 的形式,并去掉末尾多余的 0printf("%g", 3.14)3.14
%c单个字符printf("%c", 'A')A
%s字符串printf("%s", "Hi")Hi
%x / %X十六进制(小写/大写字母)printf("%x", 255)ff
%o八进制printf("%o", 8)10
%p指针(地址)见第 10 章
%zusize_t 类型(如 sizeof 的结果)printf("%zu", sizeof(int))4
%%输出百分号本身printf("100%%")100%

控制宽度、精度和对齐。 在 % 和字母之间可以加入修饰:

c
#include <stdio.h>

int main(void)
{
    int n = 42;
    double pi = 3.14159265;

    printf("[%d]\n", n);            // 原样输出
    printf("[%5d]\n", n);           // 宽度为 5,默认右对齐
    printf("[%-5d]\n", n);          // 宽度为 5,左对齐
    printf("[%05d]\n", n);          // 宽度为 5,左边补 0
    printf("[%+d]\n", n);           // 显示正号
    printf("[%f]\n", pi);           // 默认 6 位小数
    printf("[%.2f]\n", pi);         // 保留 2 位小数
    printf("[%8.3f]\n", pi);        // 总宽度 8,保留 3 位小数
    printf("[%e]\n", 123456.789);   // 科学计数法
    printf("[%x] [%X] [%o]\n", 255, 255, 8);
    printf("[%.3s]\n", "Hello");    // 字符串最多输出 3 个字符
    printf("[100%%]\n");            // 输出 % 本身
    return 0;
}

运行结果:

text
[42]
[   42]
[42   ]
[00042]
[+42]
[3.141593]
[3.14]
[   3.142]
[1.234568e+05]
[ff] [FF] [10]
[Hel]
[100%]

利用宽度控制,可以输出整齐的表格:

c
#include <stdio.h>

int main(void)
{
    printf("%-10s%6s%10s\n", "Item", "Qty", "Price");
    printf("%-10s%6d%10.2f\n", "Apple", 3, 4.5);
    printf("%-10s%6d%10.2f\n", "Banana", 12, 0.75);
    printf("%-10s%6d%10.2f\n", "Cherry", 100, 25.8);
    return 0;
}

运行结果:

text
Item         Qty     Price
Apple          3      4.50
Banana        12      0.75
Cherry       100     25.80
注意
宽度是按字节计算的,而一个汉字在 UTF-8 编码下占 3 个字节、在屏幕上却只占 2 个英文字符的宽度,所以包含汉字的内容用宽度对齐时会有偏差。
提示
%.2f 会进行四舍五入,但由于浮点数本身存在误差,个别情况下结果看起来"不像"四舍五入。例如 2.675 在内存中实际存储为 2.67499999……,所以 printf("%.2f", 2.675) 输出的是 2.67。另外,对于恰好处在正中间的值,GCC 等编译器所用的 C 库采用"四舍六入五成双"的规则,例如 printf("%.2f", 0.125) 输出 0.12。

转义字符。 有些字符无法直接写在字符串里(比如换行),就要用反斜杠 \ 开头的转义字符来表示:

转义字符含义
\n换行
\t制表符(Tab),常用于简单对齐
\\反斜杠 \ 本身
\"双引号
\'单引号
\0空字符(ASCII 码为 0),字符串的结束标记
\r回车(光标回到行首)
\a响铃(部分终端会发出提示音)

例如,要输出 他说:"C 语言很有趣!",可以写 printf("他说:\"C 语言很有趣!\"\n");。

常见错误
格式说明符必须和参数的类型一致。printf("%d", 3.14) 用 %d 输出一个 double,结果是一个莫名其妙的数字(这是未定义行为);printf("%f", 10) 同样是错的。开启 -Wall 后,编译器会对这类不匹配给出警告。

3.2 scanf:格式化输入

scanf 从键盘读取数据,它的形式与 printf 很像:

c
#include <stdio.h>

int main(void)
{
    int a, b;
    printf("请输入两个整数:");
    scanf("%d %d", &a, &b);
    printf("%d + %d = %d\n", a, b, a + b);
    return 0;
}

运行结果:

text
请输入两个整数:3 5
3 + 5 = 8

为什么要加 `&`? & 是取地址运算符,&a 表示"变量 a 的地址"。scanf 需要知道把读到的数据存到哪个"储物柜"里,所以要把柜子的编号(地址)告诉它。这背后的原理会在第 10 章讲指针时彻底讲清楚,现在先记住:用 `scanf` 读取普通变量时,变量名前必须加 `&`。 忘记加 & 是新手最常见的错误之一,程序往往会直接崩溃。

`scanf` 的格式说明符与 printf 大体相同,但有一处关键区别——读取 double 必须用 %lf:

变量类型scanf 格式printf 格式
int%d%d
long long%lld%lld
float%f%f
double`%lf`%f(也可以写 %lf)
char%c%c
字符串(字符数组)%s(不加 &,第 9 章讲原因)%s

输入时的分隔。 用 %d、%lf 等读取数字时,scanf 会自动跳过前面的空格、Tab 和回车,所以输入 3 5、3 回车 5,效果都一样。

格式字符串里的普通字符必须原样输入。 如果写成 scanf("%d,%d", &a, &b);,用户就必须输入 3,5(中间是英文逗号),输入 3 5 会导致 b 读取失败。因此,`scanf` 的格式字符串里最好只写格式说明符,不要写多余的文字,更不要写 \n——scanf("%d\n", &a) 会让程序在你按下回车后仍然"卡住"等待。

`scanf` 的返回值。 scanf 会返回成功读取并存入变量的数据个数。如果用户输入的内容与格式不符,它会返回较小的数(甚至是 0);如果还没有读到任何数据,输入就已经结束(或者发生了读取错误),则返回 EOF(一个负数常量,在常见的实现中是 -1;如何从键盘输入"结束信号",见 3.4 节)。注意,如果输入结束之前已经读到了一部分数据,scanf 返回的是已读到的个数,而不是 EOF:例如 scanf("%d %d", &a, &b) 读到 42 之后输入就结束了,返回值是 1。

c
#include <stdio.h>

int main(void)
{
    int n = 0;
    printf("请输入一个整数:");
    int count = scanf("%d", &n);
    printf("scanf 成功读取了 %d 个数据,n = %d\n", count, n);
    return 0;
}

分别输入 25 和 abc 的运行结果:

text
请输入一个整数:25
scanf 成功读取了 1 个数据,n = 25
text
请输入一个整数:abc
scanf 成功读取了 0 个数据,n = 0

学完第 5 章的 if 语句后,我们就可以根据返回值判断用户的输入是否合法了。

注意(Visual Studio 用户必读)
在 Visual Studio 中使用 scanf 会遇到编译错误 C4996:'scanf': This function or variable may be unsafe. Consider using scanf_s instead. 这是微软推荐使用它自己的 scanf_s 函数,但 scanf_s 并非所有编译器都支持。解决方法:在源文件的第一行(所有 #include 之前)加上 #define _CRT_SECURE_NO_WARNINGS,或者在项目属性 → C/C++ → 常规中把"SDL 检查"设为"否"。本文后面的代码不再重复这一行,Visual Studio 用户请自行添加。

3.3 输入缓冲区:理解 scanf 的"怪脾气"

你在键盘上敲的字符,并不是直接交给程序的,而是先存放在一个叫输入缓冲区的地方,直到你按下回车,程序才开始从缓冲区里读取。回车本身也会作为一个换行符 `'\n'` 放进缓冲区。

这会导致一个经典问题:

c
#include <stdio.h>

int main(void)
{
    int age;
    char grade;

    printf("请输入年龄:");
    scanf("%d", &age);
    printf("请输入等级:");
    scanf("%c", &grade);
    printf("年龄 = %d,等级 = [%c]\n", age, grade);
    return 0;
}

运行结果:

text
请输入年龄:18
请输入等级:年龄 = 18,等级 = [
]

程序根本没有等我们输入等级!过程是这样的:输入 18 并回车后,缓冲区里是 18\n。第一个 scanf("%d") 取走了 18,把 \n 留在了缓冲区;第二个 scanf("%c") 一看缓冲区里有字符,直接把 \n 读走了。

解决方法有两种:

方法一:在 %c 前面加一个空格,写成 scanf(" %c", &grade);。格式字符串中的空格表示"跳过所有空白字符(空格、Tab、换行)"。

方法二:读取之后清空缓冲区中剩余的内容:

c
int ch;
while ((ch = getchar()) != '\n' && ch != EOF) {
    // 循环体为空:只是把这一行剩下的字符逐个读走、丢弃
}

这里用到的 while 循环会在第 6 章学习,你可以先把它当作一个固定的"工具代码"来使用。

常见错误
网上有些资料用 fflush(stdin) 清空输入缓冲区。C 标准只定义了 fflush 对输出流的作用,对输入流使用它是未定义行为,只在部分编译器上"碰巧"有效,请不要使用。

3.4 字符输入输出:getchar 与 putchar

getchar() 从输入缓冲区读取一个字符并返回它,putchar(c) 向屏幕输出一个字符。它们比 scanf/printf 更简单、更高效:

c
#include <stdio.h>

int main(void)
{
    printf("请输入一个小写字母:");
    int ch = getchar();
    printf("它的 ASCII 码是 %d,对应的大写字母是 ", ch);
    putchar(ch - 32);
    putchar('\n');
    return 0;
}

运行结果:

text
请输入一个小写字母:b
它的 ASCII 码是 98,对应的大写字母是 B

注意 getchar() 的返回值类型是 int 而不是 char。这是因为它除了返回普通字符外,还可能返回表示"输入结束"的 EOF(一个负数,通常是 -1),需要一个能同时容纳所有字符和 EOF 的类型。在键盘上可以手动输入"结束信号":Windows 上是先按 Ctrl + Z 再按回车,Linux 和 macOS 上是 Ctrl + D。

3.5 综合示例:BMI 计算器

BMI(身体质量指数)= 体重(千克)÷ 身高(米)的平方。

c
#include <stdio.h>

int main(void)
{
    double weight, height;

    printf("请输入体重(千克):");
    scanf("%lf", &weight);
    printf("请输入身高(米):");
    scanf("%lf", &height);

    double bmi = weight / (height * height);
    printf("你的 BMI 指数是:%.1f\n", bmi);
    return 0;
}

运行结果:

text
请输入体重(千克):65
请输入身高(米):1.75
你的 BMI 指数是:21.2

学完第 5 章后,我们可以让程序再根据 BMI 值给出"偏瘦""正常""超重"等判断。

本章练习

  1. 圆的周长和面积。 输入圆的半径,输出周长和面积,结果保留 2 位小数。
  2. 求和与平均值。 输入三个整数,输出它们的和,以及保留 2 位小数的平均值。(提示:小心整数除法。)
  3. 大写转小写。 输入一个大写字母,输出对应的小写字母及其 ASCII 码。
  4. 进制与格式。 输入一个整数,分别以十进制、八进制、十六进制输出;再以宽度 10 输出三次,分别为右对齐、左对齐和左边补 0。为了看清宽度,每次输出都用方括号括起来,如 [ 255]。

第 4 章 运算符与表达式

有了数据,下一步就是对数据进行运算。C 语言提供了非常丰富的运算符,这一章我们逐一认识它们。

4.1 表达式与语句

由运算符和操作数组成的式子叫表达式,例如 3 + 5、a * b、x = 10。表达式通常都有一个值:3 + 5 的值是 8,赋值表达式 x = 10 的值是 10。(主要的例外是调用返回类型为 void 的函数,第 7 章会讲到,这种表达式没有值。)

在表达式后面加上分号,就构成了一条语句:x = 10;。

4.2 算术运算符

运算符含义示例结果
+加7 + 29
-减7 - 25
*乘7 * 214
/除7 / 23(整数除法)
%取余(求模)7 % 21

除法和取余有几个需要特别注意的地方:

c
#include <stdio.h>

int main(void)
{
    printf("7 / 2 = %d\n", 7 / 2);
    printf("-7 / 2 = %d\n", -7 / 2);
    printf("7 %% 3 = %d\n", 7 % 3);
    printf("-7 %% 3 = %d\n", -7 % 3);
    printf("7 %% -3 = %d\n", 7 % -3);
    printf("7.0 / 2 = %.1f\n", 7.0 / 2);
    return 0;
}

运行结果:

text
7 / 2 = 3
-7 / 2 = -3
7 % 3 = 1
-7 % 3 = -1
7 % -3 = 1
7.0 / 2 = 3.5
  1. 整数除法会舍去小数部分(向 0 取整),所以 7 / 2 是 3,-7 / 2 是 -3。只要有一个操作数是浮点数,就会进行浮点除法。
  2. `%` 只能用于整数,结果的符号与被除数(左边的数)相同。
  3. 整数除以 0 是未定义行为,程序通常会直接崩溃。做除法前要确保除数不为 0。

/ 和 % 配合使用可以完成很多有用的工作:

表达式作用以 n = 1234 为例
n % 10取出个位数4
n / 10去掉个位数123
n % 2 == 0判断是否为偶数结果为 1(真)
n % 7 == 0判断能否被 7 整除结果为 0(假)

4.3 赋值运算符

= 是赋值运算符,作用是把右边的值存入左边的变量,它和数学中的"等于"完全不是一回事。x = x + 1 在数学上不成立,在 C 语言中却再正常不过:"取出 x 的值,加 1,再存回 x"。

赋值号左边必须是一个可以存放数据的"位置"(称为左值),比如变量。5 = x; 或 a + b = 10; 都是错误的。

赋值运算是从右向左结合的,所以可以连续赋值:a = b = c = 0; 等价于 a = (b = (c = 0));。

复合赋值运算符是一种简写:

写法等价于
x += 5x = x + 5
x -= 5x = x - 5
x *= 5x = x * 5
x /= 5x = x / 5
x %= 5x = x % 5

下面用这些运算符实现一个"找零"程序:用最少的纸币凑出指定金额。

c
#include <stdio.h>

int main(void)
{
    int money;
    printf("请输入金额(元):");
    scanf("%d", &money);

    int n100 = money / 100;
    money %= 100;              // 剩下不足 100 元的部分
    int n50 = money / 50;
    money %= 50;
    int n20 = money / 20;
    money %= 20;
    int n10 = money / 10;
    money %= 10;
    int n5 = money / 5;
    money %= 5;
    int n1 = money;

    printf("100元 %d 张,50元 %d 张,20元 %d 张,10元 %d 张,5元 %d 张,1元 %d 张\n",
           n100, n50, n20, n10, n5, n1);
    return 0;
}

运行结果:

text
请输入金额(元):388
100元 3 张,50元 1 张,20元 1 张,10元 1 张,5元 1 张,1元 3 张

4.4 自增与自减

++ 让变量加 1,-- 让变量减 1。它们各有两种写法,区别在于表达式的值:

  • 前缀 ++i:先把 i 加 1,表达式的值是加 1 之后的 i;
  • 后缀 i++:表达式的值是加 1 之前的 i,然后 i 再加 1。
c
#include <stdio.h>

int main(void)
{
    int i = 5;
    int a = i++;    // a 得到 5,然后 i 变成 6
    printf("a = %d, i = %d\n", a, i);

    int b = ++i;    // i 先变成 7,b 得到 7
    printf("b = %d, i = %d\n", b, i);

    int c = i--;    // c 得到 7,然后 i 变成 6
    int d = --i;    // i 先变成 5,d 得到 5
    printf("c = %d, d = %d, i = %d\n", c, d, i);
    return 0;
}

运行结果:

text
a = 5, i = 6
b = 7, i = 7
c = 7, d = 5, i = 5

如果 i++; 或 ++i; 单独成为一条语句,二者效果完全相同,都是让 i 加 1。

常见错误
不要在一个表达式里修改某个变量之后,又在同一个表达式中再次修改或读取它,例如 i = i++;、a = i++ + i++;、printf("%d %d", i, i++);。这些读写操作之间没有规定的先后顺序,C 标准明确规定这属于未定义行为,不同编译器会给出不同的结果。网上流传的"i++ + ++i 等于几"之类的题目没有标准答案,不必钻研。

4.5 关系运算符

关系运算符用来比较两个值,结果为 1(成立)或 0(不成立):

运算符含义示例(a = 5, b = 3)结果
>大于a > b1
<小于a < b0
>=大于等于a >= 51
<=小于等于b <= 20
==等于a == 51
!=不等于a != b1
常见错误
判断相等要用两个等号 ==。if (x = 5) 并不是判断 x 是否等于 5,而是把 5 赋给 x,整个表达式的值为 5(非 0,即"真"),所以条件永远成立。开启 -Wall 后编译器会给出警告:suggest parentheses around assignment used as truth value。
常见错误
数学中的 1 < x < 10 不能直接照搬到 C 语言中。它会被理解为 (1 < x) < 10:先算 1 < x 得到 0 或 1,而 0 和 1 都小于 10,所以整个表达式永远为真。正确的写法是 x > 1 && x < 10。

4.6 逻辑运算符

逻辑运算符用来组合多个条件:

运算符含义规则
&&逻辑与(并且)两边都为真,结果才为真
||逻辑或(或者)只要有一边为真,结果就为真
!逻辑非(取反)真变假,假变真

真值表(1 表示真,0 表示假):

aba && ba || b!a
00001
01011
10010
11110

回忆一下:在 C 语言中,0 为假,任何非 0 值都为真。所以 !5 的值是 0,!0 的值是 1。

短路求值。 && 和 || 都是从左往右计算的,并且一旦能确定结果,就不再计算右边:

  • 对于 A && B,如果 A 为假,结果必然为假,B 不会被计算;
  • 对于 A || B,如果 A 为真,结果必然为真,B 不会被计算。
c
#include <stdio.h>

int main(void)
{
    int a = 0, b = 5;
    int r = (a != 0) && (b / a > 1);   // a != 0 为假,右边不会计算,也就不会除以 0
    printf("r = %d\n", r);

    int x = 1;
    int y = (x > 0) || (++x > 0);      // 左边已经为真,++x 不会执行
    printf("y = %d, x = %d\n", y, x);
    return 0;
}

运行结果:

text
r = 0
y = 1, x = 1

短路求值非常实用:把"保护性"的条件写在左边(如"除数不为 0"),可以保证右边的运算是安全的。

例:判断闰年。 闰年的规则是"能被 4 整除但不能被 100 整除,或者能被 400 整除",用逻辑运算符可以直接翻译:

c
#include <stdio.h>

int main(void)
{
    int year;
    printf("请输入年份:");
    scanf("%d", &year);

    int is_leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
    printf("%d 年是闰年吗?%d\n", year, is_leap);
    return 0;
}

运行结果:

text
请输入年份:2024
2024 年是闰年吗?1

你可以再试试 1900(不是闰年)和 2000(是闰年)。

4.7 条件运算符

条件运算符 ? : 是 C 语言中唯一需要三个操作数的运算符:

c
条件 ? 表达式1 : 表达式2

如果条件为真,整个表达式的值是表达式 1,否则是表达式 2。它适合用来写简单的"二选一":

c
int max = (a > b) ? a : b;                        // 取 a、b 中的较大者
printf("%d 是%s数\n", n, (n % 2 == 0) ? "偶" : "奇");

条件运算符可以嵌套,但嵌套多了会很难读,这时应该改用第 5 章的 if 语句。

4.8 逗号运算符

逗号运算符把多个表达式连在一起,从左到右依次计算,整个表达式的值是最后一个表达式的值:

c
int a, b;
a = (b = 3, b + 2);   // 先执行 b = 3,再计算 b + 2,a 的值是 5

它最常见的用途是在 for 循环中同时控制两个变量(第 6 章)。注意,定义变量时的逗号(int a, b;)和函数参数之间的逗号都只是分隔符,不是逗号运算符。

4.9 位运算符(初识)

位运算符直接对整数的二进制位进行操作。这里先简单认识一下,第 16 章会深入讲解它们的用途。以 a = 12(二进制 1100)、b = 10(二进制 1010)为例:

运算符名称规则示例结果
&按位与对应位都为 1 才为 1a & b1000,即 8
|按位或对应位有一个为 1 就为 1a | b1110,即 14
^按位异或对应位不同为 1,相同为 0a ^ b0110,即 6
~按位取反每一位 0 变 1,1 变 0~a-13(int 类型时)
<<左移所有位向左移动,右边补 0a << 2110000,即 48
>>右移所有位向右移动a >> 211,即 3

注意不要把 &、| 与逻辑运算符 &&、|| 混淆:12 && 10 的结果是 1(两边都是"真"),而 12 & 10 的结果是 8。

4.10 sizeof 运算符

sizeof 用来求一个类型或变量占用的字节数。它看起来像函数,其实是一个运算符:

c
int n = 10;
printf("%zu %zu %zu\n", sizeof(int), sizeof(n), sizeof n);   // 输出:4 4 4

对变量使用 sizeof 时可以省略括号,对类型使用时必须加括号。它的结果是 size_t 类型。对于普通的类型和变量,结果在编译时就已经确定;例外是 8.10 节介绍的变长数组,它的大小要到运行时才知道,对它使用 sizeof 也要在运行时计算。

4.11 运算符的优先级与结合性

当一个表达式中有多个运算符时,优先级和结合性决定了表达式如何分组,也就是每个运算符作用于哪些操作数。优先级高的运算符与操作数结合得更紧密:a + b * c 中 * 的优先级高于 +,所以它等价于 a + (b * c);优先级相同时,由结合性决定分组的方向:a - b - c 中两个 - 优先级相同,从左向右结合,等价于 (a - b) - c。

常用运算符的优先级从高到低大致如下(完整表格见附录 C):

优先级运算符结合方向
1(最高)() [] -> . 后缀 ++ --从左到右
2! ~ 前缀 ++ -- 正负号 *(解引用)&(取地址)sizeof 类型转换从右到左
3* / %从左到右
4+ -从左到右
5<< >>从左到右
6< <= > >=从左到右
7== !=从左到右
8&从左到右
9^从左到右
10|从左到右
11&&从左到右
12||从左到右
13?:从右到左
14= += -= 等赋值运算符从右到左
15(最低),从左到右

记忆的大方向是:单目运算 > 算术运算 > 移位 > 关系运算 > 位运算 > 逻辑运算 > 条件运算 > 赋值 > 逗号。

有两个经典陷阱:

  • x & 1 == 0 本意是"判断 x 的最低位是否为 0",但 == 的优先级高于 &,实际被理解为 x & (1 == 0),也就是 x & 0,结果永远是 0。应写成 (x & 1) == 0。
  • a << 1 + 1 等价于 a << 2,因为 + 的优先级高于 <<。

需要特别注意:优先级和结合性只决定表达式如何分组,并不决定各个操作数的求值先后。例如在 f() + g() * h() 中(f、g、h 是三个函数),结果一定按 f() + (g() * h()) 组合,但三个函数谁先被调用,C 标准并没有规定,f() 完全可能最后才被调用。只有 &&、||、?: 和逗号运算符保证先求值左边(第一个)的操作数。所以,不要写依赖求值顺序的代码,4.4 节中 a = i++ + i++ 之类的写法之所以出问题,也与此有关。

提示
没有人能记住所有运算符的优先级,专业程序员也不例外。拿不准的时候就加括号,括号能让表达式按你想要的方式分组,也能让代码更容易读懂。

本章练习

  1. 拆分三位数。 输入一个三位整数,分别输出它的百位、十位和个位。例如输入 365,输出 百位 3,十位 6,个位 5。
  2. 时间换算。 输入一个秒数,转换成"时:分:秒"的格式输出,每部分都占两位,不足两位时补 0。例如输入 3725,输出 01:02:05。
  3. 预测输出。 不运行程序,先写出下面代码的输出,再运行验证,并解释原因:
c
   int a = 5, b = 3;
   printf("%d %d %d %d\n", a / b, a % b, -a / b, -a % b);
   int x = 0, y = 0;
   int r = (x++ > 0) && (y++ > 0);
   printf("%d %d %d\n", r, x, y);
  1. 三个数的最大值。 输入三个整数,只用条件运算符 ?:(不用 if)求出其中的最大值。

第 5 章 分支结构:让程序学会选择

到目前为止,我们写的程序都是从上到下一条条执行的。但现实中的问题常常需要"具体情况具体分析":成绩及格与否给出不同的评语,输入的运算符不同就做不同的计算。这一章学习如何让程序根据条件选择不同的执行路径。

5.1 程序的三种基本结构

计算机科学家已经证明:任何算法都可以只用三种基本结构组合出来。

  • 顺序结构:语句按先后顺序依次执行;
  • 选择结构(分支结构):根据条件决定执行哪一部分语句,本章内容;
  • 循环结构:在条件满足时反复执行某些语句,第 6 章内容。

5.2 if 语句

if 语句的形式是:

c
if (条件) {
    语句块    // 条件为真时执行
}

执行流程如下:

text
            条件为真吗?
           /            \
         是              否
         |                |
     执行语句块            |
          \              /
          继续执行后面的语句

例如,求一个整数的绝对值:

c
#include <stdio.h>

int main(void)
{
    int n;
    printf("请输入一个整数:");
    scanf("%d", &n);

    if (n < 0) {
        n = -n;
    }
    printf("它的绝对值是 %d\n", n);
    return 0;
}

运行结果:

text
请输入一个整数:-7
它的绝对值是 7

条件可以是任何表达式:值为 0 表示假,非 0 表示真。所以 if (n != 0) 也可以简写成 if (n),if (n == 0) 可以写成 if (!n)。

关于花括号。 如果语句块只有一条语句,语法上可以省略花括号,但这是很多 bug 的来源:

c
if (score >= 60)
    printf("及格了\n");
    printf("恭喜你!\n");    // 这一行并不属于 if,无论是否及格都会执行!

缩进只是给人看的,编译器并不关心。没有花括号时,if 只管紧跟它的一条语句。开启 -Wall 后,GCC 会对这种"误导性缩进"给出警告。

提示
强烈建议无论语句块有几行,都加上花括号。这是许多公司编码规范中的硬性要求。
常见错误
不要在 if (...) 后面加分号。if (x > 0); 中的分号本身就是一条"空语句",if 控制的是这条空语句,后面花括号里的代码会无条件执行。

5.3 if-else 语句

if-else 用于"二选一":

c
if (条件) {
    语句块1    // 条件为真时执行
} else {
    语句块2    // 条件为假时执行
}

例如判断奇偶:

c
#include <stdio.h>

int main(void)
{
    int n;
    printf("请输入一个整数:");
    scanf("%d", &n);

    if (n % 2 == 0) {
        printf("%d 是偶数\n", n);
    } else {
        printf("%d 是奇数\n", n);
    }
    return 0;
}

运行结果:

text
请输入一个整数:17
17 是奇数

5.4 else if:多分支选择

当有多种情况时,可以把多个 if-else 串起来:

c
if (条件1) {
    语句块1
} else if (条件2) {
    语句块2
} else if (条件3) {
    语句块3
} else {
    语句块4    // 以上条件都不满足时执行
}

程序会从上往下依次判断,执行第一个满足条件的分支,然后跳过其余所有分支。

例:把百分制成绩转换为等级(90 分及以上为 A,80~89 为 B,70~79 为 C,60~69 为 D,60 分以下为 E)。这里顺便用上第 3 章提到的方法——检查 scanf 的返回值,拒绝非法输入:

c
#include <stdio.h>

int main(void)
{
    int score;
    printf("请输入成绩(0~100):");
    if (scanf("%d", &score) != 1 || score < 0 || score > 100) {
        printf("输入无效!\n");
        return 1;
    }

    char grade;
    if (score >= 90) {
        grade = 'A';
    } else if (score >= 80) {
        grade = 'B';
    } else if (score >= 70) {
        grade = 'C';
    } else if (score >= 60) {
        grade = 'D';
    } else {
        grade = 'E';
    }
    printf("等级:%c\n", grade);
    return 0;
}

运行结果:

text
请输入成绩(0~100):85
等级:B

注意两点:

  1. 条件的顺序很重要。 第二个分支只写了 score >= 80,没有写 score < 90,因为能走到这里,说明第一个条件已经不成立,score 必然小于 90。但如果把 score >= 60 放在最前面,那么 95 分也会被判为 D。
  2. 输入检查中用到了 || 的短路特性:如果 scanf 读取失败,后面的比较就不会进行。main 函数返回非 0 值表示程序"异常结束"。

5.5 嵌套 if 与"悬空 else"

if 语句里面还可以再包含 if 语句,这叫嵌套。嵌套时要当心 else 的归属问题。请看:

c
if (a > 0)
    if (b > 0)
        printf("a 和 b 都是正数\n");
else
    printf("a 不是正数\n");

从缩进看,else 似乎属于 if (a > 0),但 C 语言的规则是:`else` 总是与它前面最近的、尚未配对的 `if` 配对。所以这个 else 实际上属于 if (b > 0),当 a > 0 而 b <= 0 时,程序会错误地输出"a 不是正数"。

解决方法仍然是:始终使用花括号。

c
if (a > 0) {
    if (b > 0) {
        printf("a 和 b 都是正数\n");
    }
} else {
    printf("a 不是正数\n");
}

5.6 switch 语句

当需要根据一个整数值的不同取值做不同的事情时,switch 比一长串 else if 更清晰:

c
switch (表达式) {
case 常量1:
    语句块1
    break;
case 常量2:
    语句块2
    break;
default:
    语句块3
    break;
}

程序会计算表达式的值,然后跳转到与之相等的 case 处开始执行,遇到 break 就跳出 switch;如果没有匹配的 case,就执行 default 部分。

例:一个简易计算器。

c
#include <stdio.h>

int main(void)
{
    double a, b;
    char op;
    printf("请输入算式(如 3 + 4):");
    scanf("%lf %c %lf", &a, &op, &b);

    switch (op) {
    case '+':
        printf("%g %c %g = %g\n", a, op, b, a + b);
        break;
    case '-':
        printf("%g %c %g = %g\n", a, op, b, a - b);
        break;
    case '*':
        printf("%g %c %g = %g\n", a, op, b, a * b);
        break;
    case '/':
        if (b == 0) {
            printf("错误:除数不能为 0\n");
        } else {
            printf("%g %c %g = %g\n", a, op, b, a / b);
        }
        break;
    default:
        printf("不支持的运算符:%c\n", op);
        break;
    }
    return 0;
}

运行结果:

text
请输入算式(如 3 + 4):10 / 4
10 / 4 = 2.5

使用 switch 需要遵守以下规则:

  • switch 后面括号里的表达式必须是整数类型(char 也是整数),不能是浮点数或字符串;
  • case 后面必须是常量,不能是变量,也不能是范围(如"90 到 100");
  • 各个 case 的值不能重复;
  • default 可以省略,但建议总是写上,用来处理意外情况。

不要忘记 break。 如果某个 case 的末尾没有 break,程序会接着执行下一个 case 的语句,这叫"穿透"(fall through):

c
#include <stdio.h>

int main(void)
{
    int level = 2;
    switch (level) {
    case 1:
        printf("一级\n");
    case 2:
        printf("二级\n");
    case 3:
        printf("三级\n");
    default:
        printf("其他\n");
    }
    return 0;
}

运行结果:

text
二级
三级
其他

从 case 2 进入后,由于没有 break,程序一路执行到了底。开启 -Wextra 后,GCC 会警告 this statement may fall through。

穿透有时也可以被巧妙地利用,让多个 case 共享同一段代码:

c
switch (month) {
case 3: case 4: case 5:
    printf("春季\n");
    break;
case 6: case 7: case 8:
    printf("夏季\n");
    break;
case 9: case 10: case 11:
    printf("秋季\n");
    break;
case 12: case 1: case 2:
    printf("冬季\n");
    break;
default:
    printf("月份输入有误\n");
    break;
}

`switch` 与 `if` 如何选择? switch 只能判断"是否等于某个常量",而 if 可以处理任意条件(范围、多个变量的组合等)。有时可以通过一点数学变换让 switch 处理范围,比如成绩等级:score / 10 的值为 10 或 9 时是 A,为 8 时是 B……

c
switch (score / 10) {
case 10:
case 9:
    grade = 'A';
    break;
case 8:
    grade = 'B';
    break;
case 7:
    grade = 'C';
    break;
case 6:
    grade = 'D';
    break;
default:
    grade = 'E';
    break;
}
提示
如果需要在某个 case 里定义变量,请用花括号把这个 case 的语句括起来,如 case 1: { int t = 0; ... break; }。一方面,C23 之前的标准不允许 case 标签后面直接跟变量定义(有的编译器会报错);另一方面,不加花括号的话,这个变量的作用域会延伸到后面的 case 中,而从其他 case 跳转进来时,它并没有被初始化。

5.7 综合示例:求解一元二次方程

求解 ax² + bx + c = 0。根据判别式 Δ = b² − 4ac 的正负,方程有两个不相等的实根、两个相等的实根或两个共轭复根。

c
#include <stdio.h>
#include <math.h>

int main(void)
{
    double a, b, c;
    printf("请输入 ax^2 + bx + c = 0 的系数 a b c:");
    if (scanf("%lf %lf %lf", &a, &b, &c) != 3) {
        printf("输入无效!\n");
        return 1;
    }

    if (fabs(a) < 1e-12) {
        printf("a 不能为 0,这不是一元二次方程。\n");
        return 1;
    }

    double delta = b * b - 4 * a * c;
    if (delta > 1e-12) {
        double x1 = (-b + sqrt(delta)) / (2 * a);
        double x2 = (-b - sqrt(delta)) / (2 * a);
        printf("有两个不相等的实根:x1 = %.2f, x2 = %.2f\n", x1, x2);
    } else if (delta >= -1e-12) {
        printf("有两个相等的实根:x1 = x2 = %.2f\n", -b / (2 * a));
    } else {
        double real = -b / (2 * a);
        double imag = fabs(sqrt(-delta) / (2 * a));
        printf("有两个共轭复根:x1 = %.2f + %.2fi, x2 = %.2f - %.2fi\n",
               real, imag, real, imag);
    }
    return 0;
}

三次运行的结果:

text
请输入 ax^2 + bx + c = 0 的系数 a b c:1 -3 2
有两个不相等的实根:x1 = 2.00, x2 = 1.00
text
请输入 ax^2 + bx + c = 0 的系数 a b c:1 2 1
有两个相等的实根:x1 = x2 = -1.00
text
请输入 ax^2 + bx + c = 0 的系数 a b c:1 2 5
有两个共轭复根:x1 = -1.00 + 2.00i, x2 = -1.00 - 2.00i

这个程序综合运用了输入检查、浮点数比较(用一个很小的数 1e-12 代替 == 0)、多分支选择和数学函数 sqrt(平方根)。在 Linux 上用 GCC 编译时记得加 -lm。

本章练习

  1. 三数排序。 输入三个整数,按从小到大的顺序输出。(提示:可以通过几次"比较,必要时交换"来完成。交换两个变量需要借助第三个变量:t = a; a = b; b = t;)
  2. 月份天数。 输入年份和月份,输出这个月有多少天。要求使用 switch,并考虑闰年的二月。
  3. 阶梯电价。 某地居民电价规定:每月用电 200 度及以内的部分,每度 0.5 元;超过 200 度、不超过 400 度的部分,每度 0.55 元;超过 400 度的部分,每度 0.8 元。输入月用电量(度),输出应缴电费,保留两位小数。例如用电 450 度,电费为 250.00 元。
  4. 字符分类。 输入一个字符,判断它是大写字母、小写字母、数字还是其他字符。

第 6 章 循环结构:让程序学会重复

计算机最擅长的事情就是不知疲倦地重复。求 1 到 100 的和、打印九九乘法表、在一万个数据中查找目标——这些都需要循环。C 语言提供了三种循环语句:while、do-while 和 for。

6.1 while 循环

c
while (条件) {
    循环体
}

执行流程:先判断条件,为真就执行一遍循环体,然后回到开头再次判断条件;如此反复,直到条件为假时结束循环。

text
     ┌──▶ 条件为真吗? ──否──▶ 结束循环
     │         │
     │         是
     │         ▼
     └───── 执行循环体

例:求 1 + 2 + 3 + … + 100。

c
#include <stdio.h>

int main(void)
{
    int i = 1;            // ① 初始化循环变量
    int sum = 0;
    while (i <= 100) {    // ② 循环条件
        sum += i;
        i++;              // ③ 更新循环变量
    }
    printf("1 + 2 + ... + 100 = %d\n", sum);
    return 0;
}

运行结果:

text
1 + 2 + ... + 100 = 5050

一个正确的循环通常包含三个要素:初始化、条件和更新。如果忘了写 i++,i 永远是 1,条件永远成立,程序就会陷入死循环。遇到死循环时,在终端按 Ctrl + C 可以强制结束程序。

while 特别适合"不知道要循环多少次,只知道什么时候停"的场景。例如统计一个整数有几位,并求各位数字之和:

c
#include <stdio.h>

int main(void)
{
    int n;
    printf("请输入一个正整数:");
    scanf("%d", &n);

    int digits = 0, sum = 0;
    int temp = n;           // 用副本进行计算,保留 n 的原值
    while (temp > 0) {
        sum += temp % 10;   // 取出个位
        temp /= 10;         // 去掉个位
        digits++;
    }
    printf("%d 是 %d 位数,各位数字之和为 %d\n", n, digits, sum);
    return 0;
}

运行结果:

text
请输入一个正整数:2024
2024 是 4 位数,各位数字之和为 8

6.2 do-while 循环

c
do {
    循环体
} while (条件);    // 注意末尾有分号

do-while 先执行一次循环体,再判断条件,所以循环体至少会执行一次。

上面统计位数的程序有一个小问题:输入 0 时,while 的条件一开始就不成立,程序会说"0 是 0 位数"。用 do-while 就能解决,因为任何整数至少有 1 位:

c
do {
    sum += temp % 10;
    temp /= 10;
    digits++;
} while (temp != 0);

do-while 最典型的用途是输入校验:先让用户输入,如果输入不合法就要求重新输入。下面的写法同时处理了"输入的不是数字"和"数字超出范围"两种情况:

c
#include <stdio.h>

int main(void)
{
    int score = -1;
    int ok;
    do {
        printf("请输入成绩(0~100):");
        ok = scanf("%d", &score);
        if (ok == EOF) {
            return 1;                  // 输入已结束,直接退出
        }
        int ch;
        while ((ch = getchar()) != '\n' && ch != EOF) {
            // 清空这一行剩余的输入
        }
    } while (ok != 1 || score < 0 || score > 100);

    printf("你输入的成绩是 %d\n", score);
    return 0;
}

运行结果:

text
请输入成绩(0~100):abc
请输入成绩(0~100):120
请输入成绩(0~100):95
你输入的成绩是 95

如果不清空缓冲区,输入 abc 后,scanf 读取失败,abc 会一直留在缓冲区里,下一轮 scanf 又读到它、又失败……程序就会陷入死循环。

6.3 for 循环

for 循环把初始化、条件、更新三个要素写在了一起,结构最为紧凑:

c
for (初始化; 条件; 更新) {
    循环体
}

它的执行顺序是:

text
① 初始化 → ② 判断条件 → ③ 执行循环体 → ④ 更新 → ② 判断条件 → ③ → ④ → … → 条件为假时结束

初始化只执行一次。 例:计算 n 的阶乘 n! = 1 × 2 × … × n。阶乘增长极快,13! 就超出了 int 的范围,所以要用 long long:

c
#include <stdio.h>

int main(void)
{
    int n;
    printf("请输入 n(0~20):");
    scanf("%d", &n);

    long long result = 1;
    for (int i = 2; i <= n; i++) {
        result *= i;
    }
    printf("%d! = %lld\n", n, result);
    return 0;
}

运行结果:

text
请输入 n(0~20):20
20! = 2432902008176640000

for (int i = 2; ...) 这种在初始化部分定义变量的写法是 C99 引入的,这样定义的 i 只在循环内部有效,循环结束后就不存在了。

for 的三个部分都可以省略(但两个分号不能省)。for (;;) 表示没有条件的无限循环,等价于 while (1)。

for 循环中常常配合逗号运算符同时控制两个变量,例如从两端向中间移动:

c
for (int i = 0, j = 9; i < j; i++, j--) {
    printf("(%d, %d) ", i, j);   // 输出 (0, 9) (1, 8) (2, 7) (3, 6) (4, 5)
}

6.4 break 与 continue

  • `break`:立即结束整个循环,跳到循环后面的语句;
  • `continue`:跳过本次循环剩余的语句,直接进入下一次循环(for 循环会先执行"更新"部分)。
c
#include <stdio.h>

int main(void)
{
    // continue:输出 1~20 中不是 3 的倍数的数
    for (int i = 1; i <= 20; i++) {
        if (i % 3 == 0) {
            continue;
        }
        printf("%d ", i);
    }
    printf("\n");

    // break:找出大于 100 的第一个 7 的倍数
    int n = 101;
    while (1) {
        if (n % 7 == 0) {
            break;
        }
        n++;
    }
    printf("大于 100 的第一个 7 的倍数是 %d\n", n);
    return 0;
}

运行结果:

text
1 2 4 5 7 8 10 11 13 14 16 17 19 20
大于 100 的第一个 7 的倍数是 105

while (1) 加 break 是一种很常见的写法:当循环的结束条件在循环体中间才能判断时,这样写最自然。

注意
break 只能跳出它所在的那一层循环(或 switch)。在嵌套循环中,内层的 break 不会结束外层循环。

6.5 嵌套循环

循环体里面还可以再有循环,这叫嵌套循环。外层循环每执行一次,内层循环都要完整地执行一遍。

例 1:九九乘法表。 外层循环控制行(第 i 行),内层循环控制每行输出的算式(第 i 行有 i 个算式):

c
#include <stdio.h>

int main(void)
{
    for (int i = 1; i <= 9; i++) {
        for (int j = 1; j <= i; j++) {
            printf("%d*%d=%-3d", j, i, i * j);
        }
        printf("\n");
    }
    return 0;
}

运行结果:

text
1*1=1
1*2=2  2*2=4
1*3=3  2*3=6  3*3=9
1*4=4  2*4=8  3*4=12 4*4=16
1*5=5  2*5=10 3*5=15 4*5=20 5*5=25
1*6=6  2*6=12 3*6=18 4*6=24 5*6=30 6*6=36
1*7=7  2*7=14 3*7=21 4*7=28 5*7=35 6*7=42 7*7=49
1*8=8  2*8=16 3*8=24 4*8=32 5*8=40 6*8=48 7*8=56 8*8=64
1*9=9  2*9=18 3*9=27 4*9=36 5*9=45 6*9=54 7*9=63 8*9=72 9*9=81

%-3d 让乘积占 3 个字符宽度并左对齐,从而使各列对齐。

例 2:打印金字塔。 打印图形类题目的通用方法是:先找出第 i 行的空格数、字符数与 i 的关系。对于 5 层金字塔:

行号 i空格数星号数
141
233
325
417
509

规律是:空格数 = n − i,星号数 = 2i − 1。

c
#include <stdio.h>

int main(void)
{
    int n = 5;
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n - i; j++) {
            printf(" ");
        }
        for (int j = 1; j <= 2 * i - 1; j++) {
            printf("*");
        }
        printf("\n");
    }
    return 0;
}

运行结果:

text
    *
   ***
  *****
 *******
*********

6.6 经典算法示例

例 1:判断素数。 素数(质数)是大于 1 且只能被 1 和它自身整除的整数。判断 n 是否为素数,只需检查 2 到 √n 之间有没有 n 的因数:如果 n = a × b,那么 a 和 b 中必有一个不超过 √n。

c
#include <stdio.h>
#include <stdbool.h>

int main(void)
{
    int n;
    printf("请输入一个正整数:");
    scanf("%d", &n);

    bool is_prime = (n >= 2);          // 小于 2 的数不是素数
    for (int i = 2; i * i <= n; i++) {  // 用 i * i <= n 代替 i <= sqrt(n),避免浮点运算
        if (n % i == 0) {
            is_prime = false;
            break;                     // 已经找到因数,没必要继续检查
        }
    }
    printf("%d %s素数\n", n, is_prime ? "是" : "不是");
    return 0;
}

运行结果:

text
请输入一个正整数:97
97 是素数

例 2:辗转相除法求最大公约数。 这是已知最古老的算法之一(欧几里得算法):两个数的最大公约数,等于较小的数与两数相除的余数的最大公约数。不断用余数替换,直到余数为 0。

以 48 和 18 为例:

xyx % y
481812
18126
1260
60结束,答案为 6
c
#include <stdio.h>

int main(void)
{
    int a, b;
    printf("请输入两个正整数:");
    scanf("%d %d", &a, &b);

    int x = a, y = b;
    while (y != 0) {
        int r = x % y;
        x = y;
        y = r;
    }
    printf("%d 和 %d 的最大公约数是 %d\n", a, b, x);
    return 0;
}

运行结果:

text
请输入两个正整数:48 18
48 和 18 的最大公约数是 6

例 3:猜数字游戏。 程序随机想一个 1~100 之间的数,玩家来猜,程序提示"太大"或"太小",直到猜中为止。

c
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main(void)
{
    srand((unsigned int)time(NULL));   // 用当前时间作为随机数种子
    int target = rand() % 100 + 1;     // 生成 1~100 之间的随机整数
    int guess;
    int count = 0;

    printf("我想了一个 1~100 之间的数,猜猜是几?\n");
    do {
        printf("请输入你的猜测:");
        if (scanf("%d", &guess) != 1) {
            printf("输入无效,游戏结束。\n");
            return 1;
        }
        count++;
        if (guess > target) {
            printf("太大了!\n");
        } else if (guess < target) {
            printf("太小了!\n");
        }
    } while (guess != target);

    printf("恭喜你猜对了!答案是 %d,你一共猜了 %d 次。\n", target, count);
    return 0;
}

一次可能的运行结果:

text
我想了一个 1~100 之间的数,猜猜是几?
请输入你的猜测:50
太大了!
请输入你的猜测:25
太小了!
请输入你的猜测:37
太小了!
请输入你的猜测:43
恭喜你猜对了!答案是 43,你一共猜了 4 次。

关于随机数:

  • rand()(在 <stdlib.h> 中)返回一个 0 到 RAND_MAX 之间的"伪随机数"。它是通过数学公式算出来的,只要起点(称为种子)相同,产生的序列就完全相同。
  • srand(种子) 用来设置种子。用当前时间 time(NULL)(在 <time.h> 中)作种子,每次运行程序时种子一般都不同,结果也就不一样了(time 的精度通常是 1 秒,如果在同一秒内运行两次,得到的随机数序列会完全相同)。`srand` 在整个程序中只需调用一次,不要放在循环里。
  • 要得到 [min, max] 范围内的随机整数,可以用公式 rand() % (max - min + 1) + min。
提示
每次都猜中间值(这叫二分法),最多 7 次就一定能猜中 1~100 之间的任何数,因为 2⁷ = 128 > 100。第 8 章的二分查找就是基于这个思想。

6.7 goto 语句(了解)

goto 可以让程序跳转到同一个函数中带有标签的位置。它会让程序的执行路径变得混乱难懂,所以一般不推荐使用。它为数不多的"正当用途"之一是一次性跳出多层嵌套循环:

c
#include <stdio.h>

int main(void)
{
    for (int i = 1; i < 10; i++) {
        for (int j = 1; j < 10; j++) {
            if (i * j == 42) {
                printf("找到了:%d * %d = 42\n", i, j);
                goto found;
            }
        }
    }
found:
    printf("搜索结束\n");
    return 0;
}

运行结果:

text
找到了:6 * 7 = 42
搜索结束

另一个用途是在出错时集中释放资源,第 17 章会介绍。

6.8 如何选择循环,以及常见错误

情况推荐的循环
循环次数已知(如"重复 10 次""遍历 1 到 n")for
循环次数未知,只知道结束条件while
循环体至少要执行一次(如先输入后检查)do-while

三种循环可以相互改写,选择最能表达意图的那种即可。

循环中的常见错误:

  1. 边界差一(off-by-one):i < n 和 i <= n 相差一次循环。写完循环后,用最小的情况(如 n = 1)在脑子里走一遍。
  2. 循环后面多写了分号:for (i = 0; i < 10; i++); 的循环体是一条空语句,下面花括号里的代码只会执行一次。
  3. 在循环体内修改 `for` 的循环变量:容易导致逻辑混乱,应当避免。
  4. 用浮点数控制循环次数:for (double x = 0; x != 1.0; x += 0.1) 永远不会结束!因为浮点误差,x 永远不会恰好等于 1.0。循环计数请使用整数。

本章练习

  1. 求 1~100 之间所有奇数的和。
  2. 水仙花数。 一个三位数,如果它的各位数字的立方和等于它本身,就称为水仙花数,例如 153 = 1³ + 5³ + 3³。输出所有的水仙花数。
  3. 打印菱形。 输入 n,打印一个上半部分为 n 行的菱形。例如 n = 3 时:
text
     *
    ***
   *****
    ***
     *
  1. 整数逆序。 输入一个正整数,输出它的逆序数及位数。例如输入 12345,输出 逆序数为 54321,共 5 位。
  2. 求 1! + 2! + 3! + … + 10! 的值。(提示:可以在一个循环里,利用上一次的阶乘算出这一次的阶乘。)

第二部分 核心篇

本部分的目标:掌握 C 语言最核心的四块内容——函数、数组、字符串和指针。函数让你学会"分而治之",数组和字符串让你能处理批量数据,而指针是 C 语言的灵魂,也是它区别于大多数高级语言的地方。这一部分是全文的重中之重,请多花些时间,确保每个示例都真正理解。

第 7 章 函数:程序的积木

7.1 为什么需要函数

假设你要写一个程序,在三个不同的地方判断一个数是不是素数。如果把判断素数的代码复制三遍,不仅冗长,而且一旦发现其中有 bug,就得改三处,漏掉一处就会埋下隐患。

函数就是把一段完成特定任务的代码打包起来、起一个名字,需要时通过名字"调用"它。你可以把函数想象成一台机器:放入原料(参数),机器运转,产出成品(返回值)。使用机器的人不必关心机器内部的构造,只要知道它能做什么就行。

使用函数的好处:

  • 复用:写一次,到处使用;
  • 分而治之:把大问题拆成若干小问题,每个函数只解决一个小问题;
  • 易读易改:if (is_prime(n)) 一看就懂,修改也只需要改一处。

其实你早就在使用函数了:main、printf、scanf、sqrt 都是函数。

7.2 函数的定义与调用

函数定义的一般形式:

c
返回类型 函数名(参数列表)
{
    函数体
}

例:定义一个求两个整数中较大者的函数。

c
#include <stdio.h>

// 返回 a 和 b 中较大的那个
int max(int a, int b)
{
    if (a > b) {
        return a;
    }
    return b;
}

int main(void)
{
    int x = 3, y = 7;
    int m = max(x, y);                           // 调用 max 函数
    printf("较大的是 %d\n", m);
    printf("较大的是 %d\n", max(10, max(4, 12)));  // 函数调用可以嵌套
    return 0;
}

运行结果:

text
较大的是 7
较大的是 12

几个术语:

  • 形式参数(形参):函数定义中的参数,如 max 中的 a 和 b,相当于函数内部的局部变量;
  • 实际参数(实参):调用函数时传入的值,如 max(x, y) 中的 x 和 y;
  • 返回值:用 return 语句交还给调用者的值,它的类型应当与函数定义的返回类型一致;如果不一致,会像赋值那样被自动转换成返回类型。

调用 max(x, y) 时发生了这些事:

  1. 计算实参的值(3 和 7),复制给形参 a 和 b;
  2. 暂停 main 函数,跳转到 max 的函数体开始执行;
  3. 执行到 return b;,函数结束,把 7 带回调用处;
  4. 回到 main,max(x, y) 这个表达式的值就是 7,被赋给 m。

无返回值的函数。 如果函数只是完成某个动作,不需要返回结果,返回类型就写 void,函数体中可以没有 return,或者用不带值的 return; 提前结束:

c
void print_line(int length)
{
    for (int i = 0; i < length; i++) {
        putchar('-');
    }
    putchar('\n');
}

无参数的函数。 参数列表写 void,例如 int get_input(void)。

注意
在 C23 之前,int f() 和 int f(void) 含义不同:前者表示"参数未指定",调用时传任何参数编译器都不检查;后者才明确表示"没有参数"。为了清晰和安全,没有参数时请写 void。

几条规则:

  • return 语句执行后,函数立即结束,后面的语句不再执行;
  • 一个函数只能返回一个值(想"返回"多个值,要用第 10 章的指针);
  • 返回类型不是 void 的函数,每条执行路径都必须有 return,否则调用者拿到的是不确定的值;
  • C 语言不允许在一个函数内部定义另一个函数。

7.3 函数声明

C 编译器从上到下阅读代码。如果在调用一个函数时,编译器还没"见过"它,就会报错(或警告)。上面的例子把 max 定义在 main 之前,所以没有问题。如果想把 main 放在最前面,就需要先写函数的声明(也叫函数原型),告诉编译器这个函数的名字、参数类型和返回类型:

c
#include <stdio.h>

double circle_area(double r);   // 函数声明,末尾有分号

int main(void)
{
    printf("面积:%.2f\n", circle_area(2.0));
    return 0;
}

double circle_area(double r)     // 函数定义
{
    return 3.14159265358979 * r * r;
}

运行结果:

text
面积:12.57

声明中的参数名可以省略,写成 double circle_area(double); 也可以,但保留参数名可读性更好。实际上,#include <stdio.h> 的作用正是把 printf、scanf 等函数的声明引入进来——头文件里装的主要就是声明。第 15 章讲多文件编程时,我们会自己写头文件。

7.4 参数传递:值传递

下面这个函数想要交换两个变量的值,你觉得它能成功吗?

c
#include <stdio.h>

void swap(int a, int b)
{
    int temp = a;
    a = b;
    b = temp;
    printf("函数内:a = %d, b = %d\n", a, b);
}

int main(void)
{
    int x = 1, y = 2;
    swap(x, y);
    printf("函数外:x = %d, y = %d\n", x, y);
    return 0;
}

运行结果:

text
函数内:a = 2, b = 1
函数外:x = 1, y = 2

交换失败了。原因是 C 语言的参数传递方式是值传递:调用函数时,实参的值被复制了一份交给形参。a 和 b 只是 x 和 y 的"复印件",在函数里修改复印件,原件不受任何影响。

text
main 函数的变量           swap 函数的变量
+-------+-------+        +-------+-------+
| x = 1 | y = 2 |  复制   | a = 1 | b = 2 |   交换后 → a = 2, b = 1
+-------+-------+  ───▶  +-------+-------+
   原件不受影响                复印件被修改

那么如何写一个真正能交换两个变量的函数呢?答案是把变量的地址传给函数,这正是第 10 章指针的内容。

7.5 作用域:变量在哪里有效

作用域是指一个名字在程序中的有效范围。

  • 局部变量:在函数内部或代码块(一对花括号)内部定义的变量,只在定义它的代码块中有效。函数的形参也是局部变量。不同函数中的同名局部变量互不相干。
  • 全局变量:在所有函数外部定义的变量,从定义处到文件末尾都可以访问。
c
#include <stdio.h>

int g_count = 0;          // 全局变量

void add(void)
{
    g_count++;            // 任何函数都能访问全局变量
}

int main(void)
{
    int x = 10;           // 局部变量,只在 main 中有效
    {
        int x = 20;       // 内层代码块中的 x,"遮蔽"了外层的 x
        printf("内层 x = %d\n", x);
    }
    printf("外层 x = %d\n", x);

    add();
    add();
    printf("g_count = %d\n", g_count);
    return 0;
}

运行结果:

text
内层 x = 20
外层 x = 10
g_count = 2

当内层代码块定义了与外层同名的变量时,在内层中,外层的变量会被"遮蔽"。这种写法容易引起混淆,应当避免。

提示
尽量少用全局变量。 全局变量可以被任何函数修改,程序一复杂,就很难追查"是谁改了它"。函数之间传递数据,应优先使用参数和返回值。如果确实需要全局变量,可以像上面这样加上 g_ 前缀,以示区分。

7.6 存储期与 static

变量除了"在哪里有效"(作用域),还有"活多久"的问题,这叫存储期。

  • 自动存储期:普通局部变量在程序进入其代码块时创建,离开代码块时销毁。每次调用函数,局部变量都是全新的。
  • 静态存储期:全局变量,以及用 static 修饰的局部变量,在程序开始运行时创建,直到程序结束才销毁,并且只初始化一次。未显式初始化的静态变量会被自动初始化为 0。
c
#include <stdio.h>

void counter(void)
{
    static int count = 0;   // 静态局部变量:只初始化一次,函数返回后依然保留
    int normal = 0;         // 普通局部变量:每次调用都重新创建并初始化
    count++;
    normal++;
    printf("count = %d, normal = %d\n", count, normal);
}

int main(void)
{
    counter();
    counter();
    counter();
    return 0;
}

运行结果:

text
count = 1, normal = 1
count = 2, normal = 1
count = 3, normal = 1

static 局部变量兼具两个特点:像全局变量一样"长寿",又像局部变量一样只能在函数内部访问。它适合用来记录"函数被调用了多少次"之类的状态。

static 还可以修饰全局变量和函数,作用是让它们只在当前源文件中可见;与之相关的还有 extern 关键字。这些内容和多文件编程密切相关,将在第 15 章介绍。

7.7 递归:自己调用自己

一个函数在函数体内调用自己,就叫递归。递归适合解决"可以分解为规模更小的同类问题"的问题。

以阶乘为例:n! = n × (n−1)!,而 1! = 1。求 n! 的问题被转化为了求 (n−1)! 的问题:

c
#include <stdio.h>

long long factorial(int n)
{
    if (n <= 1) {                    // 终止条件
        return 1;
    }
    return n * factorial(n - 1);     // 递推关系
}

int main(void)
{
    for (int i = 0; i <= 5; i++) {
        printf("%d! = %lld\n", i, factorial(i));
    }
    return 0;
}

运行结果:

text
0! = 1
1! = 1
2! = 2
3! = 6
4! = 24
5! = 120

factorial(4) 的执行过程是先"层层深入",到达终止条件后再"逐层返回":

text
factorial(4)
= 4 * factorial(3)
= 4 * (3 * factorial(2))
= 4 * (3 * (2 * factorial(1)))
= 4 * (3 * (2 * 1))          ← 到达终止条件,开始逐层返回
= 4 * (3 * 2)
= 4 * 6
= 24

写递归函数必须抓住两个要素:

  1. 终止条件:最简单的情形,可以直接给出答案,不再递归;
  2. 递推关系:把问题转化为规模更小的同类问题,并且每次递归都要向终止条件靠近。

缺少终止条件,或者递归无法到达终止条件,函数就会无休止地调用自己,最终导致程序崩溃。

递归的代价。 递归代码往往非常简洁,但并不总是高效的。以斐波那契数列为例(1, 1, 2, 3, 5, 8, …,从第 3 项起每项等于前两项之和):

c
#include <stdio.h>

long long g_calls = 0;   // 记录递归函数被调用的次数

long long fib_recursive(int n)
{
    g_calls++;
    if (n <= 2) {
        return 1;
    }
    return fib_recursive(n - 1) + fib_recursive(n - 2);
}

long long fib_loop(int n)
{
    long long a = 1, b = 1;
    for (int i = 3; i <= n; i++) {
        long long c = a + b;
        a = b;
        b = c;
    }
    return b;
}

int main(void)
{
    int n = 40;
    printf("循环版:fib(%d) = %lld\n", n, fib_loop(n));
    printf("递归版:fib(%d) = %lld\n", n, fib_recursive(n));
    printf("递归版一共调用了 %lld 次函数\n", g_calls);
    return 0;
}

运行结果:

text
循环版:fib(40) = 102334155
递归版:fib(40) = 102334155
递归版一共调用了 204668309 次函数

循环版瞬间完成,递归版却调用了两亿多次函数,运行时能明显感到停顿。原因在于大量的重复计算,看看 fib(5) 的调用树就明白了:

text
                    fib(5)
                  /        \
             fib(4)          fib(3)
            /      \         /     \
        fib(3)    fib(2)  fib(2)  fib(1)
        /    \
    fib(2)  fib(1)

fib(3) 被计算了 2 次,fib(2) 被计算了 3 次。n 越大,重复就越严重,调用次数随 n 呈指数级增长。

汉诺塔。 有些问题用递归来思考会非常自然。汉诺塔问题是:有 A、B、C 三根柱子,A 上从小到大叠着 n 个盘子,要把它们全部移到 C 上,每次只能移动一个盘子,并且大盘子不能压在小盘子上面。

递归的思路是:要把 n 个盘子从 A 移到 C,只需——

  1. 先把上面 n−1 个盘子从 A 移到 B(借助 C);
  2. 再把最大的第 n 个盘子从 A 移到 C;
  3. 最后把 B 上的 n−1 个盘子移到 C(借助 A)。

而"移动 n−1 个盘子"又是一个规模更小的同类问题!

c
#include <stdio.h>

// 把 n 个盘子从 from 柱移动到 to 柱,via 为辅助柱
void hanoi(int n, char from, char via, char to)
{
    if (n == 1) {
        printf("把 1 号盘从 %c 移到 %c\n", from, to);
        return;
    }
    hanoi(n - 1, from, to, via);
    printf("把 %d 号盘从 %c 移到 %c\n", n, from, to);
    hanoi(n - 1, via, from, to);
}

int main(void)
{
    hanoi(3, 'A', 'B', 'C');
    return 0;
}

运行结果:

text
把 1 号盘从 A 移到 C
把 2 号盘从 A 移到 B
把 1 号盘从 C 移到 B
把 3 号盘从 A 移到 C
把 1 号盘从 B 移到 A
把 2 号盘从 B 移到 C
把 1 号盘从 A 移到 C

n 个盘子需要移动 2ⁿ − 1 次。传说中 64 个盘子的汉诺塔需要移动约 1.8 × 10¹⁹ 次,即使每秒移动一次,也要大约 5800 亿年。

提示
理解递归的诀窍是"相信":写 hanoi(n - 1, ...) 时,不要试图在脑子里展开每一层调用,而是相信它能正确地完成"移动 n−1 个盘子"的任务,你只需要考虑当前这一层该做什么。

7.8 函数调用栈

程序是如何记住"函数执行完后回到哪里"的呢?答案是调用栈(call stack)。

每调用一个函数,系统就在一块叫作栈的内存区域中为它分配一个栈帧(stack frame),里面存放函数的参数、局部变量和返回地址。函数返回时,它的栈帧就被销毁。栈是"后进先出"的,就像一摞盘子,最后放上去的最先拿走。

以 factorial(3) 为例,递归到最深处时,调用栈是这样的:

text
      +-----------------------+
栈顶  | factorial(1) 的栈帧    |  ← 正在执行
      +-----------------------+
      | factorial(2) 的栈帧    |  等待 factorial(1) 返回
      +-----------------------+
      | factorial(3) 的栈帧    |  等待 factorial(2) 返回
      +-----------------------+
栈底  | main 的栈帧            |  等待 factorial(3) 返回
      +-----------------------+

这也解释了两件事:

  • 局部变量为什么在函数返回后就不存在了——它们所在的栈帧被销毁了;
  • 递归太深为什么会崩溃——栈的空间是有限的(通常只有几 MB),每层递归都要占用一个栈帧,递归层数太多就会耗尽栈空间,这叫栈溢出(stack overflow)。

7.9 使用标准库函数

C 标准库提供了大量现成的函数,使用前需要包含对应的头文件:

头文件用途常用函数举例
<stdio.h>输入输出printf、scanf、getchar、fopen
<stdlib.h>通用工具rand、srand、abs、exit、malloc、qsort
<string.h>字符串处理strlen、strcpy、strcmp
<math.h>数学运算sqrt、pow、fabs、sin
<ctype.h>字符分类isdigit、isalpha、toupper
<time.h>时间日期time、clock

以 <math.h> 为例:

c
#include <stdio.h>
#include <math.h>

int main(void)
{
    printf("sqrt(2)     = %.4f\n", sqrt(2.0));    // 平方根
    printf("pow(2, 10)  = %.0f\n", pow(2, 10));   // 幂运算
    printf("fabs(-3.5)  = %.1f\n", fabs(-3.5));   // 绝对值
    printf("ceil(2.1)   = %.0f\n", ceil(2.1));    // 向上取整
    printf("floor(2.9)  = %.0f\n", floor(2.9));   // 向下取整
    printf("round(2.5)  = %.0f\n", round(2.5));   // 四舍五入
    printf("log10(1000) = %.1f\n", log10(1000));  // 常用对数
    return 0;
}

运行结果:

text
sqrt(2)     = 1.4142
pow(2, 10)  = 1024
fabs(-3.5)  = 3.5
ceil(2.1)   = 3
floor(2.9)  = 2
round(2.5)  = 3
log10(1000) = 3.0

注意 <math.h> 中的函数大多接收和返回 double 类型,整数的绝对值要用 <stdlib.h> 中的 abs。

7.10 如何设计好的函数

  1. 一个函数只做一件事。 如果你发现很难用一句话概括一个函数的功能,它可能该拆分了。
  2. 起一个好名字。 函数名通常用动词或动词短语,如 print_report、is_valid、calculate_average。返回真假的函数常以 is_、has_ 开头。
  3. 函数不宜过长。 一般建议不超过一屏(约 50 行)。
  4. 参数不宜过多。 超过 4~5 个参数,就该考虑用结构体(第 11 章)把它们打包。
  5. 避免依赖全局变量。 通过参数获取输入,通过返回值给出结果,这样的函数最容易理解和测试。

本章练习

  1. 素数函数。 编写函数 int is_prime(int n),是素数返回 1,否则返回 0。用它输出 100 以内的所有素数,每行 10 个。
  2. 最大公约数与最小公倍数。 编写函数 int gcd(int a, int b) 和 int lcm(int a, int b),输入两个正整数,输出它们的最大公约数和最小公倍数。
  3. 递归求幂。 用递归编写函数 double power(double x, int n) 计算 xⁿ(n ≥ 0)。进阶:利用 xⁿ = (x^(n/2))² 的思路,让递归次数从 n 次减少到约 log₂n 次。
  4. 递归输出二进制。 用递归编写函数 void print_binary(int n),输出非负整数 n 的二进制表示。例如 print_binary(13) 输出 1101。

第 8 章 数组:批量处理数据

8.1 为什么需要数组

要处理一个班 50 名同学的成绩,难道要定义 score1、score2……score50 共 50 个变量吗?显然不现实。数组就是用来存放一组相同类型数据的结构:它由若干个连续存放的元素组成,所有元素共用一个名字,通过下标来区分。

c
int scores[50];    // 一次性定义 50 个 int 类型的"格子"

8.2 一维数组的定义与初始化

定义数组的形式是 类型 数组名[长度];。长度通常是一个常量,例如 int scores[50];、double prices[N];(其中 N 是用 #define 定义的宏常量)。

初始化的几种方式:

c
int a[5] = {1, 2, 3, 4, 5};         // 完整初始化
int b[5] = {1, 2};                  // 部分初始化,其余元素自动为 0:{1, 2, 0, 0, 0}
int c[5] = {0};                     // 全部初始化为 0,这是最常用的清零写法
int d[] = {10, 20, 30};             // 省略长度,由初始值的个数决定,长度为 3
int e[10] = {[2] = 5, [7] = 9};     // C99 指定初始化:e[2] 为 5,e[7] 为 9,其余为 0
注意
在函数内部定义的数组如果完全没有初始化(如 int f[5];),其中每个元素的值都是不确定的"垃圾值"。只要初始化列表里写了哪怕一个值,其余元素就都会被置为 0。

求数组长度。 用整个数组的字节数除以一个元素的字节数即可:

c
int arr[] = {3, 1, 4, 1, 5, 9, 2, 6};
int n = sizeof(arr) / sizeof(arr[0]);   // 32 / 4 = 8

数组不能整体赋值。 int x[3], y[3] = {1, 2, 3}; x = y; 是错误的,编译器会报错 assignment to expression with array type。要复制数组,只能用循环逐个元素复制(或者用第 9 章介绍的 memcpy)。

8.3 访问数组元素

通过 数组名[下标] 访问元素,下标从 0 开始,长度为 n 的数组,下标范围是 0 到 n−1。用循环遍历数组是最常见的操作:

c
#include <stdio.h>

#define N 5

int main(void)
{
    int scores[N];
    printf("请输入 %d 个成绩:", N);
    for (int i = 0; i < N; i++) {
        scanf("%d", &scores[i]);
    }

    int sum = 0;
    int max_index = 0;             // 记录最高分的下标
    for (int i = 0; i < N; i++) {
        sum += scores[i];
        if (scores[i] > scores[max_index]) {
            max_index = i;
        }
    }

    printf("总分:%d\n", sum);
    printf("平均分:%.2f\n", (double)sum / N);
    printf("最高分:%d(第 %d 个)\n", scores[max_index], max_index + 1);
    return 0;
}

运行结果:

text
请输入 5 个成绩:78 92 85 66 90
总分:411
平均分:82.20
最高分:92(第 2 个)
常见错误
数组越界。 对于 int a[5],合法的下标是 0~4,a[5] 已经越界了。C 语言不检查下标是否越界:越界读取会得到不可预料的值;越界写入会悄悄改写其他变量的内存,程序可能崩溃,也可能"正常"运行却给出错误结果。这是 C 程序中最常见、也最危险的错误之一。写循环时要特别注意边界:for (i = 0; i < 5; i++) 是对的,for (i = 0; i <= 5; i++) 就越界了。

8.4 数组在内存中的样子

数组的所有元素在内存中是连续存放的。下面的程序输出每个元素的地址:

c
#include <stdio.h>

int main(void)
{
    int a[5] = {10, 20, 30, 40, 50};
    for (int i = 0; i < 5; i++) {
        printf("a[%d] = %d,地址:%p\n", i, a[i], (void *)&a[i]);
    }
    return 0;
}

一次可能的运行结果(每次运行的地址可能不同):

text
a[0] = 10,地址:0x7ffd5e3c8a10
a[1] = 20,地址:0x7ffd5e3c8a14
a[2] = 30,地址:0x7ffd5e3c8a18
a[3] = 40,地址:0x7ffd5e3c8a1c
a[4] = 50,地址:0x7ffd5e3c8a20

相邻元素的地址正好相差 4,也就是一个 int 的大小:

text
   a[0]     a[1]     a[2]     a[3]     a[4]
+--------+--------+--------+--------+--------+
|   10   |   20   |   30   |   40   |   50   |
+--------+--------+--------+--------+--------+
 ...8a10  ...8a14  ...8a18  ...8a1c  ...8a20

正因为元素是连续存放的,计算机只要知道首元素的地址和下标,就能立即算出任意元素的位置:a[i] 的地址 = 首地址 + i × 元素大小。这就是数组可以"随机访问"任意元素、而且速度极快的原因,也解释了下标为什么从 0 开始——下标其实就是"距离首元素的偏移量"。

8.5 数组的常见操作

1. 原地逆序。 用两个下标分别从两端向中间移动,交换对应的元素:

c
for (int i = 0, j = n - 1; i < j; i++, j--) {
    int temp = a[i];
    a[i] = a[j];
    a[j] = temp;
}

2. 计数统计。 用数组的下标代表数据的值,用元素记录出现的次数,这是一个非常实用的技巧。例如统计各分数段的人数:

c
#include <stdio.h>

int main(void)
{
    int scores[] = {95, 82, 67, 45, 78, 88, 91, 100, 59, 73, 84, 66};
    int n = sizeof(scores) / sizeof(scores[0]);
    int counts[11] = {0};   // counts[k] 记录 k*10 ~ k*10+9 分的人数,counts[10] 记录满分

    for (int i = 0; i < n; i++) {
        counts[scores[i] / 10]++;
    }

    printf("90~100 分:%d 人\n", counts[9] + counts[10]);
    for (int k = 8; k >= 6; k--) {
        printf("%d~%d 分:%d 人\n", k * 10, k * 10 + 9, counts[k]);
    }
    int fail = 0;
    for (int k = 0; k < 6; k++) {
        fail += counts[k];
    }
    printf("不及格:%d 人\n", fail);
    return 0;
}

运行结果:

text
90~100 分:3 人
80~89 分:3 人
70~79 分:2 人
60~69 分:2 人
不及格:2 人

counts[scores[i] / 10]++ 一行代码就完成了"判断分数段并计数"的工作,完全不需要写一长串 if-else。

8.6 数组作为函数参数

把数组传给函数时,函数的形参写成 类型 名字[],同时必须再传一个参数表示数组长度:

c
#include <stdio.h>

void print_array(const int arr[], int n)
{
    for (int i = 0; i < n; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");
}

void double_all(int arr[], int n)
{
    for (int i = 0; i < n; i++) {
        arr[i] *= 2;
    }
}

int main(void)
{
    int a[] = {1, 2, 3, 4, 5};
    int n = sizeof(a) / sizeof(a[0]);
    print_array(a, n);
    double_all(a, n);
    print_array(a, n);
    return 0;
}

运行结果:

text
1 2 3 4 5
2 4 6 8 10

这里有两个与普通变量截然不同的现象:

  1. 函数修改了原数组。 第 7 章说过 C 语言是值传递,函数改不了实参。但传递数组时,传过去的其实是数组首元素的地址,函数通过地址直接操作了原数组的内存。第 10 章会详细解释这一点。
  2. 函数内部无法用 `sizeof` 求出数组长度。 在函数内部,arr 实际上是一个指针,sizeof(arr) 得到的是指针的大小(在常见的 64 位系统上是 8),而不是整个数组的大小。GCC 会对这种用法发出警告:'sizeof' on array function parameter 'arr' will return size of 'const int *'。所以长度必须由调用者传进来。

形参前的 const 表示"这个函数只读取数组,不会修改它"。这是一个好习惯:既能防止函数意外修改数据,又能让阅读代码的人一眼看出函数的意图。

8.7 排序

排序是最基础、最重要的算法之一。这里介绍两种简单的排序算法。

冒泡排序。 从头到尾依次比较相邻的两个元素,如果顺序不对就交换。一趟下来,最大的元素就像气泡一样"浮"到了最后。对剩下的元素重复这个过程即可。

以 {5, 2, 9, 1, 7, 3} 为例(方括号表示已经排好的部分):

text
初始:    5  2  9  1  7  3
第 1 趟: 2  5  1  7  3 [9]
第 2 趟: 2  1  5  3 [7  9]
第 3 趟: 1  2  3 [5  7  9]
第 4 趟: 1  2  3  5  7  9     ← 这一趟没有发生任何交换,说明已经有序,提前结束
c
#include <stdio.h>
#include <stdbool.h>

void bubble_sort(int arr[], int n)
{
    for (int i = 0; i < n - 1; i++) {           // 最多进行 n-1 趟
        bool swapped = false;
        for (int j = 0; j < n - 1 - i; j++) {   // 末尾的 i 个元素已经排好
            if (arr[j] > arr[j + 1]) {
                int temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
                swapped = true;
            }
        }
        if (!swapped) {
            break;
        }
    }
}

void print_array(const int arr[], int n)
{
    for (int i = 0; i < n; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");
}

int main(void)
{
    int a[] = {5, 2, 9, 1, 7, 3};
    int n = sizeof(a) / sizeof(a[0]);
    printf("排序前:");
    print_array(a, n);
    bubble_sort(a, n);
    printf("排序后:");
    print_array(a, n);
    return 0;
}

运行结果:

text
排序前:5 2 9 1 7 3
排序后:1 2 3 5 7 9

选择排序。 每一趟从未排序的部分中选出最小的元素,放到未排序部分的最前面:

c
void selection_sort(int arr[], int n)
{
    for (int i = 0; i < n - 1; i++) {
        int min_index = i;                 // 假设当前位置就是最小值
        for (int j = i + 1; j < n; j++) {
            if (arr[j] < arr[min_index]) {
                min_index = j;
            }
        }
        if (min_index != i) {              // 把最小值交换到位置 i
            int temp = arr[i];
            arr[i] = arr[min_index];
            arr[min_index] = temp;
        }
    }
}

这两种排序算法在最坏情况下都需要进行大约 n²/2 次比较,我们说它们的时间复杂度是 O(n²)。当 n 为 10 万时,比较次数约为 50 亿次,就很慢了。实际开发中,通常使用标准库提供的 qsort 函数(第 10 章介绍),它采用更高效的算法。

8.8 查找

顺序查找:从头到尾逐个比较,简单但慢,最坏情况下要比较 n 次。

c
int linear_search(const int arr[], int n, int target)
{
    for (int i = 0; i < n; i++) {
        if (arr[i] == target) {
            return i;        // 找到了,返回下标
        }
    }
    return -1;               // 没找到,返回 -1
}

二分查找:只能用于已经排好序的数组。每次取中间的元素与目标比较:相等就找到了;目标更大就去右半边找;目标更小就去左半边找。每比较一次,查找范围就缩小一半,所以 100 万个元素最多只需比较 20 次(2²⁰ ≈ 100 万)。这正是猜数字游戏中"每次猜中间值"的策略。

c
#include <stdio.h>

int binary_search(const int arr[], int n, int target)
{
    int low = 0, high = n - 1;
    while (low <= high) {
        int mid = low + (high - low) / 2;   // 与 (low + high) / 2 等价,但不会溢出
        if (arr[mid] == target) {
            return mid;
        } else if (arr[mid] < target) {
            low = mid + 1;                  // 目标在右半部分
        } else {
            high = mid - 1;                 // 目标在左半部分
        }
    }
    return -1;
}

int main(void)
{
    int a[] = {3, 8, 15, 23, 42, 57, 68, 91};
    int n = sizeof(a) / sizeof(a[0]);
    printf("42 的下标:%d\n", binary_search(a, n, 42));
    printf("50 的下标:%d\n", binary_search(a, n, 50));
    return 0;
}

运行结果:

text
42 的下标:4
50 的下标:-1

查找 42 的过程:

lowhighmida[mid]比较结果
0732323 < 42,去右半部分
4755757 > 42,去左半部分
44442找到了!
提示
mid = (low + high) / 2 在 low + high 超过 int 范围时会溢出,写成 low + (high - low) / 2 可以避免这个问题。这个 bug 曾在 Java 标准库的二分查找中潜伏了将近十年才被发现。

8.9 二维数组

表格形式的数据(如多名学生的多门课成绩、矩阵、棋盘)适合用二维数组表示:

c
int scores[3][4];   // 3 行 4 列,共 12 个元素

可以把二维数组理解为"数组的数组":scores 是一个有 3 个元素的数组,每个元素又是一个有 4 个 int 的数组。访问第 i 行第 j 列的元素写作 scores[i][j],行和列的下标都从 0 开始。

c
#include <stdio.h>

#define STUDENTS 3
#define COURSES 3

int main(void)
{
    int scores[STUDENTS][COURSES] = {
        {85, 92, 78},     // 第 0 个学生的三门课成绩
        {90, 88, 95},
        {72, 65, 80}
    };

    // 每个学生的总分:固定行,遍历列
    for (int i = 0; i < STUDENTS; i++) {
        int total = 0;
        for (int j = 0; j < COURSES; j++) {
            total += scores[i][j];
        }
        printf("学生 %d 的总分:%d\n", i + 1, total);
    }

    // 每门课的平均分:固定列,遍历行
    for (int j = 0; j < COURSES; j++) {
        int sum = 0;
        for (int i = 0; i < STUDENTS; i++) {
            sum += scores[i][j];
        }
        printf("课程 %d 的平均分:%.2f\n", j + 1, (double)sum / STUDENTS);
    }
    return 0;
}

运行结果:

text
学生 1 的总分:255
学生 2 的总分:273
学生 3 的总分:217
课程 1 的平均分:82.33
课程 2 的平均分:81.67
课程 3 的平均分:84.33

二维数组在内存中是按行连续存放的(行优先)。int m[2][3] 在内存中的排列是:

text
  m[0][0]  m[0][1]  m[0][2]  m[1][0]  m[1][1]  m[1][2]
+--------+--------+--------+--------+--------+--------+
|        |        |        |        |        |        |
+--------+--------+--------+--------+--------+--------+
 \_______ 第 0 行 _______/ \_______ 第 1 行 _______/

二维数组作为函数参数时,必须指定列数(行数可以省略),因为编译器需要知道每行有多少个元素,才能算出 m[i][j] 的位置:

c
void print_matrix(int m[][3], int rows)
{
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < 3; j++) {
            printf("%4d", m[i][j]);
        }
        printf("\n");
    }
}

8.10 变长数组(了解)

C99 允许用变量作为数组长度,称为变长数组(VLA):

c
int n;
scanf("%d", &n);
int a[n];    // 长度在运行时才确定

但变长数组有不少限制:C11 起它变成了编译器"可选支持"的特性,Visual Studio 的 C 编译器就不支持;它分配在栈上,如果 n 很大,程序会因栈溢出而崩溃,并且无法检测和处理这种失败。因此,需要在运行时决定大小的数组,建议使用第 12 章的动态内存分配。

本章练习

  1. 循环右移。 输入 n(n ≤ 100)个整数和一个非负整数 k,把数组循环右移 k 位后输出。例如 1 2 3 4 5 右移 2 位得到 4 5 1 2 3。
  2. 第二大的数。 输入 10 个整数,找出其中第二大的数(与最大值相等的不算)。如果不存在(例如所有数都相等),输出提示信息。要求只遍历数组一次。
  3. 删除元素。 输入 n 个整数和一个整数 x,删除数组中所有等于 x 的元素(后面的元素依次前移),输出删除后的数组。
  4. 杨辉三角。 用二维数组输出杨辉三角的前 10 行。杨辉三角每行的首尾都是 1,其余每个数等于它"左上方"和"正上方"两个数之和:
text
   1
   1 1
   1 2 1
   1 3 3 1
   1 4 6 4 1

第 9 章 字符串

文字处理是程序最常做的事情之一:用户名、文件路径、一句话、一篇文章……在 C 语言中,这些都用字符串来表示。

9.1 C 语言中的字符串

与许多语言不同,C 语言没有专门的字符串类型。C 语言中的字符串,就是以空字符 `'\0'` 结尾的字符数组。

c
#include <stdio.h>
#include <string.h>

int main(void)
{
    char s[] = "Hello";
    printf("s = %s\n", s);
    printf("sizeof(s) = %zu\n", sizeof(s));   // 数组占用的字节数,包含末尾的 '\0'
    printf("strlen(s) = %zu\n", strlen(s));   // 字符串的长度,不含 '\0'

    for (int i = 0; s[i] != '\0'; i++) {
        printf("s[%d] = '%c'\n", i, s[i]);
    }
    return 0;
}

运行结果:

text
s = Hello
sizeof(s) = 6
strlen(s) = 5
s[0] = 'H'
s[1] = 'e'
s[2] = 'l'
s[3] = 'l'
s[4] = 'o'

"Hello" 只有 5 个字符,数组却占了 6 个字节,因为编译器自动在末尾加上了 '\0':

text
  s[0]  s[1]  s[2]  s[3]  s[4]  s[5]
+-----+-----+-----+-----+-----+-----+
| 'H' | 'e' | 'l' | 'l' | 'o' | '\0'|
+-----+-----+-----+-----+-----+-----+

'\0' 是 ASCII 码为 0 的字符(注意它不是字符 '0','0' 的 ASCII 码是 48)。所有字符串处理函数都依靠它来判断字符串在哪里结束,上面的 for 循环也是如此。没有 `'\0'`,就不是一个合法的字符串,printf 等函数会一直读下去,直到在内存中偶然碰到一个 0 为止,输出一堆乱码甚至导致程序崩溃。

字符数组的几种初始化方式:

c
char a[6] = {'H', 'e', 'l', 'l', 'o', '\0'};  // 逐个字符初始化,必须自己写 '\0'
char b[6] = "Hello";      // 用字符串初始化,编译器自动添加 '\0'
char c[] = "Hello";       // 长度自动计算为 6
char d[20] = "Hello";     // 后面剩余的元素全部是 '\0'
char e[5] = "Hello";      // 合法,但空间不够放 '\0',e 不是一个合法的字符串!

所以定义字符数组时,长度至少要比字符串的最大长度多 1。

9.2 字符串的输入与输出

输出:用 printf 的 %s,或者 puts 函数。puts(s) 会在输出字符串后自动换行。

输入有两种常用方式:

c
#include <stdio.h>
#include <string.h>

int main(void)
{
    char name[20];
    printf("请输入你的名字:");
    scanf("%19s", name);                   // 数组名本身就代表地址,不需要 &

    int ch;
    while ((ch = getchar()) != '\n' && ch != EOF) {
        // 清除 scanf 留在缓冲区里的换行符
    }

    char line[100];
    printf("请输入一句话:");
    if (fgets(line, sizeof(line), stdin) != NULL) {
        line[strcspn(line, "\n")] = '\0';  // 去掉 fgets 读入的换行符
        printf("你好,%s!你说的是:[%s]\n", name, line);
    }
    return 0;
}

运行结果:

text
请输入你的名字:Tom
请输入一句话:I love C programming
你好,Tom!你说的是:[I love C programming]

`scanf("%s", ...)` 的特点:

  • 读取时会跳过开头的空白字符,然后读到下一个空白字符(空格、Tab、换行)为止。所以它只能读取一个单词,输入 Tom Smith 只会读到 Tom。
  • 变量名前不加 `&`,因为数组名本身就代表数组首元素的地址(第 10 章详解)。
  • 一定要限制读取长度。name 有 20 个字节,最多能存 19 个字符加一个 '\0',所以写成 %19s。如果写成 %s,用户输入超过 19 个字符时,多出来的内容会写到数组外面,破坏其他内存——这就是著名的缓冲区溢出,是历史上无数安全漏洞的根源。

`fgets(数组, 大小, stdin)` 的特点:

  • 读取一整行,包括中间的空格;
  • 最多读取"大小 − 1"个字符,然后自动加上 '\0',不会溢出;
  • 如果这一行没有超过容量,换行符 `'\n'` 也会被读进来,通常需要去掉它。strcspn(line, "\n") 返回 line 中第一个换行符的位置(如果没有换行符,就返回字符串的长度),在该位置写入 '\0' 就把换行符去掉了;
  • 读取失败或遇到输入结束时返回 NULL。
常见错误
老教材中常见的 gets 函数无法限制读取长度,是缓冲区溢出的"重灾区",已在 C11 标准中被删除。永远不要使用 gets,用 fgets 代替。
注意
scanf 和 fgets 混用时,scanf 会把换行符留在缓冲区里,紧接着的 fgets 读到这个换行符就直接结束了,得到一个空字符串。所以上面的程序在两者之间清空了缓冲区。

9.3 字符串处理函数

<string.h> 中提供了大量字符串处理函数:

函数作用
strlen(s)返回字符串长度(不含 '\0')
strcpy(dest, src)把 src 复制到 dest
strncpy(dest, src, n)最多复制 n 个字符
strcat(dest, src)把 src 拼接到 dest 的末尾
strncat(dest, src, n)最多拼接 n 个字符,并自动添加 '\0'
strcmp(s1, s2)按字典序比较:s1 在前返回负数,相等返回 0,s1 在后返回正数
strncmp(s1, s2, n)只比较前 n 个字符
strchr(s, c)查找字符 c 第一次出现的位置
strstr(s, sub)查找子串 sub 第一次出现的位置
strcspn(s, set)返回 s 开头连续不含 set 中任何字符的长度
memset(p, c, n)把从 p 开始的 n 个字节都设为 c
memcpy(dest, src, n)复制 n 个字节
c
#include <stdio.h>
#include <string.h>

int main(void)
{
    char dest[50] = "Hello";
    char src[] = "World";

    printf("strlen(dest) = %zu\n", strlen(dest));

    strcat(dest, ", ");
    strcat(dest, src);
    printf("拼接后:%s\n", dest);

    char copy[50];
    strcpy(copy, dest);
    printf("复制得到:%s\n", copy);

    if (strcmp("apple", "banana") < 0) {
        printf("apple 排在 banana 前面\n");
    }
    if (strcmp(dest, copy) == 0) {
        printf("dest 和 copy 的内容相同\n");
    }

    char *pos = strstr(dest, "World");   // 返回指向子串位置的指针,找不到时返回 NULL
    if (pos != NULL) {
        printf("\"World\" 出现在下标 %d 处\n", (int)(pos - dest));
    }
    return 0;
}

运行结果:

text
strlen(dest) = 5
拼接后:Hello, World
复制得到:Hello, World
apple 排在 banana 前面
dest 和 copy 的内容相同
"World" 出现在下标 7 处

strstr 返回的是一个指针(char * 类型),pos - dest 算出的是它距离字符串开头有几个字符。指针的知识在第 10 章讲解,这里先照着用即可。

使用这些函数时要牢记以下几点:

  1. 比较字符串要用 `strcmp`,不能用 `==`。 if (s1 == s2) 比较的是两个数组的地址,而不是内容,结果几乎总是"不相等"。
  2. 字符串不能用 `=` 赋值。 char s[20]; s = "Hello"; 是错误的(数组不能整体赋值),要用 strcpy(s, "Hello");。
  3. `strcpy` 和 `strcat` 不检查目标空间是否足够。 目标数组必须足够大,否则就会发生缓冲区溢出。
  4. `strncpy` 有一个坑: 如果源字符串的长度大于等于 n,它不会在末尾添加 '\0'。

那么如何安全地复制字符串呢?一个简单可靠的方法是使用 snprintf(9.6 节介绍),它总会在末尾添加 '\0',并且不会超出指定的空间:

c
char small[6];
snprintf(small, sizeof(small), "%s", copy);   // 最多写入 5 个字符和 1 个 '\0'
printf("%s\n", small);                         // 输出:Hello

用 `strtok` 分割字符串。 strtok 可以按指定的分隔符把字符串切成若干段:

c
#include <stdio.h>
#include <string.h>

int main(void)
{
    char text[] = "apple,banana;cherry,,date";
    char *token = strtok(text, ",;");       // 第一次调用传入字符串
    while (token != NULL) {
        printf("[%s]\n", token);
        token = strtok(NULL, ",;");         // 之后的调用传入 NULL,表示继续处理同一个字符串
    }
    return 0;
}

运行结果:

text
[apple]
[banana]
[cherry]
[date]

注意 strtok 会修改原字符串(把分隔符替换为 '\0'),所以不能用于字符串常量;它还会跳过连续的分隔符,也就是说空字段会被忽略。

9.4 自己动手实现字符串函数

理解字符串最好的方法,就是自己实现几个字符串函数。它们的核心都是:逐个处理字符,直到遇到 `'\0'`。

c
#include <stdio.h>

// 求字符串长度
size_t my_strlen(const char s[])
{
    size_t len = 0;
    while (s[len] != '\0') {
        len++;
    }
    return len;
}

// 复制字符串
void my_strcpy(char dest[], const char src[])
{
    int i = 0;
    while (src[i] != '\0') {
        dest[i] = src[i];
        i++;
    }
    dest[i] = '\0';      // 别忘了复制结束标记
}

// 原地反转字符串
void reverse(char s[])
{
    int len = (int)my_strlen(s);
    for (int i = 0, j = len - 1; i < j; i++, j--) {
        char temp = s[i];
        s[i] = s[j];
        s[j] = temp;
    }
}

int main(void)
{
    char a[] = "Hello, C!";
    char b[20];

    printf("长度:%zu\n", my_strlen(a));
    my_strcpy(b, a);
    printf("复制:%s\n", b);
    reverse(b);
    printf("反转:%s\n", b);
    return 0;
}

运行结果:

text
长度:9
复制:Hello, C!
反转:!C ,olleH
注意
reverse 中特意把长度转换成了 int。如果直接用 size_t(无符号类型)来写 j = len - 1,那么对于空字符串,len - 1 不会得到 -1,而是"绕回"成一个巨大的正数,循环就会访问到数组之外。这是使用无符号类型时的一个经典陷阱。

9.5 字符处理函数

<ctype.h> 提供了一组判断和转换单个字符的函数:

函数作用
isalpha(c)是否为字母
isdigit(c)是否为数字字符 '0'~'9'
isalnum(c)是否为字母或数字
isspace(c)是否为空白字符(空格、Tab、换行等)
isupper(c) / islower(c)是否为大写 / 小写字母
toupper(c) / tolower(c)转换为大写 / 小写(非字母原样返回)
ispunct(c)是否为标点符号

判断函数在条件成立时返回非 0 值,不成立时返回 0。例:把字符串中的大小写字母互换。

c
#include <stdio.h>
#include <ctype.h>

int main(void)
{
    char s[] = "Hello World 2026!";
    for (int i = 0; s[i] != '\0'; i++) {
        unsigned char c = (unsigned char)s[i];
        if (isupper(c)) {
            s[i] = (char)tolower(c);
        } else if (islower(c)) {
            s[i] = (char)toupper(c);
        }
    }
    printf("%s\n", s);
    return 0;
}

运行结果:

text
hELLO wORLD 2026!
注意
<ctype.h> 中的函数要求参数是 unsigned char 范围内的值(或 EOF)。汉字等非 ASCII 字符的字节存入 char 后可能是负数,直接传入是未定义行为,因此上面先把字符转换为 unsigned char。

9.6 字符串与数字的相互转换

字符串转数字。 <stdlib.h> 中的 atoi(转 int)和 atof(转 double)用起来最简单:atoi("123") 得到 123。但它们有个缺点:转换失败时返回 0,你无法区分输入的是 "0" 还是 "abc"。需要检查输入合法性时,应该使用 strtol(转 long)和 strtod(转 double):

c
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    char input[] = "78xyz";
    char *end;                                 // strtol 会让 end 指向第一个未被转换的字符
    long value = strtol(input, &end, 10);      // 10 表示按十进制转换

    if (end == input) {
        printf("\"%s\" 不是数字\n", input);
    } else if (*end != '\0') {
        printf("转换出 %ld,但后面还有多余的字符 \"%s\"\n", value, end);
    } else {
        printf("转换成功:%ld\n", value);
    }
    return 0;
}

运行结果:

text
转换出 78,但后面还有多余的字符 "xyz"

这段代码用到了指针,学完第 10 章后再回头看会更清楚。第 18 章的综合项目就是用这种方法来检查用户输入的。

数字转字符串。 使用 sprintf 或更安全的 snprintf,它们的用法和 printf 一样,只是把结果"打印"到字符数组里,而不是屏幕上:

c
char buf[50];
int year = 2026;
double score = 95.5;
snprintf(buf, sizeof(buf), "%d 年的成绩:%.1f", year, score);
puts(buf);    // 输出:2026 年的成绩:95.5

snprintf 的第二个参数是数组的大小,它保证写入的内容不会超出数组。sprintf 没有这个参数,存在溢出的风险。

从字符串中解析数据。 sscanf 的用法和 scanf 一样,只是数据来源是字符串:

c
const char *date = "2026-09-26";
int y, m, d;
if (sscanf(date, "%d-%d-%d", &y, &m, &d) == 3) {
    printf("%d 年 %d 月 %d 日\n", y, m, d);    // 输出:2026 年 9 月 26 日
}

9.7 字符串数组

多个字符串可以用二维字符数组来存放,每一行是一个字符串:

c
#include <stdio.h>
#include <string.h>

#define COUNT 5
#define LEN 20

int main(void)
{
    char names[COUNT][LEN] = {"Tom", "Alice", "Bob", "Diana", "Charlie"};

    // 用冒泡排序把名字按字典序排列
    for (int i = 0; i < COUNT - 1; i++) {
        for (int j = 0; j < COUNT - 1 - i; j++) {
            if (strcmp(names[j], names[j + 1]) > 0) {
                char temp[LEN];
                strcpy(temp, names[j]);
                strcpy(names[j], names[j + 1]);
                strcpy(names[j + 1], temp);
            }
        }
    }

    for (int i = 0; i < COUNT; i++) {
        printf("%s\n", names[i]);
    }
    return 0;
}

运行结果:

text
Alice
Bob
Charlie
Diana
Tom

names[i] 表示第 i 行,也就是第 i 个字符串。因为字符串不能直接赋值,交换两个字符串需要借助 strcpy。第 10 章会介绍另一种更高效的方式:用指针数组存放字符串,交换时只需交换指针。

9.8 关于中文字符串

一个汉字在 UTF-8 编码下通常占 3 个字节,在 GBK 编码下占 2 个字节。因此在 UTF-8 环境下,strlen("你好") 的结果是 6 而不是 2;逐字节反转一个中文字符串会把汉字"拆碎",得到乱码。本章介绍的字符串函数都是按字节处理的,适合处理英文和数字。如果需要按"字符"处理中文,需要了解字符编码和宽字符(wchar_t)等进阶知识,这里不再展开。

本章练习

  1. 字符统计。 输入一行文字(可能包含空格),分别统计其中英文字母、数字、空格和其他字符的个数。
  2. 实现 strcmp。 编写函数 int my_strcmp(const char s1[], const char s2[]),返回值规则与 strcmp 相同(前者在前返回负数,相等返回 0,否则返回正数)。
  3. 单词首字母大写。 输入一行英文,把每个单词的首字母改为大写后输出。例如 hello world from c 变为 Hello World From C。
  4. 回文判断。 输入一个字符串,判断它是否为回文(正读和反读都一样),忽略大小写。例如 Level、noon 是回文,hello 不是。

第 10 章 指针:C 语言的灵魂

指针是 C 语言最强大的特性,也是初学者公认的难点。但请放心:指针的本质非常简单——指针就是地址。只要把"内存"和"地址"这两个概念想清楚,再勤画图,指针就一点也不神秘。

10.1 内存与地址

第 2 章说过,内存就像一排储物柜,每个柜子(1 个字节)都有一个编号,即地址。定义变量时,系统会为它分配若干个连续的字节,变量的地址就是它所占的第一个字节的地址。

用取地址运算符 & 可以得到变量的地址,用 %p 可以输出地址:

c
#include <stdio.h>

int main(void)
{
    int a = 10;
    double b = 3.14;
    char c = 'X';
    printf("a 的值:%d,地址:%p\n", a, (void *)&a);
    printf("b 的值:%.2f,地址:%p\n", b, (void *)&b);
    printf("c 的值:%c,地址:%p\n", c, (void *)&c);
    return 0;
}

一次可能的运行结果:

text
a 的值:10,地址:0x7ffe3b1a5c3c
b 的值:3.14,地址:0x7ffe3b1a5c40
c 的值:X,地址:0x7ffe3b1a5c3b

地址的具体数值每次运行都可能不同,这无关紧要。%p 要求对应的参数是 void * 类型,所以前面加了 (void *) 进行转换,void * 的含义本章稍后介绍。

打个比方:变量就像宾馆的房间,地址就是房间号。如果你把房间号写在一张纸条上交给别人,他就能找到这个房间——这张写着房间号的纸条,就是指针。

10.2 指针变量

指针变量是专门用来存放地址的变量。定义方式是在变量名前加 *:

c
int *p;      // p 是一个指针变量,它可以存放一个 int 类型变量的地址

指针的基本用法只有两个运算符:

  • &变量:取出变量的地址;
  • *指针:访问指针所指向的变量,称为解引用(dereference)。
c
#include <stdio.h>

int main(void)
{
    int a = 10;
    int *p = &a;          // p 保存了 a 的地址,我们说"p 指向 a"

    printf("a  的值:%d\n", a);
    printf("&a 的值:%p\n", (void *)&a);
    printf("p  的值:%p\n", (void *)p);
    printf("*p 的值:%d\n", *p);

    *p = 20;              // 通过 p 修改它所指向的变量,也就是修改 a
    printf("修改后 a 的值:%d\n", a);
    return 0;
}

一次可能的运行结果:

text
a  的值:10
&a 的值:0x7ffc8a2e4b5c
p  的值:0x7ffc8a2e4b5c
*p 的值:10
修改后 a 的值:20

画成图就是:

text
      变量 a(地址 0x...4b5c)            变量 p
     +------------------+          +------------------+
     |        10        |  ◀────── |  0x7ffc8a2e4b5c  |
     +------------------+          +------------------+

p 里面存的是 a 的地址,所以 *p 就是 a 本身:读 *p 就是读 a,写 *p 就是写 a。

同一个 `,两种含义。** 定义时的 int p` 中,` 表示"p 是一个指针";表达式中的 p` 中,`` 表示"解引用"。初学时容易混淆,请注意区分。

指针的类型有什么用? 在常见的 64 位系统上,任何类型的指针本身都占 8 个字节(因为地址都是 64 位的),sizeof(int *)、sizeof(double *)、sizeof(char *) 都是 8(C 标准本身并不要求所有类型的指针一样大,这里说的是常见平台的情况)。那为什么还要区分 int * 和 double * 呢?因为指针的类型决定了两件事:

  1. 解引用时读写多少个字节:*p 对 int * 来说是读写 4 个字节,对 double * 来说是 8 个字节;
  2. 指针运算的步长:p + 1 前进多少个字节(10.5 节)。
常见错误
int* p, q; 定义的是一个指针 p 和一个普通整数 q,因为 * 只作用于紧跟它的那个变量名。要定义两个指针,应写成 int *p, *q;。为了避免误会,建议把 * 靠近变量名书写,或者每行只定义一个指针。

10.3 空指针、野指针与悬空指针

空指针。 值为 NULL 的指针叫空指针,表示"不指向任何对象"。NULL 定义在 <stdio.h>、<stdlib.h> 等头文件中。对空指针解引用会导致程序崩溃(在 Linux 上通常显示 Segmentation fault,即"段错误")。所以使用一个可能为空的指针之前,要先检查:

c
if (p != NULL) {
    printf("%d\n", *p);
}

野指针。 没有初始化的指针,其值是随机的,指向内存中某个未知的位置,这叫野指针。对野指针解引用,轻则读到垃圾值,重则破坏数据或导致程序崩溃:

c
int *p;      // 野指针:p 里存的是一个随机地址
*p = 10;     // 危险!向一个未知的地址写入数据

好习惯:定义指针时立即初始化,如果暂时不知道指向哪里,就初始化为 `NULL`。

悬空指针。 指针原本指向一个合法的对象,但这个对象已经不存在了,指针就成了悬空指针。最典型的例子是返回局部变量的地址:

c
int *bad_function(void)
{
    int x = 10;
    return &x;      // 错误!函数返回后 x 就被销毁了,返回的地址已经失效
}

回忆第 7 章的调用栈:函数返回后,它的栈帧被销毁,局部变量也随之消失。GCC 会对此发出警告:function returns address of local variable。另一种常见的悬空指针来自已经被释放的动态内存,第 12 章会讲到。

10.4 指针与函数:真正的交换

第 7 章中,swap 函数交换失败了,因为函数拿到的只是实参的副本。现在我们有了指针,就可以把变量的地址传给函数,让函数通过地址直接操作原变量:

c
#include <stdio.h>

void swap(int *a, int *b)
{
    int temp = *a;
    *a = *b;
    *b = temp;
}

int main(void)
{
    int x = 1, y = 2;
    swap(&x, &y);
    printf("x = %d, y = %d\n", x, y);
    return 0;
}

运行结果:

text
x = 2, y = 1
text
    main 的栈帧                         swap 的栈帧
  +-------+-------+                +---------+---------+
  | x = 1 | y = 2 |                | a = &x  | b = &y  |
  +-------+-------+                +---------+---------+
      ▲       ▲                         │         │
      │       └─────────────────────────┼─────────┘
      └─────────────────────────────────┘
       swap 通过 *a、*b 直接修改了 main 中的 x 和 y

参数传递依然是值传递——只不过这次复制的是地址。地址的副本和原来的地址指向同一个地方,所以通过副本也能修改原变量。现在你也应该明白 scanf("%d", &n) 为什么要写 & 了:scanf 需要知道 n 的地址,才能把读到的数据存进去。

通过指针"返回"多个值。 函数的 return 只能返回一个值,但可以通过指针参数把多个结果"带回去":

c
#include <stdio.h>

// 计算 a 除以 b 的商和余数
void div_mod(int a, int b, int *quotient, int *remainder)
{
    *quotient = a / b;
    *remainder = a % b;
}

int main(void)
{
    int q, r;
    div_mod(17, 5, &q, &r);
    printf("17 ÷ 5 = %d 余 %d\n", q, r);
    return 0;
}

运行结果:

text
17 ÷ 5 = 3 余 2

这种用于"带回结果"的指针参数,常被称为输出参数。

10.5 指针运算

指针可以进行几种有限的运算:

运算含义
p + n、p - n指针向后(向前)移动 n 个元素,而不是 n 个字节
p++、p--指针移动一个元素
p - q两个指针之间相隔多少个元素(二者必须指向同一个数组)
p == q、p < q 等比较两个指针的位置

指针不能相加,也不能相乘或相除——两个地址相加没有任何意义。

c
#include <stdio.h>

int main(void)
{
    int arr[5] = {10, 20, 30, 40, 50};
    int *p = arr;                  // 等价于 int *p = &arr[0];

    printf("p     = %p, *p       = %d\n", (void *)p, *p);
    printf("p + 1 = %p, *(p + 1) = %d\n", (void *)(p + 1), *(p + 1));
    printf("p + 2 = %p, *(p + 2) = %d\n", (void *)(p + 2), *(p + 2));

    char str[] = "abc";
    char *q = str;
    printf("q     = %p\n", (void *)q);
    printf("q + 1 = %p\n", (void *)(q + 1));

    int *last = &arr[4];
    printf("last - p = %td\n", last - p);
    return 0;
}

一次可能的运行结果:

text
p     = 0x7ffd4f6c1e20, *p       = 10
p + 1 = 0x7ffd4f6c1e24, *(p + 1) = 20
p + 2 = 0x7ffd4f6c1e28, *(p + 2) = 30
q     = 0x7ffd4f6c1e1c
q + 1 = 0x7ffd4f6c1e1d
last - p = 4

int * 加 1,地址增加了 4(一个 int 的大小);char * 加 1,地址只增加 1。这就是指针类型决定"步长"的含义。两个指针相减的结果类型是 ptrdiff_t,对应的格式说明符是 %td。

注意
指针运算只在数组内部才有意义。p + n、p - n 的结果必须仍然指向同一个数组中的元素,或者恰好指向数组最后一个元素的下一个位置(这个"尾后"位置可以用来比较和相减,但不能解引用);超出这个范围,即使不去访问,也是未定义行为。p - q 以及 <、> 等大小比较,也只能用于指向同一个数组的两个指针。== 和 != 则可以比较任意两个同类型的指针,任何指针也都可以与 NULL 比较。

10.6 指针与数组

指针和数组关系密切,这是 C 语言的一大特色。核心规则只有一条:

在大多数表达式中,数组名会自动转换为指向其首元素的指针。

所以 int *p = arr; 与 int *p = &arr[0]; 完全等价。由此可以推出:

text
arr[i]  ≡  *(arr + i)  ≡  *(p + i)  ≡  p[i]

事实上,C 语言中的下标运算 a[i] 就是按照 *(a + i) 来定义的。

c
#include <stdio.h>

int main(void)
{
    int arr[5] = {10, 20, 30, 40, 50};
    int *p = arr;

    // 四种等价的访问方式
    printf("%d %d %d %d\n", arr[2], *(arr + 2), p[2], *(p + 2));

    // 用指针遍历数组
    for (int *q = arr; q < arr + 5; q++) {
        printf("%d ", *q);
    }
    printf("\n");

    printf("sizeof(arr) = %zu\n", sizeof(arr));
    printf("sizeof(p)   = %zu\n", sizeof(p));
    return 0;
}

运行结果(在常见的 64 位系统上):

text
30 30 30 30
10 20 30 40 50
sizeof(arr) = 20
sizeof(p)   = 8

数组名和指针并不完全相同。 它们有以下区别:

  1. 数组名不能被赋值或修改。 arr = p; 和 arr++ 都是错误的,而指针变量可以随意改变指向。
  2. `sizeof` 的结果不同。 sizeof(arr) 是整个数组的大小(20 字节),sizeof(p) 是指针本身的大小(8 字节)。
  3. `&arr` 的类型不同。 &arr 虽然与 arr 的数值相同,但它的类型是"指向整个数组的指针",所以 &arr + 1 会跨过整个数组(前进 20 个字节),而 arr + 1 只前进 4 个字节。

总结一下:数组作为 sizeof 或 & 的操作数时(C23 新增的 typeof 也是如此),数组名不会转换为指针;此外,像 char s[] = "hello"; 这样用双引号括起来的字符串(即字符串字面量)初始化字符数组时,字面量也不会转换为指针,而是把其中的字符逐个复制进数组(下一节会详细介绍)。除此之外,数组名都会"退化"为指向首元素的指针。

数组参数的真相。 现在可以解释第 8 章的两个现象了:把数组传给函数时,传过去的是首元素的地址。因此,函数形参 int arr[] 实际上就是 int *arr,二者完全等价。这就是函数能修改原数组、而函数内部 sizeof(arr) 只得到 8 的原因。

`p++ 是什么意思?** 在处理数组和字符串的代码中,你会经常看到 p++` 这样的写法。由于后缀 `++` 的优先级高于 `,它等价于 *(p++):先取出 p 当前指向的值,然后让 p` 指向下一个元素。几种写法对比如下:

表达式含义
*p++取 *p 的值,然后 p 后移一个元素
(*p)++取 *p 的值,然后让 p 所指向的值加 1,p 不动
*++pp 先后移一个元素,再取它指向的值
++*pp 所指向的值先加 1,再取这个值

10.7 指针与字符串

字符串有两种常见的表示方式,它们有着本质的区别:

c
#include <stdio.h>

int main(void)
{
    char s[] = "hello";          // 字符数组:用字符串字面量的内容初始化数组,可以修改
    const char *p = "hello";     // 字符指针:指向只读的字符串字面量

    s[0] = 'H';                  // 正确:修改的是数组自己的副本
    // p[0] = 'H';               // 错误:不能修改字符串字面量
    p = "world";                 // 正确:指针可以改为指向另一个字符串
    // s = "world";              // 错误:数组名不能被赋值

    printf("%s %s\n", s, p);
    return 0;
}

运行结果:

text
Hello world
text
 栈上的数组 s(可以修改)               只读数据区(不能修改)
+---+---+---+---+---+----+          +---+---+---+---+---+----+
| H | e | l | l | o | \0 |          | h | e | l | l | o | \0 |   ← 字面量 "hello"
+---+---+---+---+---+----+          +---+---+---+---+---+----+
                                    +---+---+---+---+---+----+
   指针 p ─────────────────────────▶ | w | o | r | l | d | \0 |   ← 字面量 "world"
                                    +---+---+---+---+---+----+

字符串字面量(如 "hello")存放在程序的只读数据区中,修改字符串字面量是未定义行为,在大多数系统上会导致程序崩溃。所以,指向字符串字面量的指针应当声明为 const char *,这样一旦试图修改,编译器就会直接报错。

用指针来处理字符串非常简洁。下面是用指针实现的 strlen 和 strcpy:

c
size_t my_strlen(const char *s)
{
    const char *p = s;
    while (*p != '\0') {
        p++;
    }
    return (size_t)(p - s);      // 两个指针的距离就是字符串的长度
}

char *my_strcpy(char *dest, const char *src)
{
    char *start = dest;
    while ((*dest++ = *src++) != '\0') {
        // 空循环体:复制和移动都在条件表达式中完成了
    }
    return start;
}

(*dest++ = *src++) != '\0' 是 C 语言中的经典写法:把 *src 赋给 *dest,两个指针各自后移,再判断刚复制的字符是不是 '\0',是就结束。这样连 '\0' 也被一并复制了。

10.8 const 与指针

const 和指针组合时,位置不同,含义也不同:

写法能否通过 p 修改所指的值能否让 p 指向别处
const int *p(等价于 int const *p)不能能
int *const p能不能
const int *const p不能不能

记忆方法:*看 `const` 在 ` 的左边还是右边。**const 在 ` 左边,修饰的是所指向的数据(数据是只读的);`const` 在 `` 右边,修饰的是指针本身(指针的指向是固定的)。

c
int a = 1, b = 2;

const int *p1 = &a;
// *p1 = 10;       // 错误:不能通过 p1 修改数据
p1 = &b;           // 正确:p1 可以指向别处

int *const p2 = &a;
*p2 = 10;          // 正确:可以通过 p2 修改数据
// p2 = &b;        // 错误:p2 不能指向别处

const int * 最常见的用途是作为函数参数,表示"这个函数只读取数据,不会修改它",比如标准库的 size_t strlen(const char *s);。只要函数不需要修改指针所指的数据,就给参数加上 `const`,这是专业 C 代码的标志之一。

10.9 指向指针的指针

指针变量本身也是变量,也有自己的地址,所以可以有"指向指针的指针",即二级指针:

c
#include <stdio.h>

int main(void)
{
    int a = 10;
    int *p = &a;       // p 指向 a
    int **pp = &p;     // pp 指向 p

    printf("a = %d, *p = %d, **pp = %d\n", a, *p, **pp);
    **pp = 20;         // 相当于 *p = 20,也就是 a = 20
    printf("a = %d\n", a);
    return 0;
}

运行结果:

text
a = 10, *p = 10, **pp = 10
a = 20
text
      pp                  p                  a
+-----------+       +-----------+       +---------+
|  &p   ●───┼─────▶ |  &a   ●───┼─────▶ |   10    |
+-----------+       +-----------+       +---------+

二级指针最重要的用途是:让函数修改调用者的指针变量。道理和 swap 完全一样——想在函数中修改一个 int,要传 int *;想在函数中修改一个 int *,就要传 int **。

c
#include <stdio.h>

// 让调用者的指针 *pmax 指向数组中最大的元素
void point_to_max(int *arr, int n, int **pmax)
{
    *pmax = &arr[0];
    for (int i = 1; i < n; i++) {
        if (arr[i] > **pmax) {
            *pmax = &arr[i];
        }
    }
}

int main(void)
{
    int a[] = {3, 9, 4, 7};
    int *pm = NULL;
    point_to_max(a, 4, &pm);
    printf("最大值是 %d,下标为 %td\n", *pm, pm - a);
    *pm = 0;                       // 通过 pm 直接修改数组中的最大元素
    printf("修改后:%d %d %d %d\n", a[0], a[1], a[2], a[3]);
    return 0;
}

运行结果:

text
最大值是 9,下标为 1
修改后:3 0 4 7

第 12 章在函数中分配内存、第 13 章修改链表的头指针时,都会用到二级指针。

10.10 指针数组与数组指针

这两个名字很像,但完全不是一回事:

  • 指针数组:首先是一个数组,它的每个元素都是指针。例如 const char *names[3];。
  • 数组指针:首先是一个指针,它指向一整个数组。例如 int (*p)[4];。

区分的关键是运算符的优先级:[] 的优先级高于 *。int *p[4] 中,p 先与 [4] 结合,所以 p 是数组(元素类型为 int *);int (*p)[4] 中,括号让 p 先与 * 结合,所以 p 是指针(指向含 4 个 int 的数组)。

c
#include <stdio.h>

int main(void)
{
    // 指针数组:每个元素都是一个指向字符串的指针
    const char *weekdays[] = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"};
    for (int i = 0; i < 7; i++) {
        printf("%s ", weekdays[i]);
    }
    printf("\n");

    // 数组指针:指向二维数组中的一行
    int m[3][4] = {{1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}};
    int (*row)[4] = m;             // m 退化为指向第 0 行的指针
    printf("row[1][2] = %d\n", row[1][2]);
    row++;                         // 移动到下一行(跨过 4 个 int)
    printf("(*row)[0] = %d\n", (*row)[0]);
    return 0;
}

运行结果:

text
Mon Tue Wed Thu Fri Sat Sun
row[1][2] = 7
(*row)[0] = 5

用指针数组存放一组字符串,比第 9 章的二维字符数组更灵活:每个字符串可以长短不一,不浪费空间;排序时只需交换指针,而不用复制整个字符串。

text
weekdays(指针数组)
+-------+       +---+---+---+----+
|   ●───┼──────▶| M | o | n | \0 |
+-------+       +---+---+---+----+
|   ●───┼──────▶| T | u | e | \0 |
+-------+       +---+---+---+----+
|  ...  |

二维数组与指针。 二维数组 m 退化后是指向第 0 行的指针,m + i 指向第 i 行,*(m + i) 就是第 i 行(一个一维数组,它又会退化为指向该行首元素的指针),所以:

text
m[i][j]  ≡  *(*(m + i) + j)

10.11 main 函数的参数

main 函数还有另一种标准形式,可以接收命令行参数:

c
#include <stdio.h>

int main(int argc, char *argv[])
{
    printf("共有 %d 个命令行参数:\n", argc);
    for (int i = 0; i < argc; i++) {
        printf("argv[%d] = %s\n", i, argv[i]);
    }
    return 0;
}

编译后在命令行中这样运行:

text
$ ./args hello 123 "C language"
共有 4 个命令行参数:
argv[0] = ./args
argv[1] = hello
argv[2] = 123
argv[3] = C language
  • argc(argument count)是参数的个数,程序名本身也算一个;
  • argv(argument vector)是一个指针数组,每个元素指向一个参数字符串,argv[0] 是程序名;
  • 参数之间用空格分隔,包含空格的参数要用引号括起来;
  • 参数都是字符串形式,需要数字时要自己转换(如用 strtol)。

很多命令行工具(如 gcc hello.c -o hello)就是通过这种方式接收参数的。第 14 章的文件复制程序会用到它。

10.12 函数指针

函数也存放在内存中,也有地址。函数指针就是存放函数地址的指针,通过它可以间接地调用函数。

c
#include <stdio.h>

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }

int main(void)
{
    int (*op)(int, int);    // op 是一个指针,指向"接收两个 int、返回 int"的函数
    op = add;               // 函数名就代表函数的地址
    printf("add: %d\n", op(6, 3));
    op = mul;
    printf("mul: %d\n", op(6, 3));

    // 函数指针数组:根据下标选择要调用的函数
    int (*ops[])(int, int) = {add, sub, mul};
    const char *names[] = {"加", "减", "乘"};
    for (int i = 0; i < 3; i++) {
        printf("6 %s 3 = %d\n", names[i], ops[i](6, 3));
    }
    return 0;
}

运行结果:

text
add: 9
mul: 18
6 加 3 = 9
6 减 3 = 3
6 乘 3 = 18

函数指针的声明写法比较绕,可以用 typedef 给它起一个别名:

c
typedef int (*BinaryOp)(int, int);   // BinaryOp 代表"指向这类函数的指针"类型
BinaryOp op = add;

回调函数与 qsort。 函数指针最重要的用途是回调:把一个函数作为参数传给另一个函数,由后者在适当的时候调用它。标准库的排序函数 qsort 就是经典的例子,它的声明是:

c
void qsort(void *base, size_t count, size_t size,
           int (*compare)(const void *, const void *));

四个参数分别是:数组首地址、元素个数、每个元素的大小、比较函数。qsort 知道怎么排序,但不知道你的数据该如何比较大小——这个规则由你通过比较函数告诉它。比较函数接收两个元素的地址,第一个应排在前面时返回负数,应排在后面时返回正数,相等时返回 0。

c
#include <stdio.h>
#include <stdlib.h>

int compare_asc(const void *a, const void *b)
{
    int x = *(const int *)a;     // 先把 void * 转换为 int *,再解引用
    int y = *(const int *)b;
    return (x > y) - (x < y);    // x > y 时为 1,x < y 时为 -1,相等时为 0
}

int compare_desc(const void *a, const void *b)
{
    return compare_asc(b, a);    // 交换参数的顺序,就得到了降序
}

void print_array(const int *arr, int n)
{
    for (int i = 0; i < n; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");
}

int main(void)
{
    int a[] = {42, 7, 19, 88, 3, 56};
    int n = sizeof(a) / sizeof(a[0]);

    qsort(a, n, sizeof(a[0]), compare_asc);
    printf("升序:");
    print_array(a, n);

    qsort(a, n, sizeof(a[0]), compare_desc);
    printf("降序:");
    print_array(a, n);
    return 0;
}

运行结果:

text
升序:3 7 19 42 56 88
降序:88 56 42 19 7 3
注意
很多资料把比较函数写成 return x - y;。当 x 和 y 相差很大时(比如一个接近 INT_MAX、一个是负数),相减会溢出,导致排序结果错误。(x > y) - (x < y) 的写法则永远不会溢出。

10.13 void 指针

void * 是一种"通用指针",可以存放任何类型数据的地址,任何对象指针都可以直接赋给它,它也可以直接赋给其他类型的对象指针。但它不知道自己指向的数据是什么类型,所以不能直接解引用,也不能做指针运算,使用前要先转换为具体类型的指针,就像上面比较函数中的 *(const int *)a。

qsort、memcpy、memset 以及第 12 章的 malloc 都使用 void *,这使得它们可以处理任意类型的数据。

10.14 指针学习要诀

  1. 画图! 画出每个变量的方框,写上值;指针就画一个箭头指向目标。绝大多数指针问题,画出图就清楚了。
  2. 时刻分清两个东西:指针变量本身的值(一个地址),和它所指向的值(*p)。
  3. 牢记指针的常见错误:
错误后果预防
使用未初始化的指针(野指针)读写随机内存,崩溃或数据损坏定义时初始化,不知道指向哪里就设为 NULL
解引用空指针程序崩溃(段错误)使用前检查是否为 NULL
返回局部变量的地址悬空指针,数据随时被覆盖不要返回局部变量的地址
通过指针越界访问数组与数组越界相同仔细检查边界条件
修改字符串字面量未定义行为,通常崩溃用 const char * 指向字符串字面量
指针类型不匹配读写的字节数和解释方式都会出错重视编译器的类型警告,谨慎使用强制转换

本章练习

  1. 最大值与最小值。 编写函数 void min_max(const int *arr, int n, int *min, int *max),通过指针参数同时"返回"数组的最小值和最大值。
  2. 指针逆序。 编写函数 void reverse_array(int *arr, int n),只用指针运算(不使用 [] 下标)将数组逆序。
  3. 分析输出。 不运行程序,写出下面代码的输出,然后运行验证:
c
   int arr[] = {10, 20, 30, 40};
   int *p = arr;
   printf("%d ", *p++);
   printf("%d ", *p);
   printf("%d ", (*p)++);
   printf("%d ", *p);
   printf("%d ", *++p);
   printf("%d\n", (int)(p - arr));
   printf("%d %d %d %d\n", arr[0], arr[1], arr[2], arr[3]);
  1. 字符串排序。 用 qsort 对指针数组 const char *words[] = {"banana", "apple", "cherry", "date", "fig"}; 按字典序排序并输出。(提示:数组元素的类型是 const char *,所以比较函数收到的参数实际上是 const char * 的地址。)

第三部分 进阶篇

本部分的目标:学会组织复杂的数据(结构体),在程序运行时按需申请内存(动态内存),实现第一个数据结构(链表),让数据保存到磁盘上(文件),并掌握多文件编程、位运算、调试与规范等工程化技能。学完这一部分,你就具备了编写实用程序的能力。

第 11 章 结构体、共用体与枚举

11.1 为什么需要结构体

描述一个学生,需要学号(整数)、姓名(字符串)、年龄(整数)、成绩(小数)等多项信息。它们类型各不相同,没法放进同一个数组;如果分别定义成独立的变量或数组,又很难体现"这些信息属于同一个人"。

结构体(struct)允许我们把多个不同类型的数据组合成一个整体,从而定义出一种新的数据类型。

11.2 结构体的定义与使用

c
#include <stdio.h>
#include <string.h>

struct Student {        // 定义一个名为 struct Student 的新类型
    int id;             // 成员:学号
    char name[20];      // 成员:姓名
    int age;            // 成员:年龄
    double score;       // 成员:成绩
};                      // 注意:末尾的分号不能少

int main(void)
{
    struct Student s1 = {1001, "张三", 19, 88.5};      // 按成员顺序初始化
    struct Student s2 = {.name = "李四", .id = 1002};  // C99 指定初始化,未指定的成员为 0

    s2.age = 20;                     // 用"."访问成员
    s2.score = 92.0;
    strcpy(s1.name, "张伟");          // 数组成员不能直接用 = 赋值

    printf("%d %s %d %.1f\n", s1.id, s1.name, s1.age, s1.score);
    printf("%d %s %d %.1f\n", s2.id, s2.name, s2.age, s2.score);

    struct Student s3 = s1;          // 结构体可以整体赋值,所有成员(包括数组)都会被复制
    s3.id = 1003;
    printf("%d %s %d %.1f\n", s3.id, s3.name, s3.age, s3.score);
    return 0;
}

运行结果:

text
1001 张伟 19 88.5
1002 李四 20 92.0
1003 张伟 19 88.5

要点如下:

  • struct Student { ... }; 只是定义了一种类型,就像一张"表格模板",并不占用内存;用它定义的变量(s1、s2)才真正占用内存。
  • 用成员运算符 . 访问成员:s1.score。每个成员的用法和同类型的普通变量完全一样,比如用 scanf 读取:scanf("%lf", &s1.score);。
  • 结构体变量之间可以整体赋值(这一点与数组不同),但不能用 `==` 比较,要比较只能逐个成员进行。

11.3 用 typedef 简化类型名

每次都写 struct Student 有些繁琐,可以用 typedef 给类型起一个别名:

c
typedef struct {
    int id;
    char name[20];
    double score;
} Student;              // Student 现在就是这个结构体类型的名字

Student s = {1001, "张三", 88.5};   // 不用再写 struct 了

typedef 的作用是为已有的类型取一个新名字,它也可以用于其他类型,例如 typedef unsigned int uint;。本文后面的代码中,结构体类型名一般采用首字母大写的写法(如 Student),以便和变量名区分。

11.4 结构体指针与箭头运算符

指向结构体的指针非常常用。通过指针访问成员可以写成 (*p).score(括号不能省,因为 . 的优先级高于 *),但这种写法太繁琐,所以 C 语言提供了箭头运算符 ->:p->score 与 (*p).score 完全等价。

结构体作为函数参数。 结构体可以直接按值传递,但这样会把整个结构体复制一份,结构体很大时开销不小,而且函数无法修改原结构体。因此,通常传递结构体的指针;如果函数不需要修改结构体,就加上 const。

c
#include <stdio.h>

typedef struct {
    char name[20];
    int age;
    double score;
} Student;

// 传指针 + const:高效,并且保证不修改数据
void print_student(const Student *s)
{
    printf("姓名:%s,年龄:%d,成绩:%.1f\n", s->name, s->age, s->score);
}

// 传指针:可以修改调用者的结构体
void add_bonus(Student *s, double bonus)
{
    s->score += bonus;
    if (s->score > 100) {
        s->score = 100;
    }
}

// 传值:函数拿到的是副本,修改不会影响原结构体
void try_modify(Student s)
{
    s.age = 99;
    printf("函数内修改后的年龄:%d\n", s.age);
}

int main(void)
{
    Student stu = {"王五", 18, 95.0};
    Student *p = &stu;

    printf("%s %d\n", (*p).name, p->age);
    add_bonus(&stu, 8);
    try_modify(stu);
    print_student(&stu);
    return 0;
}

运行结果:

text
王五 18
函数内修改后的年龄:99
姓名:王五,年龄:18,成绩:100.0

add_bonus 成功修改了成绩;try_modify 虽然在函数内部把年龄改成了 99,但改的只是副本,原结构体中的年龄仍然是 18。

11.5 结构体数组

结构体数组可以存放一组记录,就像一张表格:每个元素是一行,每个成员是一列。下面的程序用 qsort 按得分从高到低对玩家排序:

c
#include <stdio.h>
#include <stdlib.h>

typedef struct {
    char name[20];
    int score;
} Player;

int compare_by_score_desc(const void *a, const void *b)
{
    const Player *pa = a;      // void * 可以直接赋给其他类型的指针
    const Player *pb = b;
    return (pb->score > pa->score) - (pb->score < pa->score);
}

int main(void)
{
    Player players[] = {
        {"Alice", 820},
        {"Bob", 950},
        {"Carol", 760},
        {"David", 910},
        {"Eve", 880}
    };
    int n = sizeof(players) / sizeof(players[0]);

    qsort(players, n, sizeof(Player), compare_by_score_desc);

    printf("排行榜:\n");
    for (int i = 0; i < n; i++) {
        printf("%d. %-6s %d\n", i + 1, players[i].name, players[i].score);
    }
    return 0;
}

运行结果:

text
排行榜:
1. Bob    950
2. David  910
3. Eve    880
4. Alice  820
5. Carol  760

qsort 可以对任何类型的数组排序,你只需要在比较函数中告诉它比较的规则。例如想按名字排序,比较函数改成 return strcmp(pa->name, pb->name); 即可。

注意
qsort 不保证"稳定性",也就是说,比较结果相等的两个元素,排序后的先后顺序是不确定的。如果需要"得分相同时按名字排序",应该在比较函数里写明这个规则。

11.6 结构体嵌套

结构体的成员也可以是另一个结构体:

c
typedef struct {
    int year;
    int month;
    int day;
} Date;

typedef struct {
    char name[20];
    Date birthday;          // 成员是另一个结构体
} Person;

Person p = {"赵六", {2005, 3, 15}};
printf("%s 生于 %d 年\n", p.name, p.birthday.year);   // 逐层用 . 访问

11.7 结构体的内存对齐

一个结构体占用的内存,并不一定等于各成员大小之和:

c
#include <stdio.h>
#include <stddef.h>

struct A {
    char c1;
    int i;
    char c2;
};

struct B {
    char c1;
    char c2;
    int i;
};

int main(void)
{
    printf("sizeof(struct A) = %zu\n", sizeof(struct A));
    printf("sizeof(struct B) = %zu\n", sizeof(struct B));
    printf("A 中各成员的偏移量:c1 = %zu, i = %zu, c2 = %zu\n",
           offsetof(struct A, c1), offsetof(struct A, i), offsetof(struct A, c2));
    return 0;
}

运行结果(在主流的 64 位系统上):

text
sizeof(struct A) = 12
sizeof(struct B) = 8
A 中各成员的偏移量:c1 = 0, i = 4, c2 = 8

成员完全相同,只是顺序不同,大小却相差了 4 个字节。这是因为内存对齐:CPU 读取数据时,如果数据的地址是其大小的整数倍(例如 4 字节的 int 放在 4 的倍数的地址上),读取效率最高,在某些平台上甚至是必须的。因此,编译器会在成员之间插入一些填充字节。通常的规则是:

  1. 每个成员的偏移量,必须是该成员对齐要求的整数倍。对于 char、int、double 等基本类型,对齐要求一般等于其大小;数组的对齐要求与其元素类型相同,例如 char name[20] 只需按 1 字节对齐;
  2. 结构体的总大小,必须是其中最大对齐要求的整数倍(这样结构体数组中的每个元素也都能对齐)。

struct A 的布局如下(.. 表示填充字节):

text
offset:  0    1    2    3    4    5    6    7    8    9   10   11
       +----+----+----+----+----+----+----+----+----+----+----+----+
       | c1 | .. | .. | .. | i  | i  | i  | i  | c2 | .. | .. | .. |
       +----+----+----+----+----+----+----+----+----+----+----+----+

c1 之后要填充 3 个字节,i 才能从 4 的倍数开始;c2 之后还要填充 3 个字节,使总大小是 4 的倍数。而 struct B 中两个 char 挨在一起,只需在它们后面填充 2 个字节。

实用建议:定义结构体时,把成员按大小从大到小排列,可以减少填充、节省内存。 offsetof 宏(在 <stddef.h> 中)可以查看成员的偏移量。

11.8 枚举

枚举(enum)用于定义一组有名字的整数常量,适合表示"有限的几种取值",比如星期、颜色、状态:

c
#include <stdio.h>

enum Weekday { MON = 1, TUE, WED, THU, FRI, SAT, SUN };   // TUE 为 2,之后依次加 1

int main(void)
{
    enum Weekday today = SAT;
    printf("今天是星期 %d\n", today);

    switch (today) {
    case SAT:
    case SUN:
        printf("周末愉快!\n");
        break;
    default:
        printf("工作日,加油!\n");
        break;
    }
    return 0;
}

运行结果:

text
今天是星期 6
周末愉快!
  • 如果不指定值,第一个枚举常量为 0,后面的依次加 1;也可以给任意常量指定值。
  • 枚举常量本质上就是整数,可以参与运算,可以用在 switch 的 case 中。
  • 与 #define 定义的一组宏常量相比,枚举把相关的常量组织在一起,含义更明确,调试器也能显示枚举常量的名字。

同样可以用 typedef 简化枚举类型名:typedef enum { RED, GREEN, BLUE } Color;。

11.9 共用体

共用体(union,也叫联合体)的写法和结构体很像,但它的所有成员共享同一块内存,大小等于最大成员的大小(再加上可能的对齐填充)。同一时刻,只有最后写入的那个成员是有意义的。

c
#include <stdio.h>

union Data {
    int i;
    float f;
    char str[8];
};

int main(void)
{
    union Data d;
    printf("sizeof(union Data) = %zu\n", sizeof(d));

    d.i = 42;
    printf("d.i = %d\n", d.i);
    d.f = 3.14f;                   // 写入 f 会覆盖 i 所在的内存
    printf("d.f = %.2f\n", d.f);
    printf("d.i = %d(已被覆盖)\n", d.i);
    return 0;
}

运行结果:

text
sizeof(union Data) = 8
d.i = 42
d.f = 3.14
d.i = 1078523331(已被覆盖)

写入 d.f 之后,这 4 个字节里存的是浮点数 3.14 的二进制表示,再把它当作整数读出来,就得到了一个"莫名其妙"的数字。

共用体的典型用法是与枚举配合,构成"带标签的共用体":用一个枚举成员记录当前存的是哪种数据,再根据它来访问相应的成员。这样一个变量就能存放多种类型的数据之一:

c
#include <stdio.h>

typedef enum { SHAPE_CIRCLE, SHAPE_RECT } ShapeType;

typedef struct {
    ShapeType type;                       // 标签:记录这是哪种图形
    union {
        struct { double radius; } circle;
        struct { double width, height; } rect;
    } data;
} Shape;

double area(const Shape *s)
{
    switch (s->type) {
    case SHAPE_CIRCLE:
        return 3.14159265358979 * s->data.circle.radius * s->data.circle.radius;
    case SHAPE_RECT:
        return s->data.rect.width * s->data.rect.height;
    }
    return 0.0;
}

int main(void)
{
    Shape shapes[2];
    shapes[0].type = SHAPE_CIRCLE;
    shapes[0].data.circle.radius = 1.0;
    shapes[1].type = SHAPE_RECT;
    shapes[1].data.rect.width = 3.0;
    shapes[1].data.rect.height = 4.0;

    for (int i = 0; i < 2; i++) {
        printf("图形 %d 的面积:%.2f\n", i + 1, area(&shapes[i]));
    }
    return 0;
}

运行结果:

text
图形 1 的面积:3.14
图形 2 的面积:12.00

11.10 位域(了解)

在嵌入式开发等对内存非常敏感的场景中,可以用位域指定结构体成员只占用若干个二进制位:

c
struct Flags {
    unsigned int is_read  : 1;   // 只占 1 位,取值 0 或 1
    unsigned int is_write : 1;
    unsigned int level    : 4;   // 占 4 位,取值 0~15
};

位域的具体内存布局由编译器决定,不同平台之间不可移植。对于大多数程序,第 16 章介绍的"位运算 + 标志位"是更通用的做法。

11.11 综合示例:发牌程序

下面用枚举、结构体、数组和随机数实现一个洗牌发牌的小程序:

c
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

typedef enum { SPADE, HEART, CLUB, DIAMOND } Suit;   // 黑桃、红心、梅花、方块

typedef struct {
    Suit suit;
    int rank;          // 1~13,分别代表 A、2~10、J、Q、K
} Card;

const char *suit_name(Suit s)
{
    switch (s) {
    case SPADE:   return "黑桃";
    case HEART:   return "红心";
    case CLUB:    return "梅花";
    case DIAMOND: return "方块";
    }
    return "?";
}

void print_card(Card c)
{
    const char *ranks[] = {"", "A", "2", "3", "4", "5", "6", "7",
                           "8", "9", "10", "J", "Q", "K"};
    printf("%s%s ", suit_name(c.suit), ranks[c.rank]);
}

int main(void)
{
    Card deck[52];
    int k = 0;
    for (int s = SPADE; s <= DIAMOND; s++) {
        for (int r = 1; r <= 13; r++) {
            deck[k].suit = (Suit)s;
            deck[k].rank = r;
            k++;
        }
    }

    // Fisher-Yates 洗牌:从后往前,让每张牌与它前面(含它自己)的随机一张交换
    srand((unsigned int)time(NULL));
    for (int i = 51; i > 0; i--) {
        int j = rand() % (i + 1);
        Card temp = deck[i];
        deck[i] = deck[j];
        deck[j] = temp;
    }

    printf("你的五张牌:");
    for (int i = 0; i < 5; i++) {
        print_card(deck[i]);
    }
    printf("\n");
    return 0;
}

一次可能的运行结果:

text
你的五张牌:红心7 黑桃K 方块A 梅花10 红心Q

Fisher-Yates 算法能保证 52! 种排列中的每一种出现的机会均等(在随机数足够理想的前提下),是标准的洗牌方法。

本章练习

  1. 一年中的第几天。 定义日期结构体 Date(包含年、月、日),编写函数 int day_of_year(const Date *d),计算这一天是当年的第几天(注意闰年)。例如 2024 年 3 月 1 日是第 61 天。
  2. 复数运算。 定义复数结构体 Complex(包含实部和虚部),编写函数实现两个复数的加法和乘法。提示:(a+bi)(c+di) = (ac−bd) + (ad+bc)i。
  3. 图书排序。 定义图书结构体(书名、作者、价格),在程序中初始化 5 本书的信息,按价格从高到低排序后输出。
  4. 思考题。 在常见的 64 位系统上,下面两个结构体的 sizeof 分别是多少?先自己推算,再编程验证。
c
   struct X { char a; double b; int c; };
   struct Y { double b; int c; char a; };

第 12 章 动态内存管理

到目前为止,我们使用的变量和数组,大小都必须在写代码时就确定下来。但很多时候,数据量要到程序运行时才知道:用户要输入多少个数?文件里有多少条记录?动态内存管理让程序可以在运行时按需申请内存,用完后再归还。

12.1 程序的内存布局

一个运行中的 C 程序,其内存大致分为以下几个区域(这是简化的典型布局,具体情况因系统而异):

text
高地址 +--------------------------+
       |        栈(stack)        |  局部变量、函数参数;自动分配和释放;空间较小
       |            ↓             |
       |                          |
       |            ↑             |
       |        堆(heap)         |  malloc 等函数申请的内存;手动申请和释放;空间大
       +--------------------------+
       |      全局 / 静态数据区      |  全局变量、static 变量;程序运行期间一直存在
       +--------------------------+
       |        只读数据区          |  字符串字面量等常量
       +--------------------------+
       |          代码区           |  程序的机器指令
低地址 +--------------------------+

栈和堆是最重要的两个区域,对比如下:

栈堆
存放什么局部变量、函数参数malloc 等函数申请的内存
谁来管理编译器自动分配、自动释放程序员手动申请、手动释放
生命周期所在函数返回时结束从申请开始,直到调用 free 为止
空间大小较小(Linux 上通常为 8 MB,Windows 上通常为 1 MB)较大,受限于系统可用内存
大小何时确定通常在编译时(变长数组除外)运行时

在以下情况下,就需要使用堆内存:

  • 数据的大小要到运行时才能确定;
  • 数据需要在创建它的函数返回后继续存在;
  • 数据量很大,栈上放不下(比如一个包含一千万个元素的数组)。

12.2 malloc 与 free

<stdlib.h> 中的 malloc 函数用于申请内存,free 函数用于释放内存:

c
void *malloc(size_t size);   // 申请 size 个字节,成功时返回首地址,失败时返回 NULL
void free(void *ptr);        // 释放 ptr 所指向的、之前由 malloc 等函数申请的内存
c
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int n;
    printf("要输入几个整数?");
    if (scanf("%d", &n) != 1 || n <= 0) {
        printf("输入无效\n");
        return 1;
    }

    int *arr = malloc(n * sizeof(int));   // 申请能存放 n 个 int 的内存
    if (arr == NULL) {                    // 申请可能失败,必须检查
        printf("内存不足!\n");
        return 1;
    }

    printf("请输入 %d 个整数:", n);
    for (int i = 0; i < n; i++) {
        scanf("%d", &arr[i]);             // 像使用普通数组一样使用它
    }

    printf("逆序输出:");
    for (int i = n - 1; i >= 0; i--) {
        printf("%d ", arr[i]);
    }
    printf("\n");

    free(arr);                            // 用完后释放
    arr = NULL;                           // 释放后置为 NULL,防止误用
    return 0;
}

运行结果:

text
要输入几个整数?5
请输入 5 个整数:1 2 3 4 5
逆序输出:5 4 3 2 1

使用 malloc 和 free 的要点:

  1. 计算好字节数。 malloc 的参数是字节数,不是元素个数,所以要乘以元素的大小。更推荐的写法是 malloc(n * sizeof *arr):用 *arr 代替类型名,以后即使 arr 的类型改了,这里也不用跟着改。
  2. 一定要检查返回值是否为 `NULL`。 内存不足时 malloc 会返回 NULL,直接使用会导致崩溃。
  3. 新申请的内存没有初始化,里面是垃圾值。
  4. 有借有还。 每一次成功的 malloc,都必须对应一次 free。忘记释放会造成内存泄漏。
  5. 释放后把指针置为 `NULL`。 free 之后,指针变量里仍然保存着原来的地址,但那块内存已经不属于你了(这就是悬空指针)。置为 NULL 后,万一误用,程序会立即崩溃,比悄悄读写一块无效的内存更容易发现问题。另外,free(NULL) 是安全的,什么也不做。
  6. 在 C 语言中,malloc 返回的 void * 可以直接赋给任何对象指针,不需要写成 (int *)malloc(...)(C++ 中则必须转换)。

12.3 calloc 与 realloc

`calloc(个数, 每个的大小)`:申请内存,并且把所有字节初始化为 0。

c
int *scores = calloc(100, sizeof *scores);   // 100 个 int,全部为 0

`realloc(旧指针, 新的字节数)`:调整一块已申请内存的大小。扩大时,它会尽量在原地扩展;如果原地空间不够,就另找一块足够大的内存,把原来的数据复制过去,并释放旧内存。所以 realloc 返回的地址可能与原来不同。

使用 realloc 有一个经典的陷阱:

c
p = realloc(p, new_size);          // 危险!

如果 realloc 失败,它会返回 NULL,但原来的内存并不会被释放。这样写会让 p 变成 NULL,原来那块内存的地址就丢失了,造成内存泄漏。正确的写法是先用一个临时指针接收结果:

c
int *temp = realloc(p, new_size);
if (temp == NULL) {
    // 扩容失败,p 仍然有效,可以继续使用或释放
    free(p);
    return 1;
}
p = temp;                           // 成功后再更新 p

另外,realloc(NULL, size) 等价于 malloc(size)。

12.4 实战:可自动扩容的动态数组

结合结构体和 realloc,我们可以实现一个"会自动长大"的数组:元素个数不再受限,放满了就自动扩容。

c
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>

typedef struct {
    int *data;          // 指向堆上存放元素的空间
    size_t size;        // 当前的元素个数
    size_t capacity;    // 当前的容量(最多能放多少个元素)
} IntVector;

void vec_init(IntVector *v)
{
    v->data = NULL;
    v->size = 0;
    v->capacity = 0;
}

// 在末尾添加一个元素;成功返回 true,内存不足时返回 false
bool vec_push(IntVector *v, int value)
{
    if (v->size == v->capacity) {                     // 空间已满,需要扩容
        size_t new_cap = (v->capacity == 0) ? 4 : v->capacity * 2;
        int *new_data = realloc(v->data, new_cap * sizeof *new_data);
        if (new_data == NULL) {
            return false;                             // 扩容失败,原有数据仍然完好
        }
        v->data = new_data;
        v->capacity = new_cap;
        printf("  [扩容] 容量变为 %zu\n", new_cap);
    }
    v->data[v->size] = value;
    v->size++;
    return true;
}

void vec_free(IntVector *v)
{
    free(v->data);
    vec_init(v);                                      // 恢复为空的状态
}

int main(void)
{
    IntVector v;
    vec_init(&v);

    printf("请输入若干个整数,以 0 结束:\n");
    int x;
    while (scanf("%d", &x) == 1 && x != 0) {
        if (!vec_push(&v, x)) {
            printf("内存不足!\n");
            break;
        }
    }

    long long sum = 0;
    for (size_t i = 0; i < v.size; i++) {
        sum += v.data[i];
    }
    printf("共 %zu 个数,总和为 %lld\n", v.size, sum);
    if (v.size > 0) {
        printf("平均值为 %.2f\n", (double)sum / v.size);
    }

    vec_free(&v);
    return 0;
}

运行结果:

text
请输入若干个整数,以 0 结束:
3 8 1 9 4 7 2 6 5 10 0
  [扩容] 容量变为 4
  [扩容] 容量变为 8
  [扩容] 容量变为 16
共 10 个数,总和为 55
平均值为 5.50

每次扩容时,容量翻倍而不是每次加 1。因为每次扩容都可能要复制全部数据,如果每次只加 1,添加 n 个元素就要复制约 n²/2 次;翻倍策略下扩容的次数很少,平均下来每添加一个元素只需复制常数次。C++ 的 vector、Java 的 ArrayList、Python 的 list,内部都采用了类似的"按比例扩容"策略。

这个例子还体现了一种很好的代码组织方式:把数据(结构体)和操作它的函数(vec_init、vec_push、vec_free)放在一起,使用者只需调用函数,不必关心内部细节。第 18 章的综合项目会沿用这种写法。

12.5 在函数中分配内存

第 10 章说过,不能返回局部变量的地址,因为局部变量在函数返回时就被销毁了。但堆内存的生命周期不受函数的限制,直到被 free 为止都有效,所以函数可以申请堆内存并把地址交给调用者。常见的写法有两种:

c
// 方式一:通过返回值交出新申请的内存
int *create_array(size_t n, int init_value)
{
    int *arr = malloc(n * sizeof *arr);
    if (arr != NULL) {
        for (size_t i = 0; i < n; i++) {
            arr[i] = init_value;
        }
    }
    return arr;          // 调用者负责检查是否为 NULL,并在用完后 free
}

// 方式二:通过二级指针参数交出新申请的内存,返回值用来表示成功与否
bool create_zero_array(size_t n, int **out)
{
    *out = calloc(n, sizeof **out);
    return *out != NULL;
}

调用方式二时要传入指针变量的地址:int *p; if (create_zero_array(10, &p)) { ... free(p); }。

下面是一个常见的错误写法:

c
void wrong_alloc(int *p, size_t n)
{
    p = malloc(n * sizeof *p);   // 只修改了形参 p,调用者的指针不受任何影响
}                                // 而且这块内存的地址丢失了,造成内存泄漏

这和第 7 章 swap 失败的原因完全相同:要在函数中修改调用者的指针变量,必须传入它的地址,也就是二级指针。

提示
由函数申请、交给调用者释放的内存,一定要在函数的注释或文档中写明"调用者负责释放"。明确"谁申请、谁释放",是避免内存泄漏的关键。

12.6 动态二维数组

需要一个运行时才确定大小的二维数组时,最简单的方法是一次性申请 rows × cols 个元素的连续空间,然后用 i * cols + j 计算第 i 行第 j 列元素的下标:

c
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int rows = 3, cols = 4;
    int *m = malloc((size_t)rows * cols * sizeof *m);
    if (m == NULL) {
        return 1;
    }

    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
            m[i * cols + j] = i * 10 + j;    // 第 i 行第 j 列
        }
    }
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
            printf("%4d", m[i * cols + j]);
        }
        printf("\n");
    }

    free(m);
    return 0;
}

运行结果:

text
   0   1   2   3
  10  11  12  13
  20  21  22  23

这种方法只需一次 malloc 和一次 free,内存连续,效率也高。另一种方法是先申请一个"指针数组",再为每一行分别申请空间,这样就能用 m[i][j] 的写法访问元素,但需要多次申请和释放,留作本章练习。

12.7 常见的内存错误

动态内存是 C 程序中 bug 最集中的地方。下面这些错误务必牢记:

错误示例后果
内存泄漏申请后忘记 free程序占用的内存越来越多,长时间运行的程序最终会耗尽内存
丢失指针p = malloc(...); p = malloc(...);第一块内存的地址丢失,再也无法释放
使用已释放的内存free(p); printf("%d", *p);未定义行为,可能读到垃圾值,也可能破坏其他数据
重复释放free(p); free(p);未定义行为,通常导致程序崩溃
越界访问申请 5 个元素,却访问第 6 个破坏堆的内部结构,往往在很久之后才以奇怪的方式崩溃
释放非堆内存int a; free(&a);未定义行为,通常导致程序崩溃
释放非起始地址free(p + 1)未定义行为,通常导致程序崩溃。free 只接受 malloc 等函数返回的原始地址,也不能只释放一块内存的一部分
大小算错malloc(n) 或 malloc(sizeof(p))申请的空间不够,导致越界
不检查 NULL直接使用 malloc 的返回值内存不足时程序崩溃

避免这些错误的好习惯:

  • 申请后立即检查 NULL;
  • 写下 malloc 的同时,就想好在哪里 free;
  • free 之后立即把指针置为 NULL;
  • 让"申请"和"释放"成对地出现在同一层次的代码中(比如 vec_init 和 vec_free);
  • 借助工具检查(见下一节)。

12.8 内存检测工具

内存错误往往不会立刻导致崩溃,而是潜伏下来,在不相关的地方引发诡异的问题,靠肉眼很难排查。好在有专门的工具可以帮忙。

AddressSanitizer(ASan) 是 GCC 和 Clang 内置的内存错误检测器,使用时只需在编译命令中加上 -fsanitize=address。以下面这个越界写入的程序为例:

c
#include <stdlib.h>

int main(void)
{
    int *arr = malloc(5 * sizeof *arr);
    if (arr == NULL) {
        return 1;
    }
    for (int i = 0; i <= 5; i++) {    // 错误:i 会取到 5,越界了
        arr[i] = i;
    }
    free(arr);
    return 0;
}
text
$ gcc -g -fsanitize=address overflow.c -o overflow
$ ./overflow
==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x503000000024 ...
WRITE of size 4 at 0x503000000024 thread T0
    #0 0x... in main overflow.c:10
    ...
0x503000000024 is located 0 bytes after 20-byte region [0x503000000010,0x503000000024)
allocated by thread T0 here:
    #0 0x... in malloc ...
    #1 0x... in main overflow.c:5

ASan 准确地指出:第 10 行发生了堆缓冲区溢出(heap-buffer-overflow),写入了 4 个字节,越界位置正好在第 5 行申请的 20 字节内存之后。在 Linux 上,ASan 还会在程序结束时报告内存泄漏。

Valgrind 是 Linux 上另一个常用的内存检查工具,不需要重新编译(但建议加 -g 以显示行号):

text
$ valgrind --leak-check=full ./program

它会报告非法读写(Invalid read、Invalid write)和内存泄漏(definitely lost 等)的详细信息。

提示
在学习阶段,建议养成用 -fsanitize=address,undefined 编译调试版本的习惯(undefined 表示同时检测一部分未定义行为,如有符号整数溢出)。ASan 在 Linux 和 macOS 上可以直接使用;Windows 上的 Visual Studio 也支持 /fsanitize=address 选项。

本章练习

  1. 动态求平均。 先输入整数的个数 n,再用 malloc 申请空间存放 n 个整数,输入后求平均值和高于平均值的数的个数,最后释放内存。
  2. 实现 strdup。 编写函数 char *my_strdup(const char *s):申请一块刚好能容纳字符串 s 的内存,把 s 复制进去并返回新字符串的地址(由调用者负责释放)。
  3. 动态二维数组。 输入行数 m 和列数 n,用"指针数组 + 每行单独申请"的方式创建一个 m × n 的 int 二维数组,使得可以用 a[i][j] 访问元素。填入数据(如 a[i][j] = i * j)并输出,最后正确地释放全部内存。
  4. 找错题。 找出下面代码中的所有内存错误,说明后果并改正:
c
   #include <stdio.h>
   #include <stdlib.h>
   #include <string.h>

   char *make_greeting(const char *name)
   {
       char *buf = malloc(strlen("Hello, ") + strlen(name));
       strcpy(buf, "Hello, ");
       strcat(buf, name);
       return buf;
   }

   int main(void)
   {
       char *s = make_greeting("Tom");
       printf("%s\n", s);
       free(s);
       printf("%s\n", s);

       int *nums = malloc(10 * sizeof(int));
       nums = malloc(20 * sizeof(int));
       free(nums);
       free(nums);
       return 0;
   }

第 13 章 链表:第一个数据结构

有了结构体、指针和动态内存这三件工具,我们就可以构造出真正的数据结构了。链表是最基础、最经典的动态数据结构,也是学习栈、队列、树、图等数据结构的起点。

13.1 数组的局限

数组有两个明显的局限:

  1. 大小固定。 定义时就要确定大小,定大了浪费,定小了不够用。第 12 章的动态数组虽然能扩容,但每次扩容都可能要复制全部数据。
  2. 插入和删除代价高。 要在数组的开头插入一个元素,必须先把后面所有的元素都往后挪一位;删除也是同理。数组越大,挪动的代价就越高。

13.2 链表是什么

链表由一系列节点组成,每个节点包含两部分:存放数据的数据域,和指向下一个节点的指针域。节点在内存中不必连续,它们靠指针"链"在一起。我们用一个头指针记住第一个节点的位置,最后一个节点的指针域为 NULL,表示链表到此结束。

text
 head
  │
  ▼
+----+---+     +----+---+     +----+------+
| 10 | ●─┼───▶ | 20 | ●─┼───▶ | 30 | NULL |
+----+---+     +----+---+     +----+------+
 数据  指针

链表就像一列火车:每节车厢都挂着下一节车厢;想加一节车厢,只要断开一个连接、重新挂上即可,不用移动其他车厢。但想找到第 100 节车厢,就只能从车头开始一节一节地数。

对比项数组链表
内存连续分散,靠指针连接
按位置访问第 i 个元素快,直接计算地址,O(1)慢,必须从头逐个查找,O(n)
在已知位置插入、删除慢,要移动后面的元素,O(n)快,只需修改指针,O(1)(对单链表来说,前提是已经拿到了该位置前面那个节点的指针)
大小固定,或需要整体扩容随时增减节点
额外开销无每个节点都要额外存一个指针

没有哪一种结构是绝对更好的,要根据实际需要选择:频繁按位置访问,用数组;频繁插入删除,用链表。

13.3 定义节点

c
typedef struct Node {
    int data;             // 数据域
    struct Node *next;    // 指针域:指向下一个节点
} Node;

注意结构体内部写的是 struct Node *next,而不是 Node *next。因为在结构体内部,typedef 定义的别名 Node 还没有生效,只能使用带标签的完整写法 struct Node。一个结构体可以包含指向同类型结构体的指针(但不能包含同类型的结构体本身,否则大小会无穷大),这种结构体称为自引用结构体。

一个空链表,就是头指针为 NULL:

c
Node *head = NULL;

13.4 链表的基本操作

1. 遍历。 用一个指针 cur 从头节点开始,每次沿着 next 走到下一个节点,直到 NULL 为止:

c
for (const Node *cur = head; cur != NULL; cur = cur->next) {
    printf("%d -> ", cur->data);
}

几乎所有的链表操作都建立在这个模式之上。

2. 头插法:在链表最前面插入节点。 分两步:

text
插入前:   head ──▶ [10|●]──▶ [20|●]──▶ NULL

① node->next = head;     让新节点指向原来的第一个节点

           node ──▶ [5|●]──┐
                           ▼
           head ──▶ [10|●]──▶ [20|●]──▶ NULL

② head = node;           让头指针指向新节点

           head ──▶ [5|●]──▶ [10|●]──▶ [20|●]──▶ NULL

两步的顺序不能颠倒。 如果先执行 head = node,原来的第一个节点的地址就丢失了,整个链表都找不回来了。

因为头插法要修改调用者的头指针,所以函数的参数是 Node **head(二级指针)——这正是第 10 章所说的"想在函数中修改一个指针,就要传入这个指针的地址"。

3. 尾插法:在链表末尾插入节点。 先沿着链表找到最后一个节点(其 next 为 NULL),再让它指向新节点。如果链表为空,新节点就成为第一个节点,这时同样需要修改头指针。

4. 在指定节点之后插入。 与头插法的思路相同,也要先连后断:

c
node->next = pos->next;   // ① 新节点先指向 pos 的下一个节点
pos->next = node;         // ② pos 再指向新节点

5. 删除节点。 要删除一个节点,需要找到它前面的节点,让前一个节点"跳过"它,然后释放它:

text
删除 20:prev 指向 10,cur 指向 20

   head ──▶ [10|●]──▶ [20|●]──▶ [30|●]──▶ NULL
             prev       cur

prev->next = cur->next;  free(cur);

   head ──▶ [10|●]──────────────▶ [30|●]──▶ NULL

如果要删除的正好是第一个节点(它前面没有节点),就要修改头指针:*head = cur->next;。

6. 释放整个链表。 逐个释放节点。注意必须先保存下一个节点的地址,再释放当前节点,因为节点被释放后就不能再访问它的 next 了。

13.5 完整程序

下面把这些操作整合成一个完整的程序:

c
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>

typedef struct Node {
    int data;
    struct Node *next;
} Node;

// 创建一个新节点
Node *create_node(int value)
{
    Node *node = malloc(sizeof *node);
    if (node == NULL) {
        fprintf(stderr, "内存不足\n");
        exit(EXIT_FAILURE);             // 内存耗尽,无法继续,直接结束程序
    }
    node->data = value;
    node->next = NULL;
    return node;
}

// 头插法
void push_front(Node **head, int value)
{
    Node *node = create_node(value);
    node->next = *head;
    *head = node;
}

// 尾插法
void push_back(Node **head, int value)
{
    Node *node = create_node(value);
    if (*head == NULL) {                // 空链表:新节点就是第一个节点
        *head = node;
        return;
    }
    Node *cur = *head;
    while (cur->next != NULL) {         // 找到最后一个节点
        cur = cur->next;
    }
    cur->next = node;
}

// 在节点 pos 之后插入新节点
void insert_after(Node *pos, int value)
{
    Node *node = create_node(value);
    node->next = pos->next;
    pos->next = node;
}

// 查找第一个值为 value 的节点,找不到返回 NULL
Node *find(Node *head, int value)
{
    for (Node *cur = head; cur != NULL; cur = cur->next) {
        if (cur->data == value) {
            return cur;
        }
    }
    return NULL;
}

// 删除第一个值为 value 的节点,成功返回 true
bool remove_value(Node **head, int value)
{
    Node *prev = NULL;
    Node *cur = *head;
    while (cur != NULL && cur->data != value) {
        prev = cur;
        cur = cur->next;
    }
    if (cur == NULL) {                  // 没有找到
        return false;
    }
    if (prev == NULL) {                 // 要删除的是第一个节点
        *head = cur->next;
    } else {
        prev->next = cur->next;
    }
    free(cur);
    return true;
}

// 输出链表
void print_list(const Node *head)
{
    for (const Node *cur = head; cur != NULL; cur = cur->next) {
        printf("%d -> ", cur->data);
    }
    printf("NULL\n");
}

// 释放整个链表
void free_list(Node **head)
{
    Node *cur = *head;
    while (cur != NULL) {
        Node *next = cur->next;         // 先保存下一个节点的地址
        free(cur);                      // 再释放当前节点
        cur = next;
    }
    *head = NULL;
}

int main(void)
{
    Node *head = NULL;

    for (int i = 1; i <= 5; i++) {
        push_back(&head, i * 10);
    }
    printf("尾插 10~50:");
    print_list(head);

    push_front(&head, 5);
    printf("头插 5:    ");
    print_list(head);

    Node *p = find(head, 30);
    if (p != NULL) {
        insert_after(p, 35);
    }
    printf("30 后插 35:");
    print_list(head);

    remove_value(&head, 5);
    remove_value(&head, 50);
    printf("删除 5、50:");
    print_list(head);

    free_list(&head);
    printf("释放后:    ");
    print_list(head);
    return 0;
}

运行结果:

text
尾插 10~50:10 -> 20 -> 30 -> 40 -> 50 -> NULL
头插 5:    5 -> 10 -> 20 -> 30 -> 40 -> 50 -> NULL
30 后插 35:5 -> 10 -> 20 -> 30 -> 35 -> 40 -> 50 -> NULL
删除 5、50:10 -> 20 -> 30 -> 35 -> 40 -> NULL
释放后:    NULL

建议你用 -fsanitize=address 编译并运行这个程序,确认没有任何内存错误和泄漏。然后试着把 free_list 的循环体故意改成 free(cur); cur = cur->next;(先释放、再读取 next),看看 AddressSanitizer 会报告什么。

提示
写链表代码时,务必考虑以下边界情况:链表为空、只有一个节点、操作的是第一个节点、操作的是最后一个节点。大部分链表 bug 都出在这些地方。拿不准的时候,就在纸上画出节点和箭头,一步一步模拟指针的变化。

13.6 带头结点的链表(了解)

上面的实现中,插入和删除第一个节点时都需要特殊处理(修改头指针)。一种常见的技巧是在链表最前面放一个不存储有效数据的"头结点"(也叫哨兵节点),头指针永远指向它,真正的第一个数据节点是 head->next。这样,所有数据节点前面都有一个节点,插入和删除就不再需要区分特殊情况了。国内很多数据结构教材默认使用这种带头结点的链表。

13.7 链表的变体

  • 双向链表:每个节点有两个指针,分别指向前一个和后一个节点,可以双向遍历,删除某个节点时也不需要再去找它的前驱,代价是每个节点多占一个指针的空间。
text
  NULL ◀──[● | 10 | ●]◀──▶[● | 20 | ●]◀──▶[● | 30 | ●]──▶ NULL
  • 循环链表:最后一个节点的 next 指向第一个节点,形成一个环,适合处理"轮流""循环报数"之类的问题(如约瑟夫环问题)。

链表还是实现其他数据结构的基础:如果只允许在一端插入和删除,它就是栈(后进先出);如果只允许在一端插入、另一端删除,它就是队列(先进先出)。这些内容属于"数据结构"课程的范畴,第 19 章会给出后续的学习建议。

本章练习

  1. 链表长度。 分别用循环和递归编写函数,求链表的节点个数。
  2. 反转链表。 编写函数 Node *reverse_list(Node *head),将链表原地反转(不创建新节点),返回新的头指针。例如 1 -> 2 -> 3 -> NULL 变为 3 -> 2 -> 1 -> NULL。这是面试中出现频率最高的链表题之一。
  3. 合并有序链表。 已知两个链表中的元素都是从小到大排列的,编写函数把它们合并成一个有序链表(直接重新连接原有的节点,不创建新节点)。
  4. 中间节点。 编写函数返回链表的中间节点,要求只遍历链表一次。节点数为偶数时,返回中间两个节点中的第二个。(提示:用两个指针,一个每次走一步,一个每次走两步。)

第 14 章 文件操作

程序运行时,数据保存在内存中,程序一结束,数据就消失了。要想让数据"活"得更久——比如保存游戏进度、记录学生成绩、写入日志——就需要把它们存到磁盘上的文件里。

14.1 文件与流

文本文件与二进制文件。 从存储形式上看,文件分为两类:

  • 文本文件:内容是一个个字符,可以用记事本等文本编辑器直接查看,比如 .txt、.c、.csv 文件。整数 12345 存成文本,是 '1' '2' '3' '4' '5' 这 5 个字符,占 5 个字节。
  • 二进制文件:直接存储数据在内存中的二进制形式,用文本编辑器打开是乱码,比如图片、音乐、可执行程序。整数 12345 存成二进制,就是一个 int 的 4 个字节。

流。 C 语言把所有的输入输出设备(键盘、屏幕、文件)都抽象为流(stream)——一个有序的字节序列。程序通过 FILE * 类型的指针来操作流。程序启动时,有三个标准流会自动打开:

标准流含义默认设备
stdin标准输入键盘
stdout标准输出屏幕
stderr标准错误屏幕

printf(...) 其实就是 fprintf(stdout, ...) 的简写。错误信息建议输出到 stderr,这样即使普通输出被重定向到文件,错误信息仍然会显示在屏幕上。

换行符的差异。 Linux 和 macOS 用 \n 表示换行,而 Windows 的文本文件用 \r\n 两个字符表示换行。在 Windows 上以文本模式读写文件时,C 库会自动进行转换:写入 \n 时自动变成 \r\n,读取时再变回 \n。以二进制模式读写时则不做任何转换。

14.2 打开和关闭文件

使用文件的基本流程是:打开 → 读写 → 关闭。

c
#include <stdio.h>

int main(void)
{
    FILE *fp = fopen("hello.txt", "w");      // 以写入方式打开,文件不存在就创建
    if (fp == NULL) {
        perror("打开文件失败");
        return 1;
    }
    fprintf(fp, "Hello, File!\n");
    fprintf(fp, "今天是 %d 年 %d 月 %d 日\n", 2026, 9, 26);
    fclose(fp);                               // 关闭文件

    fp = fopen("hello.txt", "r");             // 以读取方式重新打开
    if (fp == NULL) {
        perror("打开文件失败");
        return 1;
    }
    char line[100];
    while (fgets(line, sizeof(line), fp) != NULL) {
        printf("读到:%s", line);
    }
    fclose(fp);
    return 0;
}

运行结果:

text
读到:Hello, File!
读到:今天是 2026 年 9 月 26 日

运行后,当前工作目录(通常就是你运行程序时所在的文件夹,见下文"关于文件路径")下会多出一个 hello.txt 文件,可以用文本编辑器打开查看。

`fopen(文件名, 模式)` 打开文件,成功时返回一个 FILE * 指针,失败时(比如文件不存在、没有权限)返回 NULL。打开文件后必须检查返回值。 常用的打开模式有:

模式含义文件不存在时文件已存在时
"r"只读打开失败从头开始读
"w"只写创建新文件清空原有内容!
"a"追加创建新文件在末尾追加写入
"r+"读写打开失败从头开始读写
"w+"读写创建新文件清空原有内容!
"a+"读和追加创建新文件可以读,写入总在末尾

在模式后面加 b(如 "rb"、"wb")表示以二进制模式打开。

常见错误
用 "w" 模式打开一个已有的文件,它的内容会被立即清空,即使你一个字节都没写。如果只是想在文件末尾添加内容,请使用 "a" 模式。

`fclose(fp)` 关闭文件。写入文件的数据往往先存放在内存中的缓冲区里,攒够一批再写入磁盘,fclose 会把缓冲区中剩余的数据真正写入磁盘,并释放相关资源。忘记关闭文件可能导致数据丢失;而且一个程序能同时打开的文件数量是有限的。

`perror(提示信息)` 会输出你给的提示信息,后面跟着系统给出的错误原因。比如打开一个不存在的文件时,会输出:

text
打开文件失败: No such file or directory

关于文件路径。 像 "hello.txt" 这样的相对路径,是相对于程序运行时的当前工作目录的,而不一定是源代码所在的目录。在 IDE 中运行程序时,当前工作目录通常是项目目录或者可执行文件所在的目录,找不到文件时要先检查这一点。Windows 的路径中如果有反斜杠,在字符串里要写成两个:"C:\\data\\scores.txt",也可以直接用正斜杠:"C:/data/scores.txt"。

14.3 按字符读写:fgetc 与 fputc

fgetc(fp) 从文件中读取一个字符,读到文件末尾时返回 EOF;fputc(c, fp) 向文件写入一个字符。它们就是文件版的 getchar 和 putchar。

下面的程序统计上一节生成的 hello.txt 有多少个字节和多少行:

c
#include <stdio.h>

int main(void)
{
    FILE *fp = fopen("hello.txt", "r");
    if (fp == NULL) {
        perror("hello.txt");
        return 1;
    }

    int ch;                             // 必须用 int,才能与 EOF 正确比较
    long bytes = 0, lines = 0;
    while ((ch = fgetc(fp)) != EOF) {
        bytes++;
        if (ch == '\n') {
            lines++;
        }
    }
    fclose(fp);

    printf("共 %ld 个字节,%ld 行\n", bytes, lines);
    return 0;
}

运行结果:

text
共 45 个字节,2 行

(第二行中的每个汉字在 UTF-8 编码下占 3 个字节。)

while ((ch = fgetc(fp)) != EOF) 是逐字符读取文件的标准写法:先读取一个字符赋给 ch,再判断它是不是 EOF。注意 ch 必须定义为 int:如果定义为 char,在 char 为无符号的平台上,它永远不会等于 EOF(一个负数),循环将永不结束;在 char 为有符号的平台上,文件中值为 0xFF 的字节存入 char 后通常会变成 -1,又会被误认为 EOF(常见实现中 EOF 就是 -1),导致提前结束。

14.4 按行读写:fgets 与 fputs

fgets(buf, size, fp) 从文件中读取一行(最多 size − 1 个字符),第 9 章已经介绍过它;fputs(s, fp) 把字符串写入文件(与 puts 不同,它不会自动添加换行符)。

按行处理文本文件的标准写法是:

c
char line[256];
while (fgets(line, sizeof(line), fp) != NULL) {
    // 处理这一行
}

如果某一行的长度超过了缓冲区,fgets 会把它分成几次读入,编程时要考虑到这种情况,或者把缓冲区开得足够大。

14.5 格式化读写:fprintf 与 fscanf

fprintf 和 fscanf 的用法与 printf、scanf 完全一样,只是多了第一个参数——文件指针。它们非常适合读写结构化的文本数据:

c
#include <stdio.h>

typedef struct {
    char name[20];
    int age;
    double score;
} Student;

int main(void)
{
    Student students[] = {
        {"张三", 19, 88.5},
        {"李四", 20, 92.0},
        {"王五", 18, 76.5}
    };
    int n = sizeof(students) / sizeof(students[0]);

    FILE *fp = fopen("students.txt", "w");
    if (fp == NULL) {
        perror("students.txt");
        return 1;
    }
    for (int i = 0; i < n; i++) {
        fprintf(fp, "%s %d %.1f\n", students[i].name, students[i].age, students[i].score);
    }
    fclose(fp);

    fp = fopen("students.txt", "r");
    if (fp == NULL) {
        perror("students.txt");
        return 1;
    }
    Student s;
    int count = 0;
    while (fscanf(fp, "%19s %d %lf", s.name, &s.age, &s.score) == 3) {
        count++;
        printf("第 %d 条:%s,%d 岁,%.1f 分\n", count, s.name, s.age, s.score);
    }
    fclose(fp);
    return 0;
}

运行结果:

text
第 1 条:张三,19 岁,88.5 分
第 2 条:李四,20 岁,92.0 分
第 3 条:王五,18 岁,76.5 分

生成的 students.txt 内容如下,人可以直接阅读和修改:

text
张三 19 88.5
李四 20 92.0
王五 18 76.5

循环条件 fscanf(...) == 3 表示"成功读取了 3 项数据",读到文件末尾或者遇到格式不符的数据时,循环就会结束。

14.6 二进制读写:fread 与 fwrite

fwrite 和 fread 直接把内存中的字节原样写入文件、或从文件读入内存:

c
size_t fwrite(const void *ptr, size_t size, size_t count, FILE *fp);
size_t fread(void *ptr, size_t size, size_t count, FILE *fp);

它们从地址 ptr 处开始,读写 count 个大小为 size 字节的数据块,返回实际成功读写的块数。

c
#include <stdio.h>

typedef struct {
    char name[20];
    int age;
    double score;
} Student;

int main(void)
{
    Student out[3] = {{"张三", 19, 88.5}, {"李四", 20, 92.0}, {"王五", 18, 76.5}};

    FILE *fp = fopen("students.dat", "wb");
    if (fp == NULL) {
        perror("students.dat");
        return 1;
    }
    size_t written = fwrite(out, sizeof(Student), 3, fp);   // 一次写入整个数组
    fclose(fp);
    printf("写入了 %zu 条记录,每条 %zu 字节\n", written, sizeof(Student));

    Student in[3];
    fp = fopen("students.dat", "rb");
    if (fp == NULL) {
        perror("students.dat");
        return 1;
    }
    size_t count = fread(in, sizeof(Student), 3, fp);
    fclose(fp);

    for (size_t i = 0; i < count; i++) {
        printf("%s %d %.1f\n", in[i].name, in[i].age, in[i].score);
    }
    return 0;
}

运行结果:

text
写入了 3 条记录,每条 32 字节
张三 19 88.5
李四 20 92.0
王五 18 76.5

文本格式与二进制格式各有优劣:

文本格式二进制格式
可读性好,可以直接查看和编辑差,需要专门的程序读取
读写速度较慢,需要在数字和字符之间转换快,直接复制内存
精度浮点数可能损失精度(取决于写入的位数)完全精确
跨平台好差:不同平台的类型大小、字节序(第 16 章)、结构体填充可能不同

一般来说,需要人阅读、或者需要在不同程序和平台之间交换的数据,用文本格式(如 CSV、JSON);追求速度、且只由同一个程序读写的数据,可以用二进制格式。

14.7 文件定位

每个打开的文件都有一个"当前位置",读写操作从这个位置开始,并使它向后移动。以下函数可以查询和改变当前位置:

函数作用
ftell(fp)返回当前位置。对以二进制模式打开的文件,就是距离文件开头的字节数;对以文本模式打开的文件,返回值只保证能交给 fseek 回到同一位置,不能当作字节数来计算
fseek(fp, offset, origin)把当前位置移动到"起点 + offset"处
rewind(fp)回到文件开头

fseek 的起点 origin 有三种:SEEK_SET(文件开头)、SEEK_CUR(当前位置)、SEEK_END(文件末尾)。例如,求一个文件的大小:

c
FILE *fp = fopen("students.dat", "rb");
if (fp != NULL) {
    fseek(fp, 0, SEEK_END);          // 移动到文件末尾
    long size = ftell(fp);           // 此时的位置就是文件大小
    printf("文件大小:%ld 字节\n", size);   // 输出:文件大小:96 字节
    fclose(fp);
}

对以文本模式打开的文件,由于存在 14.1 节提到的换行符转换等原因,fseek 的 offset 只能是 0,或者是之前某次 ftell 的返回值(这时起点必须用 SEEK_SET)。所以,凡是需要按字节计算位置的场合,都应当像上面的例子这样以二进制模式打开文件。(严格地说,C 标准也不保证二进制模式的文件一定支持以 SEEK_END 为起点定位,不过在 Windows、Linux、macOS 等常见系统上,上面的代码是求文件大小的常用写法。)

对于每条记录大小相同的二进制文件,利用 fseek 可以直接跳到第 k 条记录进行读取,而不必读取前面所有的记录,这叫随机访问。数据库就是基于类似的原理实现快速存取的。

14.8 如何正确判断文件结束

初学者常常这样写读取文件的循环:

c
while (!feof(fp)) {                      // 错误的写法!
    fgets(line, sizeof(line), fp);
    printf("%s", line);
}

这样写,文件的最后一行往往会被输出两次。原因是:feof 检查的是文件的"结束标志",而这个标志只有在某次读取操作碰到了文件末尾时才会被设置,feof 并不能"预知"下一次读取会不会失败。文本文件的最后一行通常以换行符结尾,fgets 读取最后一行时在换行符处就停下了,还没有碰到文件末尾,结束标志没有被设置,于是循环会再执行一次;这一次 fgets 什么也读不到,返回 NULL,而 line 中仍然保留着上一行的内容,于是又被输出了一遍。(如果最后一行末尾没有换行符,fgets 在读取它的同时就碰到了文件末尾,这时反而不会重复输出——同一段代码的行为取决于文件末尾有没有换行符,这本身就说明它不可靠。)如果文件是空的,循环体也会执行一次,输出的是 line 中原有的内容(如果 line 没有初始化,就是不可预料的垃圾内容)。

正确的做法是用读取函数的返回值作为循环条件:

  • fgetc:while ((ch = fgetc(fp)) != EOF)
  • fgets:while (fgets(buf, size, fp) != NULL)
  • fscanf:while (fscanf(fp, ...) == 期望读取的项数)
  • fread:while ((n = fread(buf, size, count, fp)) > 0)

循环结束后,如果需要区分"正常到达文件末尾"和"读取出错",可以再调用 feof(fp) 或 ferror(fp) 进行判断。

14.9 综合示例:文件复制工具

下面结合命令行参数,实现一个简单的文件复制工具。它以二进制模式读写,所以可以复制任何类型的文件(文本、图片、压缩包……):

c
#include <stdio.h>

int main(int argc, char *argv[])
{
    if (argc != 3) {
        fprintf(stderr, "用法:%s 源文件 目标文件\n", argv[0]);
        return 1;
    }

    FILE *src = fopen(argv[1], "rb");
    if (src == NULL) {
        perror(argv[1]);
        return 1;
    }
    FILE *dst = fopen(argv[2], "wb");
    if (dst == NULL) {
        perror(argv[2]);
        fclose(src);                      // 别忘了关闭已经打开的文件
        return 1;
    }

    char buffer[4096];                    // 每次最多读写 4 KB
    size_t n;
    long long total = 0;
    while ((n = fread(buffer, 1, sizeof(buffer), src)) > 0) {
        if (fwrite(buffer, 1, n, dst) != n) {
            perror("写入失败");
            fclose(src);
            fclose(dst);
            return 1;
        }
        total += (long long)n;
    }
    if (ferror(src)) {
        perror("读取失败");
        fclose(src);
        fclose(dst);
        return 1;
    }

    fclose(src);
    if (fclose(dst) != 0) {               // 部分数据可能在关闭时才真正写入磁盘,所以也要检查
        perror(argv[2]);
        return 1;
    }
    printf("复制完成,共 %lld 字节\n", total);
    return 0;
}

编译并运行:

text
$ gcc -Wall -Wextra mycp.c -o mycp
$ ./mycp students.dat backup.dat
复制完成,共 96 字节
$ ./mycp nothing.txt a.txt
nothing.txt: No such file or directory

注意几个细节:每次读写一整块数据(而不是一个字节),效率高得多;fread 返回实际读到的字节数,最后一块往往不满 4096 字节,所以写入时用 n 而不是 sizeof(buffer);出错返回前,要关闭已经打开的文件。

提示
标准输入输出也可以在命令行中"重定向"到文件:./program < input.txt 让程序从 input.txt 读取输入(就像有人在键盘上替你输入了文件内容),./program > output.txt 把输出写入 output.txt。测试需要大量输入的程序时,把测试数据写在文件里再重定向,可以省去反复手动输入的麻烦。

本章练习

  1. 单词计数。 编写程序,统计通过命令行参数指定的文本文件中的行数、单词数和字节数(单词是由空白字符分隔的连续非空白字符)。这就是 Linux 中 wc 命令的功能。
  2. 转换大写。 编写程序 upper 源文件 目标文件,把源文件中的所有小写字母转换为大写,写入目标文件。
  3. 随机读取。 利用 14.6 节生成的 students.dat,输入一个序号 k(从 1 开始),用 fseek 直接定位并读取第 k 条记录,显示出来。序号超出范围时给出提示。
  4. 统计数据文件。 文本文件 numbers.txt 中有若干个整数,以空格或换行分隔(个数未知)。编写程序读取所有整数,输出它们的个数、总和、平均值和最大值。

第 15 章 预处理器与多文件编程

当程序越写越长,把所有代码都塞进一个文件就会变得难以维护。这一章先学习以 # 开头的预处理指令,然后学习如何把一个程序拆分成多个文件,这是编写真实项目的必备技能。

15.1 预处理器是什么

第 1 章介绍过,编译的第一步是预处理。预处理器在真正编译之前对源代码做一些文本层面的处理:把头文件的内容复制进来、把宏替换成对应的文本、根据条件保留或删除某些代码。

预处理指令都以 # 开头,每条指令独占一行,末尾不加分号(它们不是 C 语句)。用 gcc -E 文件名.c 可以查看预处理之后的结果。

15.2 #include:包含头文件

#include 的作用非常简单:把指定文件的全部内容原样复制到当前位置。 它有两种写法:

  • #include <stdio.h>:尖括号,在系统的标准头文件目录中查找,用于标准库的头文件;
  • #include "stats.h":双引号,先在当前源文件所在的目录中查找,找不到再去系统目录,用于你自己编写的头文件。

15.3 #define:宏定义

对象式宏用来定义常量,第 2 章已经用过:

c
#define MAX_STUDENTS 100
#define PI 3.14159265358979

函数式宏可以带参数,看起来像函数,但它只是纯粹的文本替换,这带来了几个经典的陷阱:

c
#include <stdio.h>

#define SQUARE_BAD(x) x * x
#define SQUARE(x) ((x) * (x))
#define DOUBLE_BAD(x) (x) + (x)
#define DOUBLE(x) ((x) + (x))

int main(void)
{
    printf("SQUARE_BAD(1 + 2)  = %d\n", SQUARE_BAD(1 + 2));
    printf("SQUARE(1 + 2)      = %d\n", SQUARE(1 + 2));
    printf("10 * DOUBLE_BAD(3) = %d\n", 10 * DOUBLE_BAD(3));
    printf("10 * DOUBLE(3)     = %d\n", 10 * DOUBLE(3));
    return 0;
}

运行结果:

text
SQUARE_BAD(1 + 2)  = 5
SQUARE(1 + 2)      = 9
10 * DOUBLE_BAD(3) = 33
10 * DOUBLE(3)     = 60
  • SQUARE_BAD(1 + 2) 被替换为 1 + 2 * 1 + 2,由于 * 的优先级高于 +,它相当于 1 + (2 * 1) + 2,结果是 5 而不是 9;
  • 10 * DOUBLE_BAD(3) 被替换为 10 * (3) + (3),结果是 33 而不是 60。

所以编写函数式宏时要遵守两条规则:每个参数都加括号,整个表达式也加括号。

即使括号加全了,还有一个无法避免的问题——参数可能被多次求值。SQUARE(i++) 会被替换为 ((i++) * (i++)),i 被修改了两次,这是未定义行为。因此,不要把带有副作用的表达式(如 `i++`、函数调用)传给宏。

在现代 C 语言中,大多数函数式宏都可以用 `inline` 函数(C99)代替,它既有函数的类型检查,又只对参数求值一次;而且编译器可以把它的代码直接展开到调用处,像宏一样省去函数调用的开销(inline 只是给编译器的建议,是否展开由编译器决定;例如 GCC 在不开启优化时通常不会展开):

c
static inline int square(int x)
{
    return x * x;
}

宏还有两个特殊的运算符:# 把参数变成字符串,## 把两个记号拼接成一个。多行的宏用反斜杠 \ 续行;包含多条语句的宏习惯用 do { ... } while (0) 包裹起来,使它在任何地方都能像一条普通语句那样使用(比如用在没有花括号的 if-else 中也不会出错):

c
#include <stdio.h>

#define PRINT_INT(x) printf(#x " = %d\n", x)          // #x 把传入的实参原样变成字符串
#define SWAP_INT(a, b) do { int t_ = (a); (a) = (b); (b) = t_; } while (0)

int main(void)
{
    int width = 3, height = 5;
    PRINT_INT(width * height);
    SWAP_INT(width, height);
    PRINT_INT(width);
    PRINT_INT(height);
    return 0;
}

运行结果:

text
width * height = 15
width = 5
height = 3

#undef 宏名 可以取消一个宏的定义。宏名习惯上全部使用大写字母,以便和函数、变量区分,提醒使用者"这是宏,要小心"。

15.4 条件编译

条件编译让预处理器根据条件决定保留哪些代码、删除哪些代码。

指令含义
#if 常量表达式表达式为真时保留下面的代码
#elif 常量表达式相当于 else if
#else否则
#endif结束条件编译块
#ifdef 宏名如果定义了这个宏
#ifndef 宏名如果没有定义这个宏
defined(宏名)在 #if 中判断宏是否定义,如 #if defined(A) && !defined(B)

用途一:调试开关。 开发时输出调试信息,发布时一键关闭:

c
#include <stdio.h>

#ifdef DEBUG
    #define LOG(msg) fprintf(stderr, "[DEBUG] %s:%d: %s\n", __FILE__, __LINE__, msg)
#else
    #define LOG(msg) ((void)0)
#endif

int main(void)
{
    LOG("程序开始");
    int sum = 0;
    for (int i = 1; i <= 10; i++) {
        sum += i;
    }
    LOG("计算完成");
    printf("sum = %d\n", sum);
    return 0;
}

正常编译时,LOG 被替换为什么也不做的 ((void)0),程序只输出结果:

text
$ gcc log_demo.c -o log_demo
$ ./log_demo
sum = 55

编译时用 -D 选项定义 DEBUG 宏(效果相当于在代码开头写了 #define DEBUG),调试信息就出现了:

text
$ gcc -DDEBUG log_demo.c -o log_demo
$ ./log_demo
[DEBUG] log_demo.c:11: 程序开始
[DEBUG] log_demo.c:16: 计算完成
sum = 55

用途二:跨平台。 不同的操作系统会预先定义不同的宏,例如 Windows 上的 _WIN32、Linux 上的 __linux__、macOS 上的 __APPLE__。利用它们,可以为不同平台编写不同的代码:

c
#ifdef _WIN32
    #define CLEAR_SCREEN "cls"
#else
    #define CLEAR_SCREEN "clear"
#endif

用途三:临时"注释掉"一大段代码。 用 #if 0 ... #endif 包起来即可,它不像 /* */ 那样会被代码中已有的注释干扰。

15.5 预定义宏

编译器预先定义了一些有用的宏:

宏含义
__FILE__当前源文件名(字符串)
__LINE__当前行号(整数)
__DATE__、__TIME__编译的日期和时间(字符串)
__STDC_VERSION__编译时所用的 C 标准版本:C99 为 199901L,C11 为 201112L,C17 为 201710L,C23 为 202311L

此外,C99 还提供了 __func__,表示当前所在函数的名字(严格来说它是一个预定义的标识符,而不是宏)。这些信息非常适合用在日志和错误报告中,上面的 LOG 宏就用到了 __FILE__ 和 __LINE__。

15.6 为什么要多文件编程

真实的项目动辄有几万甚至几百万行代码,不可能全部写在一个文件里。把程序拆分成多个文件有很多好处:

  • 结构清晰:每个文件负责一个独立的功能模块,找代码、改代码都更容易;
  • 便于协作:不同的人可以同时编辑不同的文件;
  • 便于复用:写好的模块可以直接拿到别的项目中使用;
  • 编译更快:修改一个文件后,只需要重新编译这一个文件,而不是整个项目。

15.7 头文件与源文件

C 语言的多文件编程遵循一个约定:`.h` 头文件放"声明",`.c` 源文件放"定义"。 头文件相当于模块的"说明书",告诉别人这个模块提供了哪些功能;源文件是这些功能的具体实现。

下面把一组统计函数做成一个独立的模块,项目由三个文件组成:

stats.h(模块的接口):

c
#ifndef STATS_H
#define STATS_H

double stats_mean(const double *data, int n);
double stats_max(const double *data, int n);
double stats_min(const double *data, int n);
int stats_call_count(void);

#endif

stats.c(模块的实现):

c
#include "stats.h"

static int call_count = 0;       // static 全局变量:只在本文件中可见

// static 函数:只供本文件内部使用,其他文件无法调用
static double sum_of(const double *data, int n)
{
    double sum = 0.0;
    for (int i = 0; i < n; i++) {
        sum += data[i];
    }
    return sum;
}

double stats_mean(const double *data, int n)
{
    call_count++;
    return sum_of(data, n) / n;
}

double stats_max(const double *data, int n)
{
    call_count++;
    double m = data[0];
    for (int i = 1; i < n; i++) {
        if (data[i] > m) {
            m = data[i];
        }
    }
    return m;
}

double stats_min(const double *data, int n)
{
    call_count++;
    double m = data[0];
    for (int i = 1; i < n; i++) {
        if (data[i] < m) {
            m = data[i];
        }
    }
    return m;
}

int stats_call_count(void)
{
    return call_count;
}

main.c(使用这个模块):

c
#include <stdio.h>
#include "stats.h"

int main(void)
{
    double scores[] = {88.5, 92.0, 76.5, 95.0, 81.0};
    int n = sizeof(scores) / sizeof(scores[0]);

    printf("平均分:%.2f\n", stats_mean(scores, n));
    printf("最高分:%.1f\n", stats_max(scores, n));
    printf("最低分:%.1f\n", stats_min(scores, n));
    printf("统计函数共被调用 %d 次\n", stats_call_count());
    return 0;
}

编译和运行:

text
$ gcc -Wall -Wextra main.c stats.c -o stats_demo
$ ./stats_demo
平均分:86.60
最高分:95.0
最低分:76.5
统计函数共被调用 3 次

这个例子中有几个关键点:

1. 头文件保护。 stats.h 的内容被包在 #ifndef STATS_H ... #endif 中。第一次包含它时,STATS_H 还没有定义,于是定义它并保留全部内容;如果同一个源文件由于某种原因(比如间接包含)再次包含了它,STATS_H 已经定义过,内容就会被整个跳过,从而避免重复定义的错误。每个头文件都应该有头文件保护。 很多编译器还支持更简洁的 #pragma once,它虽然不是 C 标准的一部分,但主流编译器都支持。

2. 源文件也要包含自己的头文件。 stats.c 开头包含了 stats.h,这样编译器就能检查函数的声明和定义是否一致。

3. 用 `static` 隐藏内部细节。 sum_of 函数和 call_count 变量都用 static 修饰,它们只在 stats.c 内部可见,main.c 无法直接访问。外部想知道调用次数,只能通过 stats_call_count() 函数。这就是信息隐藏:模块只公开必要的接口,内部实现可以随时修改而不影响使用者。给公开的函数加上统一的前缀(如 stats_),也能避免与其他模块的函数重名。

15.8 extern:跨文件共享全局变量

如果确实需要一个在多个文件中共享的全局变量,正确的做法是:在一个 `.c` 文件中定义它,在头文件中用 `extern` 声明它。

c
// config.h
extern int g_verbose;       // 声明:告诉编译器"这个变量在别处定义",不分配内存

// config.c
#include "config.h"
int g_verbose = 0;          // 定义:真正分配内存,整个程序中只能出现一次

其他文件只要包含 config.h,就可以使用 g_verbose。

声明与定义的区别是理解多文件编程的关键:声明只是告诉编译器"有这么一个东西",可以出现多次;定义则真正创建它(为变量分配内存、给出函数体),在整个程序中只能出现一次。所以,不要在头文件中定义变量或函数(static inline 函数除外),否则每个包含该头文件的 .c 文件都会有一份定义,链接时就会报"重复定义"的错误。

15.9 编译、链接与常见错误

多文件项目可以一条命令编译:gcc main.c stats.c -o stats_demo。它实际上分别编译了每个 .c 文件,再把结果链接起来。也可以手动分步进行:

bash
$ gcc -Wall -Wextra -c main.c      # 编译 main.c,生成目标文件 main.o
$ gcc -Wall -Wextra -c stats.c     # 编译 stats.c,生成目标文件 stats.o
$ gcc main.o stats.o -o stats_demo # 把两个目标文件链接成可执行程序

分步编译的好处是:修改了 stats.c 之后,只需要重新编译 stats.c 并重新链接,main.o 可以直接复用。

多文件编程中最常见的两个链接错误:

错误信息常见原因
undefined reference to 'stats_mean'函数只有声明、没有定义:忘了把 stats.c 加入编译命令,或者函数名拼写不一致
multiple definition of 'g_verbose'同一个变量或函数被定义了多次:通常是在头文件中定义了变量

注意这两个错误都发生在链接阶段(报错信息中往往带有 ld,即链接器),而不是编译阶段:每个 .c 文件单独编译都没有问题,把它们组合在一起时才发现问题。

15.10 用 Makefile 自动构建

文件一多,每次都手动输入一长串编译命令就很麻烦了,而且还要自己记住哪些文件改过、需要重新编译。make 工具可以根据一个名为 Makefile 的文件自动完成这些工作:

makefile
CC = gcc
CFLAGS = -Wall -Wextra -std=c11 -g

stats_demo: main.o stats.o
	$(CC) $(CFLAGS) -o stats_demo main.o stats.o

main.o: main.c stats.h
	$(CC) $(CFLAGS) -c main.c

stats.o: stats.c stats.h
	$(CC) $(CFLAGS) -c stats.c

clean:
	rm -f stats_demo *.o

Makefile 由一条条规则组成,每条规则的格式是:

text
目标: 依赖文件
<Tab>生成目标的命令

以 main.o: main.c stats.h 为例,它的意思是:"main.o 依赖于 main.c 和 stats.h,如果这两个文件中任何一个比 main.o 新(也就是被修改过),就执行下一行的命令重新生成 main.o。"

在项目目录中输入 make,它就会构建第一个目标 stats_demo,并自动判断哪些文件需要重新编译;输入 make clean 则删除所有生成的文件。

注意
Makefile 中命令行前面的缩进必须是一个 Tab 字符,不能用空格,否则会报错 missing separator。在 Windows 上,MSYS2 中可以通过 pacman 安装 make(MinGW 工具链中的命令名可能是 mingw32-make),另外 Windows 的命令提示符中没有 rm 命令,需要改用 del。

对于更大的、需要跨平台的项目,业界普遍使用 CMake 来生成构建文件,CLion、Visual Studio、VS Code 都对它有很好的支持。学会了 Makefile 的基本原理,以后再学 CMake 就会很容易。

本章练习

  1. 宏的陷阱。 编写宏 MAX(a, b) 和 ABS(x),注意正确使用括号。然后思考:执行 int i = 5, j = 3; int m = MAX(i++, j); 之后,i 和 m 的值分别是多少?为什么说这种用法很危险?如果改用 inline 函数还会有问题吗?
  2. 断言宏。 编写一个宏 CHECK(cond):当条件 cond 不成立时,输出文件名、行号和条件本身的文字(例如 检查失败:main.c:12: x > 0),但不终止程序。(提示:用 # 运算符把参数转换为字符串。)
  3. 多文件项目。 把第 7 章练习中的 is_prime、gcd、lcm 函数整理成一个模块:mymath.h 中写声明,mymath.c 中写实现,main.c 中调用它们。写出分步编译的命令,并为它编写一个 Makefile。

第 16 章 位运算与底层视角

C 语言的一大特点是能够直接操作数据的二进制位。这在嵌入式开发(操作硬件寄存器)、网络协议、加密压缩、图形处理、算法优化等领域都有广泛应用。这一章我们深入学习位运算,并借此看一看数据在内存中的真实面貌。

16.1 六种位运算符

第 4 章已经初步认识了位运算符,这里再系统地看一遍。以 a = 12、b = 10 为例,为了方便观察,用 8 位二进制表示:

c
#include <stdio.h>

void print_bits8(unsigned int x)          // 输出 x 的低 8 位
{
    for (int i = 7; i >= 0; i--) {
        putchar(((x >> i) & 1u) ? '1' : '0');
    }
}

void show(unsigned int x, const char *label)
{
    print_bits8(x);
    printf("  %3u  %s\n", x & 0xFFu, label);
}

int main(void)
{
    unsigned int a = 12, b = 10;
    show(a, "a");
    show(b, "b");
    show(a & b, "a & b(按位与)");
    show(a | b, "a | b(按位或)");
    show(a ^ b, "a ^ b(按位异或)");
    show(~a, "~a(按位取反,只看低 8 位)");
    show(a << 2, "a << 2(左移 2 位)");
    show(a >> 2, "a >> 2(右移 2 位)");
    return 0;
}

运行结果:

text
00001100   12  a
00001010   10  b
00001000    8  a & b(按位与)
00001110   14  a | b(按位或)
00000110    6  a ^ b(按位异或)
11110011  243  ~a(按位取反,只看低 8 位)
00110000   48  a << 2(左移 2 位)
00000011    3  a >> 2(右移 2 位)

各运算符的规则:

运算符规则形象的理解
& 按位与两位都是 1,结果才是 1用来"筛选"某些位:和 0 与,结果为 0;和 1 与,保持不变
| 按位或只要有一位是 1,结果就是 1用来"打开"某些位:和 1 或,结果为 1;和 0 或,保持不变
^ 按位异或两位不同为 1,相同为 0用来"翻转"某些位:和 1 异或,翻转;和 0 异或,保持不变
~ 按位取反0 变 1,1 变 0所有位全部翻转
<< 左移所有位左移,右边补 0左移 n 位,相当于乘以 2ⁿ(不溢出时)
>> 右移所有位右移对非负数,右移 n 位相当于除以 2ⁿ(向下取整)

使用位运算的注意事项:

  1. 位运算尽量使用无符号类型。 对负数进行左移是未定义行为;对负数右移时,左边补 0 还是补符号位,由编译器决定(主流编译器补符号位)。使用 unsigned int 可以避开这些问题,这也是本章的代码都使用无符号类型、并在常量后加 u 的原因。
  2. 移位的位数不能是负数,也不能大于或等于类型的位数。 例如对 32 位的 unsigned int 执行 x << 32 是未定义行为,结果并不一定是 0。
  3. 小心优先级。 移位运算的优先级低于加减,&、^、| 的优先级低于比较运算符,所以 x & 1 == 0 实际上是 x & (1 == 0)。使用位运算时,请多加括号。

16.2 常用的位运算技巧

设 k 是位的编号(最低位为第 0 位):

操作表达式
判断奇偶n & 1u,结果为 1 是奇数,为 0 是偶数
取出第 k 位(n >> k) & 1u
将第 k 位置 1n | (1u << k)
将第 k 位清 0n & ~(1u << k)
翻转第 k 位n ^ (1u << k)
去掉最低位的 1n & (n - 1)
只保留最低位的 1n & (~n + 1)
判断是否为 2 的幂n != 0 && (n & (n - 1)) == 0
c
#include <stdio.h>
#include <stdbool.h>

void print_bits8(unsigned int x)
{
    for (int i = 7; i >= 0; i--) {
        putchar(((x >> i) & 1u) ? '1' : '0');
    }
}

void show(unsigned int x, const char *label)
{
    print_bits8(x);
    printf("  %3u  %s\n", x & 0xFFu, label);
}

bool is_power_of_two(unsigned int n)
{
    return n != 0 && (n & (n - 1)) == 0;
}

int main(void)
{
    unsigned int n = 44;
    show(n, "n 本身");
    show(n | (1u << 0), "将第 0 位置 1:n | (1u << 0)");
    show(n & ~(1u << 5), "将第 5 位清 0:n & ~(1u << 5)");
    show(n ^ (1u << 7), "将第 7 位翻转:n ^ (1u << 7)");
    show(n & (n - 1), "去掉最低位的 1:n & (n - 1)");
    show(n & (~n + 1), "只保留最低位的 1:n & (~n + 1)");
    printf("n 的第 3 位是 %u\n", (n >> 3) & 1u);
    printf("44 是 2 的幂吗?%s\n", is_power_of_two(44) ? "是" : "否");
    printf("64 是 2 的幂吗?%s\n", is_power_of_two(64) ? "是" : "否");
    return 0;
}

运行结果:

text
00101100   44  n 本身
00101101   45  将第 0 位置 1:n | (1u << 0)
00001100   12  将第 5 位清 0:n & ~(1u << 5)
10101100  172  将第 7 位翻转:n ^ (1u << 7)
00101000   40  去掉最低位的 1:n & (n - 1)
00000100    4  只保留最低位的 1:n & (~n + 1)
n 的第 3 位是 1
44 是 2 的幂吗?否
64 是 2 的幂吗?是

n & (n - 1) 为什么能去掉最低位的 1?因为 n - 1 会把 n 最低位的那个 1 变成 0,并把它右边的 0 全部变成 1,而更高的位保持不变。二者相与,最低位的 1 及其右边的位全部变成 0,其余位不变。2 的幂的二进制表示中只有一个 1,去掉它就变成了 0,这就是判断 2 的幂的原理。

16.3 实战:用标志位表示权限

一个整数有 32 个二进制位,每一位都可以表示一个"开/关"状态,这样一个整数就能同时记录 32 个布尔值,既节省空间,又便于整体传递和比较。这种用法叫标志位(flags)或位掩码(bit mask),在操作系统、图形库和硬件编程中随处可见。

c
#include <stdio.h>

#define PERM_READ  (1u << 2)    // 100:读
#define PERM_WRITE (1u << 1)    // 010:写
#define PERM_EXEC  (1u << 0)    // 001:执行

void print_perm(unsigned int perm)
{
    printf("%c%c%c\n",
           (perm & PERM_READ)  ? 'r' : '-',
           (perm & PERM_WRITE) ? 'w' : '-',
           (perm & PERM_EXEC)  ? 'x' : '-');
}

int main(void)
{
    unsigned int perm = PERM_READ | PERM_WRITE;   // 同时拥有读、写权限
    print_perm(perm);

    perm |= PERM_EXEC;                            // 添加执行权限
    print_perm(perm);

    perm &= ~PERM_WRITE;                          // 移除写权限
    print_perm(perm);

    if (perm & PERM_READ) {                       // 检查是否有读权限
        printf("拥有读权限\n");
    }
    return 0;
}

运行结果:

text
rw-
rwx
r-x
拥有读权限

用 | 组合或添加标志,用 & ~ 移除标志,用 & 检查标志——这三个操作请牢牢记住。Linux 的文件权限就是这样表示的:chmod 755 file 中的 7(二进制 111)表示所有者有读、写、执行权限,5(二进制 101)表示同组用户和其他用户有读和执行权限——读、写、执行分别对应 4、2、1,与上面程序中的定义完全相同。

16.4 异或的妙用

异或运算有几个优美的性质:a ^ a = 0,a ^ 0 = a,并且满足交换律和结合律。利用它们可以解决一些有趣的问题。

找出唯一出现一次的数。 一个数组中,除了一个数只出现一次,其他数都恰好出现两次,找出这个数:

c
int find_single(const int *arr, int n)
{
    int result = 0;
    for (int i = 0; i < n; i++) {
        result ^= arr[i];      // 成对的数异或后相互抵消为 0
    }
    return result;             // 对于 {4, 1, 2, 1, 2},结果为 4
}

不用临时变量交换两个整数:a ^= b; b ^= a; a ^= b;。这个技巧广为流传,但不建议在实际代码中使用:它的可读性差,也不比使用临时变量更快;而且当 a 和 b 是同一个变量(比如通过指针指向同一处内存)时,结果会变成 0。

16.5 定长整数类型

第 2 章说过,int、long 等类型的大小在不同平台上可能不同。在嵌入式、网络通信、文件格式等需要精确控制数据大小的场合,应该使用 C99 在 <stdint.h> 中提供的定长整数类型:

类型含义
int8_t、int16_t、int32_t、int64_t恰好 8/16/32/64 位的有符号整数
uint8_t、uint16_t、uint32_t、uint64_t恰好 8/16/32/64 位的无符号整数
intptr_t、uintptr_t能够容纳指针值的整数类型

输出这些类型时,要使用 <inttypes.h> 中定义的格式宏:

c
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>

int main(void)
{
    int32_t a = -123456;
    uint8_t b = 255;
    uint64_t c = UINT64_MAX;
    printf("a = %" PRId32 "\n", a);
    printf("b = %" PRIu8 "\n", b);
    printf("c = %" PRIu64 "\n", c);
    return 0;
}

运行结果:

text
a = -123456
b = 255
c = 18446744073709551615

PRId32 等宏会被替换为对应平台上正确的格式字符串(比如 "d" 或 "ld"),再与前后的字符串自动拼接起来。写法看起来有些奇怪,但这是保证跨平台正确性的标准做法。

16.6 字节序:大端与小端

一个 int 占 4 个字节,那么在内存中,这 4 个字节是按什么顺序存放的呢?以 0x12345678 为例,有两种存放方式:

text
                  地址:  低  ──────────────▶  高
大端序(Big-Endian):    12    34    56    78     高位字节存放在低地址
小端序(Little-Endian): 78    56    34    12     低位字节存放在低地址

我们常用的 x86 电脑和绝大多数 ARM 设备(包括手机)都是小端序。可以用一个程序来验证:

c
#include <stdio.h>

int main(void)
{
    unsigned int x = 0x12345678;
    unsigned char *p = (unsigned char *)&x;    // 用字节指针逐个查看 x 的每个字节

    printf("x = 0x%X\n", x);
    for (size_t i = 0; i < sizeof(x); i++) {
        printf("第 %zu 个字节:0x%02X\n", i, p[i]);
    }
    printf("这台电脑是%s\n", (p[0] == 0x78) ? "小端序" : "大端序");
    return 0;
}

在 x86 电脑上的运行结果:

text
x = 0x12345678
第 0 个字节:0x78
第 1 个字节:0x56
第 2 个字节:0x34
第 3 个字节:0x12
这台电脑是小端序

这个程序也展示了指针类型的作用:同一个地址,用 unsigned int * 去读是一个 4 字节的整数,用 unsigned char * 去读则是一个个单独的字节。用字符类型的指针查看任何对象的字节,都是 C 标准允许的做法。

字节序在网络通信和二进制文件中非常重要:一台小端序的电脑直接把整数的内存发送出去,大端序的电脑收到后就会解读错误。因此,网络协议统一规定使用大端序(称为网络字节序),发送和接收时都要进行转换。这也是第 14 章说二进制文件"跨平台性差"的原因之一。

16.7 浮点数的二进制表示(选读)

现代计算机普遍采用 IEEE 754 标准存储浮点数。一个 32 位的 float 由三部分组成:

text
 31  30        23 22                                    0
+---+------------+---------------------------------------+
| S |  指数(8位) |              尾数(23位)                |
+---+------------+---------------------------------------+

它表示的值为 (−1)^S × 1.尾数 × 2^(指数 − 127),思路就是二进制的科学计数法。

以 5.75 为例:5.75 的二进制是 101.11,写成科学计数法是 1.0111 × 2²。所以符号位 S = 0,指数 = 2 + 127 = 129 = 10000001,尾数取小数点后面的部分 0111,后面补 0。

下面的程序把 float 的各个部分显示出来:

c
#include <stdio.h>
#include <stdint.h>
#include <string.h>

void print_field(uint32_t value, int bits)
{
    for (int i = bits - 1; i >= 0; i--) {
        putchar(((value >> i) & 1u) ? '1' : '0');
    }
}

void print_float_bits(float f)
{
    uint32_t bits;
    memcpy(&bits, &f, sizeof bits);      // 把 float 的 4 个字节原样复制到整数中

    printf("%-6g 符号 ", f);
    print_field(bits >> 31, 1);
    printf(" | 指数 ");
    print_field((bits >> 23) & 0xFFu, 8);
    printf(" | 尾数 ");
    print_field(bits & 0x7FFFFFu, 23);
    printf("\n");
}

int main(void)
{
    print_float_bits(5.75f);
    print_float_bits(-1.0f);
    print_float_bits(0.1f);
    return 0;
}

运行结果:

text
5.75   符号 0 | 指数 10000001 | 尾数 01110000000000000000000
-1     符号 1 | 指数 01111111 | 尾数 00000000000000000000000
0.1    符号 0 | 指数 01111011 | 尾数 10011001100110011001101

注意 0.1 的尾数:1001 1001 1001 ... 是一个无限循环的二进制小数,23 位存不下,最后只能舍入。这就是第 2 章中 0.1 + 0.2 != 0.3 的根本原因。double 有 52 位尾数,精度更高,但同样无法精确表示 0.1。

程序中用 memcpy 把 float 的字节复制到整数中,这是查看浮点数内部表示的安全写法。直接写 *(uint32_t *)&f 虽然在很多编译器上也能工作,但违反了 C 语言的"严格别名"规则,属于未定义行为。

本章练习

  1. 数 1 的个数。 编写函数 int count_ones(unsigned int n),统计 n 的二进制表示中有多少个 1。请用两种方法实现:逐位检查;以及利用 n & (n - 1) 的方法(循环次数等于 1 的个数)。
  2. 2 的幂。 编写函数,求不小于 n 的最小的 2 的幂(n ≥ 1)。例如输入 5 输出 8,输入 16 输出 16。
  3. 反转二进制位。 编写函数 uint32_t reverse_bits(uint32_t n),把一个 32 位无符号整数的二进制位顺序反转(第 0 位与第 31 位交换,第 1 位与第 30 位交换……)。例如 0x00000001 反转后为 0x80000000。
  4. 位集合。 用一个 uint32_t 表示集合 {0, 1, …, 31} 的一个子集(第 k 位为 1 表示 k 在集合中)。实现函数:添加元素、删除元素、判断元素是否存在、输出集合的全部元素。再用 | 和 & 求两个集合的并集和交集。

第 17 章 调试、错误处理与编程规范

会写代码只是第一步,能快速找出并修正错误、写出健壮且易读的代码,才是一名合格的程序员。这一章介绍调试的方法和工具、错误处理的基本套路,并总结 C 语言的编程规范。

17.1 与编译器做朋友:重视警告

编译器的警告是你最廉价、最及时的"代码审查员"。看看下面这段代码,它能通过编译,却藏着好几个问题:

c
#include <stdio.h>

int square(int x)
{
    int result = x * x;
}

int main(void)
{
    int n;
    double avg = 3.5;
    unsigned int count = 10;
    int unused = 0;

    if (n = 5) {
        printf("%d\n", avg);
    }
    for (int i = 0; i < count; i++) {
    }
    printf("%d\n", square(3));
    return 0;
}

用 gcc -Wall -Wextra 编译,会得到以下警告(已省略代码引用部分):

text
warn.c: In function 'square':
warn.c:5:9: warning: unused variable 'result' [-Wunused-variable]
warn.c: In function 'main':
warn.c:15:9: warning: suggest parentheses around assignment used as truth value [-Wparentheses]
warn.c:16:18: warning: format '%d' expects argument of type 'int', but argument 2 has type 'double' [-Wformat=]
warn.c:18:23: warning: comparison of integer expressions of different signedness: 'int' and 'unsigned int' [-Wsign-compare]
warn.c:13:9: warning: unused variable 'unused' [-Wunused-variable]
warn.c: In function 'square':
warn.c:6:1: warning: control reaches end of non-void function [-Wreturn-type]

每一条都对应一个真实的问题:square 计算了结果却忘了 return;if (n = 5) 把比较写成了赋值;printf 用 %d 输出 double;有符号数与无符号数混合比较……如果不开启警告,这些问题只能等到程序运行出错时才能发现,而那时排查起来要困难得多。

建议:

  • 始终使用 -Wall -Wextra 编译,并努力做到零警告;
  • 可以加上 -Werror,把所有警告都当作错误,强制自己修正;
  • 在 IDE 中也打开相应的警告选项(Visual Studio 中可以把警告等级设为 /W4)。

17.2 错误的分类

类型发生时机典型例子如何发现
编译错误编译时语法错误、拼写错误、类型不匹配编译器报 error
链接错误链接时函数未定义、重复定义链接器报错,信息中常带有 ld 字样
运行时错误运行时段错误、除以 0、内存错误程序崩溃,或被检测工具发现
逻辑错误运行时算法写错、边界条件错误结果不对。最难发现,需要测试和调试

编译错误和链接错误有编译器帮你指出位置,相对容易解决(常见的错误信息见附录 F)。真正考验功力的是运行时错误和逻辑错误。

17.3 printf 调试法

最朴素、也最常用的调试方法,就是在关键位置输出变量的值,观察程序的实际行为与预期在哪里出现了偏差:

c
fprintf(stderr, "[调试] i = %d, sum = %d\n", i, sum);

建议把调试信息输出到 stderr,原因有二:一是方便与程序的正常输出区分开;二是 stderr 默认不带缓冲,而 stdout 的输出可能先暂存在缓冲区里,如果程序在输出之后很快崩溃,缓冲区中的内容就会丢失,你会误以为程序根本没有执行到那一行。结合第 15 章的条件编译,还可以用一个 DEBUG 宏来统一开关所有的调试输出。

printf 调试法简单直接,但需要反复修改代码、重新编译。对于复杂的问题,调试器是更强大的工具。

17.4 使用调试器 GDB

调试器可以让程序在指定的位置暂停,然后一行一行地执行,并随时查看变量的值。GDB 是 Linux 上最常用的调试器(MSYS2 的 MinGW 工具链中也包含它;macOS 上对应的调试器是 LLDB,用法类似)。

以下面这个求阶乘的程序为例,它的输出结果是错误的:

c
#include <stdio.h>

long long factorial(int n)
{
    long long result = 0;
    for (int i = 1; i <= n; i++) {
        result *= i;
    }
    return result;
}

int main(void)
{
    int n = 5;
    long long f = factorial(n);
    printf("%d! = %lld\n", n, f);
    return 0;
}

运行结果:

text
5! = 0

用 GDB 来找出原因。首先,编译时要加上 -g(生成调试信息)和 -O0(关闭优化,让程序严格按照源代码的顺序执行):

text
$ gcc -g -O0 fact.c -o fact
$ gdb ./fact
(gdb) break factorial
Breakpoint 1 at 0x1154: file fact.c, line 5.
(gdb) run
Breakpoint 1, factorial (n=5) at fact.c:5
5           long long result = 0;
(gdb) next
6           for (int i = 1; i <= n; i++) {
(gdb) print result
$1 = 0
(gdb) display result
1: result = 0
(gdb) next
7               result *= i;
1: result = 0
(gdb) next
6           for (int i = 1; i <= n; i++) {
1: result = 0
(gdb) info locals
i = 1
result = 0

执行完第一次乘法(result *= 1)之后,result 仍然是 0。问题一目了然:result 的初始值是 0,而 0 乘以任何数都是 0,初始值应该是 1。

GDB 的常用命令:

命令(缩写)作用
break 位置(b)设置断点,位置可以是函数名、行号(如 b 12)或"文件名:行号"
run(r)开始运行程序,遇到断点时暂停
next(n)执行下一行,遇到函数调用时不进入函数内部
step(s)执行下一行,遇到函数调用时进入函数内部
continue(c)继续运行,直到遇到下一个断点
finish运行到当前函数返回为止
print 表达式(p)显示变量或表达式的值,如 p arr[2]、p *ptr、p x + y
display 表达式每次暂停时都自动显示这个表达式的值
info locals显示当前函数中所有局部变量的值
backtrace(bt)显示函数调用栈:当前在哪个函数里,它又是被谁调用的
watch 变量设置监视点,变量的值一旦改变就暂停
list(l)显示当前位置附近的源代码
quit(q)退出 GDB

用 GDB 定位崩溃位置。 程序崩溃(如段错误)时,在 GDB 中运行它,GDB 会停在崩溃的地方,再用 backtrace 查看调用栈,就能知道程序是在哪里、经过怎样的调用路径崩溃的:

c
#include <stdio.h>
#include <string.h>

size_t count_chars(const char *s)
{
    return strlen(s);
}

int main(void)
{
    const char *names[] = {"Alice", NULL, "Bob"};
    for (int i = 0; i < 3; i++) {
        printf("%zu\n", count_chars(names[i]));
    }
    return 0;
}
text
$ gcc -g -O0 crash.c -o crash
$ gdb ./crash
(gdb) run
5

Program received signal SIGSEGV, Segmentation fault.
__strlen_evex () at ../sysdeps/x86_64/multiarch/strlen-evex-base.S:81
(gdb) backtrace
#0  __strlen_evex () at ../sysdeps/x86_64/multiarch/strlen-evex-base.S:81
#1  0x00005555555551a5 in count_chars (s=0x0) at crash.c:6
#2  0x00005555555551fb in main () at crash.c:13

从下往上读调用栈:main 在第 13 行调用了 count_chars,count_chars 在第 6 行调用了 strlen,而传入的参数 s=0x0 是一个空指针——strlen 对空指针解引用,于是崩溃了。第 0 帧位于标准库内部,那里通常没有问题,你应该关注的是自己代码中的第 1、2 帧。

图形化调试。 Visual Studio、VS Code、CLion 等 IDE 都提供了图形化的调试界面:在代码行号左侧点击即可设置断点,用工具栏按钮进行单步执行,在"监视"或"变量"窗口中查看变量的值。它们的底层原理与 GDB 完全相同,学会了 GDB 的概念,用图形界面会更加得心应手。

提示
调试的核心思路是"缩小范围"。先确定问题出现在哪一段代码(二分法:在中间设断点,看问题出现在前半段还是后半段),再在这段代码中逐行观察,找到"变量的实际值第一次和你的预期不一致"的地方。

17.5 断言 assert

<assert.h> 中的 assert(条件) 用于检查"按道理绝对应该成立"的条件。条件不成立时,它会输出出错的文件、行号、函数和条件内容,然后立即终止程序:

c
#include <stdio.h>
#include <assert.h>

double average(const int *arr, int n)
{
    assert(arr != NULL);
    assert(n > 0);                        // 调用者必须保证 n > 0
    long long sum = 0;
    for (int i = 0; i < n; i++) {
        sum += arr[i];
    }
    return (double)sum / n;
}

int main(void)
{
    int scores[] = {90, 85, 77};
    printf("%.2f\n", average(scores, 3));
    printf("%.2f\n", average(scores, 0)); // 违反了前提条件
    return 0;
}

运行结果:

text
84.00
assert_demo: assert_demo.c:7: average: Assertion `n > 0' failed.
Aborted (core dumped)

断言能让错误在最早的地方暴露出来,而不是等到很久以后才以奇怪的形式出现。编译时定义 NDEBUG 宏(-DNDEBUG)可以关闭所有断言,所以断言只用于发现程序员的错误(比如函数的前提条件被违反),不能用来处理用户输入错误、文件不存在等运行时可能正常发生的情况——这些情况必须用 if 语句认真处理。

17.6 错误处理的基本方法

C 语言没有其他语言中的"异常"机制,错误处理主要依靠检查返回值。

1. 检查每一个可能失败的函数调用。 fopen、malloc 可能返回 NULL,scanf 可能读取失败,fclose、fwrite 也可能出错。至少要检查那些失败后会导致后续操作出问题的调用。

2. 利用 `errno` 获取错误原因。 许多标准库函数在失败时会把错误代码存入全局变量 errno(在 <errno.h> 中),perror 会根据它输出错误描述,strerror(errno)(在 <string.h> 中)则返回描述错误的字符串,可以用在自己组织的错误信息中。

3. 设计自己的函数时,明确地报告错误。 常见的约定是:返回 0 表示成功,返回非 0 值(或负数)表示失败;真正的结果通过指针参数带回。比如:

c
// 成功返回 0,并通过 result 带回结果;失败返回 -1
int parse_score(const char *text, int *result);

4. 出错时集中释放资源。 当一个函数中依次申请了多个资源(打开文件、分配内存……),任何一步失败都需要释放之前已经申请的资源,处理不当就会遗漏。C 语言中一种被广泛接受的写法是用 goto 跳转到函数末尾统一清理,Linux 内核中大量使用这种写法:

c
int process_file(const char *in_path, const char *out_path)
{
    int status = -1;          // 默认为失败
    FILE *in = NULL;
    FILE *out = NULL;
    char *buffer = NULL;

    in = fopen(in_path, "r");
    if (in == NULL) {
        perror(in_path);
        goto cleanup;
    }
    out = fopen(out_path, "w");
    if (out == NULL) {
        perror(out_path);
        goto cleanup;
    }
    buffer = malloc(4096);
    if (buffer == NULL) {
        fprintf(stderr, "内存不足\n");
        goto cleanup;
    }

    // ……正常的处理逻辑……
    status = 0;               // 全部成功

cleanup:
    free(buffer);             // free(NULL) 是安全的
    if (out != NULL) {
        fclose(out);
    }
    if (in != NULL) {
        fclose(in);
    }
    return status;
}

所有资源的指针都先初始化为 NULL,这样在 cleanup 部分就可以统一判断和释放,不必关心是在哪一步失败的。

5. 结束程序。 在 main 中可以用 return 结束程序;在其他函数中,可以调用 exit(状态码)(在 <stdlib.h> 中)立即结束整个程序。状态码可以使用 <stdlib.h> 中定义的 EXIT_SUCCESS 和 EXIT_FAILURE。

17.7 未定义行为清单

我们在前面的章节中陆续遇到了许多未定义行为。它们的共同特点是:C 标准对结果不作任何规定,程序可能崩溃,可能输出错误的结果,也可能"看起来完全正常"——直到换了一个编译器、换了一个优化级别、或者换了一组输入数据。下面把最常见的未定义行为汇总在一起:

未定义行为示例
数组越界访问int a[5]; a[5] = 1;
解引用空指针或野指针int *p = NULL; *p = 1;
使用已释放的内存free(p); *p = 1;
重复释放同一块内存free(p); free(p);
使用未初始化变量的值int x; printf("%d", x);
有符号整数溢出int x = INT_MAX; x + 1;
整数除以 0int x = 5 / 0;
修改字符串字面量char *s = "hi"; s[0] = 'H';
在一个表达式中修改某个变量,又在没有先后顺序保证的位置修改或读取它i = i++;(两次修改)、a[i] = i++;(一次修改、一次读取)
移位位数为负数,或大于等于类型的位数1 << 32(int 为 32 位时)
格式说明符与参数类型不匹配printf("%d", 3.14);
使用未 return 的函数返回值非 void 函数没有执行 return,而调用者使用了返回值
返回局部变量的地址并使用int *f(void) { int x; return &x; }

防御未定义行为的武器:开启编译器警告;使用 -fsanitize=address,undefined 进行测试;以及,心中时刻记得这张清单。

17.8 编程规范与好习惯

代码是写给人看的,只是顺便能被机器执行。好的代码风格能大大降低出错的概率,也让别人(包括几个月后的你自己)更容易读懂。以下是一些被广泛认可的规范:

命名

  • 变量名和函数名使用小写字母加下划线(如 student_count、find_max);
  • 宏和常量全部大写(如 MAX_SIZE);
  • 自定义类型名可以首字母大写(如 Student、IntVector);
  • 名字要见名知意,避免 a、b、temp1 之类的名字(循环变量 i、j 除外);
  • 返回布尔值的函数用 is_、has_ 开头。

格式

  • 统一缩进(通常为 4 个空格),不要混用 Tab 和空格;
  • 大括号风格保持一致;
  • `if`、`for`、`while` 的语句体始终使用花括号,即使只有一行;
  • 一行只写一条语句;
  • 运算符两侧、逗号后面加空格,让代码"透气";
  • 可以使用 clang-format 等工具自动格式化代码。

结构

  • 函数短小,只做一件事;
  • 避免使用"魔法数字",用有名字的常量代替;
  • 尽量少用全局变量;
  • 变量在第一次使用时定义,并立即初始化;
  • 函数不修改的指针参数加上 const。

健壮性

  • 检查所有可能失败的函数调用;
  • 对用户输入进行合法性检查;
  • 申请的资源(内存、文件)一定要释放;
  • 永远不要使用 gets;使用 scanf 读取字符串时限制长度;优先使用 fgets、snprintf 等有长度限制的函数;
  • 注意整数溢出和数组边界。

注释

  • 注释解释"为什么",而不是重复代码"做了什么";
  • 在函数前说明它的功能、参数含义、返回值,以及调用者需要注意的事项(比如"返回的内存由调用者释放");
  • 修改代码时,同步更新注释——过时的注释比没有注释更糟糕。

看一个对比。 下面这段代码能正确运行,但几乎无法阅读:

c
#include <stdio.h>
int main(){int a[100],n,i,j,t;scanf("%d",&n);
for(i=0;i<n;i++)scanf("%d",&a[i]);
for(i=0;i<n-1;i++)for(j=0;j<n-1-i;j++)if(a[j]>a[j+1]){t=a[j];a[j]=a[j+1];a[j+1]=t;}
for(i=0;i<n;i++)printf("%d ",a[i]);return 0;}

按照规范改写后:

c
#include <stdio.h>

#define MAX_COUNT 100

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;
            }
        }
    }
}

int main(void)
{
    int numbers[MAX_COUNT];
    int count;

    if (scanf("%d", &count) != 1 || count < 1 || count > MAX_COUNT) {
        fprintf(stderr, "个数必须在 1~%d 之间\n", MAX_COUNT);
        return 1;
    }
    for (int i = 0; i < count; i++) {
        if (scanf("%d", &numbers[i]) != 1) {
            fprintf(stderr, "输入的数据有误\n");
            return 1;
        }
    }

    bubble_sort(numbers, count);

    for (int i = 0; i < count; i++) {
        printf("%d ", numbers[i]);
    }
    printf("\n");
    return 0;
}

改写后的代码长了一些,但结构一目了然,而且修正了原代码的一个隐患:原代码没有检查 n 是否超过 100,输入一个很大的数就会导致数组越界。

本章练习

  1. 调试练习。 下面的函数想要返回数组中最大值的下标,但结果有时不对。请先用 GDB(或 IDE 的调试器)单步执行,观察变量的变化,找出所有错误并修正。可以用 {5, 1, 2} 和 {-5, -2, -8} 两组数据测试。
c
   int max_index(const int arr[], int n)
   {
       int max = 0;
       int index = 0;
       for (int i = 1; i < n; i++) {
           if (arr[i] > max) {
               max = arr[i];
               index = i;
           }
       }
       return index;
   }
  1. 找出未定义行为。 下面的代码片段中,哪些包含未定义行为?请说明原因。
c
   // (a)
   int a[3] = {1, 2, 3};
   for (int i = 0; i <= 3; i++) printf("%d ", a[i]);

   // (b)
   unsigned int u = 0;
   u = u - 1;

   // (c)
   int x = 2147483647;
   x = x + 1;

   // (d)
   char s[] = "hello";
   s[0] = 'H';

   // (e)
   char *t = "hello";
   t[0] = 'H';

   // (f)
   int i = 1;
   int b = i++ + i++;
  1. 代码重构。 按照本章的规范改写下面的代码:起有意义的名字,消除魔法数字,添加必要的输入检查和花括号,把重复的代码提取成函数。
c
   #include <stdio.h>
   int main(){double s[5],t=0,m=0;int i;
   for(i=0;i<5;i++){scanf("%lf",&s[i]);t+=s[i];}
   for(i=0;i<5;i++)if(s[i]>m)m=s[i];
   printf("%.2f %.2f\n",t/5,m);
   for(i=0;i<5;i++)if(s[i]>=60)printf("%d pass\n",i+1);else printf("%d fail\n",i+1);}

第四部分 实战篇

本部分的目标:综合运用前面学到的全部知识,从需求分析、总体设计到编码测试,完整地实现一个实用的小型系统;最后为你规划学完 C 语言之后的学习路线。

第 18 章 综合项目:学生成绩管理系统

学编程,最终要落到"做出东西"上。这一章我们从零开始,完成一个命令行版的学生成绩管理系统。它的规模不大(约 600 行,其中不少是注释),却几乎用到了前面学过的所有知识,并且涵盖了真实软件开发的基本流程:需求分析 → 总体设计 → 编码实现 → 测试。

18.1 项目目标

完成这个项目,你将综合运用以下知识:

知识点在项目中的应用
结构体与 typedef(第 11 章)用 Student 表示一名学生,用 StudentList 表示学生列表
动态内存(第 12 章)学生列表是一个可以自动扩容的动态数组
指针与 const(第 10 章)函数之间通过结构体指针传递数据
字符串处理(第 9 章)学号、姓名的输入、比较和查找;CSV 行的拆分
输入校验(第 3、6、9 章)用 fgets 读取整行,再用 strtol、strtod 严格检查
函数指针(第 10 章)用 qsort 按不同规则排序
文件操作(第 14 章)数据以 CSV 格式保存和加载
分支、循环、函数(第 5~7 章)菜单循环和各个功能模块

18.2 需求分析

动手写代码之前,先把"要做什么"想清楚。系统需要提供以下功能:

编号功能说明
1添加学生输入学号、姓名和三门课(语文、数学、英语)的成绩。学号不能重复,成绩必须在 0~100 之间
2显示全部以表格形式显示所有学生的各科成绩、总分和平均分
3查询学生按学号精确查找,或按姓名模糊查找(输入"张"可以找到所有姓张的学生)
4修改成绩根据学号找到学生,重新录入各科成绩
5删除学生根据学号删除学生,删除前要求确认
6排序按总分从高到低,或按学号从小到大排序
7成绩统计各科的平均分、最高分、最低分、及格率,以及总分第一名
8保存数据把数据保存到 students.csv 文件
0退出如果有未保存的修改,询问是否保存

此外还有几条同样重要的"隐性需求":

  • 启动时自动加载数据,文件不存在时从空白数据开始;
  • 足够健壮:任何非法输入(字母、超出范围的数字、过长的内容、提前结束的输入)都不能让程序崩溃或陷入死循环;
  • 数据文件人可读,可以用文本编辑器或 Excel 打开查看;
  • 没有内存泄漏,用 AddressSanitizer 检查应当"零报告"。

18.3 总体设计

1. 数据结构

c
typedef struct {
    char id[ID_LEN];                    // 学号
    char name[NAME_LEN];                // 姓名
    double scores[COURSE_COUNT];        // 各科成绩
} Student;

typedef struct {
    Student *data;                      // 指向堆上的学生数组
    size_t size;                        // 当前学生人数
    size_t capacity;                    // 当前容量
    bool modified;                      // 是否有尚未保存的修改
} StudentList;

学生列表采用第 12 章的动态数组,而不是第 13 章的链表,原因是:学生人数通常不多,数组可以直接交给 qsort 排序,按下标访问也很方便。(改用链表实现,是本章的扩展挑战之一。)

学号、姓名的最大长度和课程数量都定义成了宏常量。调整学号、姓名的长度时只需改一处;修改课程数量 COURSE_COUNT 时,还要同步修改 COURSE_NAMES 中的课程名,已有的数据文件也要相应调整。

2. 数据文件格式

采用 CSV(逗号分隔值)格式,每行一名学生,依次为学号、姓名和各科成绩:

text
2024001,张三,85.50,92.50,78.00
2024002,李四,90.00,88.00,95.00

CSV 是一种通用的文本格式,人可以直接阅读,Excel 等表格软件也能打开。正因为逗号是分隔符,所以学号和姓名中不允许出现逗号。

3. 模块划分

按照"一个函数只做一件事"的原则,把程序划分为以下几组函数:

模块函数职责
输入工具read_line、input_text、input_int、input_score、confirm安全地读取各类输入,拒绝非法输入
学生列表list_init、list_free、list_append、list_remove_at、list_find_by_id管理动态数组
显示student_total、print_header、print_student计算总分,格式化输出
业务功能add_student、show_all、search_student、modify_student、delete_student、sort_students、show_statistics实现菜单中的各项功能
文件读写save_to_file、split_fields、parse_line、load_from_fileCSV 文件的保存与加载
主程序print_menu、main菜单循环

各模块之间是分层调用的关系:业务功能调用输入工具、学生列表和显示模块,主程序调用业务功能和文件读写模块。下层模块不依赖上层模块。

4. 程序流程

text
启动 ──▶ 加载数据文件 ──▶ 显示菜单 ──▶ 读取选择 ──▶ 执行对应功能 ──┐
                            ▲                                      │
                            └──────────────────────────────────────┘
选择 0 ──▶ 有未保存的修改?──是──▶ 询问是否保存 ──▶ 释放内存 ──▶ 结束

18.4 完整代码

新建文件 student_manager.c,输入以下代码。代码较长,建议对照上面的模块划分,一个模块一个模块地输入,每完成一部分就编译一次。

c
/*
 * 学生成绩管理系统  student_manager.c
 *
 * 功能:添加、显示、查询、修改、删除、排序、统计学生成绩,数据保存在 CSV 文件中。
 * 编译:gcc -Wall -Wextra -std=c11 student_manager.c -o student_manager
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include <errno.h>
#include <stdbool.h>

#define ID_LEN        16                /* 学号的最大长度(含 '\0') */
#define NAME_LEN      32                /* 姓名的最大长度(含 '\0') */
#define COURSE_COUNT  3                 /* 课程数量 */
#define LINE_LEN      256               /* 读取一行输入的缓冲区大小 */
#define PASS_SCORE    60.0              /* 及格线 */
#define DATA_FILE     "students.csv"    /* 数据文件名 */

/* 课程名:每个课程名用两个汉字,表格才能对齐 */
static const char *COURSE_NAMES[COURSE_COUNT] = {"语文", "数学", "英语"};

/* ======================== 数据结构 ======================== */

typedef struct {
    char id[ID_LEN];                    /* 学号 */
    char name[NAME_LEN];                /* 姓名 */
    double scores[COURSE_COUNT];        /* 各科成绩 */
} Student;

typedef struct {
    Student *data;                      /* 指向堆上的学生数组 */
    size_t size;                        /* 当前学生人数 */
    size_t capacity;                    /* 当前容量 */
    bool modified;                      /* 是否有尚未保存的修改 */
} StudentList;

/* ======================== 输入工具 ======================== */

/* 判断字符串是否全部由空白字符组成(空字符串也算) */
bool is_blank(const char *s)
{
    while (*s != '\0') {
        if (!isspace((unsigned char)*s)) {
            return false;
        }
        s++;
    }
    return true;
}

/*
 * 读取一行输入,并去掉末尾的换行符。
 * 返回 1 表示成功;0 表示输入已结束;-1 表示这一行太长(多余部分已被丢弃)。
 */
int read_line(char *buf, size_t size)
{
    if (fgets(buf, (int)size, stdin) == NULL) {
        return 0;
    }
    size_t len = strcspn(buf, "\n");
    if (buf[len] == '\n') {
        buf[len] = '\0';
        return 1;
    }
    /* 没有读到换行符:说明这一行太长,把剩余的字符读走丢弃 */
    int ch;
    bool discarded = false;
    while ((ch = getchar()) != '\n' && ch != EOF) {
        discarded = true;
    }
    return discarded ? -1 : 1;
}

/* 读取一个非空、不含逗号的字符串。输入结束时返回 false */
bool input_text(const char *prompt, char *buf, size_t size)
{
    while (true) {
        printf("%s", prompt);
        int result = read_line(buf, size);
        if (result == 0) {
            return false;
        }
        if (result < 0) {
            printf("输入过长(最多 %zu 个字节),请重新输入。\n", size - 1);
        } else if (is_blank(buf)) {
            printf("不能为空,请重新输入。\n");
        } else if (strchr(buf, ',') != NULL) {
            printf("不能包含逗号,请重新输入。\n");
        } else {
            return true;
        }
    }
}

/* 读取一个 [min, max] 范围内的整数。输入结束时返回 false */
bool input_int(const char *prompt, int min, int max, int *out)
{
    char buf[LINE_LEN];
    while (true) {
        printf("%s", prompt);
        int result = read_line(buf, sizeof buf);
        if (result == 0) {
            return false;
        }
        if (result > 0) {
            char *end;
            errno = 0;
            long value = strtol(buf, &end, 10);
            if (end != buf && is_blank(end) && errno == 0 && value >= min && value <= max) {
                *out = (int)value;
                return true;
            }
        }
        printf("请输入 %d~%d 之间的整数。\n", min, max);
    }
}

/* 读取一个 0~100 之间的成绩。输入结束时返回 false */
bool input_score(const char *prompt, double *out)
{
    char buf[LINE_LEN];
    while (true) {
        printf("%s", prompt);
        int result = read_line(buf, sizeof buf);
        if (result == 0) {
            return false;
        }
        if (result > 0) {
            char *end;
            errno = 0;
            double value = strtod(buf, &end);
            if (end != buf && is_blank(end) && errno == 0 && value >= 0.0 && value <= 100.0) {
                *out = value;
                return true;
            }
        }
        printf("成绩必须是 0~100 之间的数字,请重新输入。\n");
    }
}

/* 询问用户是否确认,输入 y 或 Y 表示确认 */
bool confirm(const char *prompt)
{
    char buf[LINE_LEN];
    printf("%s(y/n):", prompt);
    if (read_line(buf, sizeof buf) != 1) {
        return false;
    }
    return buf[0] == 'y' || buf[0] == 'Y';
}

/* ======================== 学生列表(动态数组) ======================== */

void list_init(StudentList *list)
{
    list->data = NULL;
    list->size = 0;
    list->capacity = 0;
    list->modified = false;
}

void list_free(StudentList *list)
{
    free(list->data);
    list_init(list);
}

/* 在末尾添加一名学生,空间不够时自动扩容。内存不足时返回 false */
bool list_append(StudentList *list, const Student *s)
{
    if (list->size == list->capacity) {
        size_t new_cap = (list->capacity == 0) ? 8 : list->capacity * 2;
        Student *new_data = realloc(list->data, new_cap * sizeof *new_data);
        if (new_data == NULL) {
            return false;
        }
        list->data = new_data;
        list->capacity = new_cap;
    }
    list->data[list->size] = *s;
    list->size++;
    return true;
}

/* 删除下标为 index 的学生,后面的元素依次前移 */
void list_remove_at(StudentList *list, size_t index)
{
    for (size_t i = index; i + 1 < list->size; i++) {
        list->data[i] = list->data[i + 1];
    }
    list->size--;
}

/* 按学号查找,返回下标;找不到时返回 -1 */
long list_find_by_id(const StudentList *list, const char *id)
{
    for (size_t i = 0; i < list->size; i++) {
        if (strcmp(list->data[i].id, id) == 0) {
            return (long)i;
        }
    }
    return -1;
}

/* ======================== 显示 ======================== */

double student_total(const Student *s)
{
    double total = 0.0;
    for (int c = 0; c < COURSE_COUNT; c++) {
        total += s->scores[c];
    }
    return total;
}

/*
 * printf 的宽度是按字节计算的,而一个汉字在 UTF-8 中占 3 个字节、在屏幕上占 2 列,
 * 用宽度控制中文会对不齐。所以表头直接写好空格,并把姓名放在最后一列。
 */
void print_header(void)
{
    printf("学号        ");
    for (int c = 0; c < COURSE_COUNT; c++) {
        printf("    %s", COURSE_NAMES[c]);
    }
    printf("    总分    平均  姓名\n");
}

void print_student(const Student *s)
{
    double total = student_total(s);
    printf("%-12s", s->id);
    for (int c = 0; c < COURSE_COUNT; c++) {
        printf("%8.1f", s->scores[c]);
    }
    printf("%8.1f%8.2f  %s\n", total, total / COURSE_COUNT, s->name);
}

/* ======================== 各项功能 ======================== */

/* 依次输入各科成绩 */
bool input_scores(Student *s)
{
    for (int c = 0; c < COURSE_COUNT; c++) {
        char prompt[64];
        snprintf(prompt, sizeof prompt, "%s成绩:", COURSE_NAMES[c]);
        if (!input_score(prompt, &s->scores[c])) {
            return false;
        }
    }
    return true;
}

void add_student(StudentList *list)
{
    Student s;
    if (!input_text("学号:", s.id, sizeof s.id)) {
        return;
    }
    if (list_find_by_id(list, s.id) >= 0) {
        printf("学号 %s 已存在,添加失败。\n", s.id);
        return;
    }
    if (!input_text("姓名:", s.name, sizeof s.name) || !input_scores(&s)) {
        return;
    }
    if (!list_append(list, &s)) {
        printf("内存不足,添加失败。\n");
        return;
    }
    list->modified = true;
    printf("添加成功!\n");
}

void show_all(const StudentList *list)
{
    if (list->size == 0) {
        printf("暂无学生数据。\n");
        return;
    }
    print_header();
    for (size_t i = 0; i < list->size; i++) {
        print_student(&list->data[i]);
    }
    printf("共 %zu 名学生。\n", list->size);
}

/* 按学号精确查找,或按姓名模糊查找(姓名中包含关键字即可) */
void search_student(const StudentList *list)
{
    char key[NAME_LEN];
    if (!input_text("请输入学号或姓名:", key, sizeof key)) {
        return;
    }
    size_t found = 0;
    for (size_t i = 0; i < list->size; i++) {
        const Student *s = &list->data[i];
        if (strcmp(s->id, key) == 0 || strstr(s->name, key) != NULL) {
            if (found == 0) {
                print_header();
            }
            print_student(s);
            found++;
        }
    }
    if (found == 0) {
        printf("没有找到匹配的学生。\n");
    }
}

void modify_student(StudentList *list)
{
    char id[ID_LEN];
    if (!input_text("请输入要修改的学生的学号:", id, sizeof id)) {
        return;
    }
    long index = list_find_by_id(list, id);
    if (index < 0) {
        printf("没有找到学号为 %s 的学生。\n", id);
        return;
    }
    Student *s = &list->data[index];
    print_header();
    print_student(s);
    printf("请输入新的成绩:\n");
    Student updated = *s;               /* 先修改副本,全部输入成功后再写回 */
    if (!input_scores(&updated)) {
        return;
    }
    *s = updated;
    list->modified = true;
    printf("修改成功!\n");
}

void delete_student(StudentList *list)
{
    char id[ID_LEN];
    if (!input_text("请输入要删除的学生的学号:", id, sizeof id)) {
        return;
    }
    long index = list_find_by_id(list, id);
    if (index < 0) {
        printf("没有找到学号为 %s 的学生。\n", id);
        return;
    }
    print_header();
    print_student(&list->data[index]);
    if (confirm("确定要删除这名学生吗?")) {
        list_remove_at(list, (size_t)index);
        list->modified = true;
        printf("删除成功!\n");
    } else {
        printf("已取消删除。\n");
    }
}

int compare_by_total_desc(const void *a, const void *b)
{
    double ta = student_total(a);
    double tb = student_total(b);
    return (ta < tb) - (ta > tb);       /* 总分高的排在前面 */
}

int compare_by_id(const void *a, const void *b)
{
    const Student *sa = a;
    const Student *sb = b;
    return strcmp(sa->id, sb->id);
}

void sort_students(StudentList *list)
{
    if (list->size == 0) {
        printf("暂无学生数据。\n");
        return;
    }
    int choice;
    if (!input_int("排序方式(1-按总分从高到低,2-按学号从小到大):", 1, 2, &choice)) {
        return;
    }
    qsort(list->data, list->size, sizeof(Student),
          choice == 1 ? compare_by_total_desc : compare_by_id);
    list->modified = true;
    show_all(list);
}

void show_statistics(const StudentList *list)
{
    if (list->size == 0) {
        printf("暂无学生数据。\n");
        return;
    }
    printf("课程    平均分  最高分  最低分   及格率\n");
    for (int c = 0; c < COURSE_COUNT; c++) {
        double sum = 0.0;
        double max = list->data[0].scores[c];
        double min = max;
        size_t passed = 0;
        for (size_t i = 0; i < list->size; i++) {
            double score = list->data[i].scores[c];
            sum += score;
            if (score > max) {
                max = score;
            }
            if (score < min) {
                min = score;
            }
            if (score >= PASS_SCORE) {
                passed++;
            }
        }
        printf("%s%10.2f%8.1f%8.1f%8.1f%%\n", COURSE_NAMES[c],
               sum / list->size, max, min, 100.0 * passed / list->size);
    }

    size_t best = 0;
    for (size_t i = 1; i < list->size; i++) {
        if (student_total(&list->data[i]) > student_total(&list->data[best])) {
            best = i;
        }
    }
    printf("总分第一:%s(%s),总分 %.1f\n", list->data[best].name,
           list->data[best].id, student_total(&list->data[best]));
}

/* ======================== 文件读写 ======================== */

/* 保存为 CSV 格式,每行一名学生:学号,姓名,成绩1,成绩2,成绩3 */
bool save_to_file(StudentList *list, const char *path)
{
    FILE *fp = fopen(path, "w");
    if (fp == NULL) {
        perror(path);
        return false;
    }
    for (size_t i = 0; i < list->size; i++) {
        const Student *s = &list->data[i];
        fprintf(fp, "%s,%s", s->id, s->name);
        for (int c = 0; c < COURSE_COUNT; c++) {
            fprintf(fp, ",%.2f", s->scores[c]);
        }
        fprintf(fp, "\n");
    }
    if (fclose(fp) != 0) {
        perror(path);
        return false;
    }
    list->modified = false;
    printf("已保存 %zu 条记录到 %s。\n", list->size, path);
    return true;
}

/*
 * 在逗号处把 line 拆分成若干字段(会修改 line),空字段也算一个字段。
 * 各字段的起始地址存入 fields,返回字段个数;字段多于 max 个时返回 -1
 */
int split_fields(char *line, char *fields[], int max)
{
    int count = 0;
    char *p = line;
    while (true) {
        if (count == max) {
            return -1;                  /* 字段太多 */
        }
        fields[count] = p;
        count++;
        char *comma = strchr(p, ',');
        if (comma == NULL) {
            return count;               /* 这是最后一个字段 */
        }
        *comma = '\0';                  /* 在逗号处截断当前字段 */
        p = comma + 1;                  /* 下一个字段从逗号后面开始 */
    }
}

/* 解析 CSV 中的一行(会修改 line 的内容),成功返回 true */
bool parse_line(char *line, Student *s)
{
    char *fields[2 + COURSE_COUNT];
    int count = split_fields(line, fields, 2 + COURSE_COUNT);
    if (count != 2 + COURSE_COUNT
        || is_blank(fields[0]) || is_blank(fields[1])
        || strlen(fields[0]) >= ID_LEN || strlen(fields[1]) >= NAME_LEN) {
        return false;
    }
    strcpy(s->id, fields[0]);
    strcpy(s->name, fields[1]);
    for (int c = 0; c < COURSE_COUNT; c++) {
        char *end;
        s->scores[c] = strtod(fields[2 + c], &end);
        if (end == fields[2 + c] || !is_blank(end)
            || !(s->scores[c] >= 0.0 && s->scores[c] <= 100.0)) {   /* 这样写也能拒绝 nan */
            return false;
        }
    }
    return true;
}

bool load_from_file(StudentList *list, const char *path)
{
    FILE *fp = fopen(path, "r");
    if (fp == NULL) {
        printf("未找到数据文件 %s,将从空白数据开始。\n", path);
        return false;
    }
    char line[LINE_LEN];
    int line_no = 0;
    size_t loaded = 0;
    while (fgets(line, sizeof line, fp) != NULL) {
        line_no++;
        line[strcspn(line, "\r\n")] = '\0';     /* 去掉换行符(兼容 Windows 的 \r\n) */
        if (is_blank(line)) {
            continue;
        }
        Student s;
        if (!parse_line(line, &s)) {
            printf("警告:第 %d 行格式有误,已跳过。\n", line_no);
            continue;
        }
        if (list_find_by_id(list, s.id) >= 0) {
            printf("警告:第 %d 行的学号 %s 重复,已跳过。\n", line_no, s.id);
            continue;
        }
        if (!list_append(list, &s)) {
            printf("内存不足,停止加载。\n");
            break;
        }
        loaded++;
    }
    fclose(fp);
    printf("已从 %s 加载 %zu 条记录。\n", path, loaded);
    return true;
}

/* ======================== 主菜单 ======================== */

void print_menu(void)
{
    printf("\n=========== 学生成绩管理系统 ===========\n");
    printf("  1. 添加学生          2. 显示全部\n");
    printf("  3. 查询学生          4. 修改成绩\n");
    printf("  5. 删除学生          6. 排序\n");
    printf("  7. 成绩统计          8. 保存数据\n");
    printf("  0. 退出\n");
    printf("========================================\n");
}

int main(void)
{
    StudentList list;
    list_init(&list);
    load_from_file(&list, DATA_FILE);

    while (true) {
        print_menu();
        int choice;
        if (!input_int("请选择(0-8):", 0, 8, &choice)) {
            choice = 0;                         /* 输入已结束,按退出处理 */
        }

        switch (choice) {
        case 1:
            add_student(&list);
            break;
        case 2:
            show_all(&list);
            break;
        case 3:
            search_student(&list);
            break;
        case 4:
            modify_student(&list);
            break;
        case 5:
            delete_student(&list);
            break;
        case 6:
            sort_students(&list);
            break;
        case 7:
            show_statistics(&list);
            break;
        case 8:
            save_to_file(&list, DATA_FILE);
            break;
        default:                                /* 0:退出 */
            if (list.modified && confirm("数据有修改,是否保存?")) {
                save_to_file(&list, DATA_FILE);
            }
            list_free(&list);
            printf("再见!\n");
            return 0;
        }
    }
}

18.5 关键代码解读

1. 为什么不直接用 scanf 读取输入?

第 3 章和第 6 章讲过,scanf 遇到非法输入时会把它留在缓冲区里,处理不好就会陷入死循环;第 9 章讲过,用它读取字符串时还有溢出的风险。所以本项目采用了更可靠的方案:先用 `fgets` 把一整行读进来,再对这一行进行解析和检查。

read_line 负责读取一行,它用返回值区分三种情况:读取成功(1)、输入已结束(0)、这一行太长(-1,多余的部分已被读走丢弃,不会影响下一次读取)。在它的基础上:

  • input_int 用 strtol 解析整数,并检查四个条件:至少转换了一个数字(end != buf)、数字后面只有空白(is_blank(end))、没有溢出(errno == 0)、在指定范围内。任何一条不满足,就提示用户重新输入。input_score 的思路相同,只是换成了 strtod。
  • 所有输入函数在遇到输入结束(返回 0)时都会返回 false,调用者随即放弃当前操作。main 中读取菜单选择失败时按"退出"处理,这样即使输入提前结束(例如用户按下了 Ctrl + D),程序也不会陷入死循环。

这套输入工具可以直接复用到你今后编写的任何命令行程序中。

2. 动态数组的扩容

list_append 与第 12 章的 vec_push 如出一辙:空间满了就用 realloc 把容量翻倍,并且先用临时指针接收 realloc 的结果,失败时原有数据不会丢失。list_remove_at 删除元素时,把后面的元素依次前移一位。

3. 修改成绩时"先改副本"

modify_student 没有直接修改列表中的学生,而是先复制一份 updated,在副本上录入新成绩,全部成功后才写回。这样,如果用户在录入到一半时输入结束,原有的数据不会被改得"一半新一半旧"。这种"要么全部成功,要么什么都不改"的思想,在数据库中被称为原子性。

4. 用函数指针选择排序规则

c
qsort(list->data, list->size, sizeof(Student),
      choice == 1 ? compare_by_total_desc : compare_by_id);

qsort 的最后一个参数是比较函数的指针,这里用条件运算符在两个比较函数中选择一个传进去。compare_by_total_desc 比较的是两个 double,同样采用了 (a < b) - (a > b) 的写法(注意顺序与升序相反,所以是降序)。

5. 文件的加载与容错

load_from_file 用 fgets 逐行读取,用 strcspn(line, "\r\n") 去掉行尾的换行符——同时去掉 \r,是为了兼容在 Windows 上编辑过的文件。parse_line 先调用 split_fields 按逗号拆分字段,再检查字段个数、学号和姓名是否为空、字符串长度以及成绩范围,任何一项不合格都判定这一行无效。这里没有使用第 9 章介绍的 strtok,因为它会把连续的逗号当作一个分隔符,悄悄跳过空字段:像 2024001,,Tom,85,90,95 这样多出一个空字段的行,用 strtok 拆分只会得到 5 个字段,反而会被当成合法数据。split_fields 用 strchr 逐个查找逗号,空字段也算作一个字段,字段个数的检查才可靠。无效的行和学号重复的行会被跳过并给出警告,而不是让整个加载过程失败——数据文件可能被人手工修改过,程序应当尽量"宽容"。

6. 中文表格的对齐

printf 的宽度是按字节计算的,而一个汉字在 UTF-8 下占 3 个字节,在屏幕上却只占 2 个英文字符的宽度,所以用 %-10s 输出中文时无法对齐(第 3 章提到过这一点)。项目采用了一个简单的办法:表头的空格直接写在字符串里,数据行中只对学号(纯数字)和成绩使用宽度控制,并把长度不固定的姓名放在最后一列。

7. 未保存修改的提示

StudentList 中的 modified 标志记录"自上次保存以来数据是否被修改过":添加、修改、删除、排序时置为 true,保存成功后置为 false。退出时,只有在确实有未保存的修改时才询问用户是否保存。

18.6 编译与运行

bash
$ gcc -Wall -Wextra -std=c11 student_manager.c -o student_manager
$ ./student_manager

在开发和测试阶段,建议加上 AddressSanitizer:

bash
$ gcc -Wall -Wextra -std=c11 -g -fsanitize=address,undefined student_manager.c -o student_manager

假设 students.csv 中已经有三名学生的数据:

text
2024001,张三,85.50,92.50,78.00
2024002,李四,90.00,88.00,95.00
2024003,王五,72.00,58.00,66.00

下面是一次运行的过程(为节省篇幅,除第一次外,每次重复显示的菜单都已省略):

text
已从 students.csv 加载 3 条记录。

=========== 学生成绩管理系统 ===========
  1. 添加学生          2. 显示全部
  3. 查询学生          4. 修改成绩
  5. 删除学生          6. 排序
  7. 成绩统计          8. 保存数据
  0. 退出
========================================
请选择(0-8):2
学号            语文    数学    英语    总分    平均  姓名
2024001         85.5    92.5    78.0   256.0   85.33  张三
2024002         90.0    88.0    95.0   273.0   91.00  李四
2024003         72.0    58.0    66.0   196.0   65.33  王五
共 3 名学生。

请选择(0-8):1
学号:2024004
姓名:赵六
语文成绩:abc
成绩必须是 0~100 之间的数字,请重新输入。
语文成绩:101
成绩必须是 0~100 之间的数字,请重新输入。
语文成绩:59.5
数学成绩:70.5
英语成绩:80
添加成功!

请选择(0-8):7
课程    平均分  最高分  最低分   及格率
语文     76.75    90.0    59.5    75.0%
数学     77.25    92.5    58.0    75.0%
英语     79.75    95.0    66.0   100.0%
总分第一:李四(2024002),总分 273.0

请选择(0-8):6
排序方式(1-按总分从高到低,2-按学号从小到大):1
学号            语文    数学    英语    总分    平均  姓名
2024002         90.0    88.0    95.0   273.0   91.00  李四
2024001         85.5    92.5    78.0   256.0   85.33  张三
2024004         59.5    70.5    80.0   210.0   70.00  赵六
2024003         72.0    58.0    66.0   196.0   65.33  王五
共 4 名学生。

请选择(0-8):0
数据有修改,是否保存?(y/n):y
已保存 4 条记录到 students.csv。
再见!
提示
Windows 用户如果看到中文乱码,请参考 1.8 节的解决方法。另外,如果用 Excel 直接打开 UTF-8 编码的 CSV 文件,中文也可能显示为乱码,这时可以通过 Excel 的"数据 → 从文本/CSV"功能导入,并选择 UTF-8 编码。

18.7 测试清单

写完程序后,要像一个"挑剔的用户"一样,专门去尝试各种非正常的操作。下面是一份测试清单,请逐项验证:

测试场景预期结果
菜单中输入字母、-1、9、空行提示"请输入 0~8 之间的整数",重新输入
添加学号已存在的学生提示学号已存在,添加失败
成绩输入 abc、-5、101、85分提示重新输入
学号超过 15 个字符提示输入过长,重新输入
姓名中包含逗号提示不能包含逗号,重新输入
列表为空时显示、排序、统计提示"暂无学生数据"
查询、修改、删除不存在的学号提示没有找到
删除时确认输入 n取消删除,数据不变
数据文件中有格式错误或学号重复的行跳过该行并给出警告,其余数据正常加载
数据文件中某行有空字段或多余的逗号(如 2024001,,Tom,85,90,95)判为格式错误,跳过该行并给出警告
数据文件不存在提示后从空白数据开始
在输入过程中按 Ctrl + D(Windows 上为 Ctrl + Z 后回车)程序正常退出,不会陷入死循环
用 -fsanitize=address 编译后执行以上全部操作没有任何内存错误或泄漏报告
提示
可以把测试输入写在文本文件中,用输入重定向(第 14 章)一次性执行:./student_manager < test_input.txt。每次修改代码后重新运行一遍,就能快速发现新引入的错误。这就是回归测试的思想。

18.8 扩展挑战

完成基本版本后,可以尝试以下扩展,难度大致由低到高:

  1. 分数段统计。 在统计功能中增加各分数段(优秀、良好、中等、及格、不及格)的人数,并用 * 画出简单的横向直方图。
  2. 多文件重构。 按照第 15 章的方法,把程序拆分为 input.h/.c(输入工具)、student.h/.c(学生列表与业务功能)、storage.h/.c(文件读写)和 main.c,并编写 Makefile。
  3. 自动备份。 保存数据前,先用 <stdio.h> 中的 rename 函数把旧的数据文件改名为 students.csv.bak。
  4. 链表版本。 用第 13 章的链表代替动态数组来存储学生,排序改为自己实现的链表排序算法。
  5. 课程可配置。 把课程名称写在数据文件的第一行,程序根据它动态决定课程的数量和名称(需要为每个学生的成绩动态分配内存)。
  6. 撤销功能。 用一个"栈"记录最近的若干次修改操作,实现"撤销上一步"。
  7. 登录与权限。 增加教师和学生两种角色:教师可以修改所有数据,学生只能查询自己的成绩。

第 19 章 学习路线与资源推荐

19.1 回顾:你已经掌握了什么

如果你认真读完了前面的章节、完成了练习和综合项目,那么你已经:

  • 能够搭建 C 语言开发环境,理解程序从源代码到可执行文件的完整过程;
  • 掌握了数据类型、运算符、分支、循环、函数、数组、字符串等全部基础语法;
  • 理解了内存、地址和指针,能够画出程序的内存示意图;
  • 能够使用结构体组织数据,使用动态内存按需分配空间,实现链表这样的数据结构;
  • 能够读写文件,编写多文件项目,使用调试器和内存检测工具排查问题;
  • 独立完成了一个六百行左右、结构清晰、输入健壮、没有内存泄漏的小型系统。

这是一个非常扎实的起点。接下来往哪里走,取决于你的兴趣和目标。

19.2 下一步学什么

1. 数据结构与算法(最推荐的下一步)

无论将来从事什么方向,数据结构与算法都是计算机专业最核心的基础课,也是求职面试和计算机考研的重点。你已经有了 C 语言和链表的基础,可以继续学习栈、队列、树(二叉树、二叉搜索树、平衡树)、堆、哈希表、图,以及快速排序、归并排序、二分、递归与分治、贪心、动态规划等算法。学习时一定要亲手用 C 实现每一种数据结构,并在刷题网站上大量练习。

2. 计算机系统

想真正弄清楚"程序在计算机上是如何运行的"——数据在机器中如何表示、C 代码如何变成机器指令、链接是怎么回事、内存层次与缓存、进程与虚拟内存——推荐阅读《深入理解计算机系统》(CSAPP),并完成它配套的实验。这本书是用 C 语言讲解的,正好衔接本文的内容。

3. 操作系统与 Linux 系统编程

C 语言是操作系统的"母语"。学习进程、线程、文件系统、进程间通信、网络套接字等系统编程接口,能让你写出真正与操作系统打交道的程序。

4. 嵌入式开发

如果你对硬件感兴趣,可以从单片机(如 51、STM32)、Arduino 或 ESP32 入手,用 C 语言控制 LED、传感器、电机,做出看得见摸得着的作品。这个方向还需要补充数字电路和硬件方面的知识。

5. 学习其他编程语言

有了 C 的基础,学习其他语言会轻松很多:

  • C++:在 C 的基础上增加了面向对象、模板和强大的标准库(STL),广泛用于游戏、图形、高性能系统等领域;
  • Rust:一门注重内存安全的现代系统编程语言,编译器会在编译阶段阻止悬空指针、数据竞争等 C 语言中常见的错误。学过 C 再学 Rust,你会深刻理解它为什么要这样设计;
  • Python、Go、Java 等:用于 Web 开发、数据分析、人工智能等应用领域,开发效率高。

19.3 推荐书籍

书名作者适合阶段特点
《C Primer Plus》Stephen Prata入门讲解细致,例题丰富,篇幅较长,适合系统学习
《C 语言程序设计:现代方法》K. N. King入门~进阶内容全面而严谨,习题质量很高,被许多人视为最好的 C 语言教材之一
《C 程序设计语言》Brian W. Kernighan、Dennis M. Ritchie有一定基础后C 语言设计者的经典著作,薄而精,值得反复阅读
《C 和指针》Kenneth A. Reek进阶对指针的讲解深入透彻
《C 陷阱与缺陷》Andrew Koenig进阶篇幅很短,专门剖析 C 语言中容易出错的地方
《C 专家编程》Peter van der Linden进阶讲述 C 语言的历史掌故和底层原理,趣味性强
《深入理解计算机系统》Randal E. Bryant、David R. O'Hallaron进阶从程序员的视角理解计算机系统,计算机专业必读
《数据结构与算法分析:C 语言描述》Mark Allen Weiss进阶经典的数据结构教材,全部用 C 语言实现

19.4 在线资源

  • cppreference(en.cppreference.com,也有中文版 zh.cppreference.com):最权威、最方便的 C 语言标准库参考,查函数用法的首选。
  • Compiler Explorer(godbolt.org):在网页上编写 C 代码,实时查看它被编译成的汇编代码,是理解底层的利器。
  • CS50(cs50.harvard.edu):哈佛大学的计算机入门公开课,前半部分使用 C 语言,讲解生动,作业有趣。
  • 中国大学 MOOC:浙江大学翁恺老师的《C 语言程序设计》等课程,适合喜欢跟着视频学习的同学。
  • 刷题平台:力扣(leetcode.cn)偏重面试算法题;洛谷(luogu.com.cn)题目丰富,适合从入门到竞赛的系统练习;PTA(pintia.cn)被许多高校用于课程练习和考试。
  • 阅读优秀的开源代码:cJSON(一个小巧的 JSON 解析库,核心代码只有一个 .c 文件和一个 .h 文件)、Lua 解释器、Redis、SQLite 等项目的代码质量都很高。从小项目开始读起,学习高手是如何组织代码的。

19.5 项目练习建议

"学了就用"是巩固知识最好的方法。下面是一些适合用 C 语言完成的练习项目:

难度项目练习的重点
入门通讯录、图书管理系统、记账本结构体、文件、菜单(可以参考第 18 章的结构)
入门支持括号和优先级的表达式计算器栈、字符串解析
进阶命令行小游戏:贪吃蛇、扫雷、2048、五子棋二维数组、游戏循环、随机数
进阶文本文件压缩工具(哈夫曼编码)树、优先队列、位运算、二进制文件
进阶简易 Shell(命令行解释器)字符串解析、进程管理(需要学习系统编程)
挑战文本编辑器(可以参考开源项目 kilo,它只用约一千行 C 代码实现了一个可用的编辑器)终端控制、数据结构设计
挑战JSON 解析器、简单的解释器词法分析、语法分析、递归下降
挑战HTTP 服务器、自己实现内存分配器(malloc)网络编程、系统编程、内存管理

19.6 写在最后

学习编程没有捷径,但有方法:多写代码,多读代码,多调试,多总结。遇到困难时不要气馁——每一个让你抓狂的 bug,都会让你对计算机的理解更深一层;每一次你靠自己找出段错误的原因,都是一次实实在在的成长。

C 语言是一门"让你看清一切"的语言。它不会替你隐藏任何细节,也不会替你承担任何责任,正因为如此,学透了 C,你就拥有了理解整个计算机世界的一把钥匙。

祝你在编程的道路上走得更远!

附录

附录 A 练习参考答案

编程题的正确写法往往不止一种,这里给出的只是参考。你的程序只要结果正确、思路清晰,就是好答案。建议先独立完成,再对照答案,重点比较思路上的差异。为节省篇幅,部分答案只给出关键的函数或代码片段。

第 1 章

1. 个人名片

c
#include <stdio.h>

int main(void)
{
    printf("====================\n");
    printf("姓名:张三\n");
    printf("专业:计算机科学与技术\n");
    printf("座右铭:Talk is cheap. Show me the code.\n");
    printf("====================\n");
    return 0;
}

2. 故意犯错

以 GCC 为例,你大致会看到以下报错(不同版本的措辞可能略有不同):

  • 删掉分号:error: expected ';' before 'return'。GCC 会把报错位置标在漏写分号那一行的末尾,信息中的 return 是下一行开头的词;
  • printf 写成 Printf:implicit declaration of function 'Printf'; did you mean 'printf'?,以及链接时的 undefined reference to 'Printf';
  • 删掉右花括号:error: expected declaration or statement at end of input;
  • 使用中文双引号:error: stray '\342' in program。

3. 星号三角形

c
#include <stdio.h>

int main(void)
{
    printf("   *\n");
    printf("  ***\n");
    printf(" *****\n");
    printf("*******\n");
    return 0;
}

第 2 章

1. 程序与 2.3 节相同。在 64 位 Windows 上,long 的大小是 4 字节;long double 在 MinGW 中通常是 16 字节,在 Visual Studio 中是 8 字节。

2. 合法的有:_count、Int、total_sum、score3。不合法的有:2nd_place(以数字开头)、my-name(含有减号)、int(关键字)、a b(含有空格)。注意 _count 虽然合法,但以下划线开头的名字有一部分被保留给编译器和标准库使用,自己起名时最好避免以下划线开头。Int 合法,因为 C 语言区分大小写,它与关键字 int 不同,但这样起名容易造成混淆。

3.

c
#include <stdio.h>

int main(void)
{
    char c = 'm';
    printf("大写字母:%c\n", c - 'a' + 'A');       // 也可以写 c - 32
    printf("字母表中的序号:%d\n", c - 'a' + 1);
    printf("后面第 3 个字母:%c\n", c + 3);
    return 0;
}

运行结果:

text
大写字母:M
字母表中的序号:13
后面第 3 个字母:p

4. 输出为 3 3.0 3.5 3 4294967295。7 / 2 是整数除法,结果为 3;b 同样先算出整数 3,再转换为 3.0;7.0 / 2 是浮点除法,结果为 3.5;(int)3.99 截断小数部分,得到 3;-1 的补码是 32 个 1,作为 unsigned int 解读就是 4294967295(假设 int 为 32 位)。

第 3 章

1. 圆的周长和面积

c
#include <stdio.h>

#define PI 3.14159265358979

int main(void)
{
    double r;
    printf("请输入半径:");
    scanf("%lf", &r);
    printf("周长:%.2f\n", 2 * PI * r);
    printf("面积:%.2f\n", PI * r * r);
    return 0;
}

运行结果:

text
请输入半径:3
周长:18.85
面积:28.27

2. 求和与平均值

c
#include <stdio.h>

int main(void)
{
    int a, b, c;
    printf("请输入三个整数:");
    scanf("%d %d %d", &a, &b, &c);
    int sum = a + b + c;
    printf("和:%d\n", sum);
    printf("平均值:%.2f\n", sum / 3.0);   // 用 3.0 而不是 3,避免整数除法
    return 0;
}

3. 大写转小写

c
#include <stdio.h>

int main(void)
{
    char ch;
    printf("请输入一个大写字母:");
    scanf(" %c", &ch);
    char lower = ch - 'A' + 'a';          // 也可以写 ch + 32
    printf("小写字母:%c,ASCII 码:%d\n", lower, lower);
    return 0;
}

4. 进制与格式

c
#include <stdio.h>

int main(void)
{
    int n;
    printf("请输入一个非负整数:");
    scanf("%d", &n);
    printf("十进制:%d\n", n);
    printf("八进制:%o\n", n);
    printf("十六进制:%x\n", n);
    printf("[%10d]\n", n);
    printf("[%-10d]\n", n);
    printf("[%010d]\n", n);
    return 0;
}

输入 255 的运行结果:

text
请输入一个非负整数:255
十进制:255
八进制:377
十六进制:ff
[       255]
[255       ]
[0000000255]

%o 和 %x 要求参数是无符号整数,输入负数时会按无符号数的方式输出。

第 4 章

1. 拆分三位数

c
#include <stdio.h>

int main(void)
{
    int n;
    printf("请输入一个三位数:");
    scanf("%d", &n);
    int hundreds = n / 100;
    int tens = n / 10 % 10;
    int ones = n % 10;
    printf("百位 %d,十位 %d,个位 %d\n", hundreds, tens, ones);
    return 0;
}

2. 时间换算

c
#include <stdio.h>

int main(void)
{
    int total;
    printf("请输入秒数:");
    scanf("%d", &total);
    int hours = total / 3600;
    int minutes = total % 3600 / 60;
    int seconds = total % 60;
    printf("%02d:%02d:%02d\n", hours, minutes, seconds);
    return 0;
}

3. 第一行输出 1 2 -1 -2:整数除法向 0 取整,-5 / 3 为 -1;取余结果的符号与被除数相同,-5 % 3 为 -2。第二行输出 0 1 0:x++ > 0 先用 x 的原值 0 进行比较,结果为假,然后 x 变为 1;由于 && 左边为假,发生短路,右边的 y++ 根本没有执行,所以 y 仍为 0。

4. 三个数的最大值

c
#include <stdio.h>

int main(void)
{
    int a, b, c;
    printf("请输入三个整数:");
    scanf("%d %d %d", &a, &b, &c);
    int max = (a > b) ? a : b;       // 先求 a、b 中的较大者
    max = (max > c) ? max : c;       // 再与 c 比较
    printf("最大值:%d\n", max);
    return 0;
}

也可以写成一个嵌套的表达式:(a > b) ? (a > c ? a : c) : (b > c ? b : c),但分两步写更容易读懂。

第 5 章

1. 三数排序

c
#include <stdio.h>

int main(void)
{
    int a, b, c, temp;
    printf("请输入三个整数:");
    scanf("%d %d %d", &a, &b, &c);

    if (a > b) {            // 保证 a <= b
        temp = a;
        a = b;
        b = temp;
    }
    if (a > c) {            // 保证 a 是最小的
        temp = a;
        a = c;
        c = temp;
    }
    if (b > c) {            // 保证 b <= c
        temp = b;
        b = c;
        c = temp;
    }
    printf("从小到大:%d %d %d\n", a, b, c);
    return 0;
}

2. 月份天数

c
#include <stdio.h>

int main(void)
{
    int year, month, days;
    printf("请输入年份和月份:");
    scanf("%d %d", &year, &month);

    switch (month) {
    case 1: case 3: case 5: case 7: case 8: case 10: case 12:
        days = 31;
        break;
    case 4: case 6: case 9: case 11:
        days = 30;
        break;
    case 2:
        if ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0) {
            days = 29;
        } else {
            days = 28;
        }
        break;
    default:
        printf("月份输入有误\n");
        return 1;
    }
    printf("%d 年 %d 月有 %d 天\n", year, month, days);
    return 0;
}

3. 阶梯电价

c
#include <stdio.h>

int main(void)
{
    double kwh, fee;
    printf("请输入月用电量(度):");
    if (scanf("%lf", &kwh) != 1 || kwh < 0) {
        printf("输入无效\n");
        return 1;
    }

    if (kwh <= 200) {
        fee = kwh * 0.5;
    } else if (kwh <= 400) {
        fee = 200 * 0.5 + (kwh - 200) * 0.55;
    } else {
        fee = 200 * 0.5 + 200 * 0.55 + (kwh - 400) * 0.8;
    }
    printf("应缴电费:%.2f 元\n", fee);
    return 0;
}

4. 字符分类

c
#include <stdio.h>

int main(void)
{
    char ch;
    printf("请输入一个字符:");
    scanf("%c", &ch);

    if (ch >= 'A' && ch <= 'Z') {
        printf("大写字母\n");
    } else if (ch >= 'a' && ch <= 'z') {
        printf("小写字母\n");
    } else if (ch >= '0' && ch <= '9') {
        printf("数字\n");
    } else {
        printf("其他字符\n");
    }
    return 0;
}

第 9 章学习了 <ctype.h> 之后,也可以用 isupper、islower、isdigit 来判断。

第 6 章

1. 答案为 2500。

c
#include <stdio.h>

int main(void)
{
    int sum = 0;
    for (int i = 1; i <= 100; i += 2) {    // 从 1 开始,每次加 2,只遍历奇数
        sum += i;
    }
    printf("1~100 的奇数和:%d\n", sum);
    return 0;
}

2. 水仙花数:答案为 153、370、371、407。

c
#include <stdio.h>

int main(void)
{
    for (int n = 100; n <= 999; n++) {
        int a = n / 100;          // 百位
        int b = n / 10 % 10;      // 十位
        int c = n % 10;           // 个位
        if (a * a * a + b * b * b + c * c * c == n) {
            printf("%d\n", n);
        }
    }
    return 0;
}

3. 打印菱形

c
#include <stdio.h>

void print_row(int spaces, int stars)
{
    for (int j = 0; j < spaces; j++) {
        putchar(' ');
    }
    for (int j = 0; j < stars; j++) {
        putchar('*');
    }
    putchar('\n');
}

int main(void)
{
    int n;
    printf("请输入 n:");
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) {          // 上半部分(包括中间最宽的一行)
        print_row(n - i, 2 * i - 1);
    }
    for (int i = n - 1; i >= 1; i--) {      // 下半部分
        print_row(n - i, 2 * i - 1);
    }
    return 0;
}

这里提前使用了第 7 章的函数,把"输出一行"的代码提取出来,避免了上下两部分的重复。不用函数的话,把 print_row 的内容分别写进两个循环即可。

4. 整数逆序

c
#include <stdio.h>

int main(void)
{
    int n;
    printf("请输入一个正整数:");
    scanf("%d", &n);

    long long reversed = 0;
    int digits = 0;
    int temp = n;
    do {
        reversed = reversed * 10 + temp % 10;   // 把 temp 的个位接到 reversed 的末尾
        temp /= 10;
        digits++;
    } while (temp != 0);
    printf("逆序数为 %lld,共 %d 位\n", reversed, digits);
    return 0;
}

reversed 使用 long long,是因为像 1999999999 这样的数逆序后会超出 int 的范围。另外,末尾是 0 的数(如 120)逆序后开头的 0 会消失,得到 21。

5. 答案为 4037913。

c
#include <stdio.h>

int main(void)
{
    long long factorial = 1, sum = 0;
    for (int i = 1; i <= 10; i++) {
        factorial *= i;         // 利用上一次的结果:i! = (i-1)! × i
        sum += factorial;
    }
    printf("1! + 2! + ... + 10! = %lld\n", sum);
    return 0;
}

第 7 章

1. 素数函数

c
#include <stdio.h>

int is_prime(int n)
{
    if (n < 2) {
        return 0;
    }
    for (int i = 2; i * i <= n; i++) {
        if (n % i == 0) {
            return 0;
        }
    }
    return 1;
}

int main(void)
{
    int count = 0;
    for (int n = 2; n < 100; n++) {
        if (is_prime(n)) {
            printf("%4d", n);
            count++;
            if (count % 10 == 0) {
                printf("\n");
            }
        }
    }
    printf("\n");
    return 0;
}

运行结果:

text
   2   3   5   7  11  13  17  19  23  29
  31  37  41  43  47  53  59  61  67  71
  73  79  83  89  97

2. 最大公约数与最小公倍数

c
#include <stdio.h>

int gcd(int a, int b)
{
    while (b != 0) {
        int r = a % b;
        a = b;
        b = r;
    }
    return a;
}

int lcm(int a, int b)
{
    return a / gcd(a, b) * b;    // 先除后乘,降低溢出的可能
}

int main(void)
{
    int a, b;
    printf("请输入两个正整数:");
    scanf("%d %d", &a, &b);
    printf("最大公约数:%d,最小公倍数:%d\n", gcd(a, b), lcm(a, b));
    return 0;
}

gcd 也可以写成递归形式,非常简洁:return b == 0 ? a : gcd(b, a % b);。

3. 递归求幂

c
#include <stdio.h>

double power(double x, int n)          // 递归 n 次
{
    if (n == 0) {
        return 1.0;
    }
    return x * power(x, n - 1);
}

double fast_power(double x, int n)     // 递归约 log2(n) 次
{
    if (n == 0) {
        return 1.0;
    }
    double half = fast_power(x, n / 2);
    if (n % 2 == 0) {
        return half * half;            // x^n = (x^(n/2))^2
    }
    return half * half * x;            // n 为奇数时再乘一个 x
}

int main(void)
{
    printf("%.0f %.0f\n", power(2, 10), fast_power(2, 10));   // 输出:1024 1024
    return 0;
}

fast_power 每次递归都把 n 减半,所以只需递归约 log₂n 次。例如计算 x¹⁰⁰⁰ 只需要递归十几次,而 power 需要一千次。这种算法叫作快速幂。

4. 递归输出二进制

c
#include <stdio.h>

void print_binary(int n)
{
    if (n >= 2) {
        print_binary(n / 2);    // 先输出高位(除以 2 的商)
    }
    printf("%d", n % 2);        // 再输出最低位(除以 2 的余数)
}

int main(void)
{
    print_binary(13);           // 输出:1101
    printf("\n");
    return 0;
}

这正是"除 2 取余,倒序排列":递归会先深入到最高位,在返回的过程中依次输出各位,恰好实现了"倒序"。

第 8 章

1. 循环右移

最直观的方法是借助一个临时数组:b[(i + k) % n] = a[i]。下面给出一个更巧妙的方法——三次反转,它不需要额外的数组:先反转整个数组,再分别反转前 k 个元素和后 n−k 个元素。以 1 2 3 4 5、k = 2 为例:5 4 3 2 1 → 4 5 3 2 1 → 4 5 1 2 3。

c
#include <stdio.h>

#define MAX_N 100

void reverse(int arr[], int left, int right)
{
    while (left < right) {
        int temp = arr[left];
        arr[left] = arr[right];
        arr[right] = temp;
        left++;
        right--;
    }
}

int main(void)
{
    int a[MAX_N];
    int n, k;
    printf("请输入 n(1~%d):", MAX_N);
    if (scanf("%d", &n) != 1 || n < 1 || n > MAX_N) {
        printf("输入无效\n");
        return 1;
    }
    printf("请输入 %d 个整数:", n);
    for (int i = 0; i < n; i++) {
        scanf("%d", &a[i]);
    }
    printf("请输入 k:");
    if (scanf("%d", &k) != 1 || k < 0) {
        printf("输入无效\n");
        return 1;
    }

    k %= n;                 // 右移 n 位等于没移动
    reverse(a, 0, n - 1);
    reverse(a, 0, k - 1);
    reverse(a, k, n - 1);

    for (int i = 0; i < n; i++) {
        printf("%d ", a[i]);
    }
    printf("\n");
    return 0;
}

2. 第二大的数

c
#include <stdio.h>
#include <stdbool.h>

#define N 10

int main(void)
{
    int a[N];
    printf("请输入 %d 个整数:", N);
    for (int i = 0; i < N; i++) {
        scanf("%d", &a[i]);
    }

    int first = a[0];
    int second = 0;
    bool has_second = false;
    for (int i = 1; i < N; i++) {
        if (a[i] > first) {                 // 出现新的最大值,原最大值退居第二
            second = first;
            has_second = true;
            first = a[i];
        } else if (a[i] < first && (!has_second || a[i] > second)) {
            second = a[i];                  // 比最大值小,但比当前的第二大值大
            has_second = true;
        }
    }

    if (has_second) {
        printf("第二大的数是 %d\n", second);
    } else {
        printf("不存在第二大的数\n");
    }
    return 0;
}

3. 删除元素

最朴素的方法是每找到一个 x,就把后面的元素全部前移一位,但这样最坏情况下需要移动 n² 次。更好的方法是用两个下标:i 负责遍历,k 指向"下一个要保留的元素应该放的位置"。

c
#include <stdio.h>

#define MAX_N 100

int main(void)
{
    int a[MAX_N];
    int n, x;
    printf("请输入 n(1~%d):", MAX_N);
    if (scanf("%d", &n) != 1 || n < 1 || n > MAX_N) {
        printf("输入无效\n");
        return 1;
    }
    printf("请输入 %d 个整数:", n);
    for (int i = 0; i < n; i++) {
        scanf("%d", &a[i]);
    }
    printf("要删除的数:");
    scanf("%d", &x);

    int k = 0;
    for (int i = 0; i < n; i++) {
        if (a[i] != x) {
            a[k] = a[i];      // 保留这个元素
            k++;
        }
    }
    n = k;                    // 删除后的元素个数

    for (int i = 0; i < n; i++) {
        printf("%d ", a[i]);
    }
    printf("\n");
    return 0;
}

4. 杨辉三角

c
#include <stdio.h>

#define ROWS 10

int main(void)
{
    int t[ROWS][ROWS];
    for (int i = 0; i < ROWS; i++) {
        t[i][0] = 1;                             // 每行的第一个数
        t[i][i] = 1;                             // 每行的最后一个数
        for (int j = 1; j < i; j++) {
            t[i][j] = t[i - 1][j - 1] + t[i - 1][j];
        }
    }

    for (int i = 0; i < ROWS; i++) {
        for (int j = 0; j <= i; j++) {
            printf("%d ", t[i][j]);
        }
        printf("\n");
    }
    return 0;
}

最后一行输出为 1 9 36 84 126 126 84 36 9 1。

第 9 章

1. 字符统计

c
#include <stdio.h>
#include <ctype.h>

int main(void)
{
    char line[256];
    printf("请输入一行文字:");
    if (fgets(line, sizeof(line), stdin) == NULL) {
        return 1;
    }

    int letters = 0, digits = 0, spaces = 0, others = 0;
    for (int i = 0; line[i] != '\0' && line[i] != '\n'; i++) {
        unsigned char c = (unsigned char)line[i];
        if (isalpha(c)) {
            letters++;
        } else if (isdigit(c)) {
            digits++;
        } else if (c == ' ') {
            spaces++;
        } else {
            others++;
        }
    }
    printf("字母 %d 个,数字 %d 个,空格 %d 个,其他 %d 个\n", letters, digits, spaces, others);
    return 0;
}

注意:一个汉字在 UTF-8 下占 3 个字节,会被计为 3 个"其他字符"。

2. 实现 strcmp

c
int my_strcmp(const char s1[], const char s2[])
{
    int i = 0;
    while (s1[i] != '\0' && s1[i] == s2[i]) {   // 跳过相同的部分
        i++;
    }
    return (unsigned char)s1[i] - (unsigned char)s2[i];
}

循环在遇到第一个不同的字符、或者 s1 结束时停下,此时两个字符之差的正负就决定了比较结果。如果两个字符串完全相同,二者都停在 '\0' 上,差为 0。转换为 unsigned char 是为了与标准库 strcmp 的行为保持一致(标准规定按无符号字符比较)。

3. 单词首字母大写

c
#include <stdio.h>
#include <ctype.h>
#include <stdbool.h>

int main(void)
{
    char line[256];
    printf("请输入一行英文:");
    if (fgets(line, sizeof(line), stdin) == NULL) {
        return 1;
    }

    bool in_word = false;                 // 当前是否处在一个单词内部
    for (int i = 0; line[i] != '\0'; i++) {
        unsigned char c = (unsigned char)line[i];
        if (isspace(c)) {
            in_word = false;
        } else {
            if (!in_word) {               // 单词的第一个字母
                line[i] = (char)toupper(c);
            }
            in_word = true;
        }
    }
    printf("%s", line);
    return 0;
}

用一个布尔变量记录"当前是否在单词内部",这是处理单词类问题的常用技巧,第 14 章练习 1 统计单词数时也会用到。

4. 回文判断

c
#include <stdio.h>
#include <string.h>
#include <ctype.h>
#include <stdbool.h>

bool is_palindrome(const char s[])
{
    int i = 0;
    int j = (int)strlen(s) - 1;
    while (i < j) {
        if (tolower((unsigned char)s[i]) != tolower((unsigned char)s[j])) {
            return false;
        }
        i++;
        j--;
    }
    return true;
}

int main(void)
{
    char s[100];
    printf("请输入一个字符串:");
    if (scanf("%99s", s) != 1) {
        return 1;
    }
    printf("%s\n", is_palindrome(s) ? "是回文" : "不是回文");
    return 0;
}

第 10 章

1. 最大值与最小值

c
#include <stdio.h>

void min_max(const int *arr, int n, int *min, int *max)
{
    *min = arr[0];
    *max = arr[0];
    for (int i = 1; i < n; i++) {
        if (arr[i] < *min) {
            *min = arr[i];
        }
        if (arr[i] > *max) {
            *max = arr[i];
        }
    }
}

int main(void)
{
    int a[] = {7, -3, 12, 5, 0};
    int lo, hi;
    min_max(a, 5, &lo, &hi);
    printf("最小值 %d,最大值 %d\n", lo, hi);   // 输出:最小值 -3,最大值 12
    return 0;
}

2. 指针逆序

c
void reverse_array(int *arr, int n)
{
    if (n <= 0) {
        return;
    }
    int *left = arr;
    int *right = arr + n - 1;
    while (left < right) {
        int temp = *left;
        *left = *right;
        *right = temp;
        left++;
        right--;
    }
}

开头的判断不可省略:当 n 为 0 时,arr + n - 1 会指向数组之前的位置,C 标准规定连计算出这样的指针都是未定义行为。

3. 输出为:

text
10 20 20 21 30 2
10 21 30 40

逐句分析:*p++ 取出 arr[0] 的值 10,然后 p 指向 arr[1];*p 为 20;(*p)++ 先取出 20,再把 arr[1] 加 1 变为 21,p 不动;*p 为 21;*++p 先让 p 指向 arr[2],再取出 30;此时 p 指向 arr[2],p - arr 为 2。

4. 字符串排序

c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int compare_str(const void *a, const void *b)
{
    // 数组元素的类型是 const char *,所以 a、b 实际上是 const char * 的地址
    const char *const *pa = a;
    const char *const *pb = b;
    return strcmp(*pa, *pb);
}

int main(void)
{
    const char *words[] = {"banana", "apple", "cherry", "date", "fig"};
    int n = sizeof(words) / sizeof(words[0]);

    qsort(words, n, sizeof(words[0]), compare_str);

    for (int i = 0; i < n; i++) {
        printf("%s ", words[i]);
    }
    printf("\n");                // 输出:apple banana cherry date fig
    return 0;
}

一个常见的错误是把比较函数写成 return strcmp(a, b);:a、b 指向的是数组中的指针,而不是字符串本身,必须先解引用一次。

第 11 章

1. 一年中的第几天

c
#include <stdio.h>
#include <stdbool.h>

typedef struct {
    int year;
    int month;
    int day;
} Date;

bool is_leap(int year)
{
    return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
}

int day_of_year(const Date *d)
{
    static const int days_in_month[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
    int total = d->day;
    for (int m = 1; m < d->month; m++) {
        total += days_in_month[m - 1];
    }
    if (d->month > 2 && is_leap(d->year)) {
        total++;                    // 闰年且已过二月,多加一天
    }
    return total;
}

int main(void)
{
    Date d = {2024, 3, 1};
    printf("%d 年 %d 月 %d 日是当年的第 %d 天\n", d.year, d.month, d.day, day_of_year(&d));
    return 0;
}

运行结果:

text
2024 年 3 月 1 日是当年的第 61 天

2. 复数运算

c
#include <stdio.h>
#include <math.h>

typedef struct {
    double re;    // 实部
    double im;    // 虚部
} Complex;

Complex complex_add(Complex a, Complex b)
{
    Complex r;
    r.re = a.re + b.re;
    r.im = a.im + b.im;
    return r;
}

Complex complex_mul(Complex a, Complex b)
{
    Complex r;
    r.re = a.re * b.re - a.im * b.im;
    r.im = a.re * b.im + a.im * b.re;
    return r;
}

void print_complex(Complex c)
{
    printf("%.2f %c %.2fi\n", c.re, (c.im < 0) ? '-' : '+', fabs(c.im));
}

int main(void)
{
    Complex a = {1, 2};     // 1 + 2i
    Complex b = {3, -4};    // 3 - 4i
    print_complex(complex_add(a, b));   // 输出:4.00 - 2.00i
    print_complex(complex_mul(a, b));   // 输出:11.00 + 2.00i
    return 0;
}

Complex 只有两个 double,按值传递和返回的开销很小,这样写让代码更简洁。结构体较大时,才需要改为传指针。

3. 图书排序

c
#include <stdio.h>
#include <stdlib.h>

typedef struct {
    char title[40];
    char author[20];
    double price;
} Book;

int compare_price_desc(const void *a, const void *b)
{
    const Book *x = a;
    const Book *y = b;
    return (x->price < y->price) - (x->price > y->price);
}

int main(void)
{
    Book books[] = {
        {"数据结构", "王老师", 45.0},
        {"操作系统", "李老师", 59.0},
        {"计算机网络", "赵老师", 52.5},
        {"编译原理", "钱老师", 68.0},
        {"离散数学", "孙老师", 38.0}
    };
    int n = sizeof(books) / sizeof(books[0]);

    qsort(books, n, sizeof(Book), compare_price_desc);

    for (int i = 0; i < n; i++) {
        printf("%.2f 元  《%s》 %s\n", books[i].price, books[i].title, books[i].author);
    }
    return 0;
}

运行结果:

text
68.00 元  《编译原理》 钱老师
59.00 元  《操作系统》 李老师
52.50 元  《计算机网络》 赵老师
45.00 元  《数据结构》 王老师
38.00 元  《离散数学》 孙老师

4. sizeof(struct X) 为 24,sizeof(struct Y) 为 16。

struct X:a 在偏移 0;double 需要 8 字节对齐,所以填充 7 个字节,b 在偏移 8;c 在偏移 16,占 4 个字节;总大小必须是 8 的倍数,所以末尾再填充 4 个字节,共 24 字节。

struct Y:b 在偏移 0;c 在偏移 8;a 在偏移 12;末尾填充 3 个字节,共 16 字节。成员按从大到小的顺序排列,节省了 8 个字节。

第 12 章

1. 动态求平均

c
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int n;
    printf("请输入整数的个数:");
    if (scanf("%d", &n) != 1 || n <= 0) {
        printf("输入无效\n");
        return 1;
    }

    int *nums = malloc(n * sizeof *nums);
    if (nums == NULL) {
        printf("内存不足\n");
        return 1;
    }

    long long sum = 0;
    printf("请输入 %d 个整数:", n);
    for (int i = 0; i < n; i++) {
        scanf("%d", &nums[i]);
        sum += nums[i];
    }

    double avg = (double)sum / n;
    int above = 0;
    for (int i = 0; i < n; i++) {
        if (nums[i] > avg) {
            above++;
        }
    }
    printf("平均值:%.2f,高于平均值的有 %d 个\n", avg, above);

    free(nums);
    return 0;
}

2. 实现 strdup

c
#include <stdlib.h>
#include <string.h>

// 返回 s 的副本,失败时返回 NULL。返回的内存由调用者负责释放
char *my_strdup(const char *s)
{
    size_t len = strlen(s);
    char *copy = malloc(len + 1);        // +1 为 '\0' 留出位置
    if (copy != NULL) {
        memcpy(copy, s, len + 1);        // 连同 '\0' 一起复制
    }
    return copy;
}

POSIX 系统很早就提供了 strdup 函数,C23 标准也正式把它纳入了 <string.h>。

3. 动态二维数组

c
#include <stdio.h>
#include <stdlib.h>

void free_matrix(int **m, int rows)
{
    if (m == NULL) {
        return;
    }
    for (int i = 0; i < rows; i++) {
        free(m[i]);                     // 先释放每一行
    }
    free(m);                            // 再释放指针数组本身
}

int **create_matrix(int rows, int cols)
{
    int **m = malloc(rows * sizeof *m);          // 第一步:申请指针数组
    if (m == NULL) {
        return NULL;
    }
    for (int i = 0; i < rows; i++) {
        m[i] = malloc(cols * sizeof **m);        // 第二步:为每一行申请空间
        if (m[i] == NULL) {
            free_matrix(m, i);                   // 中途失败,释放已申请的 i 行
            return NULL;
        }
    }
    return m;
}

int main(void)
{
    int rows, cols;
    printf("请输入行数和列数:");
    if (scanf("%d %d", &rows, &cols) != 2 || rows <= 0 || cols <= 0) {
        printf("输入无效\n");
        return 1;
    }

    int **a = create_matrix(rows, cols);
    if (a == NULL) {
        printf("内存不足\n");
        return 1;
    }
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
            a[i][j] = i * j;                     // 可以像普通二维数组一样使用
            printf("%4d", a[i][j]);
        }
        printf("\n");
    }
    free_matrix(a, rows);
    return 0;
}

内存结构如下:a 指向一个指针数组,其中每个指针再指向一行数据。释放时的顺序与申请时相反:先释放各行,再释放指针数组。

text
  a
  │     +-------+      +----+----+----+----+
  └───▶ |   ●───┼────▶ |    |    |    |    |   第 0 行
        +-------+      +----+----+----+----+
        |   ●───┼────▶ |    |    |    |    |   第 1 行
        +-------+      +----+----+----+----+
        |   ●───┼────▶ |    |    |    |    |   第 2 行
        +-------+      +----+----+----+----+

4. 找错题

共有五处错误:

  1. malloc(strlen("Hello, ") + strlen(name)) 没有给 '\0' 留出空间,strcat 会越界写入 1 个字节,应当再加 1;
  2. 没有检查 malloc 的返回值是否为 NULL;
  3. free(s) 之后又 printf("%s\n", s),使用了已经释放的内存;
  4. nums 第二次赋值时,第一次申请的内存的地址丢失了,造成内存泄漏;
  5. free(nums) 执行了两次,重复释放。

顺便一提,用较新版本的 GCC(12 及以上)加 -Wall 编译这段代码,编译器会对第 3、5 两处错误给出 used after 'free' 警告。这再次说明了开启警告的价值——不过也要注意,编译器并不能发现所有的内存错误。

修正后的代码:

c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

char *make_greeting(const char *name)
{
    char *buf = malloc(strlen("Hello, ") + strlen(name) + 1);
    if (buf == NULL) {
        return NULL;
    }
    strcpy(buf, "Hello, ");
    strcat(buf, name);
    return buf;
}

int main(void)
{
    char *s = make_greeting("Tom");
    if (s != NULL) {
        printf("%s\n", s);
        free(s);
        s = NULL;
    }

    int *nums = malloc(10 * sizeof(int));
    // ……使用 nums……
    free(nums);
    nums = malloc(20 * sizeof(int));   // 先释放旧的内存,再申请新的
    // ……使用 nums……
    free(nums);
    nums = NULL;
    return 0;
}

第 13 章

以下函数都基于 13.5 节中的 Node 类型,测试时可以把它们和 13.5 节的程序放在一起。

1. 链表长度

c
int length_iterative(const Node *head)
{
    int count = 0;
    for (const Node *cur = head; cur != NULL; cur = cur->next) {
        count++;
    }
    return count;
}

int length_recursive(const Node *head)
{
    if (head == NULL) {                        // 空链表的长度为 0
        return 0;
    }
    return 1 + length_recursive(head->next);   // 1 + 剩余部分的长度
}

2. 反转链表

c
Node *reverse_list(Node *head)
{
    Node *prev = NULL;
    Node *cur = head;
    while (cur != NULL) {
        Node *next = cur->next;   // ① 先记住下一个节点
        cur->next = prev;         // ② 让当前节点指向前一个节点
        prev = cur;               // ③ prev 和 cur 各向前走一步
        cur = next;
    }
    return prev;                  // 循环结束时,prev 指向原来的最后一个节点
}

以 1 -> 2 -> 3 为例,每一轮循环把一个箭头"掉头":

text
初始:        NULL   1 -> 2 -> 3 -> NULL
第 1 轮后:   NULL <- 1    2 -> 3 -> NULL
第 2 轮后:   NULL <- 1 <- 2    3 -> NULL
第 3 轮后:   NULL <- 1 <- 2 <- 3        (返回 3)

3. 合并有序链表

c
Node *merge_sorted(Node *a, Node *b)
{
    Node dummy;                   // 哨兵节点:省去"结果链表为空"的特殊处理
    dummy.next = NULL;
    Node *tail = &dummy;          // tail 始终指向结果链表的最后一个节点

    while (a != NULL && b != NULL) {
        if (a->data <= b->data) {
            tail->next = a;
            a = a->next;
        } else {
            tail->next = b;
            b = b->next;
        }
        tail = tail->next;
    }
    tail->next = (a != NULL) ? a : b;   // 把剩下的部分整体接上
    return dummy.next;
}

这里用到了 13.6 节提到的"哨兵节点"技巧:dummy 是一个局部变量,它的 next 就是结果链表的头。

4. 中间节点

c
Node *middle_node(Node *head)
{
    Node *slow = head;
    Node *fast = head;
    while (fast != NULL && fast->next != NULL) {
        slow = slow->next;          // 慢指针每次走一步
        fast = fast->next->next;    // 快指针每次走两步
    }
    return slow;
}

快指针的速度是慢指针的两倍,当快指针走到链表末尾时,慢指针恰好走到中间。"快慢指针"是链表题中非常重要的技巧,还可以用来判断链表中是否有环。

第 14 章

1. 单词计数

c
#include <stdio.h>
#include <ctype.h>
#include <stdbool.h>

int main(int argc, char *argv[])
{
    if (argc != 2) {
        fprintf(stderr, "用法:%s 文件名\n", argv[0]);
        return 1;
    }
    FILE *fp = fopen(argv[1], "r");
    if (fp == NULL) {
        perror(argv[1]);
        return 1;
    }

    long lines = 0, words = 0, bytes = 0;
    bool in_word = false;
    int ch;
    while ((ch = fgetc(fp)) != EOF) {
        bytes++;
        if (ch == '\n') {
            lines++;
        }
        if (isspace(ch)) {
            in_word = false;
        } else if (!in_word) {      // 从空白进入非空白:一个新单词开始了
            in_word = true;
            words++;
        }
    }
    fclose(fp);

    printf("%ld 行,%ld 个单词,%ld 字节\n", lines, words, bytes);
    return 0;
}

对 14.2 节生成的 hello.txt 运行,输出 2 行,9 个单词,45 字节。

2. 转换大写

c
#include <stdio.h>
#include <ctype.h>

int main(int argc, char *argv[])
{
    if (argc != 3) {
        fprintf(stderr, "用法:%s 源文件 目标文件\n", argv[0]);
        return 1;
    }
    FILE *in = fopen(argv[1], "r");
    if (in == NULL) {
        perror(argv[1]);
        return 1;
    }
    FILE *out = fopen(argv[2], "w");
    if (out == NULL) {
        perror(argv[2]);
        fclose(in);
        return 1;
    }

    int ch;
    while ((ch = fgetc(in)) != EOF) {
        fputc(toupper(ch), out);
    }

    fclose(in);
    fclose(out);
    return 0;
}

3. 随机读取

c
#include <stdio.h>

typedef struct {
    char name[20];
    int age;
    double score;
} Student;

int main(void)
{
    FILE *fp = fopen("students.dat", "rb");
    if (fp == NULL) {
        perror("students.dat");
        return 1;
    }

    fseek(fp, 0, SEEK_END);
    long count = ftell(fp) / (long)sizeof(Student);    // 文件中的记录条数

    long k;
    printf("共有 %ld 条记录,请输入要查看的序号:", count);
    if (scanf("%ld", &k) != 1 || k < 1 || k > count) {
        printf("序号超出范围\n");
        fclose(fp);
        return 1;
    }

    Student s;
    fseek(fp, (k - 1) * (long)sizeof(Student), SEEK_SET);  // 直接跳到第 k 条记录
    if (fread(&s, sizeof(Student), 1, fp) == 1) {
        printf("第 %ld 条:%s,%d 岁,%.1f 分\n", k, s.name, s.age, s.score);
    }
    fclose(fp);
    return 0;
}

4. 统计数据文件

c
#include <stdio.h>

int main(void)
{
    FILE *fp = fopen("numbers.txt", "r");
    if (fp == NULL) {
        perror("numbers.txt");
        return 1;
    }

    int x, max = 0, count = 0;
    long long sum = 0;
    while (fscanf(fp, "%d", &x) == 1) {
        if (count == 0 || x > max) {
            max = x;
        }
        sum += x;
        count++;
    }
    fclose(fp);

    if (count == 0) {
        printf("文件中没有整数\n");
    } else {
        printf("共 %d 个整数,总和 %lld,平均值 %.2f,最大值 %d\n",
               count, sum, (double)sum / count, max);
    }
    return 0;
}

第 15 章

1. 宏的陷阱

c
#define MAX(a, b) ((a) > (b) ? (a) : (b))
#define ABS(x) ((x) < 0 ? -(x) : (x))

MAX(i++, j) 展开为 ((i++) > (j) ? (i++) : (j)):先比较 5 > 3(此后 i 变为 6),条件成立,于是再执行一次 i++,得到 6,然后 i 变为 7。最终 m 为 6,i 为 7——i 被加了两次,结果与使用者的预期(m 为 5,i 为 6)完全不同。更糟糕的是,从调用处的代码根本看不出问题。如果改用 inline 函数,参数只会在调用前求值一次,就不会有这个问题。

2. 断言宏

c
#include <stdio.h>

#define CHECK(cond)                                                          \
    do {                                                                     \
        if (!(cond)) {                                                       \
            fprintf(stderr, "检查失败:%s:%d: %s\n", __FILE__, __LINE__, #cond); \
        }                                                                    \
    } while (0)

int main(void)
{
    int x = -1;
    CHECK(x > 0);             // 输出类似:检查失败:main.c:13: x > 0
    CHECK(x == -1);           // 条件成立,没有输出
    printf("程序继续运行\n");
    return 0;
}

3. 多文件项目

mymath.h:

c
#ifndef MYMATH_H
#define MYMATH_H

int is_prime(int n);
int gcd(int a, int b);
int lcm(int a, int b);

#endif

mymath.c:

c
#include "mymath.h"

int is_prime(int n)
{
    if (n < 2) {
        return 0;
    }
    for (int i = 2; i * i <= n; i++) {
        if (n % i == 0) {
            return 0;
        }
    }
    return 1;
}

int gcd(int a, int b)
{
    while (b != 0) {
        int r = a % b;
        a = b;
        b = r;
    }
    return a;
}

int lcm(int a, int b)
{
    return a / gcd(a, b) * b;
}

main.c:

c
#include <stdio.h>
#include "mymath.h"

int main(void)
{
    printf("97 是素数吗?%s\n", is_prime(97) ? "是" : "否");
    printf("gcd(48, 18) = %d\n", gcd(48, 18));
    printf("lcm(4, 6) = %d\n", lcm(4, 6));
    return 0;
}

分步编译的命令:

bash
$ gcc -Wall -Wextra -c mymath.c
$ gcc -Wall -Wextra -c main.c
$ gcc main.o mymath.o -o app

Makefile(命令行前面是 Tab):

makefile
CC = gcc
CFLAGS = -Wall -Wextra -std=c11 -g

app: main.o mymath.o
	$(CC) $(CFLAGS) -o app main.o mymath.o

main.o: main.c mymath.h
	$(CC) $(CFLAGS) -c main.c

mymath.o: mymath.c mymath.h
	$(CC) $(CFLAGS) -c mymath.c

clean:
	rm -f app *.o

第 16 章

1. 数 1 的个数

c
int count_ones_simple(unsigned int n)
{
    int count = 0;
    while (n != 0) {
        count += n & 1u;     // 检查最低位
        n >>= 1;             // 右移一位,检查下一位
    }
    return count;
}

int count_ones_fast(unsigned int n)
{
    int count = 0;
    while (n != 0) {
        n &= n - 1;          // 每次去掉最低位的一个 1
        count++;
    }
    return count;
}

第一种方法的循环次数等于 n 的有效二进制位数(最高位 1 的编号加 1),第二种方法的循环次数恰好等于 1 的个数。

2. 不小于 n 的最小的 2 的幂

c
unsigned int next_power_of_two(unsigned int n)
{
    unsigned int p = 1;
    while (p < n) {
        p <<= 1;             // 每次乘以 2
    }
    return p;
}

这里假设 n 不超过 2³¹,否则结果无法用 32 位无符号整数表示。

3. 反转二进制位

c
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>

uint32_t reverse_bits(uint32_t n)
{
    uint32_t result = 0;
    for (int i = 0; i < 32; i++) {
        result = (result << 1) | (n & 1u);   // 取出 n 的最低位,放到 result 的最低位
        n >>= 1;
    }
    return result;
}

int main(void)
{
    printf("0x%08" PRIX32 "\n", reverse_bits(0x00000001u));   // 输出:0x80000000
    printf("0x%08" PRIX32 "\n", reverse_bits(0x0000000Fu));   // 输出:0xF0000000
    return 0;
}

4. 位集合

c
#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>

uint32_t set_add(uint32_t set, int k)
{
    return set | (UINT32_C(1) << k);
}

uint32_t set_remove(uint32_t set, int k)
{
    return set & ~(UINT32_C(1) << k);
}

bool set_contains(uint32_t set, int k)
{
    return (set >> k) & 1u;
}

void set_print(uint32_t set)
{
    printf("{ ");
    for (int k = 0; k < 32; k++) {
        if (set_contains(set, k)) {
            printf("%d ", k);
        }
    }
    printf("}\n");
}

int main(void)
{
    uint32_t a = 0, b = 0;
    a = set_add(a, 1);
    a = set_add(a, 3);
    a = set_add(a, 5);
    b = set_add(b, 3);
    b = set_add(b, 4);
    b = set_add(b, 5);
    b = set_remove(b, 4);

    set_print(a);        // { 1 3 5 }
    set_print(b);        // { 3 5 }
    set_print(a | b);    // 并集:{ 1 3 5 }
    set_print(a & b);    // 交集:{ 3 5 }
    return 0;
}

UINT32_C(1) 是 <stdint.h> 中的宏,表示一个与 uint_least32_t 相对应的整数常量 1(严格地说,它的类型是 uint_least32_t 经过整型提升后的类型,不一定正好是 uint32_t;在常见平台上,它就是 unsigned int 类型的 1u)。用它来移位,k 取 0~31 时结果都正确,也避免了对有符号数 1 左移 31 位带来的未定义行为。

第 17 章

1. 调试练习

问题出在 max 的初始值 0 上。它假设数组中至少有一个元素大于 0,并且忽略了 arr[0] 本身:对于 {5, 1, 2},1 和 2 都比 0 大,函数返回了 2 的下标,而真正的最大值 5 从来没有参与比较;对于 {-5, -2, -8},没有任何元素大于 0,函数返回 0,而正确答案是 1。修正方法是用第一个元素作为初始值:

c
int max_index(const int arr[], int n)
{
    int index = 0;                     // 假设第一个元素最大
    for (int i = 1; i < n; i++) {
        if (arr[i] > arr[index]) {
            index = i;
        }
    }
    return index;                      // 调用者需保证 n >= 1
}

2. 找出未定义行为

  • (a) 有:循环条件是 i <= 3,访问了越界的 a[3];
  • (b) 没有:无符号整数的运算按模 2ⁿ 进行,u 的结果是 UINT_MAX,这是标准明确规定的行为;
  • (c) 有:有符号整数溢出(假设 int 为 32 位);
  • (d) 没有:s 是一个用字符串初始化的数组,修改的是数组自己的内容;
  • (e) 有:t 指向字符串字面量,修改字符串字面量是未定义行为;
  • (f) 有:在同一个表达式中两次修改 i,且二者之间没有规定先后顺序。

3. 代码重构

c
#include <stdio.h>

#define STUDENT_COUNT 5
#define PASS_SCORE 60.0

double average(const double scores[], int n)
{
    double total = 0.0;
    for (int i = 0; i < n; i++) {
        total += scores[i];
    }
    return total / n;
}

double highest(const double scores[], int n)
{
    double max = scores[0];
    for (int i = 1; i < n; i++) {
        if (scores[i] > max) {
            max = scores[i];
        }
    }
    return max;
}

int main(void)
{
    double scores[STUDENT_COUNT];

    printf("请输入 %d 名学生的成绩:", STUDENT_COUNT);
    for (int i = 0; i < STUDENT_COUNT; i++) {
        if (scanf("%lf", &scores[i]) != 1) {
            fprintf(stderr, "输入的成绩无效\n");
            return 1;
        }
    }

    printf("平均分:%.2f,最高分:%.2f\n",
           average(scores, STUDENT_COUNT), highest(scores, STUDENT_COUNT));

    for (int i = 0; i < STUDENT_COUNT; i++) {
        const char *result = (scores[i] >= PASS_SCORE) ? "及格" : "不及格";
        printf("第 %d 名学生:%s\n", i + 1, result);
    }
    return 0;
}

除了格式上的改进,重构还修正了原代码的一个逻辑隐患:原代码把最大值的初始值设为 0,如果所有成绩都是负数(虽然对成绩来说不太可能,但函数被复用到别处时就可能出错),结果就会出错。

附录 B ASCII 码表

ASCII 码共有 128 个字符(0~127)。其中 0~31 和 127 是不可显示的控制字符,常用的有:

十进制十六进制名称C 语言中的写法含义
00x00NUL'\0'空字符,字符串的结束标记
70x07BEL'\a'响铃
80x08BS'\b'退格
90x09HT'\t'水平制表符(Tab)
100x0ALF'\n'换行
130x0DCR'\r'回车
270x1BESC'\x1b'转义,常用于控制终端的颜色和光标
1270x7FDEL'\x7f'删除

32~126 是可显示字符:

十进制十六进制字符十进制十六进制字符十进制十六进制字符十进制十六进制字符
320x20(空格)560x388800x50P1040x68h
330x21!570x399810x51Q1050x69i
340x22"580x3A:820x52R1060x6Aj
350x23#590x3B;830x53S1070x6Bk
360x24$600x3C<840x54T1080x6Cl
370x25%610x3D=850x55U1090x6Dm
380x26&620x3E>860x56V1100x6En
390x27'630x3F?870x57W1110x6Fo
400x28(640x40@880x58X1120x70p
410x29)650x41A890x59Y1130x71q
420x2A*660x42B900x5AZ1140x72r
430x2B+670x43C910x5B[1150x73s
440x2C,680x44D920x5C\1160x74t
450x2D-690x45E930x5D]1170x75u
460x2E.700x46F940x5E^1180x76v
470x2F/710x47G950x5F_1190x77w
480x300720x48H960x60 ``1200x78x
490x311730x49I970x61a1210x79y
500x322740x4AJ980x62b1220x7Az
510x333750x4BK990x63c1230x7B{
520x344760x4CL1000x64d1240x7C|
530x355770x4DM1010x65e1250x7D}
540x366780x4EN1020x66f1260x7E~
550x377790x4FO1030x67g

几个值得记住的规律:数字 '0'~'9' 是 48~57,大写字母 'A'~'Z' 是 65~90,小写字母 'a'~'z' 是 97~122,同一个字母的大小写相差 32。

附录 C 运算符优先级表

优先级数字越小,优先级越高。同一优先级的运算符按"结合方向"一栏所示的方向分组。注意,优先级和结合性只决定表达式如何分组,不决定操作数的求值先后(见 4.11 节)。

优先级运算符含义结合方向
1()函数调用从左到右
[]数组下标
. ->成员访问、通过指针访问成员
++ --后缀自增、自减
2++ --前缀自增、自减从右到左
+ -正号、负号
! ~逻辑非、按位取反
(类型)强制类型转换
* &解引用、取地址
sizeof求大小
3* / %乘、除、取余从左到右
4+ -加、减从左到右
5<< >>左移、右移从左到右
6< <= > >=关系比较从左到右
7== !=相等、不等从左到右
8&按位与从左到右
9^按位异或从左到右
10|按位或从左到右
11&&逻辑与从左到右
12||逻辑或从左到右
13?:条件运算从右到左
14= += -= *= /= %= <<= >>= &= ^= |=赋值与复合赋值从右到左
15,逗号运算从左到右

最容易出错的几种组合:

表达式实际含义本意通常是
*p++*(p++)取值后指针后移(这通常就是本意)
x & 1 == 0x & (1 == 0)(x & 1) == 0
a << 1 + 1a << (1 + 1)(a << 1) + 1
*p.x*(p.x)(*p).x,即 p->x
int *a[10]含 10 个指针的数组如果想要"指向数组的指针",应写 int (*a)[10]
c = getchar() != EOFc = (getchar() != EOF)(c = getchar()) != EOF

附录 D 常用标准库函数速查

`<stdio.h>` 输入输出

函数功能
printf / fprintf / snprintf格式化输出到屏幕 / 文件 / 字符数组
scanf / fscanf / sscanf从键盘 / 文件 / 字符串格式化读取
getchar / putchar读取 / 输出一个字符
fgets / fputs / puts读取一行 / 写入字符串 / 输出字符串并换行
fopen / fclose打开 / 关闭文件
fgetc / fputc从文件读取 / 向文件写入一个字符
fread / fwrite按块读写二进制数据
fseek / ftell / rewind设置 / 获取 / 重置文件位置
feof / ferror判断之前的读取是否碰到了文件末尾 / 是否发生了错误
fflush把输出缓冲区的内容立即写出
perror输出错误信息
remove / rename删除 / 重命名文件

`<stdlib.h>` 通用工具

函数功能
malloc / calloc / realloc / free动态内存的申请、清零申请、调整大小、释放
atoi / atof字符串转换为 int / double(不检查错误)
strtol / strtoul / strtod字符串转换为 long / unsigned long / double(可检查错误)
rand / srand生成伪随机数 / 设置随机数种子
abs / labs整数的绝对值
qsort / bsearch通用排序 / 二分查找
exit结束程序
getenv获取环境变量的值

`<string.h>` 字符串与内存

函数功能
strlen字符串长度
strcpy / strncpy复制字符串
strcat / strncat拼接字符串
strcmp / strncmp比较字符串
strchr / strrchr查找字符第一次 / 最后一次出现的位置
strstr查找子串
strtok按分隔符拆分字符串
strspn / strcspn开头连续属于 / 不属于某个字符集合的长度
memcpy / memmove复制内存(memmove 允许源和目标重叠)
memset把一段内存的每个字节设为指定值
memcmp比较两段内存
strerror返回错误代码对应的描述字符串

`<ctype.h>` 字符处理

函数功能
isalpha / isdigit / isalnum是否为字母 / 数字 / 字母或数字
isupper / islower是否为大写 / 小写字母
isspace / ispunct / isxdigit是否为空白字符 / 标点符号 / 十六进制数字
toupper / tolower转换为大写 / 小写

`<math.h>` 数学函数(在 Linux 上使用 GCC 时,编译命令末尾可能需要加 -lm)

函数功能
sqrt / cbrt平方根 / 立方根
pow / exp幂运算 / e 的幂
log / log10 / log2自然对数 / 常用对数 / 以 2 为底的对数
fabs浮点数的绝对值
ceil / floor / round / trunc向上取整 / 向下取整 / 四舍五入 / 截断小数
fmod浮点数取余
sin / cos / tan / atan2三角函数(参数是弧度)
hypot求直角三角形的斜边长

`<time.h>` 时间

函数功能
time获取当前时间(在 Linux、macOS、Windows 等常见系统上是从 1970 年 1 月 1 日起的秒数;C 标准本身并未规定这种表示方式)
clock获取程序占用的处理器时间,除以 CLOCKS_PER_SEC 得到秒数,可用于粗略测量运行时间
difftime计算两个时间之差
localtime / strftime转换为本地时间 / 按指定格式生成时间字符串

其他常用头文件

头文件主要内容
<assert.h>assert 断言
<stdbool.h>bool、true、false(C23 中已成为关键字)
<stdint.h> / <inttypes.h>int32_t、uint64_t 等定长整数类型及其格式宏
<limits.h> / <float.h>INT_MAX、DBL_MAX 等各类型的取值范围
<stddef.h>size_t、ptrdiff_t、NULL、offsetof
<errno.h>errno 错误代码

完整的函数列表和详细用法,请查阅 cppreference。

附录 E C 语言关键字

C89/C90 的 32 个关键字(按用途分类)

类别关键字
基本类型char short int long float double void
类型修饰signed unsigned
自定义类型struct union enum typedef
存储类别auto static extern register
类型限定const volatile
分支if else switch case default
循环for while do
跳转break continue goto return
运算符sizeof

后续标准新增的关键字

标准新增关键字
C99inline restrict _Bool _Complex _Imaginary
C11_Alignas _Alignof _Atomic _Generic _Noreturn _Static_assert _Thread_local
C23alignas alignof bool constexpr false nullptr static_assert thread_local true typeof typeof_unqual _BitInt _Decimal32 _Decimal64 _Decimal128

C99 和 C11 新增的关键字大多以"下划线 + 大写字母"开头,这是为了避免与已有程序中的名字冲突(这类名字是保留给语言本身使用的)。C23 则为其中常用的几个提供了更自然的写法,例如 bool 与 _Bool、static_assert 与 _Static_assert。另外,C23 还改变了 auto 的含义,使它可以像 C++ 那样用于自动推导变量类型。

附录 F 常见错误信息速查

以下信息以 GCC 为例(Clang 的措辞与之类似)。遇到不认识的报错时,也可以直接把报错信息复制到搜索引擎中查找。

编译错误和警告

错误信息含义常见原因与解决方法
expected ';' before ...缺少分号先看报错位置所在行的末尾(GCC 通常直接在漏写分号处标出 ;);如果报错指向某一行的开头,再检查上一行的末尾
'x' undeclared (first use in this function)变量 x 未声明变量名拼写错误,或者忘记定义变量
implicit declaration of function 'f'使用了未声明的函数忘记包含头文件、函数名拼写错误,或者函数定义在调用之后却没有写声明
stray '\342' in program,或报错信息中出现 \U0000ff1b、\U0000ff08 等字样程序中出现了非法字符代码中混入了中文标点:中文引号通常引发 stray '\342',中文分号、括号、逗号则会引发含有 \U0000ffxx 的报错。把它们换成英文半角符号
missing terminating " character字符串缺少结束引号检查双引号是否配对
expected declaration or statement at end of input文件意外结束花括号不配对,缺少 }
'else' without a previous 'if'else 找不到对应的 ifif 后面多了分号,或者多条语句没有用花括号括起来
break statement not within loop or switchbreak 用错了位置break 只能用在循环或 switch 中
duplicate case valueswitch 中有重复的 case 值检查各个 case 后的常量
redefinition of 'x'重复定义同一个作用域中定义了两个同名的变量或函数
conflicting types for 'f'函数的声明与定义不一致检查声明和定义的参数、返回类型;或者函数在声明之前就被调用了
too few arguments to function 'f'调用函数时参数太少对照函数的定义检查参数个数
assignment to expression with array type对数组整体赋值数组不能用 = 赋值,字符串要用 strcpy
lvalue required as left operand of assignment赋值号左边不是可以赋值的对象例如 a + b = 5,或者把 == 误写成了 = 的某些情形
format '%d' expects argument of type 'int', but argument 2 has type 'double'格式说明符与参数类型不匹配改用正确的格式说明符(double 用 %f)
passing argument 1 of 'f' makes pointer from integer without a cast需要指针的地方传入了整数常见于把字符当作字符串传给 strlen、strcpy 等函数(如 strlen(c),而 c 是 char)。较新的 GCC(14 起)会直接把它当作错误
format '%d' expects argument of type 'int *', but argument 2 has type 'int'scanf 的参数不是地址忘记写 &,应改为 scanf("%d", &n)
initialization of 'int *' from incompatible pointer type 'double *'指针类型不匹配检查指针的类型是否正确
comparison between pointer and integer指针与整数比较常见于 if (c == "a"),字符应当用单引号:'a'
control reaches end of non-void function有返回值的函数可能没有执行 return检查每一条执行路径是否都有 return
suggest parentheses around assignment used as truth value在条件中使用了赋值通常是把 == 写成了 =
unused variable 'x'定义了变量却没有使用删除多余的变量,或者检查是否用错了变量名

链接错误

错误信息含义常见原因与解决方法
undefined reference to 'f'找不到函数 f 的定义函数名拼写错误;忘记编译某个 .c 文件;使用数学函数时忘记加 -lm
undefined reference to 'main'找不到 main 函数main 拼写错误,或者编译的文件中根本没有 main
multiple definition of 'x'同一个名字被定义了多次通常是在头文件中定义了变量或函数,应改为在头文件中声明、在一个 .c 文件中定义

运行时错误

现象含义常见原因
Segmentation fault(段错误)访问了不允许访问的内存解引用空指针或野指针、数组越界、scanf 忘记写 &、递归太深导致栈溢出
Floating point exception算术运算错误整数除以 0 或对 0 取余(虽然名字里有"浮点",但通常是整数运算引起的)
free(): double free detected重复释放内存同一个指针被 free 了两次
*** stack smashing detected ***栈上的数组被越界写入字符数组太小,或者用 strcpy、scanf("%s") 写入了过长的字符串
程序返回值为 -1073741819(即 3221225477,十六进制 0xC0000005)Windows 上的访问冲突,相当于段错误同段错误
程序返回值为 -1073741571(即 3221225725,十六进制 0xC00000FD)Windows 上的栈溢出递归过深,或者在函数中定义了过大的局部数组
程序返回值为 -1073741676(即 3221225620,十六进制 0xC0000094)Windows 上的整数除以 0同 Floating point exception

Visual Studio 特有

错误信息解决方法
C4996: 'scanf': This function or variable may be unsafe在源文件第一行加上 #define _CRT_SECURE_NO_WARNINGS,或者在项目属性中关闭"SDL 检查"(见 3.2 节)