C 语言从零到进阶:写给零基础的完整学习指南
一份写给零基础读者的 C 语言完整指南:从安装编译器讲起,覆盖指针、内存管理、链表、文件与多文件编程,最终独立完成一个学生成绩管理系统。
从安装编译器开始,一步步走到独立完成一个"学生成绩管理系统"。全文共 20 章(第 0~19 章)和 6 个附录,第 1~17 章末尾都配有练习,参考答案集中放在附录 A。
阅读指南
这份指南适合谁
- 从未写过代码、想把 C 作为第一门编程语言的人;
- 学过一点 C,但对指针、内存、文件等内容一知半解的人;
- 即将学习数据结构、操作系统、嵌入式开发,或正在准备计算机考研,需要把 C 基础打扎实的人。
怎么读效果最好
- 按顺序读。 章节由浅入深,后面的内容依赖前面的基础。标注"选读"的小节,第一遍可以跳过。
- 每个示例都亲手敲一遍。 不要复制粘贴——手指敲过的代码才会留在脑子里。敲完后试着改一改,观察结果如何变化。
- 先做练习,再看答案。 卡住半小时以上再去看附录 A,看懂之后合上答案,自己重写一遍。
- 遇到指针和内存,就画图。 用纸笔画出变量、地址和箭头,比盯着代码看十遍都管用。
建议进度(每天 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"这条指令长这样:
10111000 00000101 00000000 00000000 00000000最早的程序员就是这样一位一位地写程序的,既痛苦又容易出错。于是人们发明了汇编语言,用英文缩写代替 0 和 1:
mov eax, 5汇编语言好读多了,但它和具体的 CPU 绑定:换一种 CPU,程序就得重写。后来人们又发明了高级语言,写法更接近人类的思维方式,而且与具体硬件无关:
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 / C90 | 1989 / 1990 | 第一个官方标准(常称 ANSI C),统一了各家编译器的差异 |
| C99 | 1999 | // 单行注释、long long 类型、布尔类型、变长数组、可以在代码中间声明变量、for (int i = 0; ...) 写法 |
| C11 | 2011 | 多线程支持、_Generic、_Static_assert、匿名结构体;删除了危险的 gets 函数 |
| C17 | 2018 | 只修正缺陷,没有新特性(也常被称为 C18) |
| C23 | 2024 | bool/true/false 成为关键字,新增 nullptr、constexpr、typeof、二进制字面量 0b1010、数字分隔符 1'000'000 等 |
本文的代码以 C11 为准,这些代码在更新的标准下同样可以编译。GCC 从 15 版开始默认采用 C23 标准,你在较新的编译器上运行本文代码也不会有问题。
0.3 为什么今天还要学 C
C 语言已经五十多岁了,为什么还值得学?
- 它无处不在。 Linux、Windows、macOS 的内核主要用 C 写成;家电、汽车电子、单片机等嵌入式设备大量使用 C;SQLite、Redis、PostgreSQL 等数据库,Nginx 服务器,Git 版本控制工具,以及 Python 的官方解释器(CPython)都是用 C 写的。
- 它让你真正理解计算机。 内存、地址、指针、编译与链接——这些在 Python、Java 中被隐藏起来的概念,在 C 里一览无余。学透 C,再学任何语言都会快很多。
- 它是许多语言的"祖先"。 C++、Java、C#、JavaScript、Go 的语法都深受 C 影响:
if、for、while、花括号……学会了 C,再看这些语言会觉得非常眼熟。 - 学业和职业的需要。 它是大多数高校理工科专业的第一门编程课;计算机考研中,数据结构的算法题通常用 C 或 C++ 作答;嵌入式、驱动、操作系统、高性能计算等岗位都把 C 作为必备技能。
C 语言的特点可以概括为:
| 优点 | 缺点 |
|---|---|
| 运行速度快,占用资源少 | 需要手动管理内存 |
| 贴近硬件,可以直接操作内存 | 几乎没有安全检查,数组越界、野指针等错误不会被自动发现 |
| 可移植性好,几乎所有平台都有 C 编译器 | 标准库比较精简,很多功能需要自己实现 |
| 语法简洁,关键字少 | 某些语法(如复杂的指针声明)不够直观 |
一句话:C 给了你极大的自由,也要求你承担相应的责任。
0.4 如何学好 C 语言
- 动手第一。 编程是一门手艺,只看不练等于没学。本文的每段示例代码都请亲手敲一遍并运行。
- 学会阅读报错信息。 编译器的报错是你最好的老师。报错大多是英文,但常见的就那么几十条(附录 F 列出了最常见的错误信息),看多了自然就熟悉了。
- 理解而非背诵。 不要死记语法规则,要理解"为什么这样设计"。理解了内存模型,指针就不再可怕。
- 勤画图。 遇到数组、指针、链表时,在纸上画出内存格子和箭头,问题往往就迎刃而解。
- 小步前进。 写几行就编译运行一次,不要写了几百行才第一次编译——到时候几十个错误会让你无从下手。
- 学会查文档。 想知道某个函数怎么用,就去查 cppreference(en.cppreference.com,其中有 C 语言部分,也有中文版)。
- 合理使用 AI 工具。 让 AI 解释报错、讲解概念、检查你写好的代码,都是很好的学习方式;但如果让 AI 替你写完所有练习,就像把健身卡借给别人去跑步——卡是用了,肌肉却没长在你身上。
第 1 章 搭建开发环境,写出第一个程序
1.1 写程序需要哪些工具
写 C 程序需要两样东西:
- 编辑器:用来编写源代码。理论上记事本就够用,但专业的编辑器提供语法高亮、自动补全、错误提示,能极大提升效率。
- 编译器:把 C 源代码翻译成可以运行的程序。主流的 C 编译器有三个:GCC(GNU 编译器套件,Linux 上的标准配置)、Clang(macOS 的默认编译器)和 MSVC(微软的编译器,随 Visual Studio 一起安装)。
把编辑器、编译器和调试器打包在一起的软件叫作 IDE(集成开发环境),装好一个 IDE 就能一站式完成"编写—编译—运行—调试"。
整个流程是这样的:
编写源代码 编译 运行
hello.c(文本文件) ────────────▶ hello.exe(可执行程序) ────────▶ 屏幕上显示结果1.2 安装开发环境
根据你的操作系统,选择一种方案即可。
Windows 方案一:Visual Studio Community(最省心)
- 在微软官网下载 Visual Studio Community(社区版,个人学习免费)。
- 安装时勾选工作负载 "使用 C++ 的桌面开发"(它同时包含 C 编译器)。
- 新建项目时选择 "空项目",然后在"源文件"上右键 → 添加 → 新建项,文件名写
main.c。后缀一定要是 `.c`,如果是.cpp,代码会被当作 C++ 编译。 - 写好代码后,按 Ctrl + F5(开始执行,不调试)运行程序。
注意在 Visual Studio 中使用scanf会报 C4996 错误,解决方法见 3.2 节。
Windows 方案二:VS Code + MinGW-w64(轻量、通用)
- 在 MSYS2 官网(msys2.org)下载并安装 MSYS2,默认安装位置为
C:\msys64。 - 安装结束后会打开一个 "MSYS2 UCRT64" 终端窗口,在其中输入以下命令安装 GCC 工具链。遇到选择提示直接按回车(表示全部安装),再输入
Y确认:
pacman -S --needed base-devel mingw-w64-ucrt-x86_64-toolchain- 把
C:\msys64\ucrt64\bin添加到系统环境变量Path中(设置 → 系统 → 系统信息 → 高级系统设置 → 环境变量)。 - 打开一个新的命令提示符窗口,输入
gcc --version,能看到版本信息就说明安装成功了。 - 安装 VS Code,在扩展商店中搜索并安装微软官方的 C/C++ 扩展。
Windows 方案三:小熊猫 C++ / Dev-C++(开箱即用)
这类轻量 IDE 自带编译器,安装完就能写代码,很多学校的课堂和信息学竞赛都在用。功能相对简单,非常适合入门阶段。
macOS
打开"终端",输入:
xcode-select --install在弹出的窗口中点击安装,即可获得 Apple 的命令行开发工具(包含 Clang 编译器)。安装完成后输入 clang --version 验证。在 macOS 上输入 gcc 实际调用的也是 Clang,用法完全相同。编辑器推荐 VS Code 或 CLion。
Linux
大多数发行版一条命令就能装好:
# 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,输入以下代码:
#include <stdio.h>
int main(void)
{
printf("Hello, World!\n");
return 0;
}运行结果:
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 所在的文件夹,输入:
$ 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。
在日常学习中,建议使用下面这条"加强版"编译命令:
$ 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 这一条命令,实际上依次完成了四个步骤:
hello.c ──预处理──▶ hello.i ──编译──▶ hello.s ──汇编──▶ hello.o ──链接──▶ hello
源代码 展开后的源代码 汇编代码 目标文件 可执行程序- 预处理(Preprocessing):处理以
#开头的指令。比如#include <stdio.h>会被替换成stdio.h文件的全部内容,所以预处理后的文件往往有几百上千行。 - 编译(Compilation):把 C 代码翻译成汇编代码,语法错误就是在这一步被发现的。
- 汇编(Assembly):把汇编代码翻译成机器指令,生成目标文件(
.o文件)。 - 链接(Linking):
printf函数的机器代码并不在你的hello.o里,而是在 C 标准库中。链接器把你的目标文件和标准库"拼接"起来,生成最终的可执行程序。
你可以用下面的命令分步观察每个阶段的产物:
$ 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 语言有两种注释:
// 单行注释:从 // 开始,直到这一行结束
/* 多行注释:从斜杠星号开始,
到星号斜杠结束,
中间可以跨越多行 */注意多行注释不能嵌套:/* 外层 /* 内层 */ 外层 */ 中,第一个 */ 就会结束注释,剩下的"外层 */"会导致编译错误。
好的注释解释"为什么这样做",而不是重复代码"在做什么":
i = i + 1; // i 加 1 ← 废话,代码本身已经说明了
i = i + 1; // 跳过第一行的表头 ← 有用,说明了意图提示调试时,可以用注释临时"关掉"一段代码。大多数编辑器中,选中代码后按Ctrl + /(macOS 上是Cmd + /)即可快速注释或取消注释。
1.7 新手最常见的编译错误
写代码出错是常态,关键是能看懂报错信息。GCC 的报错格式是:
文件名:行号:列号: 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.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,并且重新打开命令行窗口(已打开的窗口不会自动读取新的环境变量)。
本章练习
- 个人名片。 编写程序,分多行输出你的姓名、专业(或职业)和一句座右铭,上下各用一行
====================围起来。 - 故意犯错。 在 Hello World 程序中分别制造以下错误:删掉分号、把
printf写成Printf、删掉右花括号}、使用中文的双引号。每次只制造一个错误,编译后记录报错信息,体会错误写法和报错之间的对应关系。 - 星号三角形。 用
printf输出下面的图案(只用printf,不用循环):
*
***
*****
*******第 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 个字节,并且都有一个唯一的编号,这个编号就叫作地址:
地址 0x1000 0x1001 0x1002 0x1003 0x1004 ...
+--------+--------+--------+--------+--------+
内容 |01001000|01101001|00000000|11111111|00101010| ...
+--------+--------+--------+--------+--------+地址通常用十六进制书写(0x 开头)。你需要熟悉三种进制:
- 十进制:日常使用,逢十进一,用 0~9 表示。
- 二进制:计算机使用,逢二进一,只用 0 和 1。
- 十六进制:二进制的"简写",逢十六进一,用 0~9 和 A~F(表示 10~15)。1 位十六进制数正好对应 4 位二进制数,所以书写内存内容时非常方便。
| 十进制 | 二进制 | 十六进制 | 十进制 | 二进制 | 十六进制 | |
|---|---|---|---|---|---|---|
| 0 | 0000 | 0 | 8 | 1000 | 8 | |
| 1 | 0001 | 1 | 9 | 1001 | 9 | |
| 2 | 0010 | 2 | 10 | 1010 | A | |
| 3 | 0011 | 3 | 11 | 1011 | B | |
| 4 | 0100 | 4 | 12 | 1100 | C | |
| 5 | 0101 | 5 | 13 | 1101 | D | |
| 6 | 0110 | 6 | 14 | 1110 | E | |
| 7 | 0111 | 7 | 15 | 1111 | F |
十进制转二进制用"除 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 变量:有名字的储物柜
变量就是一块有名字的内存空间,用来存放可以变化的数据。使用变量之前必须先定义它,告诉编译器变量的类型和名字:
#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;
}运行结果:
年龄:18
身高:1.75 米
等级:A
明年的年龄:19printf 中的 %d、%f、%c 是格式说明符,表示"在这里填入一个整数/小数/字符",第 3 章会详细讲解。
关于变量,有几个要点:
1. 初始化与赋值。 定义变量时给它一个值叫初始化(int age = 18;),定义之后再修改它的值叫赋值(age = 19;)。可以一次定义多个同类型的变量:int a = 1, b = 2, c;。
2. 一定要初始化。 函数内部定义的变量如果没有初始化,它的值是不确定的——储物柜里还留着上一个使用者的东西,俗称"垃圾值"。使用未初始化的变量是一种常见且隐蔽的错误:
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 格式 |
|---|---|---|---|---|
char | 1 字节 | -128~127 | 字符、很小的整数 | %c / %d |
short | 2 字节 | -32768~32767 | 较小的整数 | %hd |
int | 4 字节 | 约 ±21 亿 | 最常用的整数类型 | %d |
long | 4 或 8 字节 | 因平台而异 | 较大的整数 | %ld |
long long | 8 字节 | 约 ±9.2×10¹⁸ | 很大的整数 | %lld |
float | 4 字节 | 约 6~7 位有效数字 | 单精度小数 | %f |
double | 8 字节 | 约 15~16 位有效数字 | 双精度小数,最常用 | %f |
long double | 8~16 字节 | 因平台而异 | 更高精度的小数 | %Lf |
C 标准并没有规定每种类型的确切大小,只规定了最小范围和大小关系(short ≤ int ≤ long ≤ long long)。例如 long 在 64 位 Windows 上是 4 字节,在 64 位 Linux 和 macOS 上是 8 字节。用 sizeof 运算符可以查看某个类型在你电脑上的实际大小:
#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 上的运行结果:
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> 中定义了各种类型的取值范围:
#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;
}运行结果:
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 位为例:
+5 的原码:0000 0101
-5 的原码:1000 0101但原码有两个问题:0 有两种表示(0000 0000 和 1000 0000),而且加减法电路设计复杂。所以现代计算机普遍使用补码:
- 正数的补码就是它本身;
- 负数的补码 = 对应正数的二进制按位取反,再加 1。
+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 两种常用类型。浮点数有两种写法:
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 这样的数,只能存一个非常接近的近似值。请看:
#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;
}运行结果:
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> 中。从结果可以得出三条经验:
- 不要用 `==` 直接比较两个浮点数,而是判断它们的差的绝对值是否小于一个很小的数(如
1e-9)。 - 优先使用 `double`。
float只有 6~7 位有效数字,误差很快就会累积起来;double精度高得多,而在现代电脑上速度几乎没有差别。 - 涉及金额的计算不要用浮点数,可以用整数以"分"为单位存储。
提示在 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。并且数字、大写字母、小写字母各自都是连续编号的。
#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;
}运行结果:
字符 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:
#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 定义,预处理时会把代码中所有的宏名替换为对应的值。注意语法:没有等号,末尾也没有分号。
#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;
}运行结果:
半径为 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。
#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;
}运行结果:
n = 3
avg1 = 3.0, avg2 = 3.5, avg3 = 3.5对于第一行,有的编译器(如 Clang)会给出警告,提醒你小数部分会被丢弃。avg1 则是一个经典的坑:两个整数相除,结果仍然是整数,小数部分在除法时就已经丢失了,之后再转换成 double 也于事无补。计算平均值时,一定要让至少一个操作数是浮点数。
有符号与无符号混用的陷阱:
#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。尽量避免有符号数和无符号数混合运算,这也是要认真对待编译器警告的原因之一。
本章练习
- 用
sizeof输出char、short、int、long、long long、float、double、long double在你电脑上的大小,并与本章表格对比。 - 判断下列标识符是否合法,不合法的说明原因:
_count、2nd_place、my-name、int、Int、total_sum、a b、score3。 - 已知
char c = 'm';,不查 ASCII 表,分别写出表达式求:c对应的大写字母、c在字母表中的序号(a为 1)、c后面第 3 个字母。用printf输出验证。 - 不运行程序,先写出下面代码的输出,再运行验证:
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 的基本形式是:
printf("格式字符串", 参数1, 参数2, ...);格式字符串中的普通文字会原样输出,而以 % 开头的格式说明符会依次被后面的参数替换。
常用格式说明符:
| 说明符 | 含义 | 示例 | 输出 |
|---|---|---|---|
%d | 十进制整数(int) | printf("%d", 42) | 42 |
%u | 无符号十进制整数 | printf("%u", 42u) | 42 |
%ld / %lld | long / 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 的形式,并去掉末尾多余的 0 | printf("%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 章 | |
%zu | size_t 类型(如 sizeof 的结果) | printf("%zu", sizeof(int)) | 4 |
%% | 输出百分号本身 | printf("100%%") | 100% |
控制宽度、精度和对齐。 在 % 和字母之间可以加入修饰:
#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;
}运行结果:
[42]
[ 42]
[42 ]
[00042]
[+42]
[3.141593]
[3.14]
[ 3.142]
[1.234568e+05]
[ff] [FF] [10]
[Hel]
[100%]利用宽度控制,可以输出整齐的表格:
#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;
}运行结果:
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 很像:
#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;
}运行结果:
请输入两个整数: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。
#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 的运行结果:
请输入一个整数:25
scanf 成功读取了 1 个数据,n = 25请输入一个整数: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'` 放进缓冲区。
这会导致一个经典问题:
#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;
}运行结果:
请输入年龄:18
请输入等级:年龄 = 18,等级 = [
]程序根本没有等我们输入等级!过程是这样的:输入 18 并回车后,缓冲区里是 18\n。第一个 scanf("%d") 取走了 18,把 \n 留在了缓冲区;第二个 scanf("%c") 一看缓冲区里有字符,直接把 \n 读走了。
解决方法有两种:
方法一:在 %c 前面加一个空格,写成 scanf(" %c", &grade);。格式字符串中的空格表示"跳过所有空白字符(空格、Tab、换行)"。
方法二:读取之后清空缓冲区中剩余的内容:
int ch;
while ((ch = getchar()) != '\n' && ch != EOF) {
// 循环体为空:只是把这一行剩下的字符逐个读走、丢弃
}这里用到的 while 循环会在第 6 章学习,你可以先把它当作一个固定的"工具代码"来使用。
常见错误网上有些资料用fflush(stdin)清空输入缓冲区。C 标准只定义了fflush对输出流的作用,对输入流使用它是未定义行为,只在部分编译器上"碰巧"有效,请不要使用。
3.4 字符输入输出:getchar 与 putchar
getchar() 从输入缓冲区读取一个字符并返回它,putchar(c) 向屏幕输出一个字符。它们比 scanf/printf 更简单、更高效:
#include <stdio.h>
int main(void)
{
printf("请输入一个小写字母:");
int ch = getchar();
printf("它的 ASCII 码是 %d,对应的大写字母是 ", ch);
putchar(ch - 32);
putchar('\n');
return 0;
}运行结果:
请输入一个小写字母:b
它的 ASCII 码是 98,对应的大写字母是 B注意 getchar() 的返回值类型是 int 而不是 char。这是因为它除了返回普通字符外,还可能返回表示"输入结束"的 EOF(一个负数,通常是 -1),需要一个能同时容纳所有字符和 EOF 的类型。在键盘上可以手动输入"结束信号":Windows 上是先按 Ctrl + Z 再按回车,Linux 和 macOS 上是 Ctrl + D。
3.5 综合示例:BMI 计算器
BMI(身体质量指数)= 体重(千克)÷ 身高(米)的平方。
#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;
}运行结果:
请输入体重(千克):65
请输入身高(米):1.75
你的 BMI 指数是:21.2学完第 5 章后,我们可以让程序再根据 BMI 值给出"偏瘦""正常""超重"等判断。
本章练习
- 圆的周长和面积。 输入圆的半径,输出周长和面积,结果保留 2 位小数。
- 求和与平均值。 输入三个整数,输出它们的和,以及保留 2 位小数的平均值。(提示:小心整数除法。)
- 大写转小写。 输入一个大写字母,输出对应的小写字母及其 ASCII 码。
- 进制与格式。 输入一个整数,分别以十进制、八进制、十六进制输出;再以宽度 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 + 2 | 9 |
- | 减 | 7 - 2 | 5 |
* | 乘 | 7 * 2 | 14 |
/ | 除 | 7 / 2 | 3(整数除法) |
% | 取余(求模) | 7 % 2 | 1 |
除法和取余有几个需要特别注意的地方:
#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;
}运行结果:
7 / 2 = 3
-7 / 2 = -3
7 % 3 = 1
-7 % 3 = -1
7 % -3 = 1
7.0 / 2 = 3.5- 整数除法会舍去小数部分(向 0 取整),所以
7 / 2是 3,-7 / 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 += 5 | x = x + 5 |
x -= 5 | x = x - 5 |
x *= 5 | x = x * 5 |
x /= 5 | x = x / 5 |
x %= 5 | x = x % 5 |
下面用这些运算符实现一个"找零"程序:用最少的纸币凑出指定金额。
#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;
}运行结果:
请输入金额(元):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。
#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;
}运行结果:
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 > b | 1 |
< | 小于 | a < b | 0 |
>= | 大于等于 | a >= 5 | 1 |
<= | 小于等于 | b <= 2 | 0 |
== | 等于 | a == 5 | 1 |
!= | 不等于 | a != b | 1 |
常见错误判断相等要用两个等号==。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 表示假):
a | b | a && b | a || b | !a |
|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 1 |
| 0 | 1 | 0 | 1 | 1 |
| 1 | 0 | 0 | 1 | 0 |
| 1 | 1 | 1 | 1 | 0 |
回忆一下:在 C 语言中,0 为假,任何非 0 值都为真。所以 !5 的值是 0,!0 的值是 1。
短路求值。 && 和 || 都是从左往右计算的,并且一旦能确定结果,就不再计算右边:
- 对于
A && B,如果A为假,结果必然为假,B不会被计算; - 对于
A || B,如果A为真,结果必然为真,B不会被计算。
#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;
}运行结果:
r = 0
y = 1, x = 1短路求值非常实用:把"保护性"的条件写在左边(如"除数不为 0"),可以保证右边的运算是安全的。
例:判断闰年。 闰年的规则是"能被 4 整除但不能被 100 整除,或者能被 400 整除",用逻辑运算符可以直接翻译:
#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;
}运行结果:
请输入年份:2024
2024 年是闰年吗?1你可以再试试 1900(不是闰年)和 2000(是闰年)。
4.7 条件运算符
条件运算符 ? : 是 C 语言中唯一需要三个操作数的运算符:
条件 ? 表达式1 : 表达式2如果条件为真,整个表达式的值是表达式 1,否则是表达式 2。它适合用来写简单的"二选一":
int max = (a > b) ? a : b; // 取 a、b 中的较大者
printf("%d 是%s数\n", n, (n % 2 == 0) ? "偶" : "奇");条件运算符可以嵌套,但嵌套多了会很难读,这时应该改用第 5 章的 if 语句。
4.8 逗号运算符
逗号运算符把多个表达式连在一起,从左到右依次计算,整个表达式的值是最后一个表达式的值:
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 才为 1 | a & b | 1000,即 8 |
| | 按位或 | 对应位有一个为 1 就为 1 | a | b | 1110,即 14 |
^ | 按位异或 | 对应位不同为 1,相同为 0 | a ^ b | 0110,即 6 |
~ | 按位取反 | 每一位 0 变 1,1 变 0 | ~a | -13(int 类型时) |
<< | 左移 | 所有位向左移动,右边补 0 | a << 2 | 110000,即 48 |
>> | 右移 | 所有位向右移动 | a >> 2 | 11,即 3 |
注意不要把 &、| 与逻辑运算符 &&、|| 混淆:12 && 10 的结果是 1(两边都是"真"),而 12 & 10 的结果是 8。
4.10 sizeof 运算符
sizeof 用来求一个类型或变量占用的字节数。它看起来像函数,其实是一个运算符:
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++ 之类的写法之所以出问题,也与此有关。
提示没有人能记住所有运算符的优先级,专业程序员也不例外。拿不准的时候就加括号,括号能让表达式按你想要的方式分组,也能让代码更容易读懂。
本章练习
- 拆分三位数。 输入一个三位整数,分别输出它的百位、十位和个位。例如输入
365,输出百位 3,十位 6,个位 5。 - 时间换算。 输入一个秒数,转换成"时:分:秒"的格式输出,每部分都占两位,不足两位时补 0。例如输入
3725,输出01:02:05。 - 预测输出。 不运行程序,先写出下面代码的输出,再运行验证,并解释原因:
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);- 三个数的最大值。 输入三个整数,只用条件运算符
?:(不用if)求出其中的最大值。
第 5 章 分支结构:让程序学会选择
到目前为止,我们写的程序都是从上到下一条条执行的。但现实中的问题常常需要"具体情况具体分析":成绩及格与否给出不同的评语,输入的运算符不同就做不同的计算。这一章学习如何让程序根据条件选择不同的执行路径。
5.1 程序的三种基本结构
计算机科学家已经证明:任何算法都可以只用三种基本结构组合出来。
- 顺序结构:语句按先后顺序依次执行;
- 选择结构(分支结构):根据条件决定执行哪一部分语句,本章内容;
- 循环结构:在条件满足时反复执行某些语句,第 6 章内容。
5.2 if 语句
if 语句的形式是:
if (条件) {
语句块 // 条件为真时执行
}执行流程如下:
条件为真吗?
/ \
是 否
| |
执行语句块 |
\ /
继续执行后面的语句例如,求一个整数的绝对值:
#include <stdio.h>
int main(void)
{
int n;
printf("请输入一个整数:");
scanf("%d", &n);
if (n < 0) {
n = -n;
}
printf("它的绝对值是 %d\n", n);
return 0;
}运行结果:
请输入一个整数:-7
它的绝对值是 7条件可以是任何表达式:值为 0 表示假,非 0 表示真。所以 if (n != 0) 也可以简写成 if (n),if (n == 0) 可以写成 if (!n)。
关于花括号。 如果语句块只有一条语句,语法上可以省略花括号,但这是很多 bug 的来源:
if (score >= 60)
printf("及格了\n");
printf("恭喜你!\n"); // 这一行并不属于 if,无论是否及格都会执行!缩进只是给人看的,编译器并不关心。没有花括号时,if 只管紧跟它的一条语句。开启 -Wall 后,GCC 会对这种"误导性缩进"给出警告。
提示强烈建议无论语句块有几行,都加上花括号。这是许多公司编码规范中的硬性要求。
常见错误不要在if (...)后面加分号。if (x > 0);中的分号本身就是一条"空语句",if控制的是这条空语句,后面花括号里的代码会无条件执行。
5.3 if-else 语句
if-else 用于"二选一":
if (条件) {
语句块1 // 条件为真时执行
} else {
语句块2 // 条件为假时执行
}例如判断奇偶:
#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;
}运行结果:
请输入一个整数:17
17 是奇数5.4 else if:多分支选择
当有多种情况时,可以把多个 if-else 串起来:
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 的返回值,拒绝非法输入:
#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;
}运行结果:
请输入成绩(0~100):85
等级:B注意两点:
- 条件的顺序很重要。 第二个分支只写了
score >= 80,没有写score < 90,因为能走到这里,说明第一个条件已经不成立,score必然小于 90。但如果把score >= 60放在最前面,那么 95 分也会被判为 D。 - 输入检查中用到了
||的短路特性:如果scanf读取失败,后面的比较就不会进行。main函数返回非 0 值表示程序"异常结束"。
5.5 嵌套 if 与"悬空 else"
if 语句里面还可以再包含 if 语句,这叫嵌套。嵌套时要当心 else 的归属问题。请看:
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 不是正数"。
解决方法仍然是:始终使用花括号。
if (a > 0) {
if (b > 0) {
printf("a 和 b 都是正数\n");
}
} else {
printf("a 不是正数\n");
}5.6 switch 语句
当需要根据一个整数值的不同取值做不同的事情时,switch 比一长串 else if 更清晰:
switch (表达式) {
case 常量1:
语句块1
break;
case 常量2:
语句块2
break;
default:
语句块3
break;
}程序会计算表达式的值,然后跳转到与之相等的 case 处开始执行,遇到 break 就跳出 switch;如果没有匹配的 case,就执行 default 部分。
例:一个简易计算器。
#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;
}运行结果:
请输入算式(如 3 + 4):10 / 4
10 / 4 = 2.5使用 switch 需要遵守以下规则:
switch后面括号里的表达式必须是整数类型(char也是整数),不能是浮点数或字符串;case后面必须是常量,不能是变量,也不能是范围(如"90 到 100");- 各个
case的值不能重复; default可以省略,但建议总是写上,用来处理意外情况。
不要忘记 break。 如果某个 case 的末尾没有 break,程序会接着执行下一个 case 的语句,这叫"穿透"(fall through):
#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;
}运行结果:
二级
三级
其他从 case 2 进入后,由于没有 break,程序一路执行到了底。开启 -Wextra 后,GCC 会警告 this statement may fall through。
穿透有时也可以被巧妙地利用,让多个 case 共享同一段代码:
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……
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 的正负,方程有两个不相等的实根、两个相等的实根或两个共轭复根。
#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;
}三次运行的结果:
请输入 ax^2 + bx + c = 0 的系数 a b c:1 -3 2
有两个不相等的实根:x1 = 2.00, x2 = 1.00请输入 ax^2 + bx + c = 0 的系数 a b c:1 2 1
有两个相等的实根:x1 = x2 = -1.00请输入 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。
本章练习
- 三数排序。 输入三个整数,按从小到大的顺序输出。(提示:可以通过几次"比较,必要时交换"来完成。交换两个变量需要借助第三个变量:
t = a; a = b; b = t;) - 月份天数。 输入年份和月份,输出这个月有多少天。要求使用
switch,并考虑闰年的二月。 - 阶梯电价。 某地居民电价规定:每月用电 200 度及以内的部分,每度 0.5 元;超过 200 度、不超过 400 度的部分,每度 0.55 元;超过 400 度的部分,每度 0.8 元。输入月用电量(度),输出应缴电费,保留两位小数。例如用电 450 度,电费为 250.00 元。
- 字符分类。 输入一个字符,判断它是大写字母、小写字母、数字还是其他字符。
第 6 章 循环结构:让程序学会重复
计算机最擅长的事情就是不知疲倦地重复。求 1 到 100 的和、打印九九乘法表、在一万个数据中查找目标——这些都需要循环。C 语言提供了三种循环语句:while、do-while 和 for。
6.1 while 循环
while (条件) {
循环体
}执行流程:先判断条件,为真就执行一遍循环体,然后回到开头再次判断条件;如此反复,直到条件为假时结束循环。
┌──▶ 条件为真吗? ──否──▶ 结束循环
│ │
│ 是
│ ▼
└───── 执行循环体例:求 1 + 2 + 3 + … + 100。
#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;
}运行结果:
1 + 2 + ... + 100 = 5050一个正确的循环通常包含三个要素:初始化、条件和更新。如果忘了写 i++,i 永远是 1,条件永远成立,程序就会陷入死循环。遇到死循环时,在终端按 Ctrl + C 可以强制结束程序。
while 特别适合"不知道要循环多少次,只知道什么时候停"的场景。例如统计一个整数有几位,并求各位数字之和:
#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;
}运行结果:
请输入一个正整数:2024
2024 是 4 位数,各位数字之和为 86.2 do-while 循环
do {
循环体
} while (条件); // 注意末尾有分号do-while 先执行一次循环体,再判断条件,所以循环体至少会执行一次。
上面统计位数的程序有一个小问题:输入 0 时,while 的条件一开始就不成立,程序会说"0 是 0 位数"。用 do-while 就能解决,因为任何整数至少有 1 位:
do {
sum += temp % 10;
temp /= 10;
digits++;
} while (temp != 0);do-while 最典型的用途是输入校验:先让用户输入,如果输入不合法就要求重新输入。下面的写法同时处理了"输入的不是数字"和"数字超出范围"两种情况:
#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;
}运行结果:
请输入成绩(0~100):abc
请输入成绩(0~100):120
请输入成绩(0~100):95
你输入的成绩是 95如果不清空缓冲区,输入 abc 后,scanf 读取失败,abc 会一直留在缓冲区里,下一轮 scanf 又读到它、又失败……程序就会陷入死循环。
6.3 for 循环
for 循环把初始化、条件、更新三个要素写在了一起,结构最为紧凑:
for (初始化; 条件; 更新) {
循环体
}它的执行顺序是:
① 初始化 → ② 判断条件 → ③ 执行循环体 → ④ 更新 → ② 判断条件 → ③ → ④ → … → 条件为假时结束初始化只执行一次。 例:计算 n 的阶乘 n! = 1 × 2 × … × n。阶乘增长极快,13! 就超出了 int 的范围,所以要用 long long:
#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;
}运行结果:
请输入 n(0~20):20
20! = 2432902008176640000for (int i = 2; ...) 这种在初始化部分定义变量的写法是 C99 引入的,这样定义的 i 只在循环内部有效,循环结束后就不存在了。
for 的三个部分都可以省略(但两个分号不能省)。for (;;) 表示没有条件的无限循环,等价于 while (1)。
for 循环中常常配合逗号运算符同时控制两个变量,例如从两端向中间移动:
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循环会先执行"更新"部分)。
#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;
}运行结果:
1 2 4 5 7 8 10 11 13 14 16 17 19 20
大于 100 的第一个 7 的倍数是 105while (1) 加 break 是一种很常见的写法:当循环的结束条件在循环体中间才能判断时,这样写最自然。
注意break只能跳出它所在的那一层循环(或switch)。在嵌套循环中,内层的break不会结束外层循环。
6.5 嵌套循环
循环体里面还可以再有循环,这叫嵌套循环。外层循环每执行一次,内层循环都要完整地执行一遍。
例 1:九九乘法表。 外层循环控制行(第 i 行),内层循环控制每行输出的算式(第 i 行有 i 个算式):
#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;
}运行结果:
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 | 空格数 | 星号数 |
|---|---|---|
| 1 | 4 | 1 |
| 2 | 3 | 3 |
| 3 | 2 | 5 |
| 4 | 1 | 7 |
| 5 | 0 | 9 |
规律是:空格数 = n − i,星号数 = 2i − 1。
#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;
}运行结果:
*
***
*****
*******
*********6.6 经典算法示例
例 1:判断素数。 素数(质数)是大于 1 且只能被 1 和它自身整除的整数。判断 n 是否为素数,只需检查 2 到 √n 之间有没有 n 的因数:如果 n = a × b,那么 a 和 b 中必有一个不超过 √n。
#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;
}运行结果:
请输入一个正整数:97
97 是素数例 2:辗转相除法求最大公约数。 这是已知最古老的算法之一(欧几里得算法):两个数的最大公约数,等于较小的数与两数相除的余数的最大公约数。不断用余数替换,直到余数为 0。
以 48 和 18 为例:
| x | y | x % y |
|---|---|---|
| 48 | 18 | 12 |
| 18 | 12 | 6 |
| 12 | 6 | 0 |
| 6 | 0 | 结束,答案为 6 |
#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;
}运行结果:
请输入两个正整数:48 18
48 和 18 的最大公约数是 6例 3:猜数字游戏。 程序随机想一个 1~100 之间的数,玩家来猜,程序提示"太大"或"太小",直到猜中为止。
#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;
}一次可能的运行结果:
我想了一个 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 可以让程序跳转到同一个函数中带有标签的位置。它会让程序的执行路径变得混乱难懂,所以一般不推荐使用。它为数不多的"正当用途"之一是一次性跳出多层嵌套循环:
#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;
}运行结果:
找到了:6 * 7 = 42
搜索结束另一个用途是在出错时集中释放资源,第 17 章会介绍。
6.8 如何选择循环,以及常见错误
| 情况 | 推荐的循环 |
|---|---|
| 循环次数已知(如"重复 10 次""遍历 1 到 n") | for |
| 循环次数未知,只知道结束条件 | while |
| 循环体至少要执行一次(如先输入后检查) | do-while |
三种循环可以相互改写,选择最能表达意图的那种即可。
循环中的常见错误:
- 边界差一(off-by-one):
i < n和i <= n相差一次循环。写完循环后,用最小的情况(如 n = 1)在脑子里走一遍。 - 循环后面多写了分号:
for (i = 0; i < 10; i++);的循环体是一条空语句,下面花括号里的代码只会执行一次。 - 在循环体内修改 `for` 的循环变量:容易导致逻辑混乱,应当避免。
- 用浮点数控制循环次数:
for (double x = 0; x != 1.0; x += 0.1)永远不会结束!因为浮点误差,x永远不会恰好等于 1.0。循环计数请使用整数。
本章练习
- 求 1~100 之间所有奇数的和。
- 水仙花数。 一个三位数,如果它的各位数字的立方和等于它本身,就称为水仙花数,例如 153 = 1³ + 5³ + 3³。输出所有的水仙花数。
- 打印菱形。 输入 n,打印一个上半部分为 n 行的菱形。例如 n = 3 时:
*
***
*****
***
*- 整数逆序。 输入一个正整数,输出它的逆序数及位数。例如输入
12345,输出逆序数为 54321,共 5 位。 - 求 1! + 2! + 3! + … + 10! 的值。(提示:可以在一个循环里,利用上一次的阶乘算出这一次的阶乘。)
第二部分 核心篇
本部分的目标:掌握 C 语言最核心的四块内容——函数、数组、字符串和指针。函数让你学会"分而治之",数组和字符串让你能处理批量数据,而指针是 C 语言的灵魂,也是它区别于大多数高级语言的地方。这一部分是全文的重中之重,请多花些时间,确保每个示例都真正理解。
第 7 章 函数:程序的积木
7.1 为什么需要函数
假设你要写一个程序,在三个不同的地方判断一个数是不是素数。如果把判断素数的代码复制三遍,不仅冗长,而且一旦发现其中有 bug,就得改三处,漏掉一处就会埋下隐患。
函数就是把一段完成特定任务的代码打包起来、起一个名字,需要时通过名字"调用"它。你可以把函数想象成一台机器:放入原料(参数),机器运转,产出成品(返回值)。使用机器的人不必关心机器内部的构造,只要知道它能做什么就行。
使用函数的好处:
- 复用:写一次,到处使用;
- 分而治之:把大问题拆成若干小问题,每个函数只解决一个小问题;
- 易读易改:
if (is_prime(n))一看就懂,修改也只需要改一处。
其实你早就在使用函数了:main、printf、scanf、sqrt 都是函数。
7.2 函数的定义与调用
函数定义的一般形式:
返回类型 函数名(参数列表)
{
函数体
}例:定义一个求两个整数中较大者的函数。
#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;
}运行结果:
较大的是 7
较大的是 12几个术语:
- 形式参数(形参):函数定义中的参数,如
max中的a和b,相当于函数内部的局部变量; - 实际参数(实参):调用函数时传入的值,如
max(x, y)中的x和y; - 返回值:用
return语句交还给调用者的值,它的类型应当与函数定义的返回类型一致;如果不一致,会像赋值那样被自动转换成返回类型。
调用 max(x, y) 时发生了这些事:
- 计算实参的值(3 和 7),复制给形参
a和b; - 暂停
main函数,跳转到max的函数体开始执行; - 执行到
return b;,函数结束,把 7 带回调用处; - 回到
main,max(x, y)这个表达式的值就是 7,被赋给m。
无返回值的函数。 如果函数只是完成某个动作,不需要返回结果,返回类型就写 void,函数体中可以没有 return,或者用不带值的 return; 提前结束:
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 放在最前面,就需要先写函数的声明(也叫函数原型),告诉编译器这个函数的名字、参数类型和返回类型:
#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;
}运行结果:
面积:12.57声明中的参数名可以省略,写成 double circle_area(double); 也可以,但保留参数名可读性更好。实际上,#include <stdio.h> 的作用正是把 printf、scanf 等函数的声明引入进来——头文件里装的主要就是声明。第 15 章讲多文件编程时,我们会自己写头文件。
7.4 参数传递:值传递
下面这个函数想要交换两个变量的值,你觉得它能成功吗?
#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;
}运行结果:
函数内:a = 2, b = 1
函数外:x = 1, y = 2交换失败了。原因是 C 语言的参数传递方式是值传递:调用函数时,实参的值被复制了一份交给形参。a 和 b 只是 x 和 y 的"复印件",在函数里修改复印件,原件不受任何影响。
main 函数的变量 swap 函数的变量
+-------+-------+ +-------+-------+
| x = 1 | y = 2 | 复制 | a = 1 | b = 2 | 交换后 → a = 2, b = 1
+-------+-------+ ───▶ +-------+-------+
原件不受影响 复印件被修改那么如何写一个真正能交换两个变量的函数呢?答案是把变量的地址传给函数,这正是第 10 章指针的内容。
7.5 作用域:变量在哪里有效
作用域是指一个名字在程序中的有效范围。
- 局部变量:在函数内部或代码块(一对花括号)内部定义的变量,只在定义它的代码块中有效。函数的形参也是局部变量。不同函数中的同名局部变量互不相干。
- 全局变量:在所有函数外部定义的变量,从定义处到文件末尾都可以访问。
#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;
}运行结果:
内层 x = 20
外层 x = 10
g_count = 2当内层代码块定义了与外层同名的变量时,在内层中,外层的变量会被"遮蔽"。这种写法容易引起混淆,应当避免。
提示尽量少用全局变量。 全局变量可以被任何函数修改,程序一复杂,就很难追查"是谁改了它"。函数之间传递数据,应优先使用参数和返回值。如果确实需要全局变量,可以像上面这样加上g_前缀,以示区分。
7.6 存储期与 static
变量除了"在哪里有效"(作用域),还有"活多久"的问题,这叫存储期。
- 自动存储期:普通局部变量在程序进入其代码块时创建,离开代码块时销毁。每次调用函数,局部变量都是全新的。
- 静态存储期:全局变量,以及用
static修饰的局部变量,在程序开始运行时创建,直到程序结束才销毁,并且只初始化一次。未显式初始化的静态变量会被自动初始化为 0。
#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;
}运行结果:
count = 1, normal = 1
count = 2, normal = 1
count = 3, normal = 1static 局部变量兼具两个特点:像全局变量一样"长寿",又像局部变量一样只能在函数内部访问。它适合用来记录"函数被调用了多少次"之类的状态。
static 还可以修饰全局变量和函数,作用是让它们只在当前源文件中可见;与之相关的还有 extern 关键字。这些内容和多文件编程密切相关,将在第 15 章介绍。
7.7 递归:自己调用自己
一个函数在函数体内调用自己,就叫递归。递归适合解决"可以分解为规模更小的同类问题"的问题。
以阶乘为例:n! = n × (n−1)!,而 1! = 1。求 n! 的问题被转化为了求 (n−1)! 的问题:
#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;
}运行结果:
0! = 1
1! = 1
2! = 2
3! = 6
4! = 24
5! = 120factorial(4) 的执行过程是先"层层深入",到达终止条件后再"逐层返回":
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, 1, 2, 3, 5, 8, …,从第 3 项起每项等于前两项之和):
#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;
}运行结果:
循环版:fib(40) = 102334155
递归版:fib(40) = 102334155
递归版一共调用了 204668309 次函数循环版瞬间完成,递归版却调用了两亿多次函数,运行时能明显感到停顿。原因在于大量的重复计算,看看 fib(5) 的调用树就明白了:
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,只需——
- 先把上面 n−1 个盘子从 A 移到 B(借助 C);
- 再把最大的第 n 个盘子从 A 移到 C;
- 最后把 B 上的 n−1 个盘子移到 C(借助 A)。
而"移动 n−1 个盘子"又是一个规模更小的同类问题!
#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;
}运行结果:
把 1 号盘从 A 移到 C
把 2 号盘从 A 移到 B
把 1 号盘从 C 移到 B
把 3 号盘从 A 移到 C
把 1 号盘从 B 移到 A
把 2 号盘从 B 移到 C
把 1 号盘从 A 移到 Cn 个盘子需要移动 2ⁿ − 1 次。传说中 64 个盘子的汉诺塔需要移动约 1.8 × 10¹⁹ 次,即使每秒移动一次,也要大约 5800 亿年。
提示理解递归的诀窍是"相信":写hanoi(n - 1, ...)时,不要试图在脑子里展开每一层调用,而是相信它能正确地完成"移动 n−1 个盘子"的任务,你只需要考虑当前这一层该做什么。
7.8 函数调用栈
程序是如何记住"函数执行完后回到哪里"的呢?答案是调用栈(call stack)。
每调用一个函数,系统就在一块叫作栈的内存区域中为它分配一个栈帧(stack frame),里面存放函数的参数、局部变量和返回地址。函数返回时,它的栈帧就被销毁。栈是"后进先出"的,就像一摞盘子,最后放上去的最先拿走。
以 factorial(3) 为例,递归到最深处时,调用栈是这样的:
+-----------------------+
栈顶 | 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> 为例:
#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;
}运行结果:
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 如何设计好的函数
- 一个函数只做一件事。 如果你发现很难用一句话概括一个函数的功能,它可能该拆分了。
- 起一个好名字。 函数名通常用动词或动词短语,如
print_report、is_valid、calculate_average。返回真假的函数常以is_、has_开头。 - 函数不宜过长。 一般建议不超过一屏(约 50 行)。
- 参数不宜过多。 超过 4~5 个参数,就该考虑用结构体(第 11 章)把它们打包。
- 避免依赖全局变量。 通过参数获取输入,通过返回值给出结果,这样的函数最容易理解和测试。
本章练习
- 素数函数。 编写函数
int is_prime(int n),是素数返回 1,否则返回 0。用它输出 100 以内的所有素数,每行 10 个。 - 最大公约数与最小公倍数。 编写函数
int gcd(int a, int b)和int lcm(int a, int b),输入两个正整数,输出它们的最大公约数和最小公倍数。 - 递归求幂。 用递归编写函数
double power(double x, int n)计算 xⁿ(n ≥ 0)。进阶:利用 xⁿ = (x^(n/2))² 的思路,让递归次数从 n 次减少到约 log₂n 次。 - 递归输出二进制。 用递归编写函数
void print_binary(int n),输出非负整数 n 的二进制表示。例如print_binary(13)输出1101。
第 8 章 数组:批量处理数据
8.1 为什么需要数组
要处理一个班 50 名同学的成绩,难道要定义 score1、score2……score50 共 50 个变量吗?显然不现实。数组就是用来存放一组相同类型数据的结构:它由若干个连续存放的元素组成,所有元素共用一个名字,通过下标来区分。
int scores[50]; // 一次性定义 50 个 int 类型的"格子"8.2 一维数组的定义与初始化
定义数组的形式是 类型 数组名[长度];。长度通常是一个常量,例如 int scores[50];、double prices[N];(其中 N 是用 #define 定义的宏常量)。
初始化的几种方式:
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。
求数组长度。 用整个数组的字节数除以一个元素的字节数即可:
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。用循环遍历数组是最常见的操作:
#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;
}运行结果:
请输入 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 数组在内存中的样子
数组的所有元素在内存中是连续存放的。下面的程序输出每个元素的地址:
#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;
}一次可能的运行结果(每次运行的地址可能不同):
a[0] = 10,地址:0x7ffd5e3c8a10
a[1] = 20,地址:0x7ffd5e3c8a14
a[2] = 30,地址:0x7ffd5e3c8a18
a[3] = 40,地址:0x7ffd5e3c8a1c
a[4] = 50,地址:0x7ffd5e3c8a20相邻元素的地址正好相差 4,也就是一个 int 的大小:
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. 原地逆序。 用两个下标分别从两端向中间移动,交换对应的元素:
for (int i = 0, j = n - 1; i < j; i++, j--) {
int temp = a[i];
a[i] = a[j];
a[j] = temp;
}2. 计数统计。 用数组的下标代表数据的值,用元素记录出现的次数,这是一个非常实用的技巧。例如统计各分数段的人数:
#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;
}运行结果:
90~100 分:3 人
80~89 分:3 人
70~79 分:2 人
60~69 分:2 人
不及格:2 人counts[scores[i] / 10]++ 一行代码就完成了"判断分数段并计数"的工作,完全不需要写一长串 if-else。
8.6 数组作为函数参数
把数组传给函数时,函数的形参写成 类型 名字[],同时必须再传一个参数表示数组长度:
#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;
}运行结果:
1 2 3 4 5
2 4 6 8 10这里有两个与普通变量截然不同的现象:
- 函数修改了原数组。 第 7 章说过 C 语言是值传递,函数改不了实参。但传递数组时,传过去的其实是数组首元素的地址,函数通过地址直接操作了原数组的内存。第 10 章会详细解释这一点。
- 函数内部无法用 `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} 为例(方括号表示已经排好的部分):
初始: 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 ← 这一趟没有发生任何交换,说明已经有序,提前结束#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;
}运行结果:
排序前:5 2 9 1 7 3
排序后:1 2 3 5 7 9选择排序。 每一趟从未排序的部分中选出最小的元素,放到未排序部分的最前面:
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 次。
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 万)。这正是猜数字游戏中"每次猜中间值"的策略。
#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;
}运行结果:
42 的下标:4
50 的下标:-1查找 42 的过程:
| low | high | mid | a[mid] | 比较结果 |
|---|---|---|---|---|
| 0 | 7 | 3 | 23 | 23 < 42,去右半部分 |
| 4 | 7 | 5 | 57 | 57 > 42,去左半部分 |
| 4 | 4 | 4 | 42 | 找到了! |
提示mid = (low + high) / 2在low + high超过int范围时会溢出,写成low + (high - low) / 2可以避免这个问题。这个 bug 曾在 Java 标准库的二分查找中潜伏了将近十年才被发现。
8.9 二维数组
表格形式的数据(如多名学生的多门课成绩、矩阵、棋盘)适合用二维数组表示:
int scores[3][4]; // 3 行 4 列,共 12 个元素可以把二维数组理解为"数组的数组":scores 是一个有 3 个元素的数组,每个元素又是一个有 4 个 int 的数组。访问第 i 行第 j 列的元素写作 scores[i][j],行和列的下标都从 0 开始。
#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;
}运行结果:
学生 1 的总分:255
学生 2 的总分:273
学生 3 的总分:217
课程 1 的平均分:82.33
课程 2 的平均分:81.67
课程 3 的平均分:84.33二维数组在内存中是按行连续存放的(行优先)。int m[2][3] 在内存中的排列是:
m[0][0] m[0][1] m[0][2] m[1][0] m[1][1] m[1][2]
+--------+--------+--------+--------+--------+--------+
| | | | | | |
+--------+--------+--------+--------+--------+--------+
\_______ 第 0 行 _______/ \_______ 第 1 行 _______/二维数组作为函数参数时,必须指定列数(行数可以省略),因为编译器需要知道每行有多少个元素,才能算出 m[i][j] 的位置:
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):
int n;
scanf("%d", &n);
int a[n]; // 长度在运行时才确定但变长数组有不少限制:C11 起它变成了编译器"可选支持"的特性,Visual Studio 的 C 编译器就不支持;它分配在栈上,如果 n 很大,程序会因栈溢出而崩溃,并且无法检测和处理这种失败。因此,需要在运行时决定大小的数组,建议使用第 12 章的动态内存分配。
本章练习
- 循环右移。 输入 n(n ≤ 100)个整数和一个非负整数 k,把数组循环右移 k 位后输出。例如
1 2 3 4 5右移 2 位得到4 5 1 2 3。 - 第二大的数。 输入 10 个整数,找出其中第二大的数(与最大值相等的不算)。如果不存在(例如所有数都相等),输出提示信息。要求只遍历数组一次。
- 删除元素。 输入 n 个整数和一个整数 x,删除数组中所有等于 x 的元素(后面的元素依次前移),输出删除后的数组。
- 杨辉三角。 用二维数组输出杨辉三角的前 10 行。杨辉三角每行的首尾都是 1,其余每个数等于它"左上方"和"正上方"两个数之和:
1
1 1
1 2 1
1 3 3 1
1 4 6 4 1第 9 章 字符串
文字处理是程序最常做的事情之一:用户名、文件路径、一句话、一篇文章……在 C 语言中,这些都用字符串来表示。
9.1 C 语言中的字符串
与许多语言不同,C 语言没有专门的字符串类型。C 语言中的字符串,就是以空字符 `'\0'` 结尾的字符数组。
#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;
}运行结果:
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':
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 为止,输出一堆乱码甚至导致程序崩溃。
字符数组的几种初始化方式:
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) 会在输出字符串后自动换行。
输入有两种常用方式:
#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;
}运行结果:
请输入你的名字: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 个字节 |
#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;
}运行结果:
strlen(dest) = 5
拼接后:Hello, World
复制得到:Hello, World
apple 排在 banana 前面
dest 和 copy 的内容相同
"World" 出现在下标 7 处strstr 返回的是一个指针(char * 类型),pos - dest 算出的是它距离字符串开头有几个字符。指针的知识在第 10 章讲解,这里先照着用即可。
使用这些函数时要牢记以下几点:
- 比较字符串要用 `strcmp`,不能用 `==`。
if (s1 == s2)比较的是两个数组的地址,而不是内容,结果几乎总是"不相等"。 - 字符串不能用 `=` 赋值。
char s[20]; s = "Hello";是错误的(数组不能整体赋值),要用strcpy(s, "Hello");。 - `strcpy` 和 `strcat` 不检查目标空间是否足够。 目标数组必须足够大,否则就会发生缓冲区溢出。
- `strncpy` 有一个坑: 如果源字符串的长度大于等于 n,它不会在末尾添加
'\0'。
那么如何安全地复制字符串呢?一个简单可靠的方法是使用 snprintf(9.6 节介绍),它总会在末尾添加 '\0',并且不会超出指定的空间:
char small[6];
snprintf(small, sizeof(small), "%s", copy); // 最多写入 5 个字符和 1 个 '\0'
printf("%s\n", small); // 输出:Hello用 `strtok` 分割字符串。 strtok 可以按指定的分隔符把字符串切成若干段:
#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;
}运行结果:
[apple]
[banana]
[cherry]
[date]注意 strtok 会修改原字符串(把分隔符替换为 '\0'),所以不能用于字符串常量;它还会跳过连续的分隔符,也就是说空字段会被忽略。
9.4 自己动手实现字符串函数
理解字符串最好的方法,就是自己实现几个字符串函数。它们的核心都是:逐个处理字符,直到遇到 `'\0'`。
#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;
}运行结果:
长度: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。例:把字符串中的大小写字母互换。
#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;
}运行结果:
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):
#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;
}运行结果:
转换出 78,但后面还有多余的字符 "xyz"这段代码用到了指针,学完第 10 章后再回头看会更清楚。第 18 章的综合项目就是用这种方法来检查用户输入的。
数字转字符串。 使用 sprintf 或更安全的 snprintf,它们的用法和 printf 一样,只是把结果"打印"到字符数组里,而不是屏幕上:
char buf[50];
int year = 2026;
double score = 95.5;
snprintf(buf, sizeof(buf), "%d 年的成绩:%.1f", year, score);
puts(buf); // 输出:2026 年的成绩:95.5snprintf 的第二个参数是数组的大小,它保证写入的内容不会超出数组。sprintf 没有这个参数,存在溢出的风险。
从字符串中解析数据。 sscanf 的用法和 scanf 一样,只是数据来源是字符串:
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 字符串数组
多个字符串可以用二维字符数组来存放,每一行是一个字符串:
#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;
}运行结果:
Alice
Bob
Charlie
Diana
Tomnames[i] 表示第 i 行,也就是第 i 个字符串。因为字符串不能直接赋值,交换两个字符串需要借助 strcpy。第 10 章会介绍另一种更高效的方式:用指针数组存放字符串,交换时只需交换指针。
9.8 关于中文字符串
一个汉字在 UTF-8 编码下通常占 3 个字节,在 GBK 编码下占 2 个字节。因此在 UTF-8 环境下,strlen("你好") 的结果是 6 而不是 2;逐字节反转一个中文字符串会把汉字"拆碎",得到乱码。本章介绍的字符串函数都是按字节处理的,适合处理英文和数字。如果需要按"字符"处理中文,需要了解字符编码和宽字符(wchar_t)等进阶知识,这里不再展开。
本章练习
- 字符统计。 输入一行文字(可能包含空格),分别统计其中英文字母、数字、空格和其他字符的个数。
- 实现 strcmp。 编写函数
int my_strcmp(const char s1[], const char s2[]),返回值规则与strcmp相同(前者在前返回负数,相等返回 0,否则返回正数)。 - 单词首字母大写。 输入一行英文,把每个单词的首字母改为大写后输出。例如
hello world from c变为Hello World From C。 - 回文判断。 输入一个字符串,判断它是否为回文(正读和反读都一样),忽略大小写。例如
Level、noon是回文,hello不是。
第 10 章 指针:C 语言的灵魂
指针是 C 语言最强大的特性,也是初学者公认的难点。但请放心:指针的本质非常简单——指针就是地址。只要把"内存"和"地址"这两个概念想清楚,再勤画图,指针就一点也不神秘。
10.1 内存与地址
第 2 章说过,内存就像一排储物柜,每个柜子(1 个字节)都有一个编号,即地址。定义变量时,系统会为它分配若干个连续的字节,变量的地址就是它所占的第一个字节的地址。
用取地址运算符 & 可以得到变量的地址,用 %p 可以输出地址:
#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;
}一次可能的运行结果:
a 的值:10,地址:0x7ffe3b1a5c3c
b 的值:3.14,地址:0x7ffe3b1a5c40
c 的值:X,地址:0x7ffe3b1a5c3b地址的具体数值每次运行都可能不同,这无关紧要。%p 要求对应的参数是 void * 类型,所以前面加了 (void *) 进行转换,void * 的含义本章稍后介绍。
打个比方:变量就像宾馆的房间,地址就是房间号。如果你把房间号写在一张纸条上交给别人,他就能找到这个房间——这张写着房间号的纸条,就是指针。
10.2 指针变量
指针变量是专门用来存放地址的变量。定义方式是在变量名前加 *:
int *p; // p 是一个指针变量,它可以存放一个 int 类型变量的地址指针的基本用法只有两个运算符:
&变量:取出变量的地址;*指针:访问指针所指向的变量,称为解引用(dereference)。
#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;
}一次可能的运行结果:
a 的值:10
&a 的值:0x7ffc8a2e4b5c
p 的值:0x7ffc8a2e4b5c
*p 的值:10
修改后 a 的值:20画成图就是:
变量 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 * 呢?因为指针的类型决定了两件事:
- 解引用时读写多少个字节:
*p对int *来说是读写 4 个字节,对double *来说是 8 个字节; - 指针运算的步长:
p + 1前进多少个字节(10.5 节)。
常见错误int* p, q;定义的是一个指针p和一个普通整数q,因为*只作用于紧跟它的那个变量名。要定义两个指针,应写成int *p, *q;。为了避免误会,建议把*靠近变量名书写,或者每行只定义一个指针。
10.3 空指针、野指针与悬空指针
空指针。 值为 NULL 的指针叫空指针,表示"不指向任何对象"。NULL 定义在 <stdio.h>、<stdlib.h> 等头文件中。对空指针解引用会导致程序崩溃(在 Linux 上通常显示 Segmentation fault,即"段错误")。所以使用一个可能为空的指针之前,要先检查:
if (p != NULL) {
printf("%d\n", *p);
}野指针。 没有初始化的指针,其值是随机的,指向内存中某个未知的位置,这叫野指针。对野指针解引用,轻则读到垃圾值,重则破坏数据或导致程序崩溃:
int *p; // 野指针:p 里存的是一个随机地址
*p = 10; // 危险!向一个未知的地址写入数据好习惯:定义指针时立即初始化,如果暂时不知道指向哪里,就初始化为 `NULL`。
悬空指针。 指针原本指向一个合法的对象,但这个对象已经不存在了,指针就成了悬空指针。最典型的例子是返回局部变量的地址:
int *bad_function(void)
{
int x = 10;
return &x; // 错误!函数返回后 x 就被销毁了,返回的地址已经失效
}回忆第 7 章的调用栈:函数返回后,它的栈帧被销毁,局部变量也随之消失。GCC 会对此发出警告:function returns address of local variable。另一种常见的悬空指针来自已经被释放的动态内存,第 12 章会讲到。
10.4 指针与函数:真正的交换
第 7 章中,swap 函数交换失败了,因为函数拿到的只是实参的副本。现在我们有了指针,就可以把变量的地址传给函数,让函数通过地址直接操作原变量:
#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;
}运行结果:
x = 2, y = 1 main 的栈帧 swap 的栈帧
+-------+-------+ +---------+---------+
| x = 1 | y = 2 | | a = &x | b = &y |
+-------+-------+ +---------+---------+
▲ ▲ │ │
│ └─────────────────────────┼─────────┘
└─────────────────────────────────┘
swap 通过 *a、*b 直接修改了 main 中的 x 和 y参数传递依然是值传递——只不过这次复制的是地址。地址的副本和原来的地址指向同一个地方,所以通过副本也能修改原变量。现在你也应该明白 scanf("%d", &n) 为什么要写 & 了:scanf 需要知道 n 的地址,才能把读到的数据存进去。
通过指针"返回"多个值。 函数的 return 只能返回一个值,但可以通过指针参数把多个结果"带回去":
#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;
}运行结果:
17 ÷ 5 = 3 余 2这种用于"带回结果"的指针参数,常被称为输出参数。
10.5 指针运算
指针可以进行几种有限的运算:
| 运算 | 含义 |
|---|---|
p + n、p - n | 指针向后(向前)移动 n 个元素,而不是 n 个字节 |
p++、p-- | 指针移动一个元素 |
p - q | 两个指针之间相隔多少个元素(二者必须指向同一个数组) |
p == q、p < q 等 | 比较两个指针的位置 |
指针不能相加,也不能相乘或相除——两个地址相加没有任何意义。
#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;
}一次可能的运行结果:
p = 0x7ffd4f6c1e20, *p = 10
p + 1 = 0x7ffd4f6c1e24, *(p + 1) = 20
p + 2 = 0x7ffd4f6c1e28, *(p + 2) = 30
q = 0x7ffd4f6c1e1c
q + 1 = 0x7ffd4f6c1e1d
last - p = 4int * 加 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]; 完全等价。由此可以推出:
arr[i] ≡ *(arr + i) ≡ *(p + i) ≡ p[i]事实上,C 语言中的下标运算 a[i] 就是按照 *(a + i) 来定义的。
#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 位系统上):
30 30 30 30
10 20 30 40 50
sizeof(arr) = 20
sizeof(p) = 8数组名和指针并不完全相同。 它们有以下区别:
- 数组名不能被赋值或修改。
arr = p;和arr++都是错误的,而指针变量可以随意改变指向。 - `sizeof` 的结果不同。
sizeof(arr)是整个数组的大小(20 字节),sizeof(p)是指针本身的大小(8 字节)。 - `&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 不动 |
*++p | p 先后移一个元素,再取它指向的值 |
++*p | p 所指向的值先加 1,再取这个值 |
10.7 指针与字符串
字符串有两种常见的表示方式,它们有着本质的区别:
#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;
}运行结果:
Hello world 栈上的数组 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:
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` 在 `` 右边,修饰的是指针本身(指针的指向是固定的)。
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 指向指针的指针
指针变量本身也是变量,也有自己的地址,所以可以有"指向指针的指针",即二级指针:
#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;
}运行结果:
a = 10, *p = 10, **pp = 10
a = 20 pp p a
+-----------+ +-----------+ +---------+
| &p ●───┼─────▶ | &a ●───┼─────▶ | 10 |
+-----------+ +-----------+ +---------+二级指针最重要的用途是:让函数修改调用者的指针变量。道理和 swap 完全一样——想在函数中修改一个 int,要传 int *;想在函数中修改一个 int *,就要传 int **。
#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;
}运行结果:
最大值是 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 的数组)。
#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;
}运行结果:
Mon Tue Wed Thu Fri Sat Sun
row[1][2] = 7
(*row)[0] = 5用指针数组存放一组字符串,比第 9 章的二维字符数组更灵活:每个字符串可以长短不一,不浪费空间;排序时只需交换指针,而不用复制整个字符串。
weekdays(指针数组)
+-------+ +---+---+---+----+
| ●───┼──────▶| M | o | n | \0 |
+-------+ +---+---+---+----+
| ●───┼──────▶| T | u | e | \0 |
+-------+ +---+---+---+----+
| ... |二维数组与指针。 二维数组 m 退化后是指向第 0 行的指针,m + i 指向第 i 行,*(m + i) 就是第 i 行(一个一维数组,它又会退化为指向该行首元素的指针),所以:
m[i][j] ≡ *(*(m + i) + j)10.11 main 函数的参数
main 函数还有另一种标准形式,可以接收命令行参数:
#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;
}编译后在命令行中这样运行:
$ ./args hello 123 "C language"
共有 4 个命令行参数:
argv[0] = ./args
argv[1] = hello
argv[2] = 123
argv[3] = C languageargc(argument count)是参数的个数,程序名本身也算一个;argv(argument vector)是一个指针数组,每个元素指向一个参数字符串,argv[0]是程序名;- 参数之间用空格分隔,包含空格的参数要用引号括起来;
- 参数都是字符串形式,需要数字时要自己转换(如用
strtol)。
很多命令行工具(如 gcc hello.c -o hello)就是通过这种方式接收参数的。第 14 章的文件复制程序会用到它。
10.12 函数指针
函数也存放在内存中,也有地址。函数指针就是存放函数地址的指针,通过它可以间接地调用函数。
#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;
}运行结果:
add: 9
mul: 18
6 加 3 = 9
6 减 3 = 3
6 乘 3 = 18函数指针的声明写法比较绕,可以用 typedef 给它起一个别名:
typedef int (*BinaryOp)(int, int); // BinaryOp 代表"指向这类函数的指针"类型
BinaryOp op = add;回调函数与 qsort。 函数指针最重要的用途是回调:把一个函数作为参数传给另一个函数,由后者在适当的时候调用它。标准库的排序函数 qsort 就是经典的例子,它的声明是:
void qsort(void *base, size_t count, size_t size,
int (*compare)(const void *, const void *));四个参数分别是:数组首地址、元素个数、每个元素的大小、比较函数。qsort 知道怎么排序,但不知道你的数据该如何比较大小——这个规则由你通过比较函数告诉它。比较函数接收两个元素的地址,第一个应排在前面时返回负数,应排在后面时返回正数,相等时返回 0。
#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;
}运行结果:
升序: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 指针学习要诀
- 画图! 画出每个变量的方框,写上值;指针就画一个箭头指向目标。绝大多数指针问题,画出图就清楚了。
- 时刻分清两个东西:指针变量本身的值(一个地址),和它所指向的值(
*p)。 - 牢记指针的常见错误:
| 错误 | 后果 | 预防 |
|---|---|---|
| 使用未初始化的指针(野指针) | 读写随机内存,崩溃或数据损坏 | 定义时初始化,不知道指向哪里就设为 NULL |
| 解引用空指针 | 程序崩溃(段错误) | 使用前检查是否为 NULL |
| 返回局部变量的地址 | 悬空指针,数据随时被覆盖 | 不要返回局部变量的地址 |
| 通过指针越界访问数组 | 与数组越界相同 | 仔细检查边界条件 |
| 修改字符串字面量 | 未定义行为,通常崩溃 | 用 const char * 指向字符串字面量 |
| 指针类型不匹配 | 读写的字节数和解释方式都会出错 | 重视编译器的类型警告,谨慎使用强制转换 |
本章练习
- 最大值与最小值。 编写函数
void min_max(const int *arr, int n, int *min, int *max),通过指针参数同时"返回"数组的最小值和最大值。 - 指针逆序。 编写函数
void reverse_array(int *arr, int n),只用指针运算(不使用[]下标)将数组逆序。 - 分析输出。 不运行程序,写出下面代码的输出,然后运行验证:
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]);- 字符串排序。 用
qsort对指针数组const char *words[] = {"banana", "apple", "cherry", "date", "fig"};按字典序排序并输出。(提示:数组元素的类型是const char *,所以比较函数收到的参数实际上是const char *的地址。)
第三部分 进阶篇
本部分的目标:学会组织复杂的数据(结构体),在程序运行时按需申请内存(动态内存),实现第一个数据结构(链表),让数据保存到磁盘上(文件),并掌握多文件编程、位运算、调试与规范等工程化技能。学完这一部分,你就具备了编写实用程序的能力。
第 11 章 结构体、共用体与枚举
11.1 为什么需要结构体
描述一个学生,需要学号(整数)、姓名(字符串)、年龄(整数)、成绩(小数)等多项信息。它们类型各不相同,没法放进同一个数组;如果分别定义成独立的变量或数组,又很难体现"这些信息属于同一个人"。
结构体(struct)允许我们把多个不同类型的数据组合成一个整体,从而定义出一种新的数据类型。
11.2 结构体的定义与使用
#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;
}运行结果:
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 给类型起一个别名:
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。
#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;
}运行结果:
王五 18
函数内修改后的年龄:99
姓名:王五,年龄:18,成绩:100.0add_bonus 成功修改了成绩;try_modify 虽然在函数内部把年龄改成了 99,但改的只是副本,原结构体中的年龄仍然是 18。
11.5 结构体数组
结构体数组可以存放一组记录,就像一张表格:每个元素是一行,每个成员是一列。下面的程序用 qsort 按得分从高到低对玩家排序:
#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;
}运行结果:
排行榜:
1. Bob 950
2. David 910
3. Eve 880
4. Alice 820
5. Carol 760qsort 可以对任何类型的数组排序,你只需要在比较函数中告诉它比较的规则。例如想按名字排序,比较函数改成 return strcmp(pa->name, pb->name); 即可。
注意qsort不保证"稳定性",也就是说,比较结果相等的两个元素,排序后的先后顺序是不确定的。如果需要"得分相同时按名字排序",应该在比较函数里写明这个规则。
11.6 结构体嵌套
结构体的成员也可以是另一个结构体:
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 结构体的内存对齐
一个结构体占用的内存,并不一定等于各成员大小之和:
#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 位系统上):
sizeof(struct A) = 12
sizeof(struct B) = 8
A 中各成员的偏移量:c1 = 0, i = 4, c2 = 8成员完全相同,只是顺序不同,大小却相差了 4 个字节。这是因为内存对齐:CPU 读取数据时,如果数据的地址是其大小的整数倍(例如 4 字节的 int 放在 4 的倍数的地址上),读取效率最高,在某些平台上甚至是必须的。因此,编译器会在成员之间插入一些填充字节。通常的规则是:
- 每个成员的偏移量,必须是该成员对齐要求的整数倍。对于
char、int、double等基本类型,对齐要求一般等于其大小;数组的对齐要求与其元素类型相同,例如char name[20]只需按 1 字节对齐; - 结构体的总大小,必须是其中最大对齐要求的整数倍(这样结构体数组中的每个元素也都能对齐)。
struct A 的布局如下(.. 表示填充字节):
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)用于定义一组有名字的整数常量,适合表示"有限的几种取值",比如星期、颜色、状态:
#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;
}运行结果:
今天是星期 6
周末愉快!- 如果不指定值,第一个枚举常量为 0,后面的依次加 1;也可以给任意常量指定值。
- 枚举常量本质上就是整数,可以参与运算,可以用在
switch的case中。 - 与
#define定义的一组宏常量相比,枚举把相关的常量组织在一起,含义更明确,调试器也能显示枚举常量的名字。
同样可以用 typedef 简化枚举类型名:typedef enum { RED, GREEN, BLUE } Color;。
11.9 共用体
共用体(union,也叫联合体)的写法和结构体很像,但它的所有成员共享同一块内存,大小等于最大成员的大小(再加上可能的对齐填充)。同一时刻,只有最后写入的那个成员是有意义的。
#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;
}运行结果:
sizeof(union Data) = 8
d.i = 42
d.f = 3.14
d.i = 1078523331(已被覆盖)写入 d.f 之后,这 4 个字节里存的是浮点数 3.14 的二进制表示,再把它当作整数读出来,就得到了一个"莫名其妙"的数字。
共用体的典型用法是与枚举配合,构成"带标签的共用体":用一个枚举成员记录当前存的是哪种数据,再根据它来访问相应的成员。这样一个变量就能存放多种类型的数据之一:
#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;
}运行结果:
图形 1 的面积:3.14
图形 2 的面积:12.0011.10 位域(了解)
在嵌入式开发等对内存非常敏感的场景中,可以用位域指定结构体成员只占用若干个二进制位:
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 综合示例:发牌程序
下面用枚举、结构体、数组和随机数实现一个洗牌发牌的小程序:
#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;
}一次可能的运行结果:
你的五张牌:红心7 黑桃K 方块A 梅花10 红心QFisher-Yates 算法能保证 52! 种排列中的每一种出现的机会均等(在随机数足够理想的前提下),是标准的洗牌方法。
本章练习
- 一年中的第几天。 定义日期结构体
Date(包含年、月、日),编写函数int day_of_year(const Date *d),计算这一天是当年的第几天(注意闰年)。例如 2024 年 3 月 1 日是第 61 天。 - 复数运算。 定义复数结构体
Complex(包含实部和虚部),编写函数实现两个复数的加法和乘法。提示:(a+bi)(c+di) = (ac−bd) + (ad+bc)i。 - 图书排序。 定义图书结构体(书名、作者、价格),在程序中初始化 5 本书的信息,按价格从高到低排序后输出。
- 思考题。 在常见的 64 位系统上,下面两个结构体的
sizeof分别是多少?先自己推算,再编程验证。
struct X { char a; double b; int c; };
struct Y { double b; int c; char a; };第 12 章 动态内存管理
到目前为止,我们使用的变量和数组,大小都必须在写代码时就确定下来。但很多时候,数据量要到程序运行时才知道:用户要输入多少个数?文件里有多少条记录?动态内存管理让程序可以在运行时按需申请内存,用完后再归还。
12.1 程序的内存布局
一个运行中的 C 程序,其内存大致分为以下几个区域(这是简化的典型布局,具体情况因系统而异):
高地址 +--------------------------+
| 栈(stack) | 局部变量、函数参数;自动分配和释放;空间较小
| ↓ |
| |
| ↑ |
| 堆(heap) | malloc 等函数申请的内存;手动申请和释放;空间大
+--------------------------+
| 全局 / 静态数据区 | 全局变量、static 变量;程序运行期间一直存在
+--------------------------+
| 只读数据区 | 字符串字面量等常量
+--------------------------+
| 代码区 | 程序的机器指令
低地址 +--------------------------+栈和堆是最重要的两个区域,对比如下:
| 栈 | 堆 | |
|---|---|---|
| 存放什么 | 局部变量、函数参数 | malloc 等函数申请的内存 |
| 谁来管理 | 编译器自动分配、自动释放 | 程序员手动申请、手动释放 |
| 生命周期 | 所在函数返回时结束 | 从申请开始,直到调用 free 为止 |
| 空间大小 | 较小(Linux 上通常为 8 MB,Windows 上通常为 1 MB) | 较大,受限于系统可用内存 |
| 大小何时确定 | 通常在编译时(变长数组除外) | 运行时 |
在以下情况下,就需要使用堆内存:
- 数据的大小要到运行时才能确定;
- 数据需要在创建它的函数返回后继续存在;
- 数据量很大,栈上放不下(比如一个包含一千万个元素的数组)。
12.2 malloc 与 free
<stdlib.h> 中的 malloc 函数用于申请内存,free 函数用于释放内存:
void *malloc(size_t size); // 申请 size 个字节,成功时返回首地址,失败时返回 NULL
void free(void *ptr); // 释放 ptr 所指向的、之前由 malloc 等函数申请的内存#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;
}运行结果:
要输入几个整数?5
请输入 5 个整数:1 2 3 4 5
逆序输出:5 4 3 2 1使用 malloc 和 free 的要点:
- 计算好字节数。
malloc的参数是字节数,不是元素个数,所以要乘以元素的大小。更推荐的写法是malloc(n * sizeof *arr):用*arr代替类型名,以后即使arr的类型改了,这里也不用跟着改。 - 一定要检查返回值是否为 `NULL`。 内存不足时
malloc会返回NULL,直接使用会导致崩溃。 - 新申请的内存没有初始化,里面是垃圾值。
- 有借有还。 每一次成功的
malloc,都必须对应一次free。忘记释放会造成内存泄漏。 - 释放后把指针置为 `NULL`。
free之后,指针变量里仍然保存着原来的地址,但那块内存已经不属于你了(这就是悬空指针)。置为NULL后,万一误用,程序会立即崩溃,比悄悄读写一块无效的内存更容易发现问题。另外,free(NULL)是安全的,什么也不做。 - 在 C 语言中,
malloc返回的void *可以直接赋给任何对象指针,不需要写成(int *)malloc(...)(C++ 中则必须转换)。
12.3 calloc 与 realloc
`calloc(个数, 每个的大小)`:申请内存,并且把所有字节初始化为 0。
int *scores = calloc(100, sizeof *scores); // 100 个 int,全部为 0`realloc(旧指针, 新的字节数)`:调整一块已申请内存的大小。扩大时,它会尽量在原地扩展;如果原地空间不够,就另找一块足够大的内存,把原来的数据复制过去,并释放旧内存。所以 realloc 返回的地址可能与原来不同。
使用 realloc 有一个经典的陷阱:
p = realloc(p, new_size); // 危险!如果 realloc 失败,它会返回 NULL,但原来的内存并不会被释放。这样写会让 p 变成 NULL,原来那块内存的地址就丢失了,造成内存泄漏。正确的写法是先用一个临时指针接收结果:
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,我们可以实现一个"会自动长大"的数组:元素个数不再受限,放满了就自动扩容。
#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;
}运行结果:
请输入若干个整数,以 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 为止都有效,所以函数可以申请堆内存并把地址交给调用者。常见的写法有两种:
// 方式一:通过返回值交出新申请的内存
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); }。
下面是一个常见的错误写法:
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 列元素的下标:
#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;
}运行结果:
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。以下面这个越界写入的程序为例:
#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;
}$ 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:5ASan 准确地指出:第 10 行发生了堆缓冲区溢出(heap-buffer-overflow),写入了 4 个字节,越界位置正好在第 5 行申请的 20 字节内存之后。在 Linux 上,ASan 还会在程序结束时报告内存泄漏。
Valgrind 是 Linux 上另一个常用的内存检查工具,不需要重新编译(但建议加 -g 以显示行号):
$ valgrind --leak-check=full ./program它会报告非法读写(Invalid read、Invalid write)和内存泄漏(definitely lost 等)的详细信息。
提示在学习阶段,建议养成用-fsanitize=address,undefined编译调试版本的习惯(undefined表示同时检测一部分未定义行为,如有符号整数溢出)。ASan 在 Linux 和 macOS 上可以直接使用;Windows 上的 Visual Studio 也支持/fsanitize=address选项。
本章练习
- 动态求平均。 先输入整数的个数 n,再用
malloc申请空间存放 n 个整数,输入后求平均值和高于平均值的数的个数,最后释放内存。 - 实现 strdup。 编写函数
char *my_strdup(const char *s):申请一块刚好能容纳字符串s的内存,把s复制进去并返回新字符串的地址(由调用者负责释放)。 - 动态二维数组。 输入行数 m 和列数 n,用"指针数组 + 每行单独申请"的方式创建一个 m × n 的
int二维数组,使得可以用a[i][j]访问元素。填入数据(如a[i][j] = i * j)并输出,最后正确地释放全部内存。 - 找错题。 找出下面代码中的所有内存错误,说明后果并改正:
#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 数组的局限
数组有两个明显的局限:
- 大小固定。 定义时就要确定大小,定大了浪费,定小了不够用。第 12 章的动态数组虽然能扩容,但每次扩容都可能要复制全部数据。
- 插入和删除代价高。 要在数组的开头插入一个元素,必须先把后面所有的元素都往后挪一位;删除也是同理。数组越大,挪动的代价就越高。
13.2 链表是什么
链表由一系列节点组成,每个节点包含两部分:存放数据的数据域,和指向下一个节点的指针域。节点在内存中不必连续,它们靠指针"链"在一起。我们用一个头指针记住第一个节点的位置,最后一个节点的指针域为 NULL,表示链表到此结束。
head
│
▼
+----+---+ +----+---+ +----+------+
| 10 | ●─┼───▶ | 20 | ●─┼───▶ | 30 | NULL |
+----+---+ +----+---+ +----+------+
数据 指针链表就像一列火车:每节车厢都挂着下一节车厢;想加一节车厢,只要断开一个连接、重新挂上即可,不用移动其他车厢。但想找到第 100 节车厢,就只能从车头开始一节一节地数。
| 对比项 | 数组 | 链表 |
|---|---|---|
| 内存 | 连续 | 分散,靠指针连接 |
| 按位置访问第 i 个元素 | 快,直接计算地址,O(1) | 慢,必须从头逐个查找,O(n) |
| 在已知位置插入、删除 | 慢,要移动后面的元素,O(n) | 快,只需修改指针,O(1)(对单链表来说,前提是已经拿到了该位置前面那个节点的指针) |
| 大小 | 固定,或需要整体扩容 | 随时增减节点 |
| 额外开销 | 无 | 每个节点都要额外存一个指针 |
没有哪一种结构是绝对更好的,要根据实际需要选择:频繁按位置访问,用数组;频繁插入删除,用链表。
13.3 定义节点
typedef struct Node {
int data; // 数据域
struct Node *next; // 指针域:指向下一个节点
} Node;注意结构体内部写的是 struct Node *next,而不是 Node *next。因为在结构体内部,typedef 定义的别名 Node 还没有生效,只能使用带标签的完整写法 struct Node。一个结构体可以包含指向同类型结构体的指针(但不能包含同类型的结构体本身,否则大小会无穷大),这种结构体称为自引用结构体。
一个空链表,就是头指针为 NULL:
Node *head = NULL;13.4 链表的基本操作
1. 遍历。 用一个指针 cur 从头节点开始,每次沿着 next 走到下一个节点,直到 NULL 为止:
for (const Node *cur = head; cur != NULL; cur = cur->next) {
printf("%d -> ", cur->data);
}几乎所有的链表操作都建立在这个模式之上。
2. 头插法:在链表最前面插入节点。 分两步:
插入前: 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. 在指定节点之后插入。 与头插法的思路相同,也要先连后断:
node->next = pos->next; // ① 新节点先指向 pos 的下一个节点
pos->next = node; // ② pos 再指向新节点5. 删除节点。 要删除一个节点,需要找到它前面的节点,让前一个节点"跳过"它,然后释放它:
删除 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 完整程序
下面把这些操作整合成一个完整的程序:
#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;
}运行结果:
尾插 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 链表的变体
- 双向链表:每个节点有两个指针,分别指向前一个和后一个节点,可以双向遍历,删除某个节点时也不需要再去找它的前驱,代价是每个节点多占一个指针的空间。
NULL ◀──[● | 10 | ●]◀──▶[● | 20 | ●]◀──▶[● | 30 | ●]──▶ NULL- 循环链表:最后一个节点的
next指向第一个节点,形成一个环,适合处理"轮流""循环报数"之类的问题(如约瑟夫环问题)。
链表还是实现其他数据结构的基础:如果只允许在一端插入和删除,它就是栈(后进先出);如果只允许在一端插入、另一端删除,它就是队列(先进先出)。这些内容属于"数据结构"课程的范畴,第 19 章会给出后续的学习建议。
本章练习
- 链表长度。 分别用循环和递归编写函数,求链表的节点个数。
- 反转链表。 编写函数
Node *reverse_list(Node *head),将链表原地反转(不创建新节点),返回新的头指针。例如1 -> 2 -> 3 -> NULL变为3 -> 2 -> 1 -> NULL。这是面试中出现频率最高的链表题之一。 - 合并有序链表。 已知两个链表中的元素都是从小到大排列的,编写函数把它们合并成一个有序链表(直接重新连接原有的节点,不创建新节点)。
- 中间节点。 编写函数返回链表的中间节点,要求只遍历链表一次。节点数为偶数时,返回中间两个节点中的第二个。(提示:用两个指针,一个每次走一步,一个每次走两步。)
第 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 打开和关闭文件
使用文件的基本流程是:打开 → 读写 → 关闭。
#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;
}运行结果:
读到: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(提示信息)` 会输出你给的提示信息,后面跟着系统给出的错误原因。比如打开一个不存在的文件时,会输出:
打开文件失败: 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 有多少个字节和多少行:
#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;
}运行结果:
共 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 不同,它不会自动添加换行符)。
按行处理文本文件的标准写法是:
char line[256];
while (fgets(line, sizeof(line), fp) != NULL) {
// 处理这一行
}如果某一行的长度超过了缓冲区,fgets 会把它分成几次读入,编程时要考虑到这种情况,或者把缓冲区开得足够大。
14.5 格式化读写:fprintf 与 fscanf
fprintf 和 fscanf 的用法与 printf、scanf 完全一样,只是多了第一个参数——文件指针。它们非常适合读写结构化的文本数据:
#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;
}运行结果:
第 1 条:张三,19 岁,88.5 分
第 2 条:李四,20 岁,92.0 分
第 3 条:王五,18 岁,76.5 分生成的 students.txt 内容如下,人可以直接阅读和修改:
张三 19 88.5
李四 20 92.0
王五 18 76.5循环条件 fscanf(...) == 3 表示"成功读取了 3 项数据",读到文件末尾或者遇到格式不符的数据时,循环就会结束。
14.6 二进制读写:fread 与 fwrite
fwrite 和 fread 直接把内存中的字节原样写入文件、或从文件读入内存:
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 字节的数据块,返回实际成功读写的块数。
#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;
}运行结果:
写入了 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(文件末尾)。例如,求一个文件的大小:
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 如何正确判断文件结束
初学者常常这样写读取文件的循环:
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 综合示例:文件复制工具
下面结合命令行参数,实现一个简单的文件复制工具。它以二进制模式读写,所以可以复制任何类型的文件(文本、图片、压缩包……):
#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;
}编译并运行:
$ 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。测试需要大量输入的程序时,把测试数据写在文件里再重定向,可以省去反复手动输入的麻烦。
本章练习
- 单词计数。 编写程序,统计通过命令行参数指定的文本文件中的行数、单词数和字节数(单词是由空白字符分隔的连续非空白字符)。这就是 Linux 中
wc命令的功能。 - 转换大写。 编写程序
upper 源文件 目标文件,把源文件中的所有小写字母转换为大写,写入目标文件。 - 随机读取。 利用 14.6 节生成的
students.dat,输入一个序号 k(从 1 开始),用fseek直接定位并读取第 k 条记录,显示出来。序号超出范围时给出提示。 - 统计数据文件。 文本文件
numbers.txt中有若干个整数,以空格或换行分隔(个数未知)。编写程序读取所有整数,输出它们的个数、总和、平均值和最大值。
第 15 章 预处理器与多文件编程
当程序越写越长,把所有代码都塞进一个文件就会变得难以维护。这一章先学习以 # 开头的预处理指令,然后学习如何把一个程序拆分成多个文件,这是编写真实项目的必备技能。
15.1 预处理器是什么
第 1 章介绍过,编译的第一步是预处理。预处理器在真正编译之前对源代码做一些文本层面的处理:把头文件的内容复制进来、把宏替换成对应的文本、根据条件保留或删除某些代码。
预处理指令都以 # 开头,每条指令独占一行,末尾不加分号(它们不是 C 语句)。用 gcc -E 文件名.c 可以查看预处理之后的结果。
15.2 #include:包含头文件
#include 的作用非常简单:把指定文件的全部内容原样复制到当前位置。 它有两种写法:
#include <stdio.h>:尖括号,在系统的标准头文件目录中查找,用于标准库的头文件;#include "stats.h":双引号,先在当前源文件所在的目录中查找,找不到再去系统目录,用于你自己编写的头文件。
15.3 #define:宏定义
对象式宏用来定义常量,第 2 章已经用过:
#define MAX_STUDENTS 100
#define PI 3.14159265358979函数式宏可以带参数,看起来像函数,但它只是纯粹的文本替换,这带来了几个经典的陷阱:
#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;
}运行结果:
SQUARE_BAD(1 + 2) = 5
SQUARE(1 + 2) = 9
10 * DOUBLE_BAD(3) = 33
10 * DOUBLE(3) = 60SQUARE_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 在不开启优化时通常不会展开):
static inline int square(int x)
{
return x * x;
}宏还有两个特殊的运算符:# 把参数变成字符串,## 把两个记号拼接成一个。多行的宏用反斜杠 \ 续行;包含多条语句的宏习惯用 do { ... } while (0) 包裹起来,使它在任何地方都能像一条普通语句那样使用(比如用在没有花括号的 if-else 中也不会出错):
#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;
}运行结果:
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) |
用途一:调试开关。 开发时输出调试信息,发布时一键关闭:
#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),程序只输出结果:
$ gcc log_demo.c -o log_demo
$ ./log_demo
sum = 55编译时用 -D 选项定义 DEBUG 宏(效果相当于在代码开头写了 #define DEBUG),调试信息就出现了:
$ 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__。利用它们,可以为不同平台编写不同的代码:
#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(模块的接口):
#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);
#endifstats.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(使用这个模块):
#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;
}编译和运行:
$ 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` 声明它。
// 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 文件,再把结果链接起来。也可以手动分步进行:
$ 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 的文件自动完成这些工作:
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 *.oMakefile 由一条条规则组成,每条规则的格式是:
目标: 依赖文件
<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 就会很容易。
本章练习
- 宏的陷阱。 编写宏
MAX(a, b)和ABS(x),注意正确使用括号。然后思考:执行int i = 5, j = 3; int m = MAX(i++, j);之后,i和m的值分别是多少?为什么说这种用法很危险?如果改用inline函数还会有问题吗? - 断言宏。 编写一个宏
CHECK(cond):当条件cond不成立时,输出文件名、行号和条件本身的文字(例如检查失败:main.c:12: x > 0),但不终止程序。(提示:用#运算符把参数转换为字符串。) - 多文件项目。 把第 7 章练习中的
is_prime、gcd、lcm函数整理成一个模块:mymath.h中写声明,mymath.c中写实现,main.c中调用它们。写出分步编译的命令,并为它编写一个 Makefile。
第 16 章 位运算与底层视角
C 语言的一大特点是能够直接操作数据的二进制位。这在嵌入式开发(操作硬件寄存器)、网络协议、加密压缩、图形处理、算法优化等领域都有广泛应用。这一章我们深入学习位运算,并借此看一看数据在内存中的真实面貌。
16.1 六种位运算符
第 4 章已经初步认识了位运算符,这里再系统地看一遍。以 a = 12、b = 10 为例,为了方便观察,用 8 位二进制表示:
#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;
}运行结果:
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ⁿ(向下取整) |
使用位运算的注意事项:
- 位运算尽量使用无符号类型。 对负数进行左移是未定义行为;对负数右移时,左边补 0 还是补符号位,由编译器决定(主流编译器补符号位)。使用
unsigned int可以避开这些问题,这也是本章的代码都使用无符号类型、并在常量后加u的原因。 - 移位的位数不能是负数,也不能大于或等于类型的位数。 例如对 32 位的
unsigned int执行x << 32是未定义行为,结果并不一定是 0。 - 小心优先级。 移位运算的优先级低于加减,
&、^、|的优先级低于比较运算符,所以x & 1 == 0实际上是x & (1 == 0)。使用位运算时,请多加括号。
16.2 常用的位运算技巧
设 k 是位的编号(最低位为第 0 位):
| 操作 | 表达式 |
|---|---|
| 判断奇偶 | n & 1u,结果为 1 是奇数,为 0 是偶数 |
| 取出第 k 位 | (n >> k) & 1u |
| 将第 k 位置 1 | n | (1u << k) |
| 将第 k 位清 0 | n & ~(1u << k) |
| 翻转第 k 位 | n ^ (1u << k) |
| 去掉最低位的 1 | n & (n - 1) |
| 只保留最低位的 1 | n & (~n + 1) |
| 判断是否为 2 的幂 | n != 0 && (n & (n - 1)) == 0 |
#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;
}运行结果:
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),在操作系统、图形库和硬件编程中随处可见。
#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;
}运行结果:
rw-
rwx
r-x
拥有读权限用 | 组合或添加标志,用 & ~ 移除标志,用 & 检查标志——这三个操作请牢牢记住。Linux 的文件权限就是这样表示的:chmod 755 file 中的 7(二进制 111)表示所有者有读、写、执行权限,5(二进制 101)表示同组用户和其他用户有读和执行权限——读、写、执行分别对应 4、2、1,与上面程序中的定义完全相同。
16.4 异或的妙用
异或运算有几个优美的性质:a ^ a = 0,a ^ 0 = a,并且满足交换律和结合律。利用它们可以解决一些有趣的问题。
找出唯一出现一次的数。 一个数组中,除了一个数只出现一次,其他数都恰好出现两次,找出这个数:
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> 中定义的格式宏:
#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;
}运行结果:
a = -123456
b = 255
c = 18446744073709551615PRId32 等宏会被替换为对应平台上正确的格式字符串(比如 "d" 或 "ld"),再与前后的字符串自动拼接起来。写法看起来有些奇怪,但这是保证跨平台正确性的标准做法。
16.6 字节序:大端与小端
一个 int 占 4 个字节,那么在内存中,这 4 个字节是按什么顺序存放的呢?以 0x12345678 为例,有两种存放方式:
地址: 低 ──────────────▶ 高
大端序(Big-Endian): 12 34 56 78 高位字节存放在低地址
小端序(Little-Endian): 78 56 34 12 低位字节存放在低地址我们常用的 x86 电脑和绝大多数 ARM 设备(包括手机)都是小端序。可以用一个程序来验证:
#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 电脑上的运行结果:
x = 0x12345678
第 0 个字节:0x78
第 1 个字节:0x56
第 2 个字节:0x34
第 3 个字节:0x12
这台电脑是小端序这个程序也展示了指针类型的作用:同一个地址,用 unsigned int * 去读是一个 4 字节的整数,用 unsigned char * 去读则是一个个单独的字节。用字符类型的指针查看任何对象的字节,都是 C 标准允许的做法。
字节序在网络通信和二进制文件中非常重要:一台小端序的电脑直接把整数的内存发送出去,大端序的电脑收到后就会解读错误。因此,网络协议统一规定使用大端序(称为网络字节序),发送和接收时都要进行转换。这也是第 14 章说二进制文件"跨平台性差"的原因之一。
16.7 浮点数的二进制表示(选读)
现代计算机普遍采用 IEEE 754 标准存储浮点数。一个 32 位的 float 由三部分组成:
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 的各个部分显示出来:
#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;
}运行结果:
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 的个数。 编写函数
int count_ones(unsigned int n),统计 n 的二进制表示中有多少个 1。请用两种方法实现:逐位检查;以及利用n & (n - 1)的方法(循环次数等于 1 的个数)。 - 2 的幂。 编写函数,求不小于 n 的最小的 2 的幂(n ≥ 1)。例如输入 5 输出 8,输入 16 输出 16。
- 反转二进制位。 编写函数
uint32_t reverse_bits(uint32_t n),把一个 32 位无符号整数的二进制位顺序反转(第 0 位与第 31 位交换,第 1 位与第 30 位交换……)。例如0x00000001反转后为0x80000000。 - 位集合。 用一个
uint32_t表示集合 {0, 1, …, 31} 的一个子集(第 k 位为 1 表示 k 在集合中)。实现函数:添加元素、删除元素、判断元素是否存在、输出集合的全部元素。再用|和&求两个集合的并集和交集。
第 17 章 调试、错误处理与编程规范
会写代码只是第一步,能快速找出并修正错误、写出健壮且易读的代码,才是一名合格的程序员。这一章介绍调试的方法和工具、错误处理的基本套路,并总结 C 语言的编程规范。
17.1 与编译器做朋友:重视警告
编译器的警告是你最廉价、最及时的"代码审查员"。看看下面这段代码,它能通过编译,却藏着好几个问题:
#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 编译,会得到以下警告(已省略代码引用部分):
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 调试法
最朴素、也最常用的调试方法,就是在关键位置输出变量的值,观察程序的实际行为与预期在哪里出现了偏差:
fprintf(stderr, "[调试] i = %d, sum = %d\n", i, sum);建议把调试信息输出到 stderr,原因有二:一是方便与程序的正常输出区分开;二是 stderr 默认不带缓冲,而 stdout 的输出可能先暂存在缓冲区里,如果程序在输出之后很快崩溃,缓冲区中的内容就会丢失,你会误以为程序根本没有执行到那一行。结合第 15 章的条件编译,还可以用一个 DEBUG 宏来统一开关所有的调试输出。
printf 调试法简单直接,但需要反复修改代码、重新编译。对于复杂的问题,调试器是更强大的工具。
17.4 使用调试器 GDB
调试器可以让程序在指定的位置暂停,然后一行一行地执行,并随时查看变量的值。GDB 是 Linux 上最常用的调试器(MSYS2 的 MinGW 工具链中也包含它;macOS 上对应的调试器是 LLDB,用法类似)。
以下面这个求阶乘的程序为例,它的输出结果是错误的:
#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;
}运行结果:
5! = 0用 GDB 来找出原因。首先,编译时要加上 -g(生成调试信息)和 -O0(关闭优化,让程序严格按照源代码的顺序执行):
$ 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 查看调用栈,就能知道程序是在哪里、经过怎样的调用路径崩溃的:
#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;
}$ 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(条件) 用于检查"按道理绝对应该成立"的条件。条件不成立时,它会输出出错的文件、行号、函数和条件内容,然后立即终止程序:
#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;
}运行结果:
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 值(或负数)表示失败;真正的结果通过指针参数带回。比如:
// 成功返回 0,并通过 result 带回结果;失败返回 -1
int parse_score(const char *text, int *result);4. 出错时集中释放资源。 当一个函数中依次申请了多个资源(打开文件、分配内存……),任何一步失败都需要释放之前已经申请的资源,处理不当就会遗漏。C 语言中一种被广泛接受的写法是用 goto 跳转到函数末尾统一清理,Linux 内核中大量使用这种写法:
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; |
| 整数除以 0 | int 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等有长度限制的函数; - 注意整数溢出和数组边界。
注释
- 注释解释"为什么",而不是重复代码"做了什么";
- 在函数前说明它的功能、参数含义、返回值,以及调用者需要注意的事项(比如"返回的内存由调用者释放");
- 修改代码时,同步更新注释——过时的注释比没有注释更糟糕。
看一个对比。 下面这段代码能正确运行,但几乎无法阅读:
#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;}按照规范改写后:
#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,输入一个很大的数就会导致数组越界。
本章练习
- 调试练习。 下面的函数想要返回数组中最大值的下标,但结果有时不对。请先用 GDB(或 IDE 的调试器)单步执行,观察变量的变化,找出所有错误并修正。可以用
{5, 1, 2}和{-5, -2, -8}两组数据测试。
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;
}- 找出未定义行为。 下面的代码片段中,哪些包含未定义行为?请说明原因。
// (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++;- 代码重构。 按照本章的规范改写下面的代码:起有意义的名字,消除魔法数字,添加必要的输入检查和花括号,把重复的代码提取成函数。
#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. 数据结构
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(逗号分隔值)格式,每行一名学生,依次为学号、姓名和各科成绩:
2024001,张三,85.50,92.50,78.00
2024002,李四,90.00,88.00,95.00CSV 是一种通用的文本格式,人可以直接阅读,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_file | CSV 文件的保存与加载 |
| 主程序 | print_menu、main | 菜单循环 |
各模块之间是分层调用的关系:业务功能调用输入工具、学生列表和显示模块,主程序调用业务功能和文件读写模块。下层模块不依赖上层模块。
4. 程序流程
启动 ──▶ 加载数据文件 ──▶ 显示菜单 ──▶ 读取选择 ──▶ 执行对应功能 ──┐
▲ │
└──────────────────────────────────────┘
选择 0 ──▶ 有未保存的修改?──是──▶ 询问是否保存 ──▶ 释放内存 ──▶ 结束18.4 完整代码
新建文件 student_manager.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. 用函数指针选择排序规则
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 编译与运行
$ gcc -Wall -Wextra -std=c11 student_manager.c -o student_manager
$ ./student_manager在开发和测试阶段,建议加上 AddressSanitizer:
$ gcc -Wall -Wextra -std=c11 -g -fsanitize=address,undefined student_manager.c -o student_manager假设 students.csv 中已经有三名学生的数据:
2024001,张三,85.50,92.50,78.00
2024002,李四,90.00,88.00,95.00
2024003,王五,72.00,58.00,66.00下面是一次运行的过程(为节省篇幅,除第一次外,每次重复显示的菜单都已省略):
已从 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 扩展挑战
完成基本版本后,可以尝试以下扩展,难度大致由低到高:
- 分数段统计。 在统计功能中增加各分数段(优秀、良好、中等、及格、不及格)的人数,并用
*画出简单的横向直方图。 - 多文件重构。 按照第 15 章的方法,把程序拆分为
input.h/.c(输入工具)、student.h/.c(学生列表与业务功能)、storage.h/.c(文件读写)和main.c,并编写 Makefile。 - 自动备份。 保存数据前,先用
<stdio.h>中的rename函数把旧的数据文件改名为students.csv.bak。 - 链表版本。 用第 13 章的链表代替动态数组来存储学生,排序改为自己实现的链表排序算法。
- 课程可配置。 把课程名称写在数据文件的第一行,程序根据它动态决定课程的数量和名称(需要为每个学生的成绩动态分配内存)。
- 撤销功能。 用一个"栈"记录最近的若干次修改操作,实现"撤销上一步"。
- 登录与权限。 增加教师和学生两种角色:教师可以修改所有数据,学生只能查询自己的成绩。
第 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. 个人名片
#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. 星号三角形
#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.
#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;
}运行结果:
大写字母:M
字母表中的序号:13
后面第 3 个字母:p4. 输出为 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. 圆的周长和面积
#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;
}运行结果:
请输入半径:3
周长:18.85
面积:28.272. 求和与平均值
#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. 大写转小写
#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. 进制与格式
#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 的运行结果:
请输入一个非负整数:255
十进制:255
八进制:377
十六进制:ff
[ 255]
[255 ]
[0000000255]%o 和 %x 要求参数是无符号整数,输入负数时会按无符号数的方式输出。
第 4 章
1. 拆分三位数
#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. 时间换算
#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. 三个数的最大值
#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. 三数排序
#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. 月份天数
#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. 阶梯电价
#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. 字符分类
#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。
#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。
#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. 打印菱形
#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. 整数逆序
#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。
#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. 素数函数
#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;
}运行结果:
2 3 5 7 11 13 17 19 23 29
31 37 41 43 47 53 59 61 67 71
73 79 83 89 972. 最大公约数与最小公倍数
#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. 递归求幂
#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. 递归输出二进制
#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。
#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. 第二大的数
#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 指向"下一个要保留的元素应该放的位置"。
#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. 杨辉三角
#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. 字符统计
#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
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. 单词首字母大写
#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. 回文判断
#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. 最大值与最小值
#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. 指针逆序
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. 输出为:
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. 字符串排序
#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. 一年中的第几天
#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;
}运行结果:
2024 年 3 月 1 日是当年的第 61 天2. 复数运算
#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. 图书排序
#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;
}运行结果:
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. 动态求平均
#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
#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. 动态二维数组
#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 指向一个指针数组,其中每个指针再指向一行数据。释放时的顺序与申请时相反:先释放各行,再释放指针数组。
a
│ +-------+ +----+----+----+----+
└───▶ | ●───┼────▶ | | | | | 第 0 行
+-------+ +----+----+----+----+
| ●───┼────▶ | | | | | 第 1 行
+-------+ +----+----+----+----+
| ●───┼────▶ | | | | | 第 2 行
+-------+ +----+----+----+----+4. 找错题
共有五处错误:
malloc(strlen("Hello, ") + strlen(name))没有给'\0'留出空间,strcat会越界写入 1 个字节,应当再加 1;- 没有检查
malloc的返回值是否为NULL; free(s)之后又printf("%s\n", s),使用了已经释放的内存;nums第二次赋值时,第一次申请的内存的地址丢失了,造成内存泄漏;free(nums)执行了两次,重复释放。
顺便一提,用较新版本的 GCC(12 及以上)加 -Wall 编译这段代码,编译器会对第 3、5 两处错误给出 used after 'free' 警告。这再次说明了开启警告的价值——不过也要注意,编译器并不能发现所有的内存错误。
修正后的代码:
#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. 链表长度
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. 反转链表
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 为例,每一轮循环把一个箭头"掉头":
初始: NULL 1 -> 2 -> 3 -> NULL
第 1 轮后: NULL <- 1 2 -> 3 -> NULL
第 2 轮后: NULL <- 1 <- 2 3 -> NULL
第 3 轮后: NULL <- 1 <- 2 <- 3 (返回 3)3. 合并有序链表
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. 中间节点
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. 单词计数
#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. 转换大写
#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. 随机读取
#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. 统计数据文件
#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. 宏的陷阱
#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. 断言宏
#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:
#ifndef MYMATH_H
#define MYMATH_H
int is_prime(int n);
int gcd(int a, int b);
int lcm(int a, int b);
#endifmymath.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:
#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;
}分步编译的命令:
$ gcc -Wall -Wextra -c mymath.c
$ gcc -Wall -Wextra -c main.c
$ gcc main.o mymath.o -o appMakefile(命令行前面是 Tab):
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 的个数
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 的幂
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. 反转二进制位
#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. 位集合
#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。修正方法是用第一个元素作为初始值:
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. 代码重构
#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 语言中的写法 | 含义 |
|---|---|---|---|---|
| 0 | 0x00 | NUL | '\0' | 空字符,字符串的结束标记 |
| 7 | 0x07 | BEL | '\a' | 响铃 |
| 8 | 0x08 | BS | '\b' | 退格 |
| 9 | 0x09 | HT | '\t' | 水平制表符(Tab) |
| 10 | 0x0A | LF | '\n' | 换行 |
| 13 | 0x0D | CR | '\r' | 回车 |
| 27 | 0x1B | ESC | '\x1b' | 转义,常用于控制终端的颜色和光标 |
| 127 | 0x7F | DEL | '\x7f' | 删除 |
32~126 是可显示字符:
| 十进制 | 十六进制 | 字符 | 十进制 | 十六进制 | 字符 | 十进制 | 十六进制 | 字符 | 十进制 | 十六进制 | 字符 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 32 | 0x20 | (空格) | 56 | 0x38 | 8 | 80 | 0x50 | P | 104 | 0x68 | h |
| 33 | 0x21 | ! | 57 | 0x39 | 9 | 81 | 0x51 | Q | 105 | 0x69 | i |
| 34 | 0x22 | " | 58 | 0x3A | : | 82 | 0x52 | R | 106 | 0x6A | j |
| 35 | 0x23 | # | 59 | 0x3B | ; | 83 | 0x53 | S | 107 | 0x6B | k |
| 36 | 0x24 | $ | 60 | 0x3C | < | 84 | 0x54 | T | 108 | 0x6C | l |
| 37 | 0x25 | % | 61 | 0x3D | = | 85 | 0x55 | U | 109 | 0x6D | m |
| 38 | 0x26 | & | 62 | 0x3E | > | 86 | 0x56 | V | 110 | 0x6E | n |
| 39 | 0x27 | ' | 63 | 0x3F | ? | 87 | 0x57 | W | 111 | 0x6F | o |
| 40 | 0x28 | ( | 64 | 0x40 | @ | 88 | 0x58 | X | 112 | 0x70 | p |
| 41 | 0x29 | ) | 65 | 0x41 | A | 89 | 0x59 | Y | 113 | 0x71 | q |
| 42 | 0x2A | * | 66 | 0x42 | B | 90 | 0x5A | Z | 114 | 0x72 | r |
| 43 | 0x2B | + | 67 | 0x43 | C | 91 | 0x5B | [ | 115 | 0x73 | s |
| 44 | 0x2C | , | 68 | 0x44 | D | 92 | 0x5C | \ | 116 | 0x74 | t |
| 45 | 0x2D | - | 69 | 0x45 | E | 93 | 0x5D | ] | 117 | 0x75 | u |
| 46 | 0x2E | . | 70 | 0x46 | F | 94 | 0x5E | ^ | 118 | 0x76 | v |
| 47 | 0x2F | / | 71 | 0x47 | G | 95 | 0x5F | _ | 119 | 0x77 | w |
| 48 | 0x30 | 0 | 72 | 0x48 | H | 96 | 0x60 | `` | 120 | 0x78 | x |
| 49 | 0x31 | 1 | 73 | 0x49 | I | 97 | 0x61 | a | 121 | 0x79 | y |
| 50 | 0x32 | 2 | 74 | 0x4A | J | 98 | 0x62 | b | 122 | 0x7A | z |
| 51 | 0x33 | 3 | 75 | 0x4B | K | 99 | 0x63 | c | 123 | 0x7B | { |
| 52 | 0x34 | 4 | 76 | 0x4C | L | 100 | 0x64 | d | 124 | 0x7C | | |
| 53 | 0x35 | 5 | 77 | 0x4D | M | 101 | 0x65 | e | 125 | 0x7D | } |
| 54 | 0x36 | 6 | 78 | 0x4E | N | 102 | 0x66 | f | 126 | 0x7E | ~ |
| 55 | 0x37 | 7 | 79 | 0x4F | O | 103 | 0x67 | g |
几个值得记住的规律:数字 '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 == 0 | x & (1 == 0) | (x & 1) == 0 |
a << 1 + 1 | a << (1 + 1) | (a << 1) + 1 |
*p.x | *(p.x) | (*p).x,即 p->x |
int *a[10] | 含 10 个指针的数组 | 如果想要"指向数组的指针",应写 int (*a)[10] |
c = getchar() != EOF | c = (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 |
后续标准新增的关键字
| 标准 | 新增关键字 |
|---|---|
| C99 | inline restrict _Bool _Complex _Imaginary |
| C11 | _Alignas _Alignof _Atomic _Generic _Noreturn _Static_assert _Thread_local |
| C23 | alignas 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 找不到对应的 if | if 后面多了分号,或者多条语句没有用花括号括起来 |
break statement not within loop or switch | break 用错了位置 | break 只能用在循环或 switch 中 |
duplicate case value | switch 中有重复的 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 节) |