前言
关于这门课程
欢迎来到《零基础C语言入门到进阶》。本课程将带领你从零开始,系统地学习 C 语言——一门古老而又深刻的编程语言。
本课程使用的开发环境
本课程使用 VSCode + Clang + CMake 这套组合:VSCode 负责代码编辑,Clang 负责编译,CMake 负责组织多文件项目的构建。
市面上大多数 C 语言教材使用的是 Visual Studio(VS)——开箱即用,点击“运行“就能看到结果。但我们刻意选择了更难入门的 VSCode。原因很简单:VS 替你隐藏了编译和链接的所有细节,你甚至不需要知道“编译器“三个字的存在;而 C 语言本身就是一门需要理解底层才能学好的语言。从终端亲手输入 clang 命令,亲眼看到 .c 文件变成可执行程序——这份认知,是 C 语言基本功的一部分。关于工具选择的详细讨论,请见第 0.5 章。
配套源码的第一至第十六章提供了 build.sh 和 build.ps1 两套手动编译脚本,可直接使用 Clang 编译本章所有示例,并将生成的程序放入对应章节的 bin/ 目录。第十七章以后开始使用 CMake 组织项目构建。
课程内容
本课程分为两大部分:
入门篇(已完稿)——从计算机基础概念开始,逐步学习 C 语言的核心语法和基本数据结构:
- 第零章:计算机和编程的基础概念扫盲
- 第 0.5 章:开发环境与工具(VSCode + Clang)
- 第一至第十五章:C 语言核心语法体系,包括类型、数组、字符串、结构体、内存模型、指针、函数指针、动态内存、生命周期、共用体、枚举、宏与文件 I/O
- 第十六章:头文件、源文件与多文件代码组织,使用 Clang 手动编译
- 第十七章:CMake 初识,把多文件项目交给统一的构建系统组织
- 第十八章:基本数据结构,包括单链表、双链表、栈、链式队列和环形队列
- 第十九至第二十章:常见概念辨析与入门阶段总结
进阶篇(待完稿)——深入 C 语言的高级编程技巧和实战项目:
- CMake 进阶:创建与导入库、编译参数配置、构建优化
- 函数接口设计:利用
const type *约束只读语义、借助void *实现通用接口,设计出不易误用的 API - goto 的使用:C 语言最具争议的关键字——
goto的语法、陷阱与合理使用场景(错误集中处理、跳出嵌套循环、状态机) - 宏魔法:利用预处理器消除样板代码、实现编译期分支和代码生成
- 内存对齐:分析
struct与union的成员排布和整体大小,理解对齐规则如何影响内存布局;利用共用体实现 SSO(Small String Optimization)等空间优化技术 - 柔性数组:在结构体末尾放置可变长度缓冲区,一次分配同时容纳元数据和数据
- 面向对象编程:不止于结构体嵌函数指针——手动构造虚表(vtable),在 C 中实现封装、继承与运行时多态
- initcall 风格机制:程序启动时的自动注册思路,属于平台/工具链扩展话题
- 树与图:二叉树、平衡树、图的遍历与常见算法
- 实战项目:待定
C 语言:没有 GC,面向底层
C 语言诞生于 1970 年代,由 Dennis Ritchie 在贝尔实验室创造。五十余年过去,它依然健在——操作系统内核(Linux、Windows、macOS)、嵌入式设备、数据库引擎、编程语言的运行时(Python 的解释器、Lua 的虚拟机)……这些构成现代软件世界基础设施的核心组件,几乎全部由 C 语言(及其后代 C++)编写。
C 语言有两个鲜明的特点,值得你在学习之初就有所认知:
第一,C 语言没有垃圾回收(Garbage Collection,GC)。
如果你之前接触过 Java、Python、Go、JavaScript 等语言,你可能已经习惯了这样一件事:创建一个对象,用完之后就不管了——“反正 GC 会来处理的”。在 C 语言中,这个前提不成立。你通过 malloc 申请的每一块内存,都必须在不再使用时通过 free 显式归还。忘记释放会导致内存泄漏;过早释放或重复释放则会导致悬空指针和程序崩溃。这意味着你需要对每一块内存的“生命周期“保持清醒的分析——这块内存是谁分配的?它会在什么时候、由谁释放?这种纯手动内存管理使得 C 语言编程对程序员的要求更高,但也恰恰是这种“没有中间商“的透明性,使得 C 语言能够胜任那些对性能和资源控制有极致要求的场景。
第二,C 语言非常接近底层。
你写的每一个变量、每一层指针、每一次类型转换,都紧密对应着内存中的实际布局。你在 C 语言中操作的地址,直接对应操作系统虚拟地址空间中的地址。C 语言没有虚拟机,没有解释器,没有沙箱——编译器将你的代码直接翻译成 CPU 可以执行的机器指令。这意味着你需要理解一些“底层“的概念:内存模型、栈与堆、指针与地址……这些概念在学习初期可能让人感到困惑,但一旦掌握,你将对“计算机到底是怎样工作的“建立起一种不可替代的直觉。
如何学习 C 语言
每位学习者的背景不同,适合的学习路径也有所不同。请根据自己的情况,选择适合你的方式。
学习 C 语言的重点
如果你明确了 C 语言“没有 GC、贴近底层“这两个特点,那么学习 C 语言的重点也就自然浮现了——几乎全部围绕内存展开:
- 内存模型:C 语言把内存划分为栈(stack)、堆(heap)、静态区等不同区域,每一块内存属于哪个区域,决定了它的分配方式和生命周期。理解这个模型,是后续一切内存操作的基础。
- 指针与内存操作:指针是 C 语言最核心也最独特的机制。通过指针,你可以直接读写任意地址上的内存——这正是 C 语言“贴近底层“的具体体现。但这也意味着一次错误的指针操作就能让整个程序崩溃,几乎没有缓冲地带。
- 手动内存管理:C 语言没有 GC。你通过
malloc申请的每一块堆内存,都必须在你认为合适的时机通过free归还。忘记释放 → 内存泄漏;过早释放 → 悬空指针;重复释放 → 未定义行为。你需要对每一块动态内存的“所有权“有清晰的认识。 - 生命周期分析:一个变量在什么时候有效?一块内存在什么时候被释放?谁负责释放?这些问题在 GC 语言中几乎不用思考,但在 C 语言中,它们是日常。你需要养成一个思维习惯:看到一块内存,就下意识追问——它从哪来?什么时候失效?我还能不能安全地使用它?
这些概念分散在本课程的多个章节中——第六章(内存模型)、第七章(指针)、第十章(堆内存分配)、第十二章(生命周期)——它们不是彼此孤立的,而是共同构成了 C 语言的“内存世界观“。你会在反复的实践中将它们串联起来。
如果你有编程基础(学习过其他语言)
你已经有编程基础,懂得变量、循环、分支、函数等基本概念。你可以:
- 直接从第一章的猜数游戏开始——通过一个完整的代码示例快速熟悉 C 语言的语法风格,然后按章节顺序正常学习即可。
- 重点关注 C 语言与其他语言的不同之处:C 没有类(但有结构体),没有异常处理(通过返回值),没有命名空间,没有函数重载——这些“缺失“不是缺陷,而是由 C 语言的设计定位决定的。
- 如果你之前学习的是 GC 语言(Java、Python、Go、JavaScript 等),你需要特别注意:C 语言没有 GC。每当你使用
malloc分配内存时,你都要问自己一个问题——“这块内存什么时候会被释放?由谁来释放?“这种对内存生命周期的主动分析,是 GC 语言转向 C 语言时最需要刻意练习的能力。
如果你没有编程基础(零基础入门)
欢迎!编程是一项可以通过学习掌握的技能,不需要任何“天赋“作为前提。本课程在设计时已经充分考虑了零基础学习者的需求:
- 从第零章开始——第零章专门为你准备了计算机和编程的最基础概念科普:什么是 CPU、什么是内存、什么是二进制、什么是编译……这些概念是后续学习所必需的,需要提前掌握。
- 先“过“语法,再看示例——学习语法时,第一遍只需要知道“哦,原来 C 语言里有这么个东西,它大概长这个样子,大概是用来做这个的“。不必追求第一遍就百分之百掌握。然后看示例代码:先看懂,再自己手敲。看懂每一行在做什么之后,关闭文档,自己从头敲一遍。这个过程反复进行,慢慢地你就会发现语法已经从“记忆“变成了“习惯“。
- 遇到问题不要慌——编译器的错误提示看起来密密麻麻,但大多数时候,你只需要看第一行错误信息就够了。读它,理解它指向的问题,修复它。这个过程反复发生,渐渐地你就能快速定位和解决问题。
无论你属于哪一类学习者,学习 C 语言最好的方式都是一样的:多写代码。阅读一百行代码,不如自己敲十行;自己敲十行,不如自己独立写一个程序。从模仿开始,慢慢走向独立。
AI 时代为什么要学 C
在动笔写这门课程的时候,AI 辅助编程正在以惊人的速度进化。一个很自然的问题是:当 AI 能帮你写代码,我们还需要学 C 吗?在给出答案之前,先看三个客观现实。
三个客观现实
第一,AI 越来越强,“vibe coding” 能完成的事情越来越多。
从 GPT-4 到 Claude Opus 4.8,AI 的代码生成能力在过去两年里发生了质的飞跃。如今,你完全可以用自然语言描述需求,让 AI 替你生成一个可用的网站、一个命令行工具、甚至一个简单的游戏。“Vibe coding”——凭感觉和 AI 对话式编程——已经从概念变成了很多开发者的日常。
第二,截止到 2026 年中(GPT-5.5 / Opus 4.8 时代),AI 在 C 和 C++ 上依然表现不佳。
这是一个值得深思的事实。同一个模型,写 Python 可能行云流水,写 C 却屡屡踩坑——悬空指针、内存泄漏、未定义行为、复杂的编译链接问题……AI 在这些问题上仍然会犯低级错误,而且错误往往更隐蔽、更难排查。C 语言的“没有 GC“和“贴近底层“这两个特点,恰恰也是 AI 目前最不擅长处理的场景。
第三,C 正在受到 Rust 的冲击。
Rust 带着现代语言设计理念——所有权系统、借用检查、零成本抽象——正在系统编程领域快速扩张。Linux 内核已经接受了 Rust 作为第二种官方语言,越来越多的底层项目开始认真考虑用 Rust 替代 C。C 的地位不再是毫无争议的默认选项。
那么我们为什么还要学 C?
正因为以上三点,有些人可能会得出一个结论:C 正在过时,不值得学。但我们的看法恰好相反。
首先,从就业和技能角度,C 仍然是必要选择。
C 是当前底层开发——尤其是 Linux 内核开发和嵌入式开发——的绝对主流语言。Linux 内核超过 95% 的代码、FreeRTOS 等主流嵌入式实时操作系统、以及海量的设备驱动和固件,全部由 C 语言编写。Rust 在底层领域的渗透确实在加速,但它能不能真正主导底层开发、甚至在可见的未来取代 C++,至今仍是巨大的未知数。这些基础设施的代码存量以亿行为单位,它们的维护、演进和新增需求,将在未来数十年持续产生对 C 程序员的需求。把 C 作为你技能栈的一部分,是对这项确定性事实的务实回应。
其次,C 是理解计算机世界最直接的通道。
在所有主流编程语言中,C 是最接近硬件的语言——没有之一。没有虚拟机,没有垃圾回收,没有解释器。你写的每一个变量、每一层指针、每一次类型转换,都直接映射到内存中的真实字节。C 语言就是整个当代计算机系统的基石:操作系统用它写,数据库用它写,Python 的解释器用它写,Lua 的虚拟机也用它写。学习 C,就是在学习计算机底层的“母语“。
这一点的重要性,不会因为你最终选择 Java、Python、Go 还是 Rust 而改变。当你理解了栈和堆、理解了指针和地址、理解了编译和链接的全过程,你再去学任何一门高级语言,都会发现那些“魔法“不再是魔法——你知道它们在底层大概是怎么运作的。C 语言给你的,是计算机世界的底层直觉,是一种不可替代的内功。
换句话说:学 C 不一定是为了“写 C“,而是为了真正理解计算机。
⚠️ 一个严肃的提醒
AI 辅助编程越来越强,有些学习者可能会产生一种错觉:基础没那么重要了,反正 AI 能写。
这种想法很危险,尤其是对于 C 语言。理由有二:
第一,AI 无法兜底,最终兜底的人是你。
AI 能帮你写出 80% 甚至 90% 的代码,但它搞不定的那 10%,恰恰是最棘手的部分——隐蔽的内存泄漏、微妙的数据竞争、难以复现的未定义行为。换句话说,AI 过滤掉了简单问题,留给人类处理的是它不擅长的复杂问题。如果你的基础不扎实,面对这些问题你将毫无头绪——因为你连排查的起点都找不到。AI 越强,它留给你的“残局“就越难,对你的基础要求反而越高。
第二,C 语言不同于任何应用层语言——它太底层了,而 AI 对底层的理解远没有你想象的好。
同一个模型,在应用层语言上表现游刃有余,面对 C 语言却频繁出现悬空指针、栈溢出、越界访问等低级错误——根源在于 AI 对内存生命周期、所有权边界这些底层约束缺乏精确判断。
但这不仅仅是 AI 的问题——这也是你作为一个 C 程序员必须面对的现实。C 不是那种可以无脑 new 一个对象、剩下的交给 JVM 或 GC 来善后的语言。一个 C 语言程序员不懂内存管理、不懂进程调度、不懂内核的基本原理——这是说不过去的。你的程序可能跑在一块只有几十 MB 内存的单片机上,你的代码直接操作每一字节的物理内存,每一块内存都要算着用;你可能在亲手优化一个操作系统的调度器,也可能在改造一个内核模块。这种工作,即使 AI 帮你完成了 99%,它也永远不可能是 100%——世界上不存在 100% 正确的代码,AI 也不例外。而当那 1% 的问题出现、AI 无法解决的时候,你是否有能力兜底?
把 AI 生成的 C 代码不经审查直接放进项目,等于把炸弹埋进了地基。在 C 语言的世界里,你不是 AI 的监督者——你是它最后的防线。
不要因为 AI 的强大,就觉得基础内容不重要。恰恰相反——在这个 AI 能替你写大量代码的时代,你能走多远,取决于你的基础有多深。 基础,是你兜底 AI 的底牌。
第零章 计算机和编程最基础概念扫盲
本章目标:在正式学习 C 语言之前,用最通俗的方式帮你建立对计算机硬件、操作系统、编程语言和开发工具的基本认知。本章不要求记住任何技术细节——读一遍,脑子里有个大概印象就够了。当你在后面学到某个概念感到困惑时,随时可以翻回来查阅。
一、概念扫盲
1.1 计算机核心硬件
一台计算机到底由哪些部件组成的?如果你拆开一台台式机的主机箱(或者想象把笔记本电脑的外壳掀开),会看到一块主板上插着大大小小的零件。这些零件看起来眼花缭乱,但归纳起来,计算机的核心硬件其实只有五大类。下面我们就用最通俗的比喻来逐一认识它们。
一、CPU(中央处理器)——计算机的“大脑“
CPU(Central Processing Unit,中央处理器)是计算机里最核心的运算部件,可以把它理解为计算机的“大脑“。你写的程序里所有的加减乘除、逻辑判断、数据搬运,最终都是由 CPU 来执行的。
CPU 的工作节奏极快——它以“GHz“(吉赫兹,每秒十亿次)为单位运转。一个 3.0 GHz 的 CPU,每秒能执行大约 30 亿个基本操作。市面上最常听到的两大 CPU 厂商是 Intel(英特尔)和 AMD,它们就像汽车界的丰田和大众,各自有不同档次的产品线。
对学编程的你来说,可以把 CPU 想象成一个运算速度极快、但记性很差的计算器——它能瞬间算出一道复杂的数学题,但你让它记住这道题和答案,它转身就忘。
二、内存(RAM)——计算机的“草稿纸“
内存(Memory,全称 RAM——Random Access Memory,随机存取存储器)是 CPU 的“草稿纸“——CPU 在运算时,所有正在处理的数据和指令都临时写在内存上面。
当你双击打开一个程序(比如浏览器),操作系统做的第一件事就是把那个程序从硬盘搬进内存,然后 CPU 才能从内存里读取它来执行。为什么要有这一步?因为 CPU 实在太快了,如果让它直接从硬盘读数据,就好像让一个百米冲刺运动员边跑边翻开一本书查资料——硬盘的读写速度完全跟不上 CPU 的节奏。内存的速度介于 CPU 和硬盘之间,起到一个“中转站“的作用。
内存有一个关键特性:断电即忘。一旦关机或断电,内存里所有暂存的数据都会瞬间消失。所以你在写文档时如果没保存就断电了——内容丢了。因为没保存的内容只存在于内存中,还没被写入硬盘。
💡 学 C 语言为什么要知道这个? C 语言中你会频繁接触到“内存地址“、“指针“这些概念——它们就是直接和内存打交道的。现在只需要记住:内存 = 草稿纸,CPU 在上面边写边算,一断电纸就变白。
三、外存(硬盘 / SSD)——计算机的“笔记本“
如果内存是“草稿纸“,那外存就是计算机的“笔记本“——用来长久保存数据的仓库。你的操作系统、安装的软件、写的文档、拍的照片、下载的电影,统统存在外存里。和内存不同,外存上的数据断电后不会消失。
外存主要有两种形态:
- 机械硬盘(HDD,Hard Disk Drive):像一台微型唱片机,里面有一片高速旋转的金属碟片和一个悬浮在上面的磁头,通过磁性来读写数据。优点是容量大、价格便宜;缺点是读写速度较慢,而且怕摔——毕竟里面有高速旋转的精密机械部件。
- 固态硬盘(SSD,Solid State Drive):纯电子元件,没有机械转动部件,数据存储在闪存芯片上。读写速度比机械硬盘快很多倍,而且安静、省电、耐摔。缺点是同样容量下比机械硬盘贵。近几年 SSD 已经基本成为新电脑的标配。
你可能还用过 U 盘 和 移动硬盘——它们本质上也是外存,只是通过 USB 接口临时插到电脑上,方便携带和转移数据。
一个日常生活中可以感受到“内存 vs 外存“速度差异的场景:当你打开一个大型软件(比如 Photoshop),启动过程需要好几秒甚至十几秒——这段时间操作系统正在把软件从外存(慢)搬运到内存(快)。一旦加载完成,软件运行就流畅了——因为 CPU 现在可以直接从内存里读取它了。
四、输入设备——计算机的“耳朵和眼睛“
计算机需要从外部世界接收信息,这个任务由输入设备完成。最常见的输入设备就是你每天都在用的:
- 键盘:把手指的按压转换成文字和命令。你敲下的每一个字符、每一个快捷键,都是通过键盘“告诉“计算机的。
- 鼠标 / 触控板:把手部的移动和点击转换成屏幕上的光标移动和操作指令。
- 麦克风:把声音(空气振动)转换成电信号交给计算机处理。
- 触摸屏:把手指在屏幕上的触摸位置直接传给计算机(手机和平板的标准配置)。
输入设备的共同特征:它们把物理世界的信息(手指的动作、声音、光线)转换成计算机能处理的电信号。
五、输出设备——计算机的“嘴巴和脸“
计算机处理完数据之后,需要把结果“表达“出来给人看或给人听,这个任务由输出设备完成:
- 显示器 / 屏幕:把计算机内部的数据转换成眼睛能看到的图像和文字。你正在读的这篇教程,就是计算机把一堆 0 和 1 通过屏幕变成你认识的中文。
- 音箱 / 耳机:把计算机内部的音频数据转换成耳朵能听到的声音。
- 打印机:把电子文档转换成纸上的墨迹。
输出设备的共同特征:它们把计算机内部的电信号,转换成人类感官能接收的物理形式(光、声音、墨水)。
一张图理清五大部件的关系:
输入设备(键盘、鼠标) ──→ 内存(草稿纸) ←──→ CPU(大脑)
│
↓ 保存
外存(笔记本)
│
↓ 读取
内存 ←──→ CPU
│
↓ 结果输出
输出设备(屏幕、音箱)
这五大部件协同工作的流程其实你每天都在经历:按下键盘上的一个键(输入)→ CPU 接收到信号并处理 → 把对应的字符写在内存里 → 把结果送出到屏幕显示出来(输出)。整个过程在几毫秒内完成,你根本感觉不到中间的步骤。
💡 学 C 语言,你主要跟谁打交道? 你写的程序本质上就是在指挥 CPU 去操作内存里的数据,必要时把结果写入外存(保存文件),或从外存读取数据(打开文件),或是向屏幕输出文字、从键盘读取输入。五者之中,CPU 和内存是 C 语言的“主战场“——后续章节中学习的变量、指针、数组,都是针对这两个部件的抽象。现在只需要对五大部件的关系有一个整体的画面感,就足够了。
1.2 操作系统:计算机的“大管家“
有了 CPU 和内存这些硬件之后,谁来管理它们呢?谁来协调“你同时打开了浏览器、放着音乐、后台还在下载文件“这些并行进行的任务呢?
这个角色就是操作系统(Operating System,简称 OS)。它是运行在计算机硬件之上的第一层软件,是所有其他软件的“大管家“。你正在用的 Windows、macOS、Linux,就是当今最主流的三种操作系统。
操作系统最核心的四项职责:
- 管理硬件资源:把 CPU 的计算时间分配给各个程序,把内存的空间划给各家用,让所有程序井井有条地共享一台机器的硬件。当你感觉电脑“卡“的时候,往往是操作系统在疲于处理某个耗尽了 CPU 或内存的程序。
- 提供用户界面:让你能通过桌面、窗口、鼠标来操作电脑(图形界面),也能通过终端输入文字命令来操作(命令行界面)。
- 管理文件:你硬盘上的文件夹和文件——创建、删除、移动、复制——背后都是操作系统在处理。
- 运行程序:当你双击一个程序图标时,是操作系统负责把它从硬盘加载到内存里,然后让 CPU 去执行它。
用一句话概括它们之间的关系:应用程序(比如你写的 C 程序)跑在操作系统之上,操作系统跑在硬件之上。 你写的程序需要通过操作系统来使用 CPU、内存和硬盘。这套“硬件 → 操作系统 → 应用程序“的层级结构,是理解计算机工作原理最重要的框架。
1.3 计算机的“母语“:0 和 1 的故事
首先我们要明白,计算机本身并不能直接理解文字、图片或声音。它的核心——CPU,只认识一种东西:电信号。
大家还记得初中物理里学过的简单电路吗?一个电池、一个开关、一个灯泡。当开关闭合,电流流通,灯泡就亮了;开关断开,灯泡熄灭。电路中的开关,只有“通“和“断“两种状态。
CPU 内部密密麻麻地集成了数亿甚至上百亿个肉眼看不见的微型“电子开关“,它们叫晶体管。每个晶体管同样可以看成一个超微小的开关:要么让电流通过(通),要么不让电流通过(断)。这两种状态,人类习惯于用数学符号来表示:
- “通” → 用 1 表示
- “断” → 用 0 表示
这样,一个晶体管就能代表一个数字,要么是 0,要么是 1。 这就构成了计算机最底层的语言——二进制。
一个开关只能表示两种可能(比如“是“或“否“)。但如果把很多个开关组合在一起,能表示的东西就海量了。比如,用 8 个开关一组,就能组合出 2⁸ = 256 种不同的状态,足够表示我们常用的数字、大小写字母和标点符号了。无数个 0 和 1 的组合,就能代表指令、数据、图像、音乐……世间万物在计算机里,最终都化为了由 0 和 1 组成的洪流。
所以,计算机唯一能“读懂“并直接执行的,就是由 0 和 1 组成的、针对特定 CPU 设计好的命令,这叫“机器语言“。 对于人来说,满屏的 0 和 1 比天书还难读,但这确实是计算机的“母语“。
1.4 编程,就是“指挥“与“翻译“
既然计算机只懂 0 和 1,而我们人类习惯用自然语言和数学符号来思考,那怎么让计算机帮我们做事呢?
编程,本质上就是把我们想让计算机完成的任务,用一种它能理解的方式告诉它。在最早最早的时候,计算机科学家们确实就是直接写 0 和 1 去指挥机器的,但效率极低,极其容易出错。于是人类不断创造更接近我们思维方式的“语言“,并同时发明能把这种语言“翻译“成机器语言的工具。
这个“人类写 → 工具翻译 → 计算机执行“的过程,就是编程的核心。接下来我们就看看,人类是怎么一步步把这件事做得越来越顺手的。
1.5 编程语言的进化史:越来越像人话
人类是怎么把“天书“一步步变成“人话“的?
-
第一代:机器语言——由纯粹的 0 和 1 组成,是 CPU 唯一能直接执行的。难写、难读、难改,只有机器喜欢。
-
第二代:汇编语言——人们开始用简短的英文助记符来代替 0 和 1 指令。比如用
MOV表示移动数据,用ADD表示加法。这好记多了!但汇编语言仍然与具体硬件紧密绑定,换个 CPU 可能指令就不一样了。它需要一个叫汇编器(Assembler)的程序把它翻译成机器语言。 -
第三代:高级语言——人类追求的是更接近数学公式和自然语言、与具体硬件无关的编程语言。这就是我们今天使用的主流:
- FORTRAN(1950 年代):第一种广泛使用的高级语言,主要用于科学和工程计算。
- C 语言(1972 年诞生):里程碑式的语言,在贝尔实验室诞生,被用来重写 UNIX 操作系统。C 语言既具有高级语言的易懂性,又能像汇编一样直接操作硬件,高效且强大,至今仍是系统编程和嵌入式领域的王者。
- C++(1980 年代):在 C 语言的基础上增加了“面向对象“等编程思想,让管理大型复杂软件成为可能。它和 C 语言一脉相承,功能更丰富。
- Java(1995 年):由 Sun 公司推出,口号是“一次编写,到处运行“。Java 程序先被编译成叫“字节码“的中间格式,然后在任何装有 Java 虚拟机(JVM)的设备上都能运行,在互联网时代大放异彩。
我们接下来要踏上的,就是 C/C++ 这两门经典编译型语言的学习之路。
1.6 两大家族:编译型与解释型
高级语言写好了,怎么变成机器能懂的 0 和 1 呢?根据翻译方式的不同,高级语言分成了两大家族:
编译型语言(如 C、C++)
想象你要把一本英文小说送给一位不懂英文的朋友。你找一位翻译,让他把整本书一次性全部翻译成中文,然后装订成一本新书送给他。他拿到中文书直接就能流畅阅读。
在这个比喻中:英文原稿 = 源代码,翻译官 = 编译器(Compiler),翻译出的中文书 = 可执行文件。程序运行时,CPU 直接执行那个已经被编译好的可执行文件,所以运行速度通常很快。
解释型语言(如 Python、JavaScript)
这一次,你请的是一位“同声传译“。你带着朋友去听一场英文演讲,翻译官在旁边,演讲人说一句,他就立刻翻译一句给朋友听。演讲结束,朋友也明白了内容,但并没有产生一本完整的“中文演讲稿“。
在这个比喻中:“同声传译” = 解释器(Interpreter)。它不需要提前生成可执行文件,而是边读源代码边翻译执行。这种方式非常灵活,调试方便,但运行速度通常比编译型慢。
混合型(如 Java)——Java 结合了二者:先用编译器把源代码翻译成“字节码“(不是最终的机器码),然后由 Java 虚拟机(JVM)去执行这个字节码,从而实现跨平台。
我们学的 C 语言属于编译型,所以理解“编译器是什么“和“可执行文件是什么“,是学习路上必须迈过的第一道坎。
1.7 编译器:你的专属“翻译官“
编译器本身也是一个程序。它的工作,就是把我们写好的高级语言源代码文件(比如 .c 文件),经历复杂的分析、优化,最终生成机器指令。
对现在的你来说,只需要记住编译过程包含核心两步:
- 编译:编译器把你的源代码翻译成机器语言。
- 链接(编译器会自动调用这一步):把翻译好的代码和系统自带的“标准功能“打包在一起,最终生成操作系统可以直接运行的可执行文件。
市面上有很多优秀的 C/C++ 编译器。本教程使用的是 Clang——目前三大主流编译器之一,跨平台、错误提示友好。第 0.5 章会详细讲解它的安装和配置。
1.8 可执行文件:程序的“成品“
前面反复提到了“可执行文件“这个词——它是编译过程的最终产物,是你写的程序在计算机里的“可运行形态“。那么它到底是什么?在不同操作系统上又有什么区别?
在 Windows 上,可执行文件以 .exe 为后缀名。当你双击一个 .exe 文件或者在终端里输入它的名字时,操作系统就会把它加载到内存里,CPU 开始逐条执行里面的机器指令。比如 hello.exe、game.exe 就是典型的 Windows 可执行文件。
在 macOS 和 Linux 上,可执行文件通常没有特定的后缀名。一个文件能不能“执行“,不看它叫什么名字,而看操作系统是否给它标记了“可执行权限“。所以你看到的可执行文件直接就是 hello、my_program 这样的名字。在终端里运行时需要在前面加 ./(比如 ./hello),意思是“在当前目录下找这个文件来运行“。
为什么会有这个差异?这是两种不同的设计哲学:
- Windows 习惯用文件后缀名来判断文件类型——
.exe就是可执行程序,.docx就是 Word 文档,对普通用户很直观。 - macOS / Linux 习惯用文件属性和权限来判断文件能不能执行——跟它叫什么名字无关,而跟操作系统给它标记了什么权限有关。这种设计更灵活,但对新用户来说不如后缀名直观。
对于学 C 语言的你来说,只需要记住一句话:你写的
.c源文件经过编译器翻译后,在 Windows 上生成.exe文件,在 macOS / Linux 上生成一个没有特定后缀的可执行文件。 不管哪种形态,它们都是你的程序从“文字“变成“软件“的成品。
1.9 调试器:程序的“听诊器“和“X 光机“
程序不可能每次都一次写对,里面总会有错误,我们称为 Bug。很多时候光盯着代码看很难找出问题在哪,这时就需要调试器(Debugger)出马。
调试器能让你在程序运行时随时“暂停时间“:
- 设置断点:让程序运行到某一行时自动暂停,就像按下了暂停键。
- 单步执行:让程序一行一行地慢动作运行,你可以观察每一步产生了什么影响。
- 查看变量:在暂停时“偷看“程序内部某个变量的当前值,看它是不是你期望的样子。
有了调试器,你就像有了一副能透视程序内部运作的眼镜,任何 Bug 都无处遁形。调试器的具体使用会在后续章节中结合实际代码来讲解。
1.10 文件路径:告诉计算机“文件在哪里“
计算机里的文件都放在一层一层的文件夹中。所谓文件路径,就是描述一个文件或文件夹所在位置的一串文字。比如你想告诉别人“作业本放在书桌左边第二个抽屉里“,在计算机里就要写成类似这样的路径:
C:\Users\你的名字\Desktop\hello.c
或者:
/Users/你的名字/Desktop/hello.c
这里最容易让新手困惑的地方,是路径里的分隔符不一样:
- Windows 传统上使用反斜杠
\,例如C:\Users\Alice\Desktop\hello.c。 - macOS / Linux 使用正斜杠
/,例如/Users/alice/Desktop/hello.c或/home/alice/hello.c。
为什么 Windows 路径前面常常有 C:?因为 Windows 习惯把不同磁盘分成不同的“盘符“,比如 C:、D:、E:。而 macOS / Linux 把整个文件系统组织成一棵从 / 开始的目录树,一般没有 C:、D: 这种盘符写法。
那 Windows 能不能用 / 呢? 大多数情况下可以。Windows 底层和很多现代工具都能识别 / 作为路径分隔符,比如下面两种写法通常都能指向同一个文件:
C:\Users\Alice\Desktop\hello.c
C:/Users/Alice/Desktop/hello.c
不过要注意:在 Windows 的某些命令行工具里,/ 也可能被当作命令选项的开头,例如 dir /? 表示查看帮助。因此在 Windows 终端中手写路径时,看到别人使用 \ 很正常;在编程工具、配置文件、Git、CMake 或很多跨平台教程中看到 /,也很正常。
对于学 C 语言的你来说,现在只需要记住:Windows 路径常见写法是
\,macOS / Linux 是/;Windows 在很多场景也能识别/,但命令行里偶尔会因为选项语法产生差异。 后续教程中如果看到src/ch01-basics/hello.c这样的写法,通常是为了让路径在不同系统上都更容易阅读和使用。
二、计算机工具的简单使用
2.1 终端:与计算机对话的文字窗口
时间倒退回 50 多年前。那时候的计算机像一间屋子那么大,而且根本没有我们现在熟悉的桌面、鼠标、图标和窗口。
那么那时的程序员怎么跟这台庞然大物交流呢?他们需要一台看起来像老式打字机的设备,通过长长的线缆连接到远处的主机上。程序员在这台“打字机“上敲下命令,主机执行完之后,再把结果传回来显示出来。这个用来输入命令和接收结果的设备,就是终端(Terminal)的最初形态——当时人与计算机交互的唯一桥梁。
后来个人电脑普及了,图形化的操作系统(比如 Windows)让一切都变得漂亮和易用。但操作系统内部依然保留了一个“回到过去“的入口——一个黑乎乎的、只有文字的窗口。在这个窗口里,图形界面能用鼠标做的绝大多数事情,都可以通过敲纯文字的命令来完成。这个黑窗口,就是现代操作系统中的终端模拟器(简称“终端“)。
为什么学编程一定要用终端? 很多核心的开发工具——编译器、Git、调试器——最初都是为命令行设计的。通过终端可以用最直接、最灵活的方式控制它们。这正是程序员的“标准工作台“——很多在图形界面里需要来回点击十几次的操作,在终端里一句命令就能搞定。
Windows 如何打开终端:
- 右键点击任务栏上的 Win 图标(开始菜单图标),在菜单中选择 “终端”(Windows 11)或 “Windows PowerShell”(Windows 10)
- 或者按键盘上的
Win键,输入cmd或powershell,然后按回车
打开后你会看到一片深色背景,上面有一行类似 C:\Users\你的名字> 的文字,后面跟着一个闪烁的光标——这就是终端,正在等你输入命令。
macOS 如何打开终端:
- 在“启动台 → 其他“中找到 “终端”(Terminal)
- 或者按
Cmd + 空格,在搜索框中输入“终端“,然后按回车
打开后你会看到类似 MacBook-Pro:~ 你的名字$ 的文字和一个光标。
基本命令行操作——打开终端后试试这几条:
无论 Windows 还是 macOS,终端的使用方式都是一样的:输入命令 → 按回车 → 看计算机返回结果。以下是几条最基础的操作,现在就打开终端试一试:
| 操作 | Windows 命令 | macOS / Linux 命令 | 效果 |
|---|---|---|---|
| 看看当前位置 | dir | ls | 列出当前目录下有哪些文件和文件夹 |
| 进入某个文件夹 | cd 文件夹名 | cd 文件夹名 | 切换工作目录到指定文件夹 |
| 返回上一级 | cd .. | cd .. | 回到上一层文件夹 |
| 清空屏幕 | cls | clear | 把终端屏幕上的内容清干净 |
试着在你的终端里敲一下 dir(Windows)或 ls(macOS),看看它列出来的文件列表——是不是跟你用资源管理器(或访达)看到的完全一样?是的,它们是同一批文件,只是查看的方式不同。
💡 终端不可怕,它只是一条用文字与计算机对话的通道。 把它当作你的新伙伴,从今天就打开它敲几条命令试试,慢慢就熟了。
2.2 环境变量:系统的“通讯录“(附 Windows 配置指南)
当你在终端敲下 clang 这个命令时,系统是怎么知道要去哪里找到编译器这个程序的呢?这就要靠环境变量。
环境变量是操作系统里保存的一批全局设置,就像一本通讯录。其中最重要的一个叫做 PATH——它记录了一连串文件夹的路径。当你输入一个命令时,系统会按顺序去这些文件夹里寻找同名的可执行程序。一旦找到就执行它;如果翻遍了所有路径都找不到,就会提示“‘xxx’ 不是内部或外部命令“。
举个例子:你输入 notepad 能打开记事本,是因为 notepad.exe 所在的 C:\Windows\System32 目录早就被写进 PATH 里了。
当我们自己安装编译器时,如果安装程序没有自动帮我们把编译器的目录添加到 PATH 里,终端就无法直接调用它。这时就需要手动配置环境变量。
Windows 配置步骤:
- 在“此电脑“上右键 → 属性 → 高级系统设置 → 环境变量
- 在“系统变量“中找到
Path,选中后点击“编辑“ - 点击“新建“,把编译器的可执行文件所在的目录路径粘贴进去(比如
C:\Program Files\LLVM\bin) - 点击“确定“保存所有窗口
- 重新打开终端,新配置才会生效
配置完成后,在终端里输入 clang --version,如果能正常输出版本信息,说明环境变量配置成功。
如果安装编译器时勾选了“Add to PATH“选项(第 0.5 章安装步骤中会反复提醒),安装程序会自动帮你完成以上步骤。这里只需要了解原理,万一自动配置不成功,你知道去哪里手动修。
2.3 Clang 编译器的基本使用
安装好 Clang 之后,你就可以在终端里通过 clang 命令来编译 C 程序了。编译器的使用遵循一个统一的模式:
clang 源文件.c -o 输出文件名
拆开来看:
clang:调用 Clang 编译器源文件.c:你要编译的 C 源代码文件-o:指定输出文件的名字(o = output)输出文件名:你希望生成的可执行文件叫什么
举个例子:
# Windows
clang hello.c -o hello.exe
# macOS / Linux
clang hello.c -o hello
编译成功之后,运行生成的可执行文件:
# Windows
hello.exe
# macOS / Linux
./hello
如果屏幕上出现了你期望的输出(比如 “Hello, World!”),恭喜——你走通了生命中第一个 C 程序的完整流程:编写 → 编译 → 运行。关于 clang 的更多细节,第一章的末尾还有一个小贴士专门展开。
2.4 Git:给你的代码建一座“时光机“
Git 是一个版本控制系统。它有什么用呢?
想象你在写一篇非常重要的长篇论文。你可能会这样保存:论文_v1.docx、论文_v2_修改了开头.docx、论文_最终版.docx、论文_最终版打死也不改了.docx……如果哪天你发现删掉的一段话其实很精彩想找回来,面对这一堆文件就崩溃了。
Git 完美解决了这个问题。它像一台时光机,能自动记录下你的项目文件的每一次改动。有了 Git,你就可以放心大胆地修改代码,因为所有历史都被安全保存着,随时可以“读档“回到过去。
基本工作流程(都在你自己的电脑上完成):
| 步骤 | 命令 | 做了什么 |
|---|---|---|
| ① 初始化 | git init | 告诉 Git:“开始管理这个文件夹吧!” |
| ② 暂存 | git add 文件名 | 把改动的文件加入“待提交清单“(暂存区) |
| ③ 提交 | git commit -m "说明信息" | 正式将暂存区的改动永久记录在仓库里,形成一次“存档“ |
| ④ 查看 | git log | 查看所有的提交历史记录 |
每当你完成一个有意义的小改动(比如“修好了一个 bug“、“添加了登录功能”),就执行一次 ②→③ 的流程。这样,你的整个项目就像一本翻页日记,每一页都清清楚楚地记录着谁在什么时候改了什么。
这些操作都在终端里完成。更详细的 Git 用法会在后续章节中结合实际项目逐步展开。现阶段只需要知道 Git 是什么、大概怎么用就够了。
2.5 GitHub:代码的云端家园
Git 在你的电脑上建造了一座单机版“时光机“,而 GitHub 就是为这座时光机插上网线的云端托管平台。
你可以把本地的 Git 仓库推送到 GitHub 上,这会带来几个实实在在的好处:
- 备份:代码不仅在一台电脑上,云端还有一份。电脑坏了也不怕。
- 同步:在家里的台式机上写完 push,到公司笔记本上 pull 下来继续工作,无缝衔接。
- 协作:世界各地的开发者可以共同为一个项目贡献代码,轻松分享彼此的改动。
- 作品集:对于程序员来说,GitHub 个人主页就是一份活的技术简历和作品展示。
简单记:Git 是工具(运行在终端里),GitHub 是网站(在浏览器里访问)。 我们以后的练习代码也都会上传到 GitHub,从一开始就养成备份和分享的好习惯。
⚠️ 关于网络访问: GitHub 的服务器部署在海外,国内访问时有概率出现网页打不开、或
push/pull超时的情况。这是网络环境的问题,并非 Git 或 GitHub 本身出了故障。遇到这种情况需要自行解决网络连通性(通俗地说就是“需要魔法“),这不是本教程的范畴,请自行搜索了解。如果你希望使用国内的替代平台,Gitee(码云,gitee.com)和 GitCode(gitcode.com)是目前最主流的选择。它们的操作逻辑和 GitHub 几乎一致——同样基于 Git,同样是网页端托管,同样支持
push/pull/clone等操作。本教程以 GitHub 为默认演示平台,但你完全可以把同样的 Git 命令用于 Gitee 或 GitCode,把远程仓库地址换成对应的链接即可。
第0.5章 C语言开发环境与工具
总述
在学习 C 语言之前,我们首先需要准备好开发工具。本教程选择的工具组合是:VSCode + Clang。
你可能会注意到,市面上许多 C 语言教程使用的并不是这套组合。那么,我们为什么做出这个选择?
本章分为两大部分:先带你完成 Windows 和 macOS 两个平台上的开发环境搭建,再通过开发环境和工具科普梳理代码编辑器、编译器、C 语言标准以及工具选型背后的权衡——这些是你迟早会接触到的概念。
一、Windows 平台开发环境搭建
📦 开发工具百度网盘地址链接:https://pan.baidu.com/s/1ipTFXUcJ_isVz_sf9ncyzA?pwd=bkpg 提取码: bkpg
本链接包含了 LLVM-Clang、CMake 和 Python 的安装包,后续小节中提到的安装包均可从此处获取。
1.1 打开终端
Windows 11 打开终端的方式非常简单:右键点击任务栏上的 Win 图标(开始菜单图标),在弹出的菜单中选择**“终端”**,即可打开。
后续的所有命令行操作——编译、运行程序——都将在这个终端中完成。
1.2 下载并安装 LLVM-Clang
从上述网盘链接中下载 LLVM 安装包。下载完成后,运行安装程序。
⚠️ 重要:安装时务必勾选“Add LLVM to the system PATH“(将 LLVM 添加到系统 PATH)选项。 这个选项确保你可以在终端中直接输入
clang命令。如果忘记勾选,后续需要手动配置环境变量,较为麻烦。
1.3 验证 Clang 安装
安装完成后,打开终端,输入以下命令验证是否安装成功:
clang --version
如果正常输出版本信息(包含 “clang version 22” 等字样),说明 Clang 编译器已安装成功。
1.4 安装 Python(lldb 调试器依赖)
本教程使用的 LLVM 22 中,lldb 调试器依赖 Python 3.11.x。 因此,你还需要额外安装 Python。
从 Python 官网(https://www.python.org/)下载 Python 3.11.x 版本的安装包。安装时务必勾选 “Add Python to PATH”。
安装完成后验证:
python --version
应输出类似 Python 3.11.x 的版本信息。
1.5 安装 CMake(必需)
CMake 不是可选的——它是本教程后续项目构建的核心工具。 随着项目从单文件增长到多文件、再到引入第三方库,手动输入编译命令会变得越来越繁琐。CMake 让你用一份简洁的配置文件描述项目结构,然后自动生成对应平台的构建文件。
CMake 安装包同样可以从上述网盘链接中获取。下载后运行安装程序,安装时勾选 “Add CMake to the system PATH”。
安装完成后验证:
cmake --version
如果正常输出版本号,说明 CMake 安装成功。
1.6 安装 VSCode
访问 VSCode 官网,下载 Windows 版本的 .exe 安装程序,运行后按提示完成安装。
1.7 安装 C/C++ Extension Pack
首次启动 VSCode 后,你需要安装 C/C++ 相关插件来获得语法高亮、代码补全、错误提示和调试支持。
点击 VSCode 左侧活动栏的“扩展“图标(或按 Ctrl+Shift+X),搜索 “C/C++ Extension Pack”,安装由 Microsoft 发布的这个扩展包。
C/C++ Extension Pack 是一个插件集合包,它一次性为你安装了:
- C/C++ 核心扩展——语法高亮、代码补全、错误提示、调试支持
- CMake Tools——CMake 项目的集成支持
- C/C++ Themes——代码配色主题

相比单独安装 “C/C++” 扩展,安装 Extension Pack 可以一并获得 CMake 等工具的集成支持,省去了逐个安装的麻烦。
1.8 验证编译环境
至此,Windows 上的 C 语言开发环境已经搭建完成。依次输入以下命令来确认各工具已正确安装:
# 确认 Clang 编译器
clang --version
# 确认 lldb 调试器
lldb --version
# 确认 Python
python --version
# 确认 CMake
cmake --version
每一条命令都应该输出对应的版本信息。如果某条命令提示 command not found,说明对应工具的安装步骤可能未成功,请回到相应小节重新操作。
二、macOS 平台开发环境搭建
2.1 打开终端
macOS 的终端可以通过以下方式打开:在“启动台 → 其他“中找到“终端(Terminal)“,或按 Cmd + 空格 搜索“终端”。
2.2 安装 Xcode Command Line Tools(含 Clang 编译器)
macOS 自带的默认编译器就是 Clang——但前提是你需要先安装 Xcode Command Line Tools。这是一个由 Apple 官方提供的命令行开发者工具包,包含了:
- Clang 编译器(C/C++/Objective-C 前端)
- LLVM 后端(代码优化与机器码生成)
- make(构建自动化工具)
- lldb(调试器)
- C/C++ 标准库头文件(如
stdio.h、stdlib.h等)
安装方法:打开终端,输入以下命令:
xcode-select --install
系统会弹出安装确认对话框,点击“安装“,同意许可协议后等待下载完成即可。整个过程通常在几分钟到十几分钟之间,取决于网络速度。
注意:
xcode-select --install安装的是命令行工具包,体积约 1–2 GB。你不需要从 App Store 下载完整的 Xcode(体积超过 10 GB)。对 C 语言学习而言,Command Line Tools 已经完全够用。
2.3 安装 Homebrew
Homebrew 是 macOS 上最流行的软件包管理器。它的角色类似于 Linux 上的 apt 或 yum——通过命令行即可搜索、安装、更新和卸载各种开源软件,而无需手动访问各个网站下载安装包。
打开终端,输入以下命令:
/bin/zsh -c "$(curl -fsSL https://gitee.com/cunkai/HomebrewCN/raw/master/Homebrew.sh)"
这是 Homebrew 的国内镜像安装脚本,适合网络访问 GitHub 不稳定的用户。按提示操作即可——脚本会自动完成下载、安装和环境变量配置。
安装完成后,验证 Homebrew 是否可用:
brew --version
如果正常输出版本号(如 Homebrew 4.x.x),说明安装成功。
2.4 使用 Homebrew 安装 CMake(必需)
CMake 不是可选的——它是本教程后续项目构建的核心工具。
有了 Homebrew 之后,安装 CMake 只需要一行命令:
brew install cmake
安装完成后验证:
cmake --version
2.5 安装 VSCode
访问 VSCode 官网,下载 macOS 版本的 .dmg 文件,将 VSCode 拖入“应用程序“文件夹即可。
2.6 安装 C/C++ Extension Pack
首次启动 VSCode 后,你需要安装 C/C++ 相关插件来获得语法高亮、代码补全、错误提示和调试支持。
点击 VSCode 左侧活动栏的“扩展“图标(或按 Cmd+Shift+X),搜索 “C/C++ Extension Pack”,安装由 Microsoft 发布的这个扩展包。
C/C++ Extension Pack 是一个插件集合包,它一次性为你安装了:
- C/C++ 核心扩展——语法高亮、代码补全、错误提示、调试支持
- CMake Tools——CMake 项目的集成支持
- C/C++ Themes——代码配色主题
相比单独安装 “C/C++” 扩展,安装 Extension Pack 可以一并获得 CMake 等工具的集成支持,省去了逐个安装的麻烦。
2.7 验证编译环境
至此,macOS 上的 C 语言开发环境已经搭建完成。打开终端,依次输入以下命令来确认各工具已正确安装:
# 确认 Clang 编译器
clang --version
# 确认 lldb 调试器
lldb --version
# 确认 CMake
cmake --version
每一条命令都应该输出对应的版本信息。如果某条命令提示 command not found,说明对应工具的安装步骤可能未成功,请回到相应小节重新操作。
接下来,你就可以进入下一章,编写并运行你的第一个 C 程序了。 如果对 Clang 命令行的基本用法还不太熟悉,可以直接翻到第一章末尾的“小贴士“——那里给出了你最需要掌握的第一个编译选项。
开发环境和工具科普
代码编辑器
从“记事本能写代码吗“说起
从技术上讲,你完全可以用 Windows 自带的记事本编写 C 语言代码。一个代码文件本质上就是一个纯文本文件(后缀为 .c),里面只有你敲入的字符,不包含任何特殊格式。
但问题在于:记事本不会给你任何帮助。它不会——
- 用不同颜色区分关键字和变量(语法高亮)
- 在你漏掉分号时给出提醒(错误提示)
- 在你键入
prin时自动补全为printf(代码补全) - 帮你快速跳转到某个函数的定义处(代码导航)
代码编辑器,就是为编写代码而专门设计的文本编辑器。它处理的依然是纯文本,但在此之上叠加了大量辅助功能,使你的编码效率获得数量级的提升。
而 Visual Studio Code(简称 VSCode),是当前全球使用最广泛的代码编辑器。它由微软开发,完全免费,开源,跨平台——在 Windows、macOS 和 Linux 上均可安装使用。
大部分教程用的其实是 Visual Studio(VS)
翻开市面上大多数 C/C++ 中文教材,你会发现它们几乎无一例外地使用 Visual Studio(注意,名字里没有 “Code” 这个词)。
Visual Studio(简称 VS)和 Visual Studio Code(简称 VSCode),虽然名字只相差一个单词,但它们是两个截然不同的产品:
| 对比维度 | VSCode | Visual Studio (VS) |
|---|---|---|
| 定位 | 代码编辑器 | 集成开发环境(IDE) |
| 体积 | 轻量,数百 MB | 重量级,十几 GB 起步 |
| 启动速度 | 快,秒级启动 | 较慢,需加载大量组件 |
| 内置编译器 | 无,需手动安装 | 自带微软 MSVC 编译器 |
| 内置调试器 | 需安装插件 | 自带功能完善的调试器 |
| 项目管理 | 通过插件支持 | 内置解决方案/项目体系 |
| GUI 可视化设计 | 不支持 | 支持拖拽式窗口设计(Windows) |
| 跨平台 | Windows / macOS / Linux 均可 | 仅 Windows(Mac 版功能不完整) |
| 插件生态 | 极其丰富,几乎覆盖所有场景 | 较丰富 |
| 免费 | 完全免费 | 社区版免费,企业版收费 |
代码编辑器 vs IDE:根本区别在哪?
代码编辑器(如 VSCode、Sublime Text、Vim)的核心任务只有一个:高效地编辑代码文本。你可以把它理解为一个“超级记事本“,专注于让你顺畅地书写和修改代码。至于编译、调试、项目管理这些环节,它本身并不内置,需要你手动安装插件或搭配外部工具来完成。
集成开发环境(IDE)(如 Visual Studio、CLion、Eclipse)则是一个“全家桶“。它将代码编辑、编译构建、调试、项目管理、性能分析、可视化界面设计等开发过程中涉及的几乎所有功能打包在一起,追求开箱即用的体验。
打个比方:
- 代码编辑器像一套专业的厨师刀——轻便、锋利、灵活。但你需要自己准备锅、灶台和食材,也可以自由选择任何品牌的锅和灶具。
- IDE 像一个集成厨房——刀架、炉灶、抽油烟机、蒸烤箱全部预装到位,按下开关就能开火烹饪。方便固然方便,但如果你想换一个不同品牌的灶台,恐怕就很难装进去了。
我们选择 VSCode 而不是 VS 的原因,可以归纳为以下四点:
- 轻量:你不需要为编写一个
hello.c而下载安装十几个 GB 的软件。 - 跨平台兼容性:Windows、macOS、Linux 均可使用。VS 基本只适用于 Windows,而本教程需要兼顾使用不同操作系统的读者。
- 插件生态:在当下的 AI 工具浪潮中,VSCode 拥有目前最活跃的 AI 辅助编程插件生态;相比之下,VS 在这方面的进展要迟缓得多。
- 学习价值:IDE 替你隐藏了大量底层细节。使用 VSCode,你需要亲自配置编译器和构建流程——起初确实会多花一些功夫,但在这个过程中,你对整个工具链的理解会扎实许多。
需要提前说明的是:VSCode 在 C/C++ 初学者群体中有一个广为人知的外号——“劝退器”。
这个说法并非空穴来风。VSCode 本质上只是一个代码编辑器——它不附带编译器,不附带构建环境,没有开箱即用的 C/C++ 开发工具链。你需要自己去下载编译器(比如 Clang),自己在本地把环境配置好,自己学会如何用命令行编译和运行程序。
相比之下,VS 安装完成后,你就能直接点击“运行“按钮看到程序结果。因此,刚开始用 VSCode 学习 C 语言时,你可能会感到繁琐。这种感受是正常的——但每一个让你感到繁琐的步骤,背后都对应着一项你在真正掌握的工具链知识。当 IDE 替你一键完成了所有事情,你也就失去了理解“一键背后到底发生了什么“的机会。
编译器
编译器是什么?
你已经写好了一个 hello.c 文件,但计算机的“母语“只有 0 和 1 组成的机器语言——它并不认识 printf,也不认识 int 和 return。你需要一个翻译,把人类能够读写的 C 语言代码,转换成计算机能够执行的机器指令。
这个翻译,就是编译器。
LLVM 和 Clang:两个经常一起出现的名字
你可能经常看到 “LLVM” 和 “Clang” 这两个词被并列提及。它们之间是什么关系?
Clang 是一个编译器前端。所谓“前端“,指的是编译过程中负责“读懂“源代码的那个部分:它检查你的 C/C++ 代码是否符合语法规则,然后将代码转换成一种中间表示——你可以把它理解为一种“半成品“代码,已经完成了语法层面的处理,但还没有绑定到具体的硬件指令。
LLVM 是一个编译器后端框架。所谓“后端“,指的是接过 Clang 生成的“半成品“代码,进行优化,并最终翻译成特定 CPU 能够执行的机器指令的那个部分。
它们的分工可以理解为一条流水线:
C/C++ 源代码 → [ Clang:前端——读代码、检查语法、生成中间表示 ] → [ LLVM:后端——优化、生成机器码 ] → 可执行文件
Clang 是 LLVM 项目的一部分。 LLVM 项目起源于伊利诺伊大学的一个研究项目,经过多年发展,已成为业界最先进的编译器基础设施之一。而 Clang,则是这个项目中的 C/C++ 前端。
实际下载时你会注意到的: 当你搜索“下载 Clang“时,你会发现它并没有一个独立的“Clang 官网“。它的源代码托管在 LLVM 的 GitHub 仓库中——仓库名为
llvm-project,里面同时包含了 LLVM 后端和 Clang 前端的全部代码。很多场合也会直接称其为 LLVM-Clang。因此,如果你在搜索或阅读资料时看到 “llvm-project” 或 “llvm-clang” 这样的名称,不用困惑——它们指向的就是你需要的那个东西。
如果上面关于“前端“和“后端“的讨论你现在没有完全理解,完全不必担心。 对初学者而言,你只需要知道一件事:你写的
.c文件,通过一个叫clang的命令就能编译成可执行程序。LLVM 和 Clang 之间的分工与协作,随着你学习的深入,会自然而然地变得清晰。
C/C++ 的三大主流编译器——以及它们带来的最大挑战
与 Java 或 Python 这类通常只有一个“官方实现“的语言不同,C/C++ 的世界在历史上形成了三大主流编译器:
| 编译器 | 开发者 | 主要平台 | 特点 |
|---|---|---|---|
| MSVC | 微软 | Windows | Visual Studio 自带,Windows 平台上的事实标准 |
| GCC | GNU 社区 | Linux / macOS | Linux 系统的默认编译器,历史最为悠久,几乎无处不在 |
| Clang | LLVM 社区 | 所有平台 | 三大编译器中诞生最晚,但进步最快,错误提示最为友好 |
这就是 C/C++ 学习中最常遇到的挑战之一:同一种语言,多个不同的编译器实现。
虽然它们都遵循 C 语言标准(后面会详述),但在具体细节上各有各的处理方式:
- 同一个关键字,MSVC 可能提供了某种扩展写法,而 GCC 没有。
- 同一个未定义行为,在 GCC 下能够运行,在 MSVC 下可能直接崩溃。
- 同一个项目,用 A 编译器编译顺利通过,用 B 编译器却报出一连串警告甚至错误。
- 每个编译器还有自己的一套命令行参数体系,互不通用。
因此,当你从网上下载了一个 C 语言项目,却发现按照教程无法编译通过时,首先要排查的就是:你是否使用了和教程不同的编译器。
C语言标准
既然编译器由不同的团队各自开发,每家都可以有自己的实现方式,那么靠什么来保证“C 语言代码“在不同编译器下的行为基本一致?
这就是语言标准的角色。
C/C++ 标准是一份极为严谨的技术文档,它详细规定了:
- C 语言包含哪些关键字(如
int、if、return) - 语法规则是什么(如函数如何定义、循环如何书写)
- 标准库中有哪些函数,每个函数的行为应当如何(如
printf应该怎样输出)
这两门语言由 ISO 下属的不同工作组维护:WG14 负责 C,WG21 负责 C++。工作组成员来自产业界和学术界,会通过提案、讨论和投票推进各自语言标准。虽然 C 与 C++ 联系紧密,但不存在一个统一决定两门语言规则的“C/C++ 标准委员会“。
C 语言标准的主要版本:
| 标准 | 发布年份 | 俗称 | 重要变化 |
|---|---|---|---|
| C89 / C90 | 1989/1990 | ANSI C | 第一个正式标准,奠定了 C 语言的基石 |
| C99 | 1999 | — | 增加了// 单行注释、变长数组等 |
| C11 | 2011 | — | 增加了多线程支持、_Generic 泛型选择等 |
| C17 | 2018 | — | 主要是缺陷修复,没有新增语言特性 |
| C23 | 2024 | — | 当前标准,引入了多项现代语言特性 |
一个关键的认知:标准委员会和编译器开发者是两批不同的人。
标准委员会负责撰写文档,规定“C 语言应该是什么样子“。 编译器开发者(微软、GCC 团队、LLVM 社区)负责编写软件,将这份标准实现为能够实际使用的编译器。
正因为如此,你会在实践中发现:新标准发布后,编译器并不会立刻 100% 支持其中的所有特性。不同编译器对新特性的跟进速度也不相同——某个 C23 特性可能 Clang 已经支持了,而 MSVC 尚在追赶之中。
这个“标准与实现之间的时间差“,是 C/C++ 编程中需要持续留意的一个现实。
同样重要的是:标准委员会只负责说明“C 语言应该是什么样的“,但“如何做到“是各家编译器自己的事情。
语言标准是一份文档,它不是可执行的代码。标准规定了“应该怎样“,但每家的具体做法可以不同:
- 标准规定
int必须覆盖的最小取值范围以及各整数类型之间的大小关系,但不规定它必须恰好是 32 位;具体宽度、对象表示和部分运算细节由实现决定- 标准规定标准库中必须包含
printf— 但 MSVC、GCC、Clang 三家对printf的底层实现代码,是三套完全不同的工程方案这就意味着:相同的语法、相同的库函数,三家编译器在底层的处理方式可能截然不同。 一个程序在 Clang 下运行正常,换用 GCC 编译后行为可能有所差异;再换 MSVC 编译,又可能是另一种结果。这不是 bug,这是 C/C++ 生态的客观现实。
理解这一点对于学习 C 语言至关重要:你学习的核心是 C 语言本身,而不是某一个具体的编译器。但由于你必须通过某个编译器来实际运行代码,因此你同时也在和这个编译器的具体实现打交道。将“语言标准规定了什么“和“编译器具体做了什么“区分清楚,是 C 语言学习中一项重要的思维习惯。
为什么选择 VSCode + Clang
为什么选用 Clang?
在三大主流编译器中,我们选择 Clang。以下是主要理由:
1. 真正意义上的跨平台编译器
这是 Clang 最突出的优势。GCC 在 Windows 上虽然可以通过 MinGW 等工具使用,但那本质上是移植版本,其行为与 Linux 上的原生 GCC 并不完全一致。MSVC 则几乎只能在 Windows 上使用。
Clang 在 Windows、macOS、Linux 三大平台上都可以使用,核心命令行风格也高度一致。不过,不同平台仍然使用各自的 ABI、系统库、链接器和目标文件格式,因此“同一个 Clang“不代表生成结果或所有细节完全相同。教程中学习的常用命令可以迁移,平台差异仍需保留意识。
事实上,macOS 自带的默认编译器(在安装 Xcode Command Line Tools 之后)就是 Clang——它在终端中伪装成了 gcc 这个名字,但本质就是 Clang。
我们选择了一条需要更多投入的路
看到这里,你应该已经意识到了:VSCode + Clang 这套组合,没有个“一键编译运行“的按钮。
安装好 VS 之后,你写好代码,点击绿色的运行按钮,程序就跑起来了——整个过程你甚至不需要知道“编译器“三个字的存在。但在我们的环境中,你需要:
- 打开终端
- 手动输入
clang hello.c -o hello这样的命令来编译 - 再输入
./hello(Windows 上是hello.exe)来运行
这对初学者而言,不是额外增加了入门的难度吗?
坦率地说,是的。这是有意为之的选择。
C 语言——以及 C++——从来不是那种“拖拽一个控件就能做出东西“的高层语言。它们被设计出来的目的,就是用来写操作系统内核、写设备驱动、写数据库引擎——直接与底层硬件和操作系统打交道。你写的每一行 C 代码,最终都会变成 CPU 执行的机器指令;你调用的每一个函数,最终都会经由操作系统的系统调用去操作真实的内存和文件。
因此,从一开始就建立起对底层环节的认知,并非可有可无——它是 C 语言的基本功。 编译是什么?命令行怎么使用?编译器在按下回车键之后到底做了哪些事情?这些不是“进阶话题“,而是 C 语言学习的题中应有之义。
从另一个角度来看一个颇为常见的现象:有人在 VS 中学习了相当长时间的 C 语言,代码也写得不错,但当你问他——
- “你用的什么编译器?”——他回答:“VS。”
- “那 MSVC 是什么?”——他说:“没听过。”
这对于一个学习系统级语言的人来说,是一个值得反思的现象。VS 是一个 IDE(集成开发环境),不是一个编译器。藏在 VS 内部、真正负责编译代码的,是 MSVC。学了那么久的 C 语言,却对自己代码的编译过程缺乏最基本的了解——这正是 IDE 过度封装所带来的代价。
我们选择这条需要更多投入的路,不是因为 VSCode + Clang 比 VS 更方便,而是因为它让你能看清每一步在发生什么。当你在终端里亲手输入 clang 命令,亲眼看到 hello.c 变成一个可以运行的 hello.exe 时——你知道这中间发生了什么,你知道是谁在做这件事。
这份认知,会贯穿你的整个 C/C++ 学习生涯。
第一章 C语言核心基础语法
本章要点
本章是 C 语言语法的初步概览,目的是让读者对 C 语言的核心语法建立一个整体轮廓认知,而不是要求一次性全部掌握。整章以猜数游戏这个完整程序为核心线索——从游戏所需的功能出发,逐一拆解和讲解其中涉及到的语法点:程序的基本骨架、注释、变量与数据类型、运算符、判断语句、循环语句、函数、头文件、标准输入输出,最后将所有知识点串联回猜数游戏。具体涵盖以下内容:
- C 语言程序的基本骨架:
main函数、花括号、分号以及预处理指令#include - 注释的写法与用途
- 变量与基本数据类型:
int、float、double、char - 双引号与字符串初识(为第四章做铺垫)
- 算术运算符、自增自减运算符、复合赋值运算符、逻辑运算符与比较运算符,以及运算符优先级
- 判断语句:
if、if-else、if-else if-else,以及等值多分支switch - 循环语句:
while、do-while、for,以及break与continue - 函数的定义、调用与声明
- 标准输入输出:
printf与scanf - 综合练习:编写一个完整的猜数游戏
这些内容是 C 语言的根基。后续所有章节——无论是数组、指针、结构体,还是动态内存分配与文件操作——都建立在它们之上。初学阶段看不懂某些概念完全正常,建议以最后的猜数游戏代码为锚点反复阅读,遇到不懂的语法就翻回对应小节查阅,多敲几遍自然就通了。
一、C语言程序的基本结构
1.1 C语言的源文件
在正式编写程序之前,有一个看似简单但必须首先明确的问题:编程这件事,到底是在“写什么“?
答案很朴素——写的是代码文件。编程的本质,就是按照编程语言的语法规则,将你的意图写成纯文本内容,保存为一个带有特定后缀的文件。这个文件称为源代码文件(Source Code File),简称源文件。编译器读取这个源文件,将其中的代码翻译为计算机能够执行的机器指令。
不同的编程语言使用不同的文件后缀:
- Python →
.py - Java →
.java - C 语言 →
.c
当你看到 hello.c、main.c、game.c 这样的文件名时,就可以立刻知道——它们都是 C 语言的源代码文件。
后缀 .c 有两个作用:给开发者看(一眼就知道这是什么语言的代码)和给工具看(VSCode 根据后缀启用语法高亮和代码提示,编译器根据后缀判断如何处理这个文件)。
因此,编写一个 C 程序的第一步就很清晰了:创建一个以 .c 为后缀的新文件,在文件中按照 C 语言的语法编写代码,然后交给编译器去处理。下面,我们就从创建一个名为 hello.c 的文件开始,完整地走一遍这条路。
1.2 第一个程序:Hello World
学习任何一门编程语言,几乎都从“Hello World“开始。这个传统并非偶然——它用最小的篇幅展示了程序从源代码到运行的完整路径,让学习者对“写程序“这件事建立一个直观的全局印象。
下面准备了你的第一个 C 程序,它的功能很简单:在终端上打印出 “Hello, World!” 这行文字。打开 VSCode,创建一个名为 hello.c 的文件,把以下代码原样输入进去:
#include <stdio.h>
int main(void)
{
printf("Hello, World!\n");
return 0;
}
对这段代码的简单讲解: 你现在看到的是一个完整的、可以运行的 C 程序。其中 printf 的作用是在屏幕上输出文字——你可以把它理解为 C 语言提供的“打印“功能,小括号里双引号内的文字就是它要输出的内容。至于 #include <stdio.h>、int main(void)、return 0 各是什么意思,下一节(1.3 程序骨架解析)会逐一拆解;printf 的更多用法会在第九节(标准输入与输出)中系统讲解。现在你只需要知道:照抄这段代码,它就能在屏幕上打印出一行字。
写好代码之后,我们还需要一步才能让它真正运行起来。回忆一下第零章的内容:计算机的“母语“是由 0 和 1 组成的机器指令,它并不认识 printf,也不认识 int 和 return。因此,我们需要一个“翻译“,把人类能读懂的 C 语言代码,转换成计算机能够执行的机器指令——这个翻译的过程叫作编译(Compile),负责做这件事的工具叫作编译器(Compiler)。
根据第零章的配置,我们使用的编译器是 Clang。打开终端,进入 hello.c 所在的目录,输入以下命令:
# Windows
clang hello.c -o hello.exe
# macOS / Linux
clang hello.c -o hello
这条命令的含义是:调用 Clang 编译器,编译 hello.c 这个源文件,将生成的可执行文件命名为 hello.exe(或 hello)。拆开来看:clang 是编译器的名字,hello.c 是要编译的源文件,-o 选项用来指定输出文件的名字(o 是 output 的缩写),后面紧跟的 hello.exe 就是你给可执行文件起的名字。
编译成功后,运行生成的可执行文件:
# Windows
hello.exe
# macOS / Linux
./hello
如果一切顺利,屏幕上会显示:
Hello, World!
这就是从源代码到程序运行的全过程——你写好了 .c 源文件,用 clang 命令把它编译成可执行文件,然后运行它。今后你写的每一个 C 程序,都要经过同样的“编写 → 编译 → 运行“三步流程。关于 clang 命令和 -o 选项的更多细节,可以翻到本章末尾的小贴士查阅。
1.3 程序骨架解析
上面这个只有六行的程序,包含了 C 语言程序的全部基本元素。让我们将其拆解开来看:
#include <stdio.h> // 预处理指令
int main(void) // 主函数头部:void 明确表示不接收参数
{ // 函数体的开始
printf("Hello, World!\n"); // 语句
return 0; // 语句
} // 函数体的结束
下面逐一解释这三个核心要点。
要点一:main 函数是程序的入口。 int main(void) { ... } 定义了一个名为 main 的主函数,其中 void 明确表示不接收参数。宿主环境中的普通 C 程序从 main 函数开始执行,直到 main 返回或程序以其他方式终止。
类比:一本书的目录指定了从哪里开始读——
main就是 C 程序的“起始页“。写在main外面的函数不会自动执行,只有被main(或其他函数)显式调用时才运行。
必须记住的规则:普通的 C 可执行程序必须有且只有一个 main 函数。没有它,链接器就找不到程序的入口。后续在编写库文件或嵌入式程序时会遇到例外,初学阶段先记住这条规则即可。
💡 提示:什么是“函数“?
如果你现在对“函数“这个词感到陌生,完全没关系。现阶段你只需要把
int main(void) { ... }这个结构照抄下来,知道“程序要写在这对花括号里面“就够了。函数的概念会在第七章(函数)中详细讲解,到那时你会彻底明白它是什么、怎么用。
要点二:花括号 { } 标记代码的范围。 在 C 语言中,花括号总是成对出现,用来标明一段代码的起止边界。main 后面的 { 表示“从这里开始是 main 函数的内容“,对应的 } 表示“main 函数的内容到此结束“。可以把一对花括号视为一个包裹——左花括号打开包裹,中间装入需要执行的语句,右花括号封口。
要点三:分号 ; 是语句的结束标志。 C 语言中,绝大多数语句必须以分号结尾。分号的作用是告诉编译器:“这句话到此结束”。上面的程序中,printf(...) 和 return 0 都以分号结尾。如果遗漏分号,编译器会报错,程序无法通过编译。
💡 提示:为什么有些行不需要分号?
你可能已经发现了:
#include <stdio.h>没分号,int main(void)没分号,{和}也没分号——但printf(...)和return 0却有。现阶段不用深究原因,记住两条硬规矩就行:(1) 以#开头的行,一律不加分号;(2) 函数定义的头部(比如int main(void)),不加分号,函数体紧跟其后。背后的原理——什么是预处理指令、什么是函数定义——在学习了第七章(函数)和第八章(#include 与头文件)之后自然就懂了。
1.4 基本框架与规则
C 语言最基本的骨架如下:
int main(void) {
return 0;
}
短短三行代码,包含了 C 程序最核心的五条规则:
- 普通的 C 可执行程序必须有且只有一个
main函数——int main(void)就是程序的入口 - 程序从
main函数的第一条语句开始执行——这里是return 0; - 每条语句以分号
;结尾——return 0;末尾的分号 - 花括号
{ }必须成对出现,用于包裹函数体——main的函数体就在这对花括号里 int main(void)是函数定义头部,后面紧跟函数体,不加分号
1.5 动手练习
请创建一个名为 first.c 的文件,编写以下程序并运行:
#include <stdio.h>
int main(void)
{
printf("这是我的第一个C程序\n");
printf("我可以写出多行文字\n");
return 0;
}
编译运行:
# Windows
clang first.c -o first.exe
first.exe
# macOS / Linux
clang first.c -o first
./first
预期输出:
这是我的第一个C程序
我可以写出多行文字
请尝试以下修改:
- 将其中一个
\n去掉,观察输出有什么变化 - 将双引号中的文字改成任意内容
- 再添加一行
printf,输出你的名字
二、注释
2.1 什么是注释
注释是写在代码中的说明文字。它不会被编译,也不会对程序的运行产生任何影响。编译器在处理源代码时,会完全忽略注释的内容。
注释的唯一读者是人——包括写代码的你自己,以及将来可能阅读这份代码的其他开发者。在课本空白处写笔记,是为了帮助自己理解课本内容;在代码中写注释,是为了帮助人理解代码的意图和逻辑。
2.2 为什么需要注释
编程中的一个常见困境是:你花了一下午写了一段复杂的逻辑,三个月后再打开这个文件,却发现自己已经读不懂当初写的是什么了。注释正是为了解决这个问题而存在的。它的主要用途:
- 解释代码的作用:说明这一段代码是干什么的
- 记录复杂逻辑的思路:为什么这样写,当时的考虑是什么
- 调试时临时禁用代码:不删除,只注释掉,方便随时恢复
养成写注释的习惯,是成为合格程序员的第一步。这并非夸张——在一个团队协作的项目中,没有注释的代码会大幅增加他人的理解成本,也容易在不经意间引入 bug。
2.3 C语言中的两种注释
C 语言提供了两种注释方式。
单行注释以 // 开头,从 // 开始到该行结束的所有内容都是注释:
// 这是单行注释,只对这一行有效
printf("Hello\n"); // 也可以写在代码后面
多行注释以 /* 开头,以 */ 结尾,中间的所有内容(可以跨越多行)都是注释:
/* 这是多行注释的开始
这一行也是注释
这一行还是注释
这是多行注释的结束 */
需要特别注意一点:多行注释不能嵌套。也就是说,在一个多行注释的内部不能再写另一个多行注释。编译器遇到第一个 */ 就会认为注释结束了,后面的内容会被当作普通代码处理,这通常会导致编译错误。
2.4 动手练习
#include <stdio.h>
int main(void)
{
// 下面这行代码在屏幕上输出 Hello
printf("Hello\n");
/*
* 作者:我自己
* 日期:2026年
* 功能:练习使用注释
*/
printf("World\n"); // 输出 World
return 0;
}
请尝试:
- 在
printf("Hello\n");前面加上//,把它变成注释,观察输出发生了什么变化 - 用多行注释把
printf("World\n");整行注释掉,观察输出变化 - 在代码中添加你自己的注释,说明每一行的作用
三、变量与数据类型
3.1 变量的概念
在数学中,我们常用字母表示数——设 x = 5,那么 x + 3 = 8。这里的 x 就是一个变量,它的值可以随赋值而改变。
在 C 语言中,变量的含义与此类似,但更加具体:变量是内存中的一块命名存储空间,用于存放数据。你可以给这块空间起一个名字(比如 age),把数据放进去(比如 18),之后就可以通过这个名字来读取或修改里面的数据。
💡 提示:什么是“内存“?
如果你对“内存“这个概念感到陌生,可以先这样理解:内存就像电脑运行时的一张巨大的“草稿纸“,程序运行过程中需要用到的所有数据都临时记在这张纸上。变量就是在这张草稿纸上划定一个小格子,给它起个名字,往里面填数据。至于内存的物理本质、地址、以及它和硬盘的区别,会在后续学习第七章(指针)和动态内存分配时逐步深入。目前只需要记住:变量 = 内存中的一块有名字的存储空间,用类型来指定这块空间的大小。
3.2 变量的声明与初始化
在 C 语言中,使用变量之前必须先声明它,即告诉编译器“我需要一块存储空间,它的名字叫 xxx,用来存放 xxx 类型的数据“。声明的基本格式为:
类型 变量名;
例如:
int age; // 声明一个名为 age 的整数变量
也可以在声明的同时给它一个初始值,称为初始化:
int age = 18; // 声明变量 age,同时初始化为 18
如果声明变量时没有初始化,变量的值是不确定的——它取决于那块内存中此前存了什么数据。在后续章节学习指针和内存模型之后,你会对这个机制有更透彻的理解。
3.3 基本数据类型
不同类型的数据需要分配不同大小的存储空间。C 语言提供了以下四种基本数据类型,先通过一张表快速浏览:
| 类型名 | 含义 | 存储内容 | 占用空间 | 示例 |
|---|---|---|---|---|
int | 整数型 | 整数(正负整数和零) | 通常 4 字节 | 5, -3, 0, 100 |
float | 单精度浮点型 | 小数(有效数字约6-7位) | 通常 4 字节 | 3.14, -0.5, 2.0 |
double | 双精度浮点型 | 小数(有效数字约15-16位) | 通常 8 字节 | 3.1415926535 |
char | 字符型 | 单个字符 | 1 字节 | 'A', 'b', '#', '7' |
除 char 在 C 标准中固定为 1 字节外,其他类型的实际大小会受平台和编译器的影响。在多数现代桌面环境中,int 为 4 字节、double 为 8 字节。如果需要确认当前环境下的真实大小,可以使用 sizeof 运算符。
下面逐一认识这四种类型。
3.3.1 int 类型——整数
int(integer 的缩写)是 C 语言中最常用的数据类型,专门用来存储整数——也就是没有小数点的数字,包括正整数、负整数和零。
int age = 18; // 年龄——正整数
int count = 0; // 计数——零也是整数
int temperature = -5; // 温度——负整数
在大多数现代桌面环境中,一个 int 占用 4 个字节(32 位),能表示的整数范围大约是 -21 亿到 21 亿(精确值是 -2,147,483,648 到 2,147,483,647)。对于日常的计数、年龄、序号等场景,这个范围绰绰有余。
使用 int 时需要特别注意整数除法的行为:两个 int 相除,结果仍然是 int,小数部分会被直接丢弃(不是四舍五入):
int a = 7 / 3; // 结果是 2,不是 2.333...
int b = 5 / 2; // 结果是 2,不是 2.5
这个行为在初学时经常让人困惑。如果需要得到小数结果,需要把至少一个操作数写成浮点数(如 7.0 / 3),这个细节在第四节 运算符中会详细说明。
3.3.2 float 类型——单精度浮点数
float(floating-point number 的缩写)用于存储小数——也就是带有小数点的数字。
float height = 1.75; // 身高(米)
float weight = 65.5; // 体重(公斤)
float score = 98.5; // 成绩
一个 float 占用 4 个字节,有效数字大约是 6 到 7 位。这意味着 float 适合日常场景(身高、体重、成绩),但不适合需要高精度的计算——比如你把 1.23456789 存进 float,它可能只能精确记住前 6~7 位,后面的数字会产生微小误差。这是浮点数本身的特性,不是 C 语言的 bug,任何编程语言的浮点数都有这个问题。
3.3.3 double 类型——双精度浮点数
double(double-precision floating-point number 的缩写)和 float 一样用于存储小数,但精度翻倍——占用 8 个字节,有效数字约 15 到 16 位。
double pi = 3.141592653589793;
double distance = 1.496e11; // 科学记数法:1.496 × 10¹¹ 米(地球到太阳的距离)
选择 float 还是 double 的简单判断:日常场景(身高体重)用 float 就够;科学计算、需要高精度的场景用 double。如果不确定用哪个,直接用 double 是最稳妥的选择。在 printf / scanf 中,两者输出时都用 %f,不会增加额外的学习负担。
3.3.4 char 类型——字符
char(character 的缩写)用于存储单个字符,固定占用 1 个字节。
char grade = 'A'; // 成绩等级
char symbol = '#'; // 符号
char digit = '7'; // 字符 '7'
字符必须用单引号包裹。这里有一个初学者极其容易混淆的地方:数字 7 和字符 '7' 是完全不同的东西。
7是整数,可以参与加减乘除(7 + 1 = 8)'7'是一个字符符号,它的本质是一个编码值——在 ASCII 编码中,'7'对应的编码值是 55
可以这样理解:7 是数学意义上“七“这个数量,而 '7' 是写在纸上的“7“这个符号——就像键盘上印着的那个数字键。关于字符编码的更多细节,会在后续学习 ASCII 编码时进一步展开。
💡 提示:C 语言的布尔类型
很多语言(比如 Python、Java)都有专门的布尔类型(
bool)来表示true/false。C 语言的处理方式则有所不同:C 语言内置了“真“和“假“的概念——0 表示假,非 0 表示真(前面比较运算符中,5 == 5的结果是1即真,5 == 3的结果是0即假,就是这个道理)。但是,C 语言没有内置bool这个类型名来专门存放真假值。从 C99 标准开始,C 语言新增了
_Bool类型(注意前面有个下划线)。而我们平时代码里写的bool、true、false,都来自<stdbool.h>这个头文件——它用宏做了三件事:把bool定义为_Bool的别名,把true定义为1,把false定义为0。所以在实际开发中,如果你想用更直观的
bool/true/false写法,只需在文件开头加上:#include <stdbool.h>之后就可以放心使用了。它们本质上还是整数(
true= 1,false= 0),但代码读起来清晰很多。
3.3.5 双引号与字符串初识
在 printf("Hello, World!\n") 这行代码中,"Hello, World!\n" 用双引号包裹了一串文字。那么双引号到底是什么意思?它和单引号有什么区别?
简单来说:
- 单引号
' '包裹的是一个字符,类型是char,占 1 个字节。例如'A'、'7'。 - 双引号
" "包裹的是一串字符,称为字符串。例如"Hello"、"你好"。
当你写下 "Hello" 时,C 语言会在内存中依次存放字符 H、e、l、l、o,并在最后自动追加一个隐藏的结束标志 '\0'(空字符),用来标记“字符串到这里结束“。因此 "Hello" 实际上占用了 6 个字节——5 个可见字符 + 1 个看不见的 '\0'。
💡 提示:先有个印象即可
这里只需要记住两件事:(1) 单引号是字符,双引号是字符串;(2) 双引号创建的字符串末尾会自动带一个
'\0'。关于字符串的声明、输入输出、标准库函数以及'\0'的关键作用,将在第四章 字符串中专门展开。目前你在printf中直接写"Hello"这样的双引号文字就够了,暂时不用深究。
单引号 vs 双引号速查:
单引号 ' ' | 双引号 " " | |
|---|---|---|
| 包含内容 | 单个字符 | 一串字符 |
| 类型 | char | 字符串(字符数组) |
| 示例 | 'A'、'#'、'7' | "Hello"、"C语言" |
| 占用空间 | 1 字节 | 可见字符数 + 1(末尾有 '\0') |
能否赋给 char 变量 | 可以:char c = 'A'; | 不可以:char c = "A"; 会报错 |
3.4 变量的命名规则
C 语言对变量命名有三条硬性规定:
- 只能使用英文字母(a-z, A-Z)、数字(0-9)和下划线(
_) - 不能以数字开头(
2nd不合法,second2合法) - 不能与 C 语言的关键字重名(如
int、return、if等) - 区分大小写:
Age和age是两个不同的变量
💡 提示:什么是 C 语言的关键字?
关键字(Keywords)是 C 语言预先保留的一批单词,每个关键字在语言中有固定的含义和用途——比如
int表示整数类型,if表示条件判断,return表示函数返回。这些单词已经被语言本身“征用“了,你不能把它们拿来做变量名或函数名,否则编译器会分不清你到底是想使用语言内置的功能,还是定义了一个新东西,从而导致编译错误。截止 C11 标准,C 语言共有 44 个关键字(部分编译器可能有少量扩展)。以下是完整列表,现阶段不需要全部记住,大致浏览一遍有个印象即可:
类别 关键字 数据类型相关 char,short,int,long,float,double,void,signed,unsigned,_Bool,_Complex,_Imaginary存储类型相关 auto,register,static,extern,typedef,_Thread_local结构相关 struct,union,enum,const,volatile,restrict,_Atomic控制流程 if,else,switch,case,default,for,while,do,break,continue,goto,return其他 sizeof,inline,_Alignas,_Alignof,_Noreturn,_Static_assert,_Generic其中以
_开头后跟大写字母的(如_Bool、_Thread_local)是 C11 新增的底层关键字,初学阶段接触不到,不用在意。日常编程中只要记住:变量名不要取int、float、if、for、return这类常见单词即可。
除了硬性规定,还有两条值得从一开始就遵守的习惯:
- 使用有意义的英文单词命名:
age(年龄)、score(分数)、student_count(学生数量) - 多个单词用下划线连接:
math_score、total_price - 避免使用
a、b、c、x这类无意义的名字,除非在极短的示例中
3.5 动手练习
#include <stdio.h>
int main(void)
{
// 声明整数变量
int age = 18;
int year = 2026;
// 声明小数变量
float height = 1.75;
double pi = 3.1415926;
// 声明字符变量
char grade = 'A';
// 使用 printf 输出变量的值
// %d 用于输出整数, %f 用于输出小数, %c 用于输出字符
printf("年龄:%d\n", age);
printf("年份:%d\n", year);
printf("身高:%f 米\n", height);
printf("圆周率:%f\n", pi);
printf("成绩等级:%c\n", grade);
return 0;
}
编译运行后输出:
年龄:18
年份:2026
身高:1.750000 米
圆周率:3.141593
成绩等级:A
printf中的%d、%f、%c称为格式占位符,作用是告诉printf“在这个位置插入对应变量的值”。本章第九节将系统讲解这些占位符的用法。目前只需记住:%d对应整数,%f对应小数,%c对应字符。注意:在printf中,float会自动提升为double,二者都用%f输出;scanf读取double时才使用%lf。
请尝试:
- 把
age的值改成你自己的年龄 - 添加一个
float类型的变量weight(体重),并输出它 - 声明一个变量但不赋初始值,直接输出它,观察会发生什么(这是未定义行为,可能输出一个随机的数值)
四、运算符
4.1 算术运算符
C 语言提供了五种基本算术运算符,与数学中的运算一一对应:
| 运算符 | 名称 | 示例 | 结果 | 说明 |
|---|---|---|---|---|
+ | 加法 | 5 + 3 | 8 | 两数相加 |
- | 减法 | 5 - 3 | 2 | 两数相减 |
* | 乘法 | 5 * 3 | 15 | 两数相乘(注意不是数学中的 ×) |
/ | 除法 | 6 / 3 | 2 | 两数相除(注意不是数学中的 ÷) |
% | 取余 | 7 % 3 | 1 | 求整数除法的余数 |
其中取余运算符 % 仅对整数有效。a % b 的结果是 a 除以 b 的余数——例如 7 % 3 = 1,因为 7 除以 3 商 2 余 1。
整数除法的注意事项。 在 C 语言中,两个整数相除的结果仍然是整数,小数部分会被直接丢弃(截断),而非四舍五入:
int a = 7 / 3; // a 的值是 2,不是 2.333...
int b = 5 / 2; // b 的值是 2,不是 2.5
如果要得到小数结果,需要将至少一个操作数写为浮点数形式:
float c = 7.0 / 3; // c 的值约等于 2.333
float d = 5.0 / 2; // d 的值是 2.5
这个行为的根源在于,编译器会根据操作数的类型来决定使用整数除法还是浮点除法。当两个操作数都是整数时,执行的是整数除法;只要其中一个是浮点数,编译器就会将另一个也转为浮点数,然后执行浮点除法。
4.2 比较运算符
比较运算符用于判断两个值之间的大小关系。比较的结果是一个逻辑值——在 C 语言中,真用整数 1 表示,假用整数 0 表示。
| 运算符 | 名称 | 示例 | 结果 | 说明 |
|---|---|---|---|---|
== | 等于 | 5 == 5 | 1(真) | 判断两边是否相等 |
!= | 不等于 | 5 != 3 | 1(真) | 判断两边是否不相等 |
> | 大于 | 5 > 3 | 1(真) | 左边是否大于右边 |
< | 小于 | 5 < 3 | 0(假) | 左边是否小于右边 |
>= | 大于等于 | 5 >= 5 | 1(真) | 左边是否大于或等于右边 |
<= | 小于等于 | 5 <= 3 | 0(假) | 左边是否小于或等于右边 |
初学者最容易犯的一个错误是:判断“等于“使用两个等号 ==,而一个等号 = 是赋值操作。如果在 if 条件中误写成 if (x = 5),编译器不会报错(语法上是合法的),但含义变成了“把 5 赋给 x,然后判断 x 是否为 0“,这与“判断 x 是否等于 5“截然不同。
4.3 自增与自减运算符
C 语言提供了两个极其常用的单目运算符:++(自增)和 --(自减)。它们的作用分别是对变量的值加 1 或减 1,常用于循环计数和指针移动等场景。
基本用法
int a = 5;
a++; // a 变为 6(等价于 a = a + 1)
a--; // a 变回 5(等价于 a = a - 1)
这两种写法的效果看起来和 a = a + 1 完全一样,初学者自然会问:为什么要专门引入 ++ 和 -- 运算符?直接写 a = a + 1 不行吗?
答案是:++ 和 -- 不仅仅是“短一点“的写法,它们有一个 a = a + 1 做不到的特性——可以作为表达式的一部分使用,并且根据写在变量前还是变量后,产生不同的求值顺序。
前缀与后缀的区别——这是重点
++ 和 -- 都可以写在变量的前面(前缀)或后面(后缀),两种写法对变量自身的最终效果相同(加 1 或减 1),但表达式的求值结果不同:
| 写法 | 名称 | 规则 | 等价逻辑 |
|---|---|---|---|
++i | 前缀自增 | 先加 1,再使用 i 的值 | i = i + 1; 使用 i; |
i++ | 后缀自增 | 先使用 i 的值,再加 1 | 使用 i; i = i + 1; |
--i | 前缀自减 | 先减 1,再使用 i 的值 | i = i - 1; 使用 i; |
i-- | 后缀自减 | 先使用 i 的值,再减 1 | 使用 i; i = i - 1; |
“使用“是什么意思?就是取这个表达式的值,把它赋给别人、拿去运算、或作为函数参数传递。
看一个具体例子就清楚了:
#include <stdio.h>
int main(void)
{
int a = 5, b = 5;
int x = ++a; // 前缀:a 先加 1 变成 6,然后把 6 赋给 x
int y = b++; // 后缀:先把 b 的当前值 5 赋给 y,然后 b 再加 1 变成 6
printf("a = %d, x = %d\n", a, x); // a = 6, x = 6
printf("b = %d, y = %d\n", b, y); // b = 6, y = 5
return 0;
}
++a 和 b++ 都让变量加到了 6,但 ++a 表达式的值是加完之后的 6,b++ 表达式的值是加之前的 5。这就是前缀和后缀唯一的区别——表达式的值不同,变量本身最终一样。
单独使用时没有区别
如果你只是想让变量加 1,不把结果赋给别人,也不参与别的运算,那么 i++、++i、i = i + 1 三者完全等价:
for (int i = 0; i < 5; i++) // 用 i++,常见写法
for (int i = 0; i < 5; ++i) // 用 ++i,效果完全相同
这里 i++ 和 ++i 没有区别,因为 for 的“更新“部分只看变量最终变了没有,不关心表达式的值。所以挑你喜欢的写法即可——i++ 更常见,++i 在 C++ 中某些场景效率略高(C 语言中内置类型无所谓)。
初学阶段最常见的误解
int i = 3;
printf("%d\n", i++); // 输出 3(先用后加)
printf("%d\n", i); // 输出 4(i 已经加过了)
printf("%d\n", ++i); // 输出 5(先加后用)
如果你预期 i++ 会输出 4,说明还没掌握“先使用、后加 1“这条规则。下次看到 i++,在心里默念:先用后加。
⚠️ 警告:不要在同一个表达式里对同一个变量多次使用 ++/–
int i = 5; int x = i++ + ++i; // 未定义行为!结果不可预测这种“炫技“写法在任何正经代码里都不应该出现。C 标准没有规定这种表达式的求值顺序,不同编译器可能算出不同结果。一条语句里只用一次自增/自减,让它干干净净。
4.4 运算符优先级
现在你已经学过了算术运算符、比较运算符、自增自减运算符,再加上之前接触过的赋值运算符 =,种类已经不少了。当多个运算符同时出现在一个表达式中时,哪个先算、哪个后算?这就需要运算符优先级来回答。
优先级类似于小学数学中的“先乘除、后加减“——3 + 4 × 5 等于 23 而不是 35,因为乘法优先级高于加法。C 语言继承了这套直觉,并扩展到了更多运算符上。
常用运算符优先级表(从高到低)
下表列出了初学阶段会遇到的运算符,优先级从高到低排列。越往上越先算,同一行的优先级相同:
| 优先级 | 运算符 | 名称 | 结合方向 |
|---|---|---|---|
| 1(最高) | () | 圆括号 | 从左到右 |
| 2 | ++ --(后缀) | 后缀自增、自减 | 从左到右 |
| 3 | ++ --(前缀)、+ -(单目) | 前缀自增自减、正负号 | 从右到左 |
| 4 | * / % | 乘、除、取余 | 从左到右 |
| 5 | + - | 加、减 | 从左到右 |
| 6 | < <= > >= | 比较运算符 | 从左到右 |
| 7 | == != | 等于、不等于 | 从左到右 |
| 8 | = | 赋值 | 从右到左 |
几点关键直觉:
- 圆括号无敌。 优先级最高,任何表达式只要用
()包起来,里面的内容一定先算。不确定优先级时,加括号是最稳妥的做法——既不会出错,又能让代码意图一目了然。 - 乘除优先于加减。 和数学完全一致。
- 比较运算符在算术之后。
a + b > c等价于(a + b) > c,而不是a + (b > c)。这符合直觉:先算出结果,再做比较。 - 赋值几乎最晚。
=的优先级很低,保证等号右边的整个表达式先算完,再把结果赋给左边。 - 单目运算符(正负号、前缀自增自减)优先级高于乘除。
-a * b等价于(-a) * b。后缀自增自减的优先级比前缀还高,但初学阶段用括号规避即可。
结合方向是什么?
当优先级相同时,结合方向决定从左往右算还是从右往左算:
// 算术运算符是"从左到右"
10 - 3 - 2; // (10 - 3) - 2 = 5,而不是 10 - (3 - 2) = 9
// 赋值运算符是"从右到左"
int a, b, c;
a = b = c = 0; // 等价于 a = (b = (c = 0)),三个变量全部赋为 0
实用建议:不需要背,用括号
运算符优先级表不需要死记硬背。初学阶段记住两条规则就够用:
- 不确定谁先谁后,就加括号。
(a + b) * c比a + b * c更不会让人想岔。 - 赋值右边先算完。
x = a + b * c不需要写x = (a + b * c),因为=优先级最低。
4.5 其他常用运算符速览
除了上面介绍的几类运算符,C 语言还有几个在初学阶段就会频繁遇到的运算符。这里快速过一遍,建立印象即可,后续章节用到时会反复见到它们。
复合赋值运算符
在 C 语言中,a = a + 3 这种“先计算、再赋回给自己“的模式非常常见,C 语言提供了更简洁的复合写法:
a += 3; // 等价于 a = a + 3
b -= 5; // 等价于 b = b - 5
c *= 2; // 等价于 c = c * 2
d /= 4; // 等价于 d = d / 4
e %= 3; // 等价于 e = e % 3
规则很简单:x op= y 就是 x = x op y。op 可以是 +、-、*、/、% 等。这种写法不仅更短,而且在某些场景下更清晰地表达了“在原有值上累加/累减“的语义。循环中最常见的 i += 1,还有更短的 i++(见第 4.3 节)。
逻辑运算符
当你需要组合多个判断条件时——比如“年龄在 18 到 60 之间“、或者“不是周末“——就需要用到逻辑运算符:
| 运算符 | 名称 | 含义 | 示例 |
|---|---|---|---|
&& | 逻辑与 | 两边都为真,结果才为真 | age >= 18 && age <= 60 |
| ` | ` | 逻辑或 | |
! | 逻辑非 | 取反:真变假,假变真 | !(score < 60) 等价于 score >= 60 |
一个典型的实际例子:
// 判断一个年份是不是闰年
if ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0)
{
printf("%d 是闰年\n", year);
}
逻辑运算符也遵从我们在第 4.4 节讲过的优先级规则:! > && > ||。记不住也没关系,用括号把每个条件包起来就一目了然。
💡 提示:短路求值
C 语言的
&&和||有一个高效的设计叫短路求值。对于a && b,如果a已经为假,b根本不会被计算——因为不管b是什么,整个表达式都一定是假。同理,对于a || b,如果a已经为真,b也不会被计算。这个特性可以用来写出简洁安全的代码,例如:if (p != NULL && *p == 5)——如果p是NULL,第二个条件就不会执行,避免了通过空指针解引用导致的程序崩溃。
条件运算符(三目运算符)? :
这是 C 语言中唯一一个需要三个操作数的运算符,可以看作 if-else 的表达式版本:
int max = (a > b) ? a : b; // 如果 a > b,取 a;否则取 b
条件 ? 值1 : 值2 ——条件为真时整个表达式的结果是值1,条件为假时结果是值2。适合在需要根据条件取不同值的场景,比写一个 if-else 更紧凑。
位运算符(初步了解)
C 语言还提供了对二进制位直接操作的运算符:&(按位与)、|(按位或)、^(按位异或)、~(按位取反)、<<(左移位)、>>(右移位)。它们在底层编程、嵌入式系统和性能优化中有重要用途。初学阶段只需知道它们的存在即可,第七章学习指针时会结合具体场景补充。
💡 重要区分:
&vs&&,|vs||这是初学者非常容易混淆的两对符号:
&&/||是逻辑运算符,用于组合判断条件(真/假),有短路求值&/|是位运算符,用于操作二进制位,两个操作数都会完整求值举个例子:
if (x > 0 && y > 0)是“x 和 y 都大于 0“——用&&。而z = x & y是把 x 和 y 的每一个二进制位做与运算——用&。目前不需要记住位运算的细节,只需注意:写条件判断时别忘了打两个符号——&&和||,不是&和|。
4.6 动手练习
#include <stdio.h>
int main(void)
{
int a = 10;
int b = 3;
// 算术运算
printf("a + b = %d\n", a + b); // 13
printf("a - b = %d\n", a - b); // 7
printf("a * b = %d\n", a * b); // 30
printf("a / b = %d\n", a / b); // 3(整数除法,丢弃小数)
printf("a %% b = %d\n", a % b); // 1(10除以3余1)
printf("\n");
// 比较运算
printf("a == b: %d\n", a == b); // 0(假)
printf("a != b: %d\n", a != b); // 1(真)
printf("a > b: %d\n", a > b); // 1(真)
printf("a <= b: %d\n", a <= b); // 0(假)
return 0;
}
注意 printf("a %% b = %d\n", a % b); 中的 %%——因为 % 在 printf 的格式字符串中有特殊含义,要输出一个百分号本身,需要写成 %%。
请尝试:
- 修改
a和b的值,观察计算结果的变化 - 试试浮点除法:
float c = 10.0 / 3; printf("%f\n", c); - 用
==比较两个相等的数,验证结果是否为1
五、判断语句
前几节所写的程序都是从上到下顺序执行的,每一行都会被执行,没有例外。然而在实际问题中,程序往往需要根据不同的条件做出不同的响应:如果考试成绩大于等于 60 分,显示“及格“;否则显示“不及格“。如果用户猜的数字比答案大,提示“太大了“;比答案小,提示“太小了“。这种“如果…就…否则…“的逻辑,在 C 语言中由判断语句来实现。
5.1 if 语句
if 语句的含义是:如果某个条件成立,就执行一段指定的代码。其基本结构为:
if (条件)
{
条件成立时要执行的语句;
}
一个简单的例子:
#include <stdio.h>
int main(void)
{
int score = 85;
if (score >= 60)
{
printf("及格了。\n");
}
return 0;
}
程序执行到这里时,会先计算 score >= 60 这个条件。85 >= 60 为真,因此执行花括号中的 printf。如果 score 的值为 50,条件不成立,花括号中的代码就会被跳过。
5.2 if-else 语句
只判断“如果“是不够的——更多时候我们需要同时处理“条件成立“和“条件不成立“两种情况。if-else 语句满足了这一需求:
if (条件)
{
条件成立时执行的语句;
}
else
{
条件不成立时执行的语句;
}
示例:
#include <stdio.h>
int main(void)
{
int score = 55;
if (score >= 60)
{
printf("及格了。\n");
}
else
{
printf("不及格,需要补考。\n");
}
return 0;
}
程序的逻辑流程可以用下图表示:
开始
|
score >= 60 ?
/ \
是 否
| |
输出"及格" 输出"不及格"
| |
结束
关键在于:两个分支中有且仅有一个会被执行。条件为真走 if 分支,条件为假走 else 分支,不存在两条都走或都不走的情况。
5.3 多分支判断:if-else if-else
当需要判断的条件超过两个时,可以使用 if-else if-else 结构。其基本结构如下:
if (条件1)
{
条件1成立时执行的语句;
}
else if (条件2)
{
条件2成立时执行的语句;
}
else if (条件3)
{
条件3成立时执行的语句;
}
else
{
以上条件都不成立时执行的语句;
}
注意:else if 可以有任意多个,末尾的 else 是可选的——如果省略,当所有条件都不满足时,什么也不会执行。
下面是一个根据考试成绩评定等级的示例:
#include <stdio.h>
int main(void)
{
int score = 85;
if (score >= 90)
{
printf("等级:优秀\n");
}
else if (score >= 80)
{
printf("等级:良好\n");
}
else if (score >= 60)
{
printf("等级:及格\n");
}
else
{
printf("等级:不及格\n");
}
return 0;
}
执行过程是自上而下的:程序先检查第一个条件 score >= 90,不满足则检查第二个条件 score >= 80,依此类推。一旦某个条件成立,它对应的分支被执行,后续所有条件都会被跳过。在上面的例子中,score 为 85,第一个条件不满足,第二个条件 score >= 80 成立,于是程序输出“等级:良好“;第三个条件 score >= 60 虽然也成立,但不会再被检查。这种“匹配即退出“的机制,是多分支判断的核心规则。
else if 的数量没有限制,可以根据需要添加任意多个。末尾的 else 是可选的——如果省略,那么当所有条件都不满足时,什么也不会执行。
5.4 嵌套判断
在一个 if 语句的内部可以再写 if 语句,称为嵌套判断:
#include <stdio.h>
int main(void)
{
int score = 75;
if (score >= 60)
{
printf("及格了\n");
if (score >= 90)
{
printf("而且非常优秀!\n");
}
}
else
{
printf("不及格\n");
}
return 0;
}
嵌套判断本身没有新的语法规则,但需要注意花括号的配对——内层的 if 必须完整地包含在外层 if 的某个分支内部。缩进是保持嵌套结构可读性的关键手段。
5.5 switch 语句——等值多分支
if-else if-else 适合处理范围判断(大于、小于、区间),但有一种常见场景它处理起来比较啰嗦:对同一个变量的不同取值分别执行不同的操作。比如根据用户输入的编号选择功能:
if (choice == 1)
{
printf("新建文件\n");
}
else if (choice == 2)
{
printf("打开文件\n");
}
else if (choice == 3)
{
printf("保存文件\n");
}
else if (choice == 4)
{
printf("退出\n");
}
else
{
printf("无效选项\n");
}
choice == 某值 这个模式重复了四遍,眼花缭乱。C 语言为此提供了 switch 语句,专门处理“一个变量等于不同值时走不同分支“的场景。其基本结构为:
switch (表达式)
{
case 常量1:
匹配常量1时执行的语句;
break;
case 常量2:
匹配常量2时执行的语句;
break;
case 常量3:
匹配常量3时执行的语句;
break;
default:
以上常量都不匹配时执行的语句;
}
💡 提示:注意
case后面没有小括号。 这是初学switch时很容易写错的地方——if的条件要写在()里(if (x == 1)),但case后面直接跟常量(case 1:),不需要也不能加括号。写成case (1):虽然在某些编译器下可能碰巧通过,但这是非标准的写法,不要这样做。
上面的菜单选择用 switch 改写如下:
switch (choice)
{
case 1:
printf("新建文件\n");
break;
case 2:
printf("打开文件\n");
break;
case 3:
printf("保存文件\n");
break;
case 4:
printf("退出\n");
break;
default:
printf("无效选项\n");
}
switch 的执行流程:先计算括号内表达式的值,然后从上到下依次与每个 case 后面的常量比较。匹配到哪个 case,就从那里开始执行,直到遇到 break 或者 switch 结束的花括号 }。末尾的 default 是可选的——当所有 case 都不匹配时执行 default 分支,作用类似 if-else 链末尾的 else。
break 是关键。 如果漏写了 break,程序会“穿透“到下一个 case 继续执行,这通常不是你想要的行为:
switch (n)
{
case 1:
printf("一\n"); // n==1 时会输出 一、二、三 三行!
case 2:
printf("二\n");
case 3:
printf("三\n");
}
但有时刻意利用穿透也能写出紧凑的代码——比如多个 case 共享同一段处理逻辑:
switch (day)
{
case 1:
case 2:
case 3:
case 4:
case 5:
printf("工作日\n");
break;
case 6:
case 7:
printf("周末\n");
break;
}
switch 有两条使用限制需要记住:第一,括号内的表达式必须是整数类型(int、char、枚举等),float 和 double 不能用于 switch;第二,每个 case 后面必须是常量,不能是变量或范围表达式。
5.6 语法规则总结
if-else 系列:
// 形式一:单分支
if (条件) { 语句; }
// 形式二:双分支
if (条件) { 语句; } else { 语句; }
// 形式三:多分支
if (条件1) { 语句; }
else if (条件2) { 语句; }
else if (条件3) { 语句; }
else { 语句; }
核心规则:
if后面必须跟一对圆括号(),条件写在括号内- 条件为真(非 0)时执行
if对应的花括号中的代码 else不能单独使用,必须与一个if配对else if可以有多个,else至多一个- 如果某个分支内部只有一条语句,花括号可以省略,但初学者不建议这样做——省略花括号后容易在修改代码时引入逻辑错误
switch:
switch (表达式)
{
case 常量1: 语句; break;
case 常量2: 语句; break;
default: 语句;
}
核心规则:
- 表达式必须是整数类型,
case后必须是常量 - 匹配到
case后一直执行到break才跳出 - 漏写
break会导致穿透到下一个case default是可选的,所有case都不匹配时执行
什么时候用 if-else,什么时候用 switch? 范围判断(大于、小于、区间)用 if-else;对同一个变量的不同取值做等值判断,且分支较多时,用 switch 更清晰。
5.7 动手练习
#include <stdio.h>
int main(void)
{
int number = 15;
// 判断一个数是正数、负数还是零
if (number > 0)
{
printf("%d 是正数\n", number);
}
else if (number < 0)
{
printf("%d 是负数\n", number);
}
else
{
printf("这是零\n");
}
// 判断奇偶性
if (number % 2 == 0)
{
printf("%d 是偶数\n", number);
}
else
{
printf("%d 是奇数\n", number);
}
return 0;
}
请尝试:
- 把
number改成负数,观察输出变化 - 把
number改成 0,观察输出变化 - 编写代码判断一个年份是否为闰年(条件:能被 4 整除但不能被 100 整除,或者能被 400 整除)
六、循环语句
编程中有一类非常常见的需求:重复执行某些操作。计算 1 到 100 的累加和,需要做 99 次加法;将“Hello“打印 10 次,如果每次都写一行 printf,程序会变得冗长且难以修改。循环语句的作用正是——在满足条件的前提下,让一段代码被反复执行。
C 语言提供了三种循环结构:while、for 和 do-while。它们的核心逻辑是相同的(重复执行),区别在于判断条件的位置和时机不同。下面按这个顺序逐一讲解,掌握它们并能够根据场景选择最合适的一种,是写好 C 程序的基本功。
6.1 while 循环
while 循环的逻辑是:先判断条件,条件成立才执行循环体。执行完一遍循环体之后,回到开头重新判断条件,如此反复。其语法为:
while (条件)
{
循环体语句;
}
示例——从 1 数到 5:
#include <stdio.h>
int main(void)
{
int count = 1;
while (count <= 5)
{
printf("第 %d 次循环\n", count);
count = count + 1; // 每次循环让 count 增加 1
}
printf("循环结束。\n");
return 0;
}
输出:
第 1 次循环
第 2 次循环
第 3 次循环
第 4 次循环
第 5 次循环
循环结束。
将执行过程按轮次展开,可以清楚地看到条件判断和变量变化的节奏:
| 轮次 | count 的值 | count <= 5? | 执行的操作 |
|---|---|---|---|
| 第1次 | 1 | 真 | 输出“第 1 次循环“,count 变为 2 |
| 第2次 | 2 | 真 | 输出“第 2 次循环“,count 变为 3 |
| 第3次 | 3 | 真 | 输出“第 3 次循环“,count 变为 4 |
| 第4次 | 4 | 真 | 输出“第 4 次循环“,count 变为 5 |
| 第5次 | 5 | 真 | 输出“第 5 次循环“,count 变为 6 |
| 第6次 | 6 | 假 | 条件不满足,退出循环 |
这里有一个极其重要的原则:循环体内必须有能让条件最终变为假的代码——本例中是 count = count + 1。如果缺失了这一步,count 永远是 1,条件 count <= 5 永远为真,循环便永不终止,形成所谓的死循环。
6.2 for 循环
当你明确知道需要循环多少次时(比如“从 1 数到 5“、“重复 10 次”),for 循环是最常用也最简洁的选择。它把循环变量的初始化、条件判断和更新全部集中在一行中,使循环的结构一目了然。其语法为:
for (初始化; 条件; 更新)
{
循环体语句;
}
for 后面小括号里的三个部分,分别负责一件事:
| 部分 | 执行时机 | 作用 |
|---|---|---|
| 初始化 | 循环开始前,仅执行一次 | 设置循环计数变量的初始值 |
| 条件 | 每次循环开始前判断 | 为真则进入循环体,为假则退出 |
| 更新 | 每次循环体执行完毕后 | 更新循环计数变量 |
示例——从 1 数到 5:
#include <stdio.h>
int main(void)
{
for (int i = 1; i <= 5; i = i + 1)
{
printf("第 %d 次循环\n", i);
}
printf("循环结束。\n");
return 0;
}
输出:
第 1 次循环
第 2 次循环
第 3 次循环
第 4 次循环
第 5 次循环
循环结束。
逐轮手动推理——跟着程序走一遍:
下面我们用纸笔推演的方式,逐行跟踪程序的执行过程。这是理解循环最有效的方法——不要只“看“,跟着手动推一遍。
| 轮次 | 代码执行顺序 | i 的值 | i <= 5? | 执行的操作 |
|---|---|---|---|---|
| 准备 | int i = 1(初始化,只执行一次) | 1 | — | i 被设为 1 |
| 第1轮 | 判断 i <= 5 → 真 → 执行循环体 → i = i + 1 | 1 → 2 | 真 | 输出“第 1 次循环“,i 从 1 变为 2 |
| 第2轮 | 判断 i <= 5 → 真 → 执行循环体 → i = i + 1 | 2 → 3 | 真 | 输出“第 2 次循环“,i 从 2 变为 3 |
| 第3轮 | 判断 i <= 5 → 真 → 执行循环体 → i = i + 1 | 3 → 4 | 真 | 输出“第 3 次循环“,i 从 3 变为 4 |
| 第4轮 | 判断 i <= 5 → 真 → 执行循环体 → i = i + 1 | 4 → 5 | 真 | 输出“第 4 次循环“,i 从 4 变为 5 |
| 第5轮 | 判断 i <= 5 → 真 → 执行循环体 → i = i + 1 | 5 → 6 | 真 | 输出“第 5 次循环“,i 从 5 变为 6 |
| 第6轮 | 判断 i <= 5 → 假 → 退出循环 | 6 | 假 | printf("循环结束。\n") 被执行 |
关键观察:i 从 1 开始,每轮加 1。第 5 轮结束时 i 变成 6,第 6 轮判断时 6 > 5,条件不成立,循环结束。循环体的执行发生在“条件为真“的时候——条件一旦为假,循环体就被跳过了。
对于 i = i + 1 这种让变量加 1 的操作,C 语言提供了一个更简洁的写法:i++(称为自增运算符),效果和 i = i + 1 完全相同。因此上面的 for 循环通常写成:
for (int i = 1; i <= 5; i++)
{
printf("第 %d 次循环\n", i);
}
对初学者而言,建议先用
i = i + 1把逻辑想清楚,熟练之后再改用i++。i++和++i的区别已经在第四节 运算符中详细讲过,忘了可以回去翻看。
6.3 do-while 循环
while 和 for 都是“先判断,再执行“——条件不成立的话,循环体一次都不会执行。但有些时候,你希望不管条件是否成立,先执行一次再说。do-while 就是为这种场景设计的。其语法为:
do
{
循环体语句;
} while (条件);
注意
while (条件)后面有一个分号;,这是do-while在语法上区别于while的重要标志——漏掉这个分号会导致编译错误。
示例——从 1 数到 5:
#include <stdio.h>
int main(void)
{
int count = 1;
do
{
printf("第 %d 次循环\n", count);
count = count + 1;
} while (count <= 5);
printf("循环结束。\n");
return 0;
}
输出与 while 版本完全相同:
第 1 次循环
第 2 次循环
第 3 次循环
第 4 次循环
第 5 次循环
循环结束。
逐轮手动推理——跟着程序走一遍:
| 轮次 | 代码执行顺序 | count 的值 | count <= 5? | 执行的操作 |
|---|---|---|---|---|
| 第1轮 | 先执行循环体 → count = count + 1 → 再判断 | 1 → 2 | 真 | 输出“第 1 次循环“,count 从 1 变为 2,判断 2<=5 真 |
| 第2轮 | 执行循环体 → count = count + 1 → 判断 | 2 → 3 | 真 | 输出“第 2 次循环“,count 从 2 变为 3,判断 3<=5 真 |
| 第3轮 | 执行循环体 → count = count + 1 → 判断 | 3 → 4 | 真 | 输出“第 3 次循环“,count 从 3 变为 4,判断 4<=5 真 |
| 第4轮 | 执行循环体 → count = count + 1 → 判断 | 4 → 5 | 真 | 输出“第 4 次循环“,count 从 4 变为 5,判断 5<=5 真 |
| 第5轮 | 执行循环体 → count = count + 1 → 判断 | 5 → 6 | 真 → 假 | 输出“第 5 次循环“,count 从 5 变为 6,判断 6<=5 假 |
注意第 1 轮:count 的初始值是 1,程序不先判断,直接进入循环体执行 printf,然后才做 count = count + 1,最后判断 count <= 5。这就是 do-while 与 while 的核心差异——先斩后奏。
while 和 do-while 的关键区别: 当条件一开始就不成立时,两者的差异最明显:
// while 版本:循环体一次都不会执行
int count = 10;
while (count <= 5)
{
printf("这句话不会输出\n"); // 永远不会执行
}
// do-while 版本:循环体至少执行一次
int count = 10;
do
{
printf("这句话会输出一次\n"); // 执行一次后,判断 10 <= 5 为假,退出
} while (count <= 5);
总结两者的核心区别:
| 特性 | while / for | do-while |
|---|---|---|
| 判断时机 | 先判断,后执行 | 先执行,后判断 |
| 最少执行次数 | 0 次 | 1 次 |
选择哪一种取决于业务逻辑:如果“先检查再行动“是合理的(比如检查文件是否还存在再读取),用 while 或 for;如果“先行动再检查“是合理的(比如先让用户输入,再检查输入是否合法),用 do-while。
6.4 循环的选择
三种循环在功能上是等价的——任何一个循环都可以用另外两种形式改写。但根据场景选择最自然的形式,能让代码的可读性大为提升:
| 场景 | 推荐选择 |
|---|---|
| 循环次数已知(如“重复10次“、“从1到100”) | for |
| 循环次数未知,但条件明确(如“直到用户输入正确“) | while |
| 循环体必须先执行一次,再判断是否继续 | do-while |
6.5 break 与 continue
循环体内还可以使用两个特殊的关键字来控制循环的执行流程。
break 的作用是立即终止整个循环,程序跳到循环后面的第一条语句继续执行:
#include <stdio.h>
int main(void)
{
for (int i = 1; i <= 10; i++)
{
if (i == 5)
{
printf("遇到 5,提前结束循环\n");
break; // 跳出整个 for 循环
}
printf("当前数字:%d\n", i);
}
printf("循环外的代码\n");
return 0;
}
输出:
当前数字:1
当前数字:2
当前数字:3
当前数字:4
遇到 5,提前结束循环
循环外的代码
continue 的作用是跳过本次循环的剩余部分,直接进入下一次循环的条件判断:
#include <stdio.h>
int main(void)
{
for (int i = 1; i <= 5; i++)
{
if (i == 3)
{
printf("跳过 3\n");
continue; // 跳过本次循环剩余代码
}
printf("当前数字:%d\n", i);
}
return 0;
}
输出:
当前数字:1
当前数字:2
跳过 3
当前数字:4
当前数字:5
两者的区别值得牢记:break 是彻底离开循环,continue 只是跳过当前这一轮。另外,break 和 continue 只对它们所在的最内层循环有效——如果嵌套了多层循环,它们无法影响外层循环。
6.6 动手练习
#include <stdio.h>
int main(void)
{
// 练习1:计算 1 到 100 的和
int sum = 0;
for (int i = 1; i <= 100; i++)
{
sum = sum + i;
}
printf("1 到 100 的和是:%d\n", sum);
// 练习2:打印 1 到 20 中的偶数
printf("1 到 20 的偶数:");
for (int i = 2; i <= 20; i = i + 2)
{
printf("%d ", i);
}
printf("\n");
// 练习3:用 while 实现倒计时
int count = 5;
while (count > 0)
{
printf("倒计时:%d\n", count);
count--;
}
printf("发射!\n");
return 0;
}
请尝试:
- 修改练习1,计算 1 到 100 中所有奇数的和
- 用
for循环打印九九乘法表的前五行 - 用
do-while写一个模拟输入验证的程序:不断生成随机数,直到生成的数大于 100 为止
七、函数
7.1 从数学中的函数说起
函数(function)这个概念并非编程所独有——你在数学课上已经见过它了。
数学中的函数 f(x) = x + 5 表达了一种“输入—处理—输出“的关系:给它一个输入值 x,它经过计算 x + 5,返回一个结果。当 x = 3 时,f(3) = 8;当 x = 10 时,f(10) = 15。这个模型包含三个要素:输入(参数 x)、处理(运算 x + 5)、输出(返回值)。
C 语言中的函数在概念上与数学函数高度一致,只是用代码来描述“处理“这一步。这种一致性是刻意为之——函数式思维是结构化编程的基石。
那么,为什么需要自己定义函数呢?假设程序中有多处需要计算“一个数的平方再加 5“,如果不使用函数,代码会写成这样:
int a = 3;
int b = 7;
int c = 10;
int result1 = a * a + 5;
int result2 = b * b + 5;
int result3 = c * c + 5;
同一个计算逻辑 x * x + 5 重复出现了三次。如果逻辑本身更复杂,或者需要对公式做修改(比如改为加 10),每一处都需要单独修改,这既低效又容易出错。函数的解决方案是:把这段逻辑封装起来,起一个名字,之后每次用到它时只需调用这个名字即可:
#include <stdio.h>
// 自定义函数:计算 x 的平方加 5
int square_plus_five(int x)
{
int result = x * x + 5;
return result;
}
int main(void)
{
int result1 = square_plus_five(3); // 14
int result2 = square_plus_five(7); // 54
int result3 = square_plus_five(10); // 105
printf("f(3) = %d\n", result1);
printf("f(7) = %d\n", result2);
printf("f(10) = %d\n", result3);
return 0;
}
使用函数带来了三个明确的收益:避免重复代码(写一次,多次调用);使程序结构更清晰(把复杂问题拆分为小块);便于维护(修改函数内部一处,所有调用处自动生效)。
7.2 函数的定义
定义一个函数需要指定以下五个要素:
返回值类型 函数名(参数类型 参数名1, 参数类型 参数名2, ...)
{
函数体语句;
return 返回值;
}
| 部分 | 作用 | 示例 |
|---|---|---|
| 返回值类型 | 函数返回结果的数据类型,如果不返回任何值则写 void | int, float, void |
| 函数名 | 调用函数时使用的名字 | square_plus_five, get_max |
| 参数列表 | 函数的输入,可以为空(写 void 或留空) | int x, int a, int b |
| 函数体 | 函数执行的代码 | { ... } 中的内容 |
| return 语句 | 将计算结果返回给调用者 | return result; |
根据有无参数和有无返回值,函数可以归纳为四种形式:
形式一:有参数,有返回值
int add(int a, int b)
{
int sum = a + b;
return sum;
}
形式二:无参数,有返回值
int get_hundred(void)
{
return 100;
}
形式三:有参数,无返回值
void greet(char name[])
{
printf("你好,%s!\n", name);
// 没有 return 语句,或写 return;(不带任何值)
}
形式四:无参数,无返回值
void print_line(void)
{
printf("--------------------\n");
}
void 关键字表示“空“、“没有”。用在返回值类型的位置表示函数不返回任何值,用在参数列表的位置表示函数不接受任何参数。
7.2.1 函数不能重名——C 语言不支持函数重载
在 C 语言中有一条铁律:同一个程序中,不能存在两个名字相同的函数。 无论它们的参数类型或参数个数是否不同,只要函数名相同,编译器就会报错。
下面的代码在 C 语言中是不合法的:
// 错误:两个函数都叫 add,编译器无法区分
int add(int a, int b)
{
return a + b;
}
double add(double a, double b) // 编译错误:函数 add 重复定义!
{
return a + b;
}
如果你接触过 C++、Java 或 Python 等语言,你可能听说过函数重载(Function Overloading)——允许同一个函数名定义多个版本,根据调用时传入的参数类型和个数自动匹配对应的版本。这是这些语言提供的一项便利特性。
但 C 语言不支持函数重载。原因在于 C 语言的编译模型:编译器在生成目标代码时,函数在内部符号表中的名字就是你在代码中写的名字。如果你定义了两个同名的函数,编译器在链接阶段会发现同一个名字对应了两段不同的代码——它不知道该选哪一个,于是直接报错。
这实际上是 C 和 C++ 的一个关键差异点。C++ 支持函数重载,因为它采用了名字修饰(Name Mangling)技术——编译器在内部会根据函数的参数类型和个数,为每个重载版本生成一个独一无二的内部名字(例如
add_int_int和add_double_double),从而在底层区分它们。而 C 语言不做这种修饰,函数名是什么,内部符号名就是什么。
因此,如果你需要处理不同类型的数据,就需要给函数起不同的名字来区分:
int add_int(int a, int b) // 整数加法
{
return a + b;
}
double add_double(double a, double b) // 浮点加法
{
return a + b;
}
7.3 函数的调用
定义函数之后,需要通过调用来使用它。调用时写出函数名,并在圆括号中提供具体的参数值(称为实参,以区别于函数定义中的形参):
#include <stdio.h>
// 函数定义:求两个数中的较大值
int get_max(int a, int b)
{
if (a > b)
{
return a;
}
else
{
return b;
}
}
int main(void)
{
int max1 = get_max(5, 3); // max1 = 5
int max2 = get_max(10, 20); // max2 = 20
int max3 = get_max(-1, -5); // max3 = -1
printf("5 和 3 中的较大值:%d\n", max1);
printf("10 和 20 中的较大值:%d\n", max2);
printf("-1 和 -5 中的较大值:%d\n", max3);
return 0;
}
函数调用的执行过程可以分为四步:程序执行到调用处,将实参的值传递给形参;跳转到函数定义处;执行函数体内的代码;遇到 return 后带着返回值跳回调用处,继续执行后续代码。
7.4 函数的声明与定义
在 C 语言中,声明(Declaration)和定义(Definition)是函数相关的两个重要概念:
- 函数声明 = 告诉编译器“有这么一个函数存在,它叫什么名字、接受什么参数、返回什么类型“。声明不包含函数体,只给出函数的“签名“。
- 函数定义 = 不仅告诉编译器函数的名字和类型签名,还给出了函数的完整实现——即花括号
{ }中的函数体代码。
两者在代码层面很容易区分:
// 这是函数声明——只有函数头部,末尾是分号,没有函数体
int add(int a, int b);
// 这是函数定义——有完整的函数头部 + 花括号中的函数体
int add(int a, int b)
{
return a + b;
}
C 语言的编译器是从上到下逐行阅读源代码的。如果 main 函数中调用了一个尚未被编译器“见过“的函数,编译器会报错。解决这个问题有两种方式:一是将函数定义写在 main 前面(前面大多数例子采用的都是这种方式);二是在 main 前面写上函数声明,将函数的具体定义放在 main 后面:
#include <stdio.h>
// 函数声明:提前告诉编译器 add 函数的存在
int add(int a, int b);
int main(void)
{
int result = add(3, 5);
printf("3 + 5 = %d\n", result);
return 0;
}
// 函数定义:实际的实现
int add(int a, int b)
{
return a + b;
}
函数声明的格式与函数定义的第一行相同,只是末尾要加上分号。它的作用就是提前告知编译器:“后面会有一个叫 add 的函数,它的参数类型和返回值类型是下面这样的,你先记下来,别急着报错。”
💡 提示:声明与定义的关系
- 声明可以出现多次(你可以在多个地方声明同一个函数),但定义只能有一份——因为函数的具体实现代码当然只需要写一次。
- 声明不分配内存、不生成可执行代码;定义会实际编译成机器指令。
- 现阶段你写的函数大多直接把定义写在
main前面,这既是声明也是定义,不需要刻意区分。但在大型项目中,函数声明通常被集中放在头文件(.h文件)中,而定义放在源文件(.c文件)中,二者分离,供多个源文件共享。这个主题将在第十六章详细展开。
7.5 动手练习
#include <stdio.h>
// 函数声明
int square(int x);
int is_even(int n);
void print_stars(int count);
int main(void)
{
printf("4 的平方 = %d\n", square(4));
printf("7 的平方 = %d\n", square(7));
printf("\n");
if (is_even(10) == 1)
{
printf("10 是偶数\n");
}
if (is_even(7) == 0)
{
printf("7 是奇数\n");
}
printf("\n");
printf("打印 5 个星号:");
print_stars(5);
return 0;
}
// 函数定义:计算平方
int square(int x)
{
return x * x;
}
// 函数定义:判断是否为偶数(1 表示是,0 表示否)
int is_even(int n)
{
if (n % 2 == 0)
{
return 1;
}
else
{
return 0;
}
}
// 函数定义:打印 count 个星号
void print_stars(int count)
{
for (int i = 0; i < count; i++)
{
printf("*");
}
printf("\n");
}
请尝试:
- 编写一个
get_min函数,返回两个整数中的较小值 - 编写一个
factorial函数,计算正整数 n 的阶乘(n! = 1 × 2 × 3 × … × n) - 编写一个
is_prime函数,判断一个数是否为素数
八、#include 与头文件
8.1 什么是 #include 和头文件
#include 是 C 语言的预处理指令(Preprocessor Directive)之一。以 # 开头的行都属于预处理指令,它们在实际编译开始之前,由预处理器(Preprocessor)先行处理。
头文件(Header File)是以 .h 为后缀的文件,内部通常包含函数声明、宏定义、类型定义等内容。它的作用是把一些公共的声明集中存放,供多个源文件引用。
#include 这条预处理指令的工作方式是:在编译之前,将指定头文件的全部内容原样复制、插入到 #include 所在的位置。换句话说,#include <stdio.h> 的效果就相当于把 stdio.h 这个文件的所有文字直接粘贴进你的源代码。
8.2 语法形式
#include 有两种写法:
#include <stdio.h> // 尖括号形式:用于标准系统头文件
#include "myheader.h" // 双引号形式:用于用户自定义头文件
| 形式 | 搜索方式 | 适用场景 |
|---|---|---|
尖括号 < > | 编译器在系统预设的目录中搜索 | C 语言官方提供的标准头文件 |
双引号 " " | 先在当前源文件所在目录搜索,找不到再去系统目录 | 你自己编写的头文件,或第三方库的头文件 |
初学阶段记住一个简单原则即可:官方提供的头文件用 < >,自己写的头文件用 " "。
8.3 通俗理解
如果觉得上面的概念比较抽象,可以先这样理解:
编程就像写文章。你写文章时,有些“素材“不会自己从头编——比如你要引用一句名人名言,或者引用一条公式,你会直接去查资料、抄过来用。C 语言也是同样的道理:很多基础功能(打印文字、做数学运算、获取随机数)已经有人帮你写好了,存在一个个叫“头文件“的文档里。你想用哪个功能,就在文件开头用 #include 把对应的文档“拿过来“。编译器看到 #include,就会在编译之前先去把那篇文档的内容拷过来,这样后续的代码就能顺利使用这些现成的功能了。
你可以暂时把 #include 理解为“导入工具包“——就像你要用螺丝刀,得先把工具箱打开;你要用 printf 输出文字,得先 #include <stdio.h> 把标准输入输出工具包导入。至于预处理器具体怎么处理、头文件和源文件的深层关系,后续章节会展开讲,现阶段先按这个理解来写代码就够了。
8.4 常用标准头文件速览
C 语言提供了一组标准头文件,各有不同的分工。以下列出初学阶段最常接触的几个,现阶段只需混个眼熟,用到时再回来查:
| 头文件 | 主要功能 | 常用函数/内容 |
|---|---|---|
stdio.h | 标准输入输出 | printf, scanf, puts, fgets |
stdlib.h | 标准库函数 | rand, srand, exit, malloc, free |
math.h | 数学函数 | sqrt, pow, sin, cos, fabs |
string.h | 字符串处理 | strlen, strcpy, strcmp, strcat |
time.h | 时间和日期 | time, clock |
使用某个标准库函数之前,需要先包含对应的头文件——查阅函数文档时,文档总会注明需要哪个头文件。
8.5 代码示例
下面是一个同时使用多个头文件的简单示例,帮助建立直观印象:
#include <stdio.h> // 为了使用 printf
#include <math.h> // 为了使用 sqrt(平方根)
#include <stdlib.h> // 为了使用 rand(随机数)
int main(void)
{
printf("4 的平方根是 %f\n", sqrt(4));
printf("一个随机数是 %d\n", rand());
return 0;
}
这个程序同时“导入“了三个工具包:stdio.h 负责屏幕输出,math.h 提供数学计算,stdlib.h 提供随机数功能。每个 #include 各司其职,需要什么功能就包含对应的头文件——这个模式将贯穿你今后写的每一个 C 程序。
💡 提示:关于自定义头文件
本章只涉及 C 语言官方提供的标准头文件。在大型项目中,程序员也会编写自己的头文件(例如
myutils.h),将自定义的函数声明集中管理。关于如何组织和编写自定义头文件,将在第十六章详细展开。
九、标准输入与输出
9.1 输出:printf 函数
前面各节中已经大量使用了 printf,本节对它做一个系统性的梳理。
printf 函数的作用是向屏幕输出格式化的文字。它的第一个参数是一个格式字符串,其中可以包含两种内容:普通字符(原样输出)和格式占位符(以 % 开头,会被后续参数的值替换)。
什么是占位符?为什么需要占位符?
设想你要输出一句话:“我叫张三,今年 18 岁”。其中“张三“和“18“这两个值是存放在变量中的,每次运行程序时可能不同。你当然不能把变量名直接写进双引号——printf("我叫 name,今年 age 岁") 会在屏幕上原样输出 我叫 name,今年 age 岁,而不是变量的值。
用比喻来理解:占位符就像在句子中先挖一个坑(
%d),标记好“这里要填一个整数“,然后printf按照顺序,从后面的参数列表中取出对应的值填进去。坑挖在哪里、填什么类型,由你通过占位符精确控制。
占位符以 % 开头,常见的有 %d(整数)、%f(小数)、%c(字符)、%s(字符串)。
基本的调用格式为:
printf("格式字符串", 参数1, 参数2, ...);
常用格式占位符如下:
| 占位符 | 对应的数据类型 | 示例 | 输出结果 |
|---|---|---|---|
%d | int | printf("%d", 42); | 42 |
%f | float / double | printf("%f", 3.14); | 3.140000 |
%c | char | printf("%c", 'A'); | A |
%s | 字符串(char[]) | printf("%s", "Hello"); | Hello |
%u | unsigned int | printf("%u", 100); | 100 |
%% | 输出 % 本身 | printf("%%"); | % |
注意:
scanf和printf对double的格式要求不同。printf输出double使用%f;scanf读取double使用%lf。
控制小数位数——在 %f 的 % 和 f 之间加入 .n(n 为整数),可以指定保留 n 位小数:
float pi = 3.14159;
printf("默认:%f\n", pi); // 3.141590
printf("保留2位:%.2f\n", pi); // 3.14
printf("保留4位:%.4f\n", pi); // 3.1416(四舍五入)
控制输出宽度——在 % 和格式字符之间写一个数字,可以指定最小输出宽度:
int num = 42;
printf("[%d]\n", num); // [42]
printf("[%5d]\n", num); // [ 42](右对齐,占5个字符宽度)
printf("[%-5d]\n", num); // [42 ](左对齐,占5个字符宽度)
转义字符——格式字符串中,以下反斜杠组合有特殊含义:
| 转义字符 | 含义 |
|---|---|
\n | 换行 |
\t | 制表符(Tab) |
\\ | 反斜杠本身 |
\" | 双引号 |
9.2 输入:scanf 函数
scanf 函数用于从键盘读取用户输入的数据,并将读到的值存入指定的变量。其调用格式为:
scanf("格式字符串", &变量1, &变量2, ...);
最关键的注意事项是:变量名前必须加 & 符号。& 是取地址运算符(Address-of Operator),它的作用是获取变量在内存中的存储地址。关于地址的概念,这里先做一个直观的类比:
变量就像你在银行租的一个保险柜——保险柜本身有编号(地址),柜子里存放着你的物品(变量的值)。scanf 要做的事情是“把你从键盘输入的数据放进保险柜里“,所以它需要的不是柜子里的东西(变量的值),而是柜子的编号(变量的地址)。& 的作用就是告诉 scanf:“这个柜子的编号是多少,你把数据存到那个编号对应的柜子里去。”
如果你忘了写 &,scanf 就拿不到正确的地址,它会把数据存到一个错误的位置——这通常会导致程序崩溃。遗忘 & 是初学者在使用 scanf 时最常见的错误。
💡 提示:不理解没关系
地址和指针是 C 语言中比较抽象的概念,如果你现在对
&取地址的理解还比较模糊,完全正常。目前只需要记住一条规则:使用scanf读取普通变量时,变量名前必须加&。至于“地址“到底是什么、&的深层原理,将在第七章指针中详细展开。先会用,再理解。
下面的程序演示了 scanf 读取三种不同类型的数据:
#include <stdio.h>
int main(void)
{
int age;
float height;
char grade;
printf("请输入你的年龄:");
if (scanf("%d", &age) != 1) return 1;
printf("请输入你的身高(米):");
if (scanf("%f", &height) != 1) return 1;
// %c 前加空格,跳过之前遗留的换行符
printf("请输入你的成绩等级(A/B/C/D):");
if (scanf(" %c", &grade) != 1) return 1;
printf("\n--- 你的信息 ---\n");
printf("年龄:%d 岁\n", age);
printf("身高:%.2f 米\n", height);
printf("成绩等级:%c\n", grade);
return 0;
}
运行示例:
请输入你的年龄:18
请输入你的身高(米):1.75
请输入你的成绩等级(A/B/C/D):A
--- 你的信息 ---
年龄:18 岁
身高:1.75 米
成绩等级:A
%c 的特殊问题。 当用户输入完一个数字后按回车时,回车符(\n)会残留在输入缓冲区中。如果紧接着用 %c 读取字符,scanf 会把这个回车符当作一个“字符“读进去,导致用户还没来得及输入自己的字符,程序就已经“读“完了。解决方法是在 %c 前加一个空格:scanf(" %c", &grade);,这个空格会指示 scanf 跳过所有空白字符(空格、回车、Tab),直到遇到真正的非空白字符。
字符串输入(初步了解)。 用 scanf 读取字符串时,变量名前面不需要 &,因为数组名本身就代表地址:
char name[20];
printf("请输入你的名字:");
if (scanf("%19s", name) != 1) return 1; // 数组名不加 &,并检查读取结果
printf("你好,%s!\n", name);
这里的 19 是为了给数组最后的 '\0' 留出一个位置,避免用户输入过长时写出数组边界。第四章学习字符串时会更系统地讨论这个问题。
关于数组和字符串的详细内容,将在第二章和第三章中专门讨论。
9.3 动手练习:简易计算器
#include <stdio.h>
int main(void)
{
int num1, num2;
printf("=== 简易加法计算器 ===\n\n");
printf("请输入第一个整数:");
if (scanf("%d", &num1) != 1) return 1;
printf("请输入第二个整数:");
if (scanf("%d", &num2) != 1) return 1;
// 提升后再运算,避免 int 加减乘以及 INT_MIN / -1 发生有符号溢出
long long left = num1;
long long right = num2;
printf("\n%d + %d = %lld\n", num1, num2, left + right);
printf("%d - %d = %lld\n", num1, num2, left - right);
printf("%d * %d = %lld\n", num1, num2, left * right);
if (num2 != 0)
{
printf("%d / %d = %lld\n", num1, num2, left / right);
printf("%d %% %d = %lld\n", num1, num2, left % right);
}
else
{
printf("除数不能为 0!\n");
}
return 0;
}
请尝试:
- 将程序改为支持
float类型的运算(对应的占位符为%f) - 编写一个程序,让用户输入圆的半径,计算并输出面积(面积 = π × 半径²)
- 编写一个程序,让用户输入三个整数,输出其中的最大值
十、综合练习:猜数游戏
本章覆盖了大量基础语法,逐一练习之后,本节用一个完整的程序将它们串联起来。这个猜数游戏的规则很直观:程序在 1 到 100 之间随机选定一个数,玩家反复猜测,每次猜测后程序给出“猜大了“或“猜小了“的提示,直到猜中为止。程序还会记录猜测次数并给出评价,并支持再来一局。
这个程序虽然不长,但几乎用到了本章介绍的所有知识点:变量与数据类型、运算符、判断语句、循环语句、函数、头文件、标准输入输出。
10.1 完整代码
将以下代码保存为 guess_game.c,编译并运行。
/* =============================================
* 猜数游戏
* 功能:程序随机生成 1-100 的整数,玩家猜数字
* 知识点:变量、类型、运算符、判断、循环、函数、
* 头文件、标准输入输出
* ============================================= */
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
/* ---------- 函数声明 ---------- */
int generate_random_number(int min, int max);
int get_valid_guess(void);
int check_guess(int guess, int target);
/* ---------- 主函数 ---------- */
int main(void)
{
int target_number;
int user_guess;
int guess_count;
int game_count = 0;
int is_correct;
char play_again;
srand((unsigned int)time(NULL));
printf("=================================\n");
printf(" 欢迎来到猜数游戏!\n");
printf("=================================\n\n");
do
{
game_count++;
target_number = generate_random_number(1, 100);
guess_count = 0;
is_correct = 0;
printf("【第 %d 局】我已经想好了一个 1 到 100 之间的数字。\n", game_count);
printf("请你来猜一猜吧!\n\n");
while (!is_correct)
{
user_guess = get_valid_guess();
guess_count++;
is_correct = check_guess(user_guess, target_number);
}
printf("\n恭喜你猜对了!你一共猜了 %d 次。\n", guess_count);
if (guess_count <= 5)
{
printf("评价:太厉害了,简直是猜数天才!\n");
}
else if (guess_count <= 10)
{
printf("评价:不错不错,继续加油!\n");
}
else
{
printf("评价:还可以更棒,多练习几次吧!\n");
}
printf("\n想再来一局吗?(y/n):");
if (scanf(" %c", &play_again) != 1) play_again = 'n';
printf("\n");
} while (play_again == 'y' || play_again == 'Y');
printf("感谢游玩,再见!\n");
return 0;
}
/* ---------- 函数定义 ---------- */
/*
* 函数:generate_random_number
* 功能:生成 [min, max] 范围内的随机整数
*/
int generate_random_number(int min, int max)
{
int random_num = rand() % (max - min + 1) + min;
return random_num;
}
/*
* 函数:get_valid_guess
* 功能:获取用户输入的有效猜测(1-100 之间的整数),无效输入会提示重新输入
*/
int get_valid_guess(void)
{
int guess;
int is_valid;
do
{
printf("请输入你的猜测(1-100):");
is_valid = scanf("%d", &guess);
if (is_valid == EOF)
{
printf("\n输入已结束,游戏退出。\n");
exit(EXIT_SUCCESS);
}
// 清除输入缓冲区中的无效字符
int ch;
while ((ch = getchar()) != '\n' && ch != EOF)
{
// 空循环体:逐字符读取直到遇到换行符
}
if (is_valid != 1 || guess < 1 || guess > 100)
{
printf("输入无效!请输入 1 到 100 之间的整数。\n\n");
is_valid = 0;
}
} while (!is_valid);
return guess;
}
/*
* 函数:check_guess
* 功能:比较猜测值和目标值,给出提示
* 返回值:1 表示猜中,0 表示未猜中
*/
int check_guess(int guess, int target)
{
if (guess > target)
{
printf(" → 猜大了!再小一点试试。\n\n");
return 0;
}
else if (guess < target)
{
printf(" → 猜小了!再大一点试试。\n\n");
return 0;
}
else
{
printf(" → 恭喜你,猜对了!\n");
return 1;
}
}
10.2 分步讲解
第一步:头文件包含
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
这三个 #include 行各自引入一个“工具箱“:
<stdio.h>→ 提供printf(屏幕输出)和scanf(键盘输入)<stdlib.h>→ 提供rand(生成随机数)和srand(设置随机种子)<time.h>→ 提供time(获取系统时间,用作随机种子)
需要哪个功能,就包含哪个头文件——这是 C 语言组织代码的基本方式。
第二步:函数声明
int generate_random_number(int min, int max);
int get_valid_guess(void);
int check_guess(int guess, int target);
这三行是函数声明——它们告诉编译器:“后面会定义这三个函数,它们的名字、参数类型和返回值类型长这样”。因为这三个函数的定义写在 main 后面,而 main 中要调用它们,所以需要提前“打个招呼“。
每行末尾的分号告诉编译器“这里只是声明,不是定义“。
第三步:变量声明与随机种子初始化
int main(void)
{
int target_number; // 存储本局的目标数字
int user_guess; // 存储用户每次的猜测
int guess_count; // 记录猜测次数
int is_correct; // 0 = 未猜中,1 = 猜中
char play_again; // 存储用户的重玩意愿 (y/n)
int game_count = 0; // 记录已玩局数(初始为 0)
srand((unsigned int)time(NULL));
进入 main 函数后,第一件事是声明需要用到的变量。每个变量都有明确的职责(见注释),声明类型后编译器才知道要为它们分配多少内存。
紧接着,程序把 time(NULL) 的结果转换成 unsigned int 后交给 srand,用当前日历时间初始化伪随机数生成器。不同秒启动时通常会得到不同种子;同一秒内多次启动仍可能得到相同序列,而且 rand() 不适合密码学或安全用途。这里把它用于入门猜数游戏已经足够。
第四步:外层 do-while 循环——控制多局游戏
do
{
game_count++; // 局数 +1
target_number = generate_random_number(1, 100); // 生成 1~100 的随机目标
guess_count = 0; // 重置猜测次数
is_correct = 0; // 重置猜中状态
printf("【第 %d 局】我已经想好了一个 1 到 100 之间的数字。\n", game_count);
printf("请你来猜一猜吧!\n\n");
// ← 内层循环在这里,见第五步
// ← 成绩评价与重玩在这里,见第八步
} while (play_again == 'y' || play_again == 'Y');
整个游戏逻辑放在一个 do-while 循环中。选择 do-while 的理由很简单:游戏至少需要运行一局,而 do-while 保证“先执行,后判断“,正好符合这个需求。
每局开始前做了三件事:
- 调用
generate_random_number(1, 100)生成新目标 - 把
guess_count和is_correct复位(否则上一局的数据会干扰本局) - 打印本局的欢迎提示
循环末尾检查 play_again == 'y' || play_again == 'Y',用户输入 y 或 Y 就再来一局,否则退出。
第五步:内层 while 循环——单局猜测逻辑
while (!is_correct) // is_correct 为 0 时继续循环
{
user_guess = get_valid_guess(); // ① 获取一个合法输入
guess_count++; // ② 猜测次数 +1
is_correct = check_guess(user_guess, target_number); // ③ 判断结果
}
这个 while 循环是单局游戏的心脏。!is_correct 的含义是“还没猜中“(is_correct 为 0 时,!is_correct 为 1,循环继续;猜中后 is_correct 变为 1,!is_correct 为 0,循环退出)。
每轮循环做三件事,一行对应一个操作:
| 行 | 代码 | 做了什么 |
|---|---|---|
| ① | user_guess = get_valid_guess() | 等待用户输入一个 1~100 的合法整数 |
| ② | guess_count++ | 猜了一次,计数加 1 |
| ③ | is_correct = check_guess(...) | 判断猜得对不对,返回 0 或 1 |
这三行代码完美对应“输入 → 计数 → 判断“的游戏流程。直到猜中,循环才退出。
第六步:get_valid_guess 函数——输入验证
int get_valid_guess(void)
{
int guess;
int is_valid;
do
{
printf("请输入你的猜测(1-100):");
is_valid = scanf("%d", &guess); // 尝试读取一个整数
int ch;
while ((ch = getchar()) != '\n' && ch != EOF) // 清理本行剩余输入
{
// 空循环体:逐字符吃掉剩余内容直到换行符
}
if (is_valid != 1 || guess < 1 || guess > 100)
{
printf("输入无效!请输入 1 到 100 之间的整数。\n\n");
is_valid = 0; // 标记无效,让外层 do-while 再来一轮
}
} while (!is_valid); // 输入不合法就重新来
return guess;
}
这个函数用 do-while 循环死磕到底——用户不输入合法数字,就一直提示重新输入。有两个关键细节:
scanf("%d", &guess) 的返回值等于“成功读取的项目数“。如果用户输入了 abc 之类的非数字内容,scanf 读不到整数,返回的不是 1,函数就知道输入无效。
这个空循环负责丢弃当前输入行中尚未读取的字符,直到遇到换行符或 EOF。必须判断 EOF,否则当输入流关闭且没有换行时,getchar() 会持续返回 EOF,循环将永远无法结束。
第七步:check_guess 函数——比较与提示
int check_guess(int guess, int target)
{
if (guess > target)
{
printf(" → 猜大了!再小一点试试。\n\n");
return 0; // 未猜中,返回 0
}
else if (guess < target)
{
printf(" → 猜小了!再大一点试试。\n\n");
return 0; // 未猜中,返回 0
}
else
{
printf(" → 恭喜你,猜对了!\n");
return 1; // 猜中,返回 1
}
}
这个函数是游戏规则的直接体现,只有三种可能:
- 猜大了 → 提示“再小一点“,返回
0 - 猜小了 → 提示“再大一点“,返回
0 - 猜对了 → 祝贺,返回
1
返回值 0 或 1 被赋给 is_correct,从而控制第五步的内层循环是继续还是退出。
第八步:成绩评价与重玩
printf("\n恭喜你猜对了!你一共猜了 %d 次。\n", guess_count);
if (guess_count <= 5)
printf("评价:太厉害了,简直是猜数天才!\n");
else if (guess_count <= 10)
printf("评价:不错不错,继续加油!\n");
else
printf("评价:还可以更棒,多练习几次吧!\n");
printf("\n想再来一局吗?(y/n):");
if (scanf(" %c", &play_again) != 1) play_again = 'n';
猜中之后,用 if-else if-else 按猜测次数分档评价。三条路径只会走一条:≤5 次是天才,6~10 次是不错,>10 次鼓励继续练习。
最后询问是否重玩。注意 scanf(" %c", &play_again) 中 %c 前面有一个空格——这个空格不是笔误,它的作用是跳过缓冲区中残留的空白字符(换行符、空格等)。如果省略这个空格,残留的换行符会被当成用户输入直接吃掉,导致程序跳过“是否重玩“的询问。
10.3 编译与运行
# Windows
clang guess_game.c -o guess_game.exe
guess_game.exe
# macOS / Linux
clang guess_game.c -o guess_game
./guess_game
运行示例:
=================================
欢迎来到猜数游戏!
=================================
【第 1 局】我已经想好了一个 1 到 100 之间的数字。
请你来猜一猜吧!
请输入你的猜测(1-100):50
→ 猜小了!再大一点试试。
请输入你的猜测(1-100):75
→ 猜大了!再小一点试试。
请输入你的猜测(1-100):60
→ 猜小了!再大一点试试。
请输入你的猜测(1-100):68
→ 恭喜你,猜对了!
恭喜你猜对了!你一共猜了 4 次。
评价:太厉害了,简直是猜数天才!
想再来一局吗?(y/n):n
感谢游玩,再见!
本章小结
本章以猜数游戏这个完整程序为核心,在逐步拆解游戏所需语法的过程中,初步介绍了 C 语言的核心基础语法:从 hello.c 的程序骨架,到注释、变量与数据类型、运算符、判断语句、循环语句、函数、头文件、标准输入输出,最后用猜数游戏将所有知识点串联在一起。
💡 提示:第一遍看不懂很正常
如果你是零基础初学者,读完这一章后感到有些概念还比较模糊——这是完全正常的。编程不是靠“看会“的,而是靠“练会“的。本章的内容不是让你一次性全部背下来,而是让你对 C 语言的语法有一个初步的轮廓认知。建议的学习方式是:以最后的猜数游戏为核心,反复阅读它的每一行代码,遇到不懂的语法点就翻回前面对应的小节去查。看不懂没关系,先跟着代码敲一遍,跑起来,再回头理解。多敲几遍,慢慢就通了。
以下是本章关键知识点的回顾:
- C 程序从
main函数开始执行,花括号定义代码范围,分号终结语句,#include引入外部代码 - 注释不会被编译,是写给人类阅读的说明文字
- 变量使用前必须声明类型;
int、float、double、char是四种基本数据类型 - 算术运算符、自增自减运算符(
++/--的前缀与后缀形式)、复合赋值运算符(+=、-=等)、逻辑运算符(&&、||、!)与比较运算符,以及运算符优先级 if、if-else、if-else if-else实现条件分支,程序根据条件的真假选择不同的执行路径while、do-while、for三种循环各有适用的场景,break终止循环,continue跳过本轮- 函数封装可复用的代码逻辑,接收参数、返回结果;声明与定义可以分离
printf和scanf分别负责屏幕输出和键盘输入,格式占位符是二者通用的核心机制
⚠️ 初学者常见错误提醒
在进入下一章之前,请务必注意以下两个最容易犯的错误——它们虽然简单,但几乎每个初学者都会踩坑:
一、所有符号必须是英文符号。 C 语言代码中的每一个标点符号——分号 ;、逗号 ,、双引号 "、单引号 '、小括号 ()、花括号 {}、小于号 <、大于号 >——都必须使用英文输入法输入。中文输入法下打出的符号(如 ;、,、"、'、()、《》)在编译器眼里全是非法字符,会导致编译失败。这是初学阶段最高频的问题,没有之一。当你看到编译器报出一大串看不懂的错误时,先检查有没有打成中文标点,往往问题就出在这里。
二、关键字和函数名的拼写要准确。 C 语言的关键字和标准库函数名都是固定的英文单词,一个字母都不能错。常见的拼写错误包括:
prnitf/print→ 正确写法是printfscanf→ 容易写成scanmain→ 容易写成mianinclude→ 容易写成inlcude或incluedfloat→ 容易写成flaotdouble→ 容易写成doubel
编译器对拼写错误零容忍,差一个字母就完全不认识。遇到“未定义的标识符“(undeclared identifier)这类报错时,检查拼写是第一步。
💡 小贴士:clang 命令行的 -o 选项
在安装好 Clang 之后,你将在终端中通过 clang 命令来编译你的 C 程序。截止目前,Clang 已经积累了上百个命令行选项,但对初学者而言,你真正需要立刻掌握的只有一个:-o。
最基本的编译命令:
打开终端,进入你的 .c 文件所在目录,输入:
clang hello.c
编译成功后,Clang 会默认在当前目录下生成一个名为 a.out(在 Windows 上是 a.exe)的可执行文件。这个默认文件名 a.out 源自 Unix 的传统——“a” 代表 “assembler output”(汇编器输出),这个命名习惯从 1970 年代一直沿用至今。
-o:指定输出文件名:
显然,每次编译完都得到一个叫 a.out 的文件并不方便——你很快就会分不清它到底是谁编译出来的。-o 选项(字母 o,代表 output)就是用来解决这个问题的:
clang hello.c -o hello
这条命令的含义是:编译 hello.c,并将输出的可执行文件命名为 hello(Windows 上会自动添加 .exe 后缀,即 hello.exe)。
-o 后面跟的,就是你希望生成的文件叫什么名字。你可以随便取:
clang hello.c -o my_first_program
clang hello.c -o hello_world
clang hello.c -o test
编译完成后,在 Linux/macOS 上用 ./hello 运行,在 Windows 上用 hello.exe(或直接 hello)运行。
-o 的位置:
-o 可以放在源文件的前面,也可以放在后面——两种写法都是合法的:
clang -o hello hello.c # -o 在前面
clang hello.c -o hello # -o 在后面
两种写法效果完全相同。本教程统一采用源文件在前、-o 在后的写法,因为读起来更自然:“编译 hello.c,输出为 hello”。
第二章 关于类型和变量,我还想说这些
本章概览
在第一章中,我们认识了变量——一块被命名的存储空间,用来存放数据,它的值可以随程序运行而改变。变量之所以叫“变量“,正是因为它可以“变化“。
但一个很自然的追问是:有没有不变量?——有没有一种数据,它也有名字、也占存储空间,但一旦被赋予了一个值之后,就再也不允许被改变?
答案是有的。C 语言给“变量“这个概念附加了丰富的内涵。一个变量除了可以存储数据,还可以被施加额外的约束:它可以被标记为 const(只读),不允许在初始化之后再修改。
此外,数据的类型也不是铁板一块——在不同类型之间,变量可以按照规则进行类型转换。
本章围绕这两个主题展开:
- const——让一个变量在初始化之后不可修改,回答“有没有不变量“的问题。
- 类型转换——在不同类型之间架设桥梁,让数据在需要时以另一种类型的面貌参与运算。
它们不创造新的数据类型,也不引入新的程序结构。它们只是对“变量“这个你已经熟悉的概念,进行一次更深入的审视和补充。
一、const —— 只读的契约
1.1 不变的变量
在第一章中,我们使用的变量都是可读可写的——你可以随时修改它的值。但实际编程中,有些数据一旦确定下来,就不应该再被改变:
- 数学常量:比如圆周率 π
- 物理常量:一天的小时数(24)
- 运行时确定的配置:程序开头计算出来的、后续不应改变的参数
如果你或他人在代码中意外修改了这些值,程序的行为就会变得难以预测。
C 语言提供了 const 关键字来解决这个问题。在变量声明时加上 const,意味着“这个变量在初始化之后不能被修改“:
const double PI = 3.1415926;
const int HOURS_PER_DAY = 24;
// PI = 3.14; // 编译错误!不能修改 const 变量
// HOURS_PER_DAY = 12; // 编译错误!
实际编程中,const 对象应当在声明时完成初始化,否则之后不能再用赋值语句补上初值。需要精确区分的是:下面第一行声明本身在块作用域中是合法的,但 MAX 具有不确定值,读取它会产生未定义行为;第二行赋值才是编译器必须诊断的问题:
const int MAX; // 语法合法,但值不确定,不能读取
MAX = 100; // 错误:不能在声明之后给 const 对象赋值
从语义上说,const 变量更像一个不可修改的承诺。你告诉编译器(也告诉阅读代码的人):“这个值在这里确定下来之后,整个程序中再也不会变。“编译器会帮助你强制执行这个承诺——任何试图修改 const 变量的代码都会触发编译错误,而不是在运行时才暴露问题。
二、强制类型转换
2.1 隐式转换:编译器帮你做的“自动换算“
在第一章中,我们学过整数除法的规则:两个整数相除,结果是整数,小数部分被截断。如果想得到小数结果,需要用 7.0 / 3 这样的写法。这背后的机制就是隐式类型转换——编译器在运算双方类型不匹配时,会自动将其中一方“提升“或“转换“为另一方的类型。
常见的隐式转换场景:
#include <stdio.h>
int main(void)
{
// 整数 → 浮点数(自动提升)
int a = 5;
double b = 2.0;
double result = a / b; // a 被自动转换为 5.0,再执行浮点除法
printf("5 / 2.0 = %.2f\n", result); // 2.50
// 赋值时的隐式转换
double pi = 3.14159;
int truncated = (int)pi; // 显式表达有意丢弃小数部分,truncated = 3
printf("pi 截断为 int:%d\n", truncated);
// 混合运算中的提升
int x = 10;
long y = 200;
// x + y 的结果是 long 类型(int 被提升为 long)
printf("sizeof(x + y) = %d\n", (int)sizeof(x + y));
return 0;
}
隐式转换遵循一套明确的规则(“普通算术转换”),但初学阶段只需记住最核心的一点:当不同类型的数值混合运算时,编译器会自动向“更大“或“更精确“的类型转换——int 向 long 和 double 转换,float 向 double 转换。
隐式转换虽然方便,但也可能在不经意间造成精度丢失(如浮点数赋值给整数时的小数截断)或者意料之外的运算结果(如两个 int 相除本期望得到小数,却忘了至少转一个为浮点)。因此,很多时候显式地标明类型转换,比依赖隐式行为更安全。
2.2 显式类型转换:(type) 语法
当隐式转换不够精确,或者想明确表达转换意图时,可以使用强制类型转换(cast)。它的语法很简单:
(目标类型) 表达式
基本用法示例:
#include <stdio.h>
int main(void)
{
// 确保浮点除法
int a = 5, b = 2;
double result = (double)a / b; // 把 a 显式转为 double,b 自动跟着提升
printf("5 / 2 = %.2f\n", result); // 2.50
// 截断小数
double pi = 3.14159;
int int_pi = (int)pi; // 显式截断,int_pi = 3
printf("(int)3.14159 = %d\n", int_pi);
// 在整数运算中保留精度
int total = 100, count = 3;
double avg = (double)total / count;
printf("平均 = %.2f\n", avg); // 33.33 而不是 33.00
return 0;
}
(double)a 对 a 本身没有任何影响——a 仍然是 int 类型,值为 5。转换只产生一个临时值参与后续运算。这就像你查词典把一个英文词翻译成中文——原文不变,只是你用译文去接续下一步工作。
2.3 常见应用场景
强制类型转换在实际编程中频繁出现,以下是几个最常见的场景:
场景一:整数除法中取得小数结果。 这已经在前面的例子中演示过了。要点是:只要在除法表达式中的任何一个操作数上使用 (double) 或 (float) 转换,整个除法就会按浮点运算执行。
场景二:在 printf 中使用正确的占位符。 比如 sizeof 的返回值类型是 size_t,要使输出在不同平台上一致,有时需要显式转换:
printf("数组大小:%d 字节\n", (int)sizeof(arr)); // 显式转为 int
场景三:与 malloc 返回值配合(将在堆内存章节详述)。 malloc 返回的是 void * 类型,可以直接赋值给任意指针类型。但在一些编码规范中,习惯显式写出转换以增强可读性。
场景四:浮点数取整。 (int) 转换直接丢弃小数部分(向零截断),不同于四舍五入。如果需要对负数取整,需注意 (int)(-3.7) 的结果是 -3 而非 -4。
注意:强制类型转换并非万能。C 只允许标准规定的一组转换,例如算术类型之间的转换、满足约束的指针转换等;结构体不能直接转换成
int,数组也不能整体转换成double。“兼容类型”在 C 标准中另有严格含义,因此不要把强制转换笼统理解成只发生在“兼容类型”之间。转换也不会自动保证数值不丢失或指针可安全解引用。
三、动手练习
#include <stdio.h>
/* ============================================
* 练习1:const 变量的使用
* ============================================ */
int main(void)
{
// 声明 const 变量并尝试修改(观察编译错误)
const int MAX_STUDENTS = 50;
printf("最大学生人数:%d\n", MAX_STUDENTS);
// MAX_STUDENTS = 60; // 取消注释会触发编译错误
printf("\n");
/* ============================================
* 练习2:类型转换实战——精确除法与取整
* ============================================ */
int numerator = 22, denominator = 7;
// 整数除法(丢失精度)
printf("整数除法:%d / %d = %d\n", numerator, denominator,
numerator / denominator);
// 显式转换,保留精度
printf("浮点除法:%d / %d = %.4f\n", numerator, denominator,
(double)numerator / denominator);
// 浮点数转整数
double value = 9.87;
printf("(int)%.2f = %d\n", value, (int)value);
// 四舍五入的简单实现(正数)
double pi = 3.14159;
int rounded = (int)(pi * 100 + 0.5); // 先放大100倍,加0.5再截断
printf("pi 保留两位小数:%d.%02d\n", rounded / 100, rounded % 100);
return 0;
}
第三章 数组
本章要点
第一章介绍了如何用单个变量存储一个数据——一个整数、一个小数、一个字符。但在实际编程中,我们面对的往往不是孤立的数据点,而是一组具有相同性质的数据:一个班级 30 名学生的成绩、一页传感器在 24 小时内每小时记录的温度值、一段文本中的所有字符。如果为每一个数据都单独定义一个变量,程序会迅速膨胀到无法管理的程度。
数组正是为解决这一问题而设计的。本章将系统学习 C 语言中的数组,具体涵盖以下内容:
- 一维数组的声明、初始化和元素访问
- 数组与循环的配合使用——遍历、累加、输入输出
- 数组越界的原因、后果与规避方法
- 一维数组的经典应用:查找最大值与最小值
- 二维数组的定义、初始化与遍历
- 用嵌套循环处理表格型数据
数组是 C 语言中最基础的数据结构。后续学习字符串(第四章)、指针(第七章)以及动态内存分配(第十章)时,数组的概念和用法将不断被延续和深化。建议读者在阅读本章时,每学完一节就动手编写并运行对应的练习代码。
一、数组基础
1. 同一类型数据的集合
在开始学习数组的语法之前,不妨先思考一个具体的问题:假设老师让你记录班上 5 名同学的数学成绩——小明 85 分,小红 92 分,小刚 78 分,小丽 90 分,小华 88 分。如果用第一章学过的变量来存储,你很自然地会写出如下代码:
int score1 = 85;
int score2 = 92;
int score3 = 78;
int score4 = 90;
int score5 = 88;
5 名同学尚且可以应付,但如果是 50 名同学呢?500 名呢?你需要定义 500 个互不重名的变量,写 500 行声明和赋值代码——这显然不可行。问题的根源在于,这里的每一个数据在逻辑上属于同一类别(都是“数学成绩“),但我们却被迫用彼此孤立的变量去管理它们。
数组的解决方案简洁而直观:将这组数据放进一个带编号的容器中。你可以把数组想象成一排紧挨着的储物柜,整排柜子有一个统一的名字(比如 scores),每个小格子有一个编号(0 号、1 号、2 号……),通过编号即可定位到具体的格子,往里面存入数据或从中取出数据。
在 C 语言中,数组就是一组相同类型变量的集合,它们在内存中连续存放,共用一个名字,通过下标(即格子的编号)来区分和访问每一个元素。
2. 一维数组的定义
数组在 C 语言中是一种复合数据类型,它可以一次性容纳多个同类型的数据。和普通变量一样,使用数组之前必须先声明它。
声明一维数组的基本语法如下:
数据类型 数组名[数组大小];
各部分含义:
- 数据类型:数组中每个元素的数据类型(
int、float、char等),数组中所有元素类型必须一致。 - 数组名:给这个数组起的名字,命名规则与普通变量相同。
- 方括号
[ ]:标志“这是一个数组“。 - 数组大小:这个数组能存放多少个元素。数组的大小必须在声明时确定,且必须是一个固定的正整数常量(如
5、10、26),不能是变量,也不能在程序运行过程中改变。一旦声明了int scores[5];,这个数组就固定能放 5 个元素,多一个放不下,少一个空着也不行。
以下是几个合法的数组声明:
int scores[5]; // 声明一个能存放 5 个 int 类型数据的数组
float heights[10]; // 声明一个能存放 10 个 float 类型数据的数组
char letters[26]; // 声明一个能存放 26 个 char 类型数据的数组
当编译器处理 int scores[5]; 时,它会在内存中分配出连续的 5 个 int 大小的存储空间,就像一排紧挨着的储物柜格子:
这里有一个需要从一开始就牢记的规则:数组下标从 0 开始,而不是 1。一个大小为 5 的数组,有效下标范围是 0 到 4,不存在下标 5。这个“差 1“的规则是 C 语言数组的基础约定,后续所有关于数组的操作都建立在这一约定之上。
💡 提示:数组在内存中是怎样存放的?
数组的所有元素在内存中是紧挨着、一个接一个连续存放的。比如
int scores[5],scores[0]挨着scores[1],scores[1]挨着scores[2]……就像一排首尾相连的储物柜格子。正是因为这种连续排列,程序才能通过“首地址 + 偏移量“的方式,用下标[i]一步到位地计算出任意元素的准确位置。如果你现在对“内存中连续存放“这个概念还比较模糊,完全没关系。目前只需要记住一个形象的理解:数组就是一排紧挨着的格子。至于“首地址“、“偏移量“这些名词背后的原理,将在第六章内存模型和第七章指针中详细展开。先会用,再理解。
3. 一维数组的初始化
声明数组的同时,也可以给它赋予初始值。C 语言提供了几种初始化方式,各自适用于不同的场景。
方式一:完全初始化。 在声明时用花括号 { } 列出所有元素的值,用逗号分隔:
int scores[5] = {85, 92, 78, 90, 88};
执行后,5 个格子的内容就全部确定了:
方式二:部分初始化。 如果只给出了部分值,剩余的元素会被编译器自动初始化为 0:
int scores[5] = {85, 92}; // 只给了前两个值
结果:
方式三:省略大小的初始化。 如果在初始化时给出了所有元素的值,可以省略方括号中的大小,编译器会自动根据初始值的个数推断数组的大小:
int scores[] = {85, 92, 78, 90, 88}; // 编译器自动推断大小为 5
注意,只有在声明的同时初始化才可以省略大小。如果先声明再赋值,必须明确指定大小。
方式四:逐个赋值。 也可以先声明数组,再逐个给每个元素赋值:
int scores[5];
scores[0] = 85;
scores[1] = 92;
scores[2] = 78;
scores[3] = 90;
scores[4] = 88;
这种方式下有一处需要特别留意:具有自动存储期且未初始化的数组元素具有不确定值,读取它可能产生未定义行为,不能把它当成某个可观察的“随机垃圾数”。这与部分初始化不同——使用初始化列表时,未明确给出的剩余元素会按规则初始化为零。
补充:两个实用工具——sizeof 和 memset。 在操作数组时,有两个常用的工具值得提前认识一下。
sizeof 是一个运算符(不是函数),它可以告诉你一个变量或类型在内存中占多少字节。对于数组,sizeof(数组名) 返回整个数组占用的总字节数。用它除以单个元素的大小,就能算出数组的元素个数:
int scores[5] = {85, 92, 78, 90, 88};
int total_bytes = sizeof(scores); // 整个数组占多少字节(通常 5 × 4 = 20)
int element_bytes = sizeof(scores[0]); // 单个元素占多少字节(通常 4)
int count = sizeof(scores) / sizeof(scores[0]); // 数组的元素个数 = 20 / 4 = 5
用 sizeof 计算数组长度有一个很重要的好处:当你修改了数组的大小,不需要手动去修改所有用到数组长度的循环条件——代码自己会算。后续在函数中传递数组时,sizeof 的行为会有所不同,这一点将在函数与数组章节中说明。
memset 是一个标准库函数(需要 #include <string.h>),它能将数组的每个字节都设置为同一个值。最常见的用法是把整个数组快速清零:
#include <string.h>
int scores[5];
memset(scores, 0, sizeof(scores)); // 将 scores 的每个字节都设为 0
// 执行后:scores = {0, 0, 0, 0, 0}
memset 的三个参数依次是:数组名(目标内存)、要填入的值(0)、要填充的字节数(用 sizeof 算)。
注意:
memset是按字节填充的,所以它最适合用来清零(填 0)。填其他值时行为可能不符合直觉,目前只需要知道用它来清零数组即可。关于memset的深层原理,将在指针章节中进一步讨论。现阶段会用就够了,不必深究。
4. 访问数组元素
要访问数组中的某个元素,使用下标运算符 [ ]:
数组名[下标]
- 下标从 0 开始。
- 大小为 N 的数组,有效下标范围是 0 到 N-1。
读取和赋值的写法与普通变量完全一致,只是变量名变成了“数组名加下标“的形式:
数组名[下标] = 值; // 给某个元素赋值
变量 = 数组名[下标]; // 读取某个元素的值
下面的程序演示了数组元素的读取和修改:
#include <stdio.h>
int main(void)
{
int scores[5] = {85, 92, 78, 90, 88};
// 读取数组元素
printf("第一个同学的成绩:%d\n", scores[0]); // 85
printf("第三个同学的成绩:%d\n", scores[2]); // 78
printf("第五个同学的成绩:%d\n", scores[4]); // 88
// 修改数组元素
scores[2] = 95; // 把第三个同学的成绩改成 95
printf("修改后第三个同学的成绩:%d\n", scores[2]); // 95
return 0;
}
输出:
第一个同学的成绩:85
第三个同学的成绩:78
第五个同学的成绩:88
修改后第三个同学的成绩:95
代码中 scores[0] 访问的是第一个元素,scores[1] 是第二个。数组的下标编号与日常生活中的顺序编号相差 1——这一习惯需要在持续的练习中逐渐内化,直到它成为你的第二本能。
二、数组操作
掌握了数组的声明、初始化和元素访问之后,你已经能够创建和使用数组了。但数组真正的威力并不在于单个元素的存取,而在于它能够与循环结合,用极少的代码批量处理大量数据。从本节开始,我们将探索数组在实际编程中的典型操作模式。
1. 数组与循环
1.1 用 for 循环遍历数组
#include <stdio.h>
int main(void)
{
int scores[5] = {85, 92, 78, 90, 88};
// 遍历数组:逐个输出每个元素
for (int i = 0; i < 5; i++)
{
printf("第 %d 个同学的成绩:%d\n", i + 1, scores[i]);
}
return 0;
}
输出:
第 1 个同学的成绩:85
第 2 个同学的成绩:92
第 3 个同学的成绩:78
第 4 个同学的成绩:90
第 5 个同学的成绩:88
循环变量 i 从 0 递增到 4,恰好覆盖了数组的全部有效下标。这一执行过程可以按轮次拆解如下:
| 循环轮次 | i 的值 | i < 5? | 输出的 scores[i] | 实际元素 |
|---|---|---|---|---|
| 第1次 | 0 | 真 | scores[0] = 85 | 第一个 |
| 第2次 | 1 | 真 | scores[1] = 92 | 第二个 |
| 第3次 | 2 | 真 | scores[2] = 78 | 第三个 |
| 第4次 | 3 | 真 | scores[3] = 90 | 第四个 |
| 第5次 | 4 | 真 | scores[4] = 88 | 第五个 |
| 第6次 | 5 | 假 | — | 退出循环 |
for (int i = 0; i < 数组大小; i++) 是遍历数组的标准写法。这里的 <(而非 <=)是关键——因为下标最大值为 数组大小 - 1,用 < 恰好保证不会越界。建议将这一模式牢记为遍历数组的固定范式。
1.2 用循环计算总和与平均值
循环遍历不仅是输出的手段,更是对数组进行聚合计算的基础。下面的程序计算了一组成绩的总分与平均分:
#include <stdio.h>
int main(void)
{
int scores[5] = {85, 92, 78, 90, 88};
int sum = 0;
float average;
for (int i = 0; i < 5; i++)
{
sum = sum + scores[i]; // 累加每个成绩
}
average = (float)sum / 5; // 注意:转成 float 避免整数除法
printf("总分:%d\n", sum); // 433
printf("平均分:%.2f\n", average); // 86.60
return 0;
}
其中 (float)sum / 5 中的 (float) 是类型转换——它告诉编译器,在除法执行之前将 sum 的值从 int 转为 float,从而触发浮点除法。如果写成 sum / 5,两个 int 相除会执行整数除法,小数部分将被丢弃,得到的结果是 86 而非 86.60。
1.3 用循环从键盘输入数组元素
将循环与 scanf 结合,可以让用户在程序运行时动态地填充数组内容:
#include <stdio.h>
int main(void)
{
int scores[5];
printf("请输入 5 个同学的成绩:\n");
for (int i = 0; i < 5; i++)
{
printf("第 %d 个:", i + 1);
if (scanf("%d", &scores[i]) != 1)
{
fprintf(stderr, "第 %d 个成绩不是有效整数。\n", i + 1);
return 1;
}
}
printf("\n你输入的成绩是:\n");
for (int i = 0; i < 5; i++)
{
printf("%d ", scores[i]);
}
printf("\n");
return 0;
}
运行示例:
请输入 5 个同学的成绩:
第 1 个:85
第 2 个:92
第 3 个:78
第 4 个:90
第 5 个:88
你输入的成绩是:
85 92 78 90 88
scanf("%d", &scores[i]) 中,scores[i] 是一个普通的 int 变量,因此前面需要加 & 取地址,与给普通变量输入时的写法完全一致。
2. 数组越界
数组越界,指的是访问了数组有效下标范围之外的位置。例如,声明了 int arr[5];,有效下标是 0 到 4,如果写了 arr[5] 或 arr[-1],就发生了越界。
下面展示越界写法,但用 #if 0 阻止危险语句进入可执行程序:
#include <stdio.h>
int main(void)
{
int scores[5] = {85, 92, 78, 90, 88};
printf("最后一个有效元素:%d\n", scores[4]);
#if 0
// 错误示范:取消保护后会产生未定义行为
printf("scores[5] = %d\n", scores[5]); // 越界!
printf("scores[100] = %d\n", scores[100]); // 严重越界!
#endif
return 0;
}
若取消 #if 0 保护,编译器可能给出告警,但 C 语言不会在每次数组访问时自动检查边界。真正执行越界访问会产生未定义行为:
- 越界读取:读到其他变量正在使用的内存,得到一个毫无意义的数值
- 越界写入:不经意间修改了其他变量的值,导致程序行为异常
- 访问受保护区域:操作系统直接终止程序(崩溃)
无论哪种结果,都不是你想要的。
防止数组越界的方法并不复杂,但需要持续的纪律:
- 始终记住:大小为 N 的数组,下标范围是 0 到 N-1。
- 遍历数组时使用
for (int i = 0; i < N; i++)而非i <= N。 - 用常量定义数组大小,避免在代码中散布含义不明的“魔法数字“:
#define SIZE 5 // 用宏定义数组大小
int scores[SIZE];
for (int i = 0; i < SIZE; i++) // 使用常量,不容易写错
{
// ...
}
将数组大小集中定义为一处常量,一旦需要修改只需改一个地方,循环条件也会自动保持一致——这是一种低成本、高收益的编程习惯。
3. 查找最大值和最小值
在理解数组与循环的基本配合模式之后,本节通过一个经典的算法问题——查找一组数据中的最大值和最小值——来展示如何将这种配合模式应用于实际的逻辑判断。以下是完整的程序:
#include <stdio.h>
int main(void)
{
int numbers[8] = {34, 17, 89, 5, 72, 41, 60, 23};
int max, min;
// 假设第一个元素既是最大值又是最小值
max = numbers[0];
min = numbers[0];
// 从第二个元素开始,逐个比较
for (int i = 1; i < 8; i++)
{
if (numbers[i] > max)
{
max = numbers[i]; // 发现更大的,更新最大值
}
if (numbers[i] < min)
{
min = numbers[i]; // 发现更小的,更新最小值
}
}
printf("数组内容:");
for (int i = 0; i < 8; i++)
{
printf("%d ", numbers[i]);
}
printf("\n");
printf("最大值:%d\n", max); // 89
printf("最小值:%d\n", min); // 5
return 0;
}
输出:
数组内容:34 17 89 5 72 41 60 23
最大值:89
最小值:5
算法思路可以概括为三步:
- 假设:将第一个元素设为当前的最大值和最小值
- 遍历:从第二个元素开始,逐个与当前最大值、最小值比较
- 更新:发现更大就更新最大值,发现更小就更新最小值
遍历完整个数组之后,max 和 min 中保留的就是真正的最大值和最小值。这个**“假设 → 遍历 → 更新”**的模式在数组处理中非常普遍,后续查找、统计类的问题都会用到相似的思路。
三、二维数组
1. 从列表到表格
到目前为止,我们处理的数组都是一维的——一条线性的数据序列。
但在许多实际问题中,数据天然具有行列结构。例如,5 名学生每人有 3 门课的成绩,这个数据模型本身就包含两个维度:学生(行)和科目(列)。用 5 个独立的一维数组虽然也能表达,但操作起来笨重且容易出错。
二维数组正是为这类“表格型“数据设计的。你可以把二维数组想象成一个有行有列的表格,或者一个多层储物柜——每个格子需要两个编号来定位:行号和列号。
声明二维数组的语法与一维数组类似,只是多了一对方括号用于指定列数:
数据类型 数组名[行数][列数];
int scores[5][3]; // 5 行 3 列的二维数组(5个学生,每人3门课)
初始化二维数组有两种等效的方式。第一种按行组织,结构清晰、便于阅读:
int scores[5][3] = {
{85, 92, 78}, // 第 0 行(第一个学生的 3 门课)
{90, 88, 95}, // 第 1 行
{76, 82, 89}, // 第 2 行
{93, 87, 91}, // 第 3 行
{80, 85, 84} // 第 4 行
};
第二种按内存顺序线性列出所有值:
int scores[5][3] = {85, 92, 78, 90, 88, 95, 76, 82, 89, 93, 87, 91, 80, 85, 84};
两种写法在内存中的效果完全相同——二维数组在内存中仍然是连续存放的,先存第 0 行的所有列,再存第 1 行的所有列,以此类推。第一种按行分组的写法更直观,推荐使用。
访问二维数组元素时,需要同时指定行下标和列下标:
数组名[行下标][列下标]
#include <stdio.h>
int main(void)
{
int scores[5][3] = {
{85, 92, 78},
{90, 88, 95},
{76, 82, 89},
{93, 87, 91},
{80, 85, 84}
};
// 访问第 1 个学生的第 2 门课成绩(下标从 0 开始)
printf("第1个学生第2门课:%d\n", scores[0][1]); // 92
// 访问第 3 个学生的第 1 门课成绩
printf("第3个学生第1门课:%d\n", scores[2][0]); // 76
return 0;
}
遍历二维数组需要使用嵌套循环——外层循环控制行,内层循环控制列。下面的程序展示了如何用嵌套循环输出一个整齐的成绩表:
#include <stdio.h>
int main(void)
{
int scores[5][3] = {
{85, 92, 78},
{90, 88, 95},
{76, 82, 89},
{93, 87, 91},
{80, 85, 84}
};
printf("学生成绩表:\n");
printf("学号\t语文\t数学\t英语\n");
for (int i = 0; i < 5; i++) // 外层循环控制行(学生)
{
printf("%d\t", i + 1); // 输出学号
for (int j = 0; j < 3; j++) // 内层循环控制列(科目)
{
printf("%d\t", scores[i][j]);
}
printf("\n");
}
return 0;
}
输出:
学生成绩表:
学号 语文 数学 英语
1 85 92 78
2 90 88 95
3 76 82 89
4 93 87 91
5 80 85 84
执行过程可以按行列展开:
| 外层 i | 内层 j | 访问的元素 | 含义 |
|---|---|---|---|
| 0 | 0 | scores[0][0] = 85 | 第1个学生,语文 |
| 0 | 1 | scores[0][1] = 92 | 第1个学生,数学 |
| 0 | 2 | scores[0][2] = 78 | 第1个学生,英语 |
| 1 | 0 | scores[1][0] = 90 | 第2个学生,语文 |
| 1 | 1 | scores[1][1] = 88 | 第2个学生,数学 |
| … | … | … | … |
内层循环完整地遍历完一行的所有列之后,外层循环才推进到下一行。\t 是制表符,使输出按列对齐。嵌套循环——外层管行、内层管列——是处理二维数组的标准模式,后续学习矩阵运算和图像处理时同样会用到。
计算每个学生的总分
在嵌套循环的基础上稍作变通,就可以实现更具体的计算。以下程序计算每位学生三门课的总分与平均分:
#include <stdio.h>
int main(void)
{
int scores[5][3] = {
{85, 92, 78},
{90, 88, 95},
{76, 82, 89},
{93, 87, 91},
{80, 85, 84}
};
for (int i = 0; i < 5; i++)
{
int sum = 0;
for (int j = 0; j < 3; j++)
{
sum = sum + scores[i][j]; // 累加当前学生的各科成绩
}
float avg = (float)sum / 3;
printf("学生 %d:总分 = %d,平均分 = %.2f\n", i + 1, sum, avg);
}
return 0;
}
输出:
学生 1:总分 = 255,平均分 = 85.00
学生 2:总分 = 273,平均分 = 91.00
学生 3:总分 = 247,平均分 = 82.33
学生 4:总分 = 271,平均分 = 90.33
学生 5:总分 = 249,平均分 = 83.00
这里的关键在于 sum 变量必须在外层循环内部(每处理一个新学生时)重置为 0——如果放在外层循环外面,sum 会一直累积,到第二个学生时就已经包含了第一个学生的成绩,结果也就失去了意义。
四、动手练习
#include <stdio.h>
int main(void)
{
// 练习1:逆序输出数组
int arr[6] = {1, 2, 3, 4, 5, 6};
printf("原数组:");
for (int i = 0; i < 6; i++)
{
printf("%d ", arr[i]);
}
printf("\n");
printf("逆序输出:");
for (int i = 5; i >= 0; i--) // 从最后一个元素开始往前
{
printf("%d ", arr[i]);
}
printf("\n\n");
// 练习2:统计数组中奇数和偶数的个数
int numbers[10] = {12, 7, 33, 8, 15, 24, 9, 40, 3, 18};
int odd_count = 0; // 奇数个数
int even_count = 0; // 偶数个数
for (int i = 0; i < 10; i++)
{
if (numbers[i] % 2 == 0)
{
even_count++;
}
else
{
odd_count++;
}
}
printf("数组中有 %d 个偶数,%d 个奇数\n\n", even_count, odd_count);
// 练习3:二维数组——矩阵转置
int matrix[3][3] = {
{1, 2, 3},
{4, 5, 6},
{7, 8, 9}
};
printf("原矩阵:\n");
for (int i = 0; i < 3; i++)
{
for (int j = 0; j < 3; j++)
{
printf("%d ", matrix[i][j]);
}
printf("\n");
}
printf("转置矩阵:\n");
for (int i = 0; i < 3; i++)
{
for (int j = 0; j < 3; j++)
{
printf("%d ", matrix[j][i]); // 交换行和列
}
printf("\n");
}
return 0;
}
第四章 字符和字符串
本章要点
在第一章中,我们学习了 char 类型——它可以存储一个字符,并在 3.3.1 双引号与字符串初识 中初步接触了双引号包裹的字符串。本章从这两个起点出发,先深入理解单个字符在计算机中的表示和运算,再过渡到**一串字符(字符串)**的存储与操作。具体涵盖以下内容:
- 字符——
char类型回顾、ASCII 编码、字符与整数的转换 - 字符串的本质:以
'\0'结尾的字符数组 - 字符串的输出(
printf)与输入(scanf、fgets),以及输入中的安全陷阱 - 标准库中的字符串处理函数:
strlen、strcpy、strcat、strcmp - 字符串数组——用二维字符数组管理多个字符串
- 字符与字符串的区别(单引号与双引号)——在第一章初步感知的基础上做系统对比
本章的所有内容都建立在第一章的 char 类型和第三章的数组概念之上,建议读者在阅读本章之前,确保对数组的声明、索引和内存布局有清晰的理解。
一、字符
第一章中我们学过:char 类型占 1 个字节,用单引号包裹,比如 'A'、'#'、'7'。本节在此基础上深入两个问题:字符在计算机内部到底是怎么存的?字符和整数之间是什么关系?
1.1 字符的“身份证“:ASCII 编码
计算机只认识 0 和 1,不认识 'A' 或 '#' 这样的符号。为了让计算机能处理文字,必须给每个字符分配一个数字编号。这套编号规则就是 ASCII(American Standard Code for Information Interchange,美国信息交换标准代码),它定义了 128 个字符与数字 0~127 之间的一一对应。
以下是编程中最常打交道的几组 ASCII 码:
| 字符 | 十进制 | 说明 |
|---|---|---|
'\0' | 0 | 空字符(字符串结束标志) |
' ' | 32 | 空格 |
'0' ~ '9' | 48 ~ 57 | 数字字符 |
'A' ~ 'Z' | 65 ~ 90 | 大写字母 |
'a' ~ 'z' | 97 ~ 122 | 小写字母 |
从这个 ASCII 表可以立刻看出几个很有用的规律:
'\0'的值就是 0——这就是它为什么叫“空字符“。- 数字字符
'0'的值是 48,不是 0——'0'和整数0是两个完全不同的东西。 - 在 ASCII 中,大写字母和小写字母相差 32——
'A'是 65,'a'是 97。所以'a' - 32等于'A'。 - 在 ASCII 中,字母和数字各自连续排列——
'A''Z'是 65~90,'0''9'是 48~57。这意味着可以用>= 'A' && <= 'Z'判断大写字母。
💡 提示:ASCII 只需记住四个关键值——
'0'=48,'A'=65,'a'=97,'\0'=0。其余用到时查表即可。
1.2 字符与整数的转换
由于 char 本质上就是一个 1 字节的小整数,字符和整数之间可以直接相互转换,不需要任何额外操作。
字符 → 整数:用 %d 输出即可看到字符对应的 ASCII 码。
char c = 'A';
printf("字符 %c 的 ASCII 码是 %d\n", c, c); // 输出:字符 A 的 ASCII 码是 65
整数 → 字符:把整数赋给 char 变量,用 %c 输出即可看到对应的字符。
char c = 65;
printf("ASCII 码 %d 对应的字符是 %c\n", c, c); // 输出:ASCII 码 65 对应的字符是 A
这种字符与整数的等价性,带来了三个非常实用的编程技巧:
技巧一:数字字符转整数。 '5' 的 ASCII 码是 53,要得到真正的整数 5,只需减去 '0'(即 48):
char digit = '7';
int num = digit - '0'; // num = 7,因为 55 - 48 = 7
printf("%d\n", num); // 输出 7
技巧二:大小写转换。 在 ASCII 兼容环境中,大写字母和小写字母相差 32,加减 32 即可切换大小写:
char upper = 'G';
char lower = upper + 32; // 'G'(71) + 32 = 103 = 'g'
printf("%c → %c\n", upper, lower); // 输出:G → g
char c = 'm';
char C = c - 32; // 'm'(109) - 32 = 77 = 'M'
printf("%c → %c\n", c, C); // 输出:m → M
技巧三:判断字符类别。 利用 ASCII 的连续性,用区间判断即可识别字母、数字、大小写:
char c = 'K';
if (c >= 'A' && c <= 'Z') printf("大写字母\n");
else if (c >= 'a' && c <= 'z') printf("小写字母\n");
else if (c >= '0' && c <= '9') printf("数字\n");
这三个技巧在后续处理字符串时会频繁用到——遍历字符串中的每个字符、判断类型、做大小写转换,它们的原理就来自本节所说的“字符就是整数“。
注意:上面的大小写差值和字母区间判断建立在 ASCII 兼容编码的前提上。日常 Windows、macOS、Linux 的 C 开发环境基本都满足这个前提;但更通用、更规范的写法是使用
<ctype.h>中的toupper、tolower、isalpha、isdigit等函数。
二、字符串
理解了单个字符的存储和运算之后,下一步就是把多个字符串在一起。这正是第一章 3.3.1 中双引号所做的事情。
2.1 字符串的本质:字符数组 + 结束标志
C 语言没有专门的“字符串类型“。字符串的本质,就是一个以
'\0'结尾的字符数组。
这句话有两个要点:(1) 字符串就是字符数组,(2) 末尾必须有 '\0'。两者缺一不可:
// 方式一:逐个字符写,手动加 '\0'(繁琐,不常用)
char str1[6] = {'H', 'e', 'l', 'l', 'o', '\0'};
// 方式二:用双引号(编译器自动在末尾加 '\0',推荐)
char str2[] = "Hello";
两种写法在内存中产生的效果完全相同,都是 6 个字节的连续空间:
显然,"" 才是日常写法——不用数字符个数,不用手动补 '\0',编译器全部代劳。
核心结论:
""的本质就是创建了一个末尾带'\0'的字符数组。数组的实际大小 = 可见字符数 + 1(那个隐藏的'\0')。
2.2 字符串的输出与输入
用 printf 输出字符串
格式占位符 %s 专门用来输出字符串:
#include <stdio.h>
int main(void)
{
char name[] = "Xiao Ming";
printf("你好,%s!\n", name); // 你好,Xiao Ming!
// 也可以直接输出字符串常量
printf("欢迎学习C语言\n");
return 0;
}
执行细节:printf 从给定地址的第一个字符开始,一直输出,直到遇到 '\0' 才停止。如果字符数组末尾忘了放 '\0',printf 就会越过数组边界继续读取内存,直到在内存中碰巧遇上一个 '\0' 为止——输出的结果将是一堆无法预料的乱码。这个行为再次印证了 '\0' 作为字符串结束标志的关键地位。
用 scanf 输入字符串
可以用 %s 让用户从键盘输入一个字符串:
#include <stdio.h>
int main(void)
{
char name[20]; // 预留 20 个字符的空间
printf("请输入你的名字:");
if (scanf("%19s", name) != 1)
{
fprintf(stderr, "没有读到有效名字。\n");
return 1;
}
printf("你好,%s!\n", name);
return 0;
}
两个需要特别注意的地方:
第一,scanf 读取字符串时,变量名前不加 &。
因为数组名本身代表的就是数组的起始地址,所以不需要再用 & 取地址。这一点与读取 int 或 float 类型的变量时不同。
第二,%s 输入有两个常见缺陷:它遇到空格或回车就停止;如果不限制最大宽度,还可能写出数组边界。
如果你输入 Xiao Ming,scanf("%19s", name) 只会读入 Xiao,Ming 会被留在输入缓冲区中。更危险的是,如果写成 scanf("%s", name) 且用户输入超过数组能容纳的长度,scanf 不会阻止——它会继续往内存里写,覆盖掉数组后面的其他数据。这就是所谓的缓冲区溢出,是 C 语言中最常见的安全漏洞之一。
更安全的输入方式:fgets
为了避免 scanf 的这些问题,推荐使用 fgets 来读取字符串:
#include <stdio.h>
int main(void)
{
char name[20];
printf("请输入你的全名:");
if (fgets(name, sizeof name, stdin) == NULL)
{
fprintf(stderr, "读取失败。\n");
return 1;
}
printf("你好,%s\n", name);
return 0;
}
fgets 的三个参数:
| 参数 | 含义 | 本例中的值 |
|---|---|---|
| 第一个 | 字符数组的名字(存储输入的地方) | name |
| 第二个 | 目标数组容量;最多存入容量减一的 char,再补 '\0' | sizeof name |
| 第三个 | 输入流;stdin 通常连接终端,也可能被重定向 | stdin |
fgets会保留用户按下的回车符。如果你不想要最后的换行,需要手动去掉它(本章 4.3 节 会讲到具体方法)。
三、字符串处理函数
掌握了字符串的输入输出之后,下一个自然的问题是:拿到一个字符串,我们能对它做什么?最常见的需求:
- 测量长度:这个字符串有多长?
- 复制内容:把字符串拷贝到另一个地方
- 拼接:把两个字符串合并成一个
- 比较:判断两个字符串是否相等
C 语言的标准库已经准备好了这些函数,它们全部声明在 <string.h> 头文件中。使用之前记得 #include <string.h>。
3.1 获取字符串长度:strlen
#include <stdio.h>
#include <string.h>
int main(void)
{
char str[] = "Hello";
size_t len = strlen(str);
printf("字符串 \"%s\" 的长度是:%zu\n", str, len); // 5
return 0;
}
注意:strlen 返回的是 '\0' 之前的字节数,不包括 '\0' 本身,返回值类型是 size_t,输出时使用 %zu。对 ASCII 字符串 "Hello" 来说,一个字符恰好占一个字节,所以结果是 5;对 UTF-8 中文字符串,字节数通常大于人眼看到的字符数。分配字符串副本时仍要在 strlen 的结果上加 1,为 '\0' 留出位置。
3.2 复制字符串:strcpy
将一个字符串的内容复制到另一个字符数组中:
char dest[20];
char src[] = "Hello, World!";
strcpy(dest, src); // 把 src 的内容复制到 dest 中
危险提示:strcpy 不会检查目标数组的大小。如果 dest 放不下 src 的内容,同样会发生缓冲区溢出。实际开发中可以使用带长度限制的写法(如 snprintf,或谨慎使用 strncpy),核心原则是:目标数组必须有足够空间,并且最终要保证字符串以 '\0' 结尾。
3.3 拼接字符串:strcat
把一个字符串追加到另一个字符串的末尾:
char str1[30] = "Hello"; // 必须足够大,能容纳拼接后的内容
char str2[] = " World";
strcat(str1, str2); // str1 变成 "Hello World"
规则:
str1必须有足够的空间容纳两个字符串的总长度 + 1('\0')strcat从str1的'\0'位置开始,把str2的内容(包括'\0')拷贝过去
3.4 比较字符串:strcmp
两个字符串是否相等,不能用 == 来判断!必须用 strcmp 函数:
#include <stdio.h>
#include <string.h>
int main(void)
{
char str1[] = "apple";
char str2[] = "banana";
if (strcmp(str1, str2) == 0)
{
printf("两个字符串相等\n");
}
else
{
printf("两个字符串不相等\n");
}
return 0;
}
strcmp 的返回值:
| 比较结果 | 返回值 |
|---|---|
| str1 等于 str2 | 0 |
| str1 小于 str2(按字典序) | 负数(如 -1) |
| str1 大于 str2 | 正数(如 1) |
为什么不能用
==比较字符串? 数组名str1和str2代表的是两个不同的内存地址。str1 == str2是在比较两个地址是否相同,而不是比较内容。就像两间不同的房子,即使里面的家具一模一样,门牌号也是不同的。
四、进阶话题
前三节分别介绍了字符、字符串的基本概念以及标准库函数。但在实际编程中,还会遇到一些稍复杂但非常常见的场景:
- 一次性管理多个字符串(字符串数组)
- 混淆了字符和字符串的类型区别
fgets留下的那个多余的换行符该怎么处理
本节逐一讨论这些问题。
4.1 字符串数组——存储多个字符串
如果有很多个字符串需要处理——比如一个班级的学生名单、一份菜单的项目列表——可以用二维字符数组,也就是“字符串数组“。
#include <stdio.h>
int main(void)
{
// 存储 3 个水果名称,每个最多 20 个字符
char fruits[3][20] = {
"apple",
"banana",
"orange"
};
for (int i = 0; i < 3; i++)
{
printf("水果 %d:%s\n", i + 1, fruits[i]);
}
return 0;
}
理解方式:
fruits是一个二维数组:3 行,20 列- 每一行就是一个独立的字符串(字符数组)
fruits[i]可以当作一个一维字符数组来用,也就是第i个字符串
输出:
水果 1:apple
水果 2:banana
水果 3:orange
这种组织方式本质上是把多个一维字符数组叠在一起。声明时第一个方括号(行数)表示有多少个字符串,第二个方括号(列数)表示每个字符串最多能容纳多少个字符(包括 '\0')。
4.2 字符串与字符:单引号与双引号的区别
很多初学者会混淆字符和字符串——它们看起来只差一个引号,但在内存中是完全不同的存在:
char c = 'A'; // 字符:单引号,占 1 字节
char str[] = "A"; // 字符串:双引号,占 2 字节('A' + '\0')
| 写法 | 包含内容 | 大小 | |
|---|---|---|---|
字符 'A' | 单引号 | 只包含字符 A 本身 | 1 字节 |
字符串 "A" | 双引号 | 包含字符 A 和结束符 '\0' | 2 字节 |
你可以把 "A" 赋值给字符数组,但不能赋值给单个字符变量——类型不匹配:
char c = "A"; // 错误!类型不匹配
char str[] = "A"; // 正确
一个记忆的诀窍是:单引号括住的是“一个字符的值“,双引号括住的是“一个以 '\0' 结尾的字符序列“。即使这个序列里只有一个可见字符,它仍然带有隐藏的 '\0'。
4.3 去除 fgets 读入的换行符
前面提到 fgets 会保留用户输入的回车符。如果不需要它——比如你想把用户的名字和一句问候语拼接在同一行输出——可以手动把末尾的 '\n' 换成 '\0':
#include <stdio.h>
#include <string.h>
int main(void)
{
char name[20];
printf("请输入名字:");
if (fgets(name, sizeof(name), stdin) == NULL)
{
printf("读取输入失败\n");
return 1;
}
// 找到最后一个字符(换行符的位置)
size_t len = strlen(name);
if (len > 0 && name[len - 1] == '\n')
{
name[len - 1] = '\0'; // 把换行符替换成结束符
}
printf("你好,%s!\n", name);
return 0;
}
原理:fgets 会把用户按下的回车(\n)也存入数组,并在其后添加 \0。比如用户输入 Li Hua 并回车,数组内容是 "Li Hua\n\0"。我们找到 \n 的位置,把它改成 \0:
- 修改前:
"Li Hua\n\0" - 修改后:
"Li Hua\0"
多余的回车符从逻辑上“消失“了。
关键理解:这里并没有真正删除任何字节——只是提前放置了结束符
\0,让后续的字符串操作函数在处理到\n的位置之前就停下来。
五、动手练习
#include <stdio.h>
#include <string.h>
int main(void)
{
// 练习1:反转字符串
char str[] = "abcdef";
size_t len = strlen(str);
printf("原字符串:%s\n", str);
// 首尾交换
for (size_t i = 0; i < len / 2; i++)
{
char temp = str[i];
str[i] = str[len - 1 - i];
str[len - 1 - i] = temp;
}
printf("反转后: %s\n\n", str);
// 练习2:统计字符串中数字、字母和其他字符的个数
char text[] = "Hello 123 World!";
int letters = 0, digits = 0, others = 0;
for (int i = 0; text[i] != '\0'; i++)
{
if ((text[i] >= 'A' && text[i] <= 'Z') ||
(text[i] >= 'a' && text[i] <= 'z'))
{
letters++;
}
else if (text[i] >= '0' && text[i] <= '9')
{
digits++;
}
else
{
others++;
}
}
printf("字符串 \"%s\" 中:\n", text);
printf(" 字母:%d 个\n", letters);
printf(" 数字:%d 个\n", digits);
printf(" 其他:%d 个\n\n", others);
// 练习3:比较用户输入的两个字符串
char word1[20], word2[20];
printf("请输入第一个单词:");
if (scanf("%19s", word1) != 1) return 1;
printf("请输入第二个单词:");
if (scanf("%19s", word2) != 1) return 1;
int result = strcmp(word1, word2);
if (result == 0)
{
printf("两个单词相同\n");
}
else if (result < 0)
{
printf("\"%s\" 在 \"%s\" 的前面\n", word1, word2);
}
else
{
printf("\"%s\" 在 \"%s\" 的后面\n", word1, word2);
}
return 0;
}
第五章 结构体
本章要点
先想一个具体的问题:如何用程序来描述和存储一个学生的信息?
一个学生,通常包含哪些基本信息?班级、姓名、学号——这三样是最常见的。班级可能是一个编号(整数),姓名是一段文字(字符串),学号也可能是一串数字。也就是说,描述一个学生,需要把不同类型的数据组合在一起。
回顾一下我们目前学过的工具:int 和 float 只能存单个数值,char 只能存单个字符,数组要求所有元素类型相同,字符串本质上也是字符数组。有没有哪个东西能同时容纳“一个整数 + 一个字符串 + 一个整数“?没有。我们似乎只能这样写:
int class = 3; // 班级
char name[20] = "小明"; // 姓名
int student_id = 20240001; // 学号
三个变量,分别代表班级、姓名和学号。这能工作——但你能明显感觉到不对劲:这三个变量在逻辑上属于同一个学生,但在代码中却是三个互不相关的独立变量。问题出在三个层面:
- 分散:如果要处理第二个学生,就得再声明三个变量(
class2、name2、student_id2),数据多一个,变量翻一倍 - 传参混乱:如果要传给函数,就得写一长串参数,顺序稍错就出 bug
- 无约束:没有任何机制保证“
class和name是同一个人的“——全靠变量命名和程序员的自觉
这个问题的本质是:缺少一种能把多个不同类型的数据打包在一起的东西。
这个问题的本质是:缺少一种能把多个不同类型的数据打包在一起的东西。 C 语言为此提供了结构体(struct)。本章将系统学习结构体的用法。具体涵盖以下内容:
- 结构体的定义与变量声明
- 结构体成员的初始化与访问(点运算符
.) - 结构体数组——批量管理多条结构相同的记录
- 结构体与函数的配合:传参、返回值
- 字符串成员的赋值方法(
strcpy) - 结构体的嵌套——在结构体中包含另一个结构体
typedef关键字——给类型起别名,让代码更简洁
结构体是 C 语言从“基本类型“迈向“自定义类型“的关键一步。掌握了它,你才真正开始用 C 语言建模现实世界中的事物。后续章节中的指针、动态内存分配、链表等内容,都离不开结构体作为数据载体。
一、结构体基础
1.1 结构体是什么
回到上面的问题:班级、姓名、学号这三个数据,在逻辑上属于同一个学生,但在代码中却是三个分离的变量。结构体就是解决这个问题的——它允许你将多个不同类型的变量打包成一个整体,给这个整体起一个名字,然后把它当作一个统一的单位来使用。
你可以把结构体想象成一个快递包裹:里面放进不同类型的东西(一本书、一支笔、一个U盘),贴上标签,然后把这个包裹作为一个整体来传递。需要取出包裹里的某件东西时,打开包裹拿即可。
用结构体来描述“一个学生“,代码就变成了这样:
struct Student
{
int class; // 班级
char name[20]; // 姓名
int student_id; // 学号
};
struct Student 是一个自定义的类型——就像 int 和 float 一样,只不过它不是 C 语言内置的,而是你自己根据需求定义出来的。有了这个类型之后,就可以像声明普通变量一样声明“学生变量“:
struct Student stu1; // stu1 是一个"学生"变量,内部包含班级、姓名、学号
struct Student stu2; // stu2 是另一个"学生"变量
stu1 和 stu2 各自都拥有 class、name、student_id 这三个成员。它们互不影响——修改 stu1 的 class 不会改变 stu2 的 class。原本需要三个分离变量才能描述的一个学生,现在一个变量就够了;原本要靠命名约定来维持的关联,现在由类型本身来保证。
1.2 结构体的定义
结构体是一种自定义的复合数据类型。它允许你把多个不同类型的变量打包在一起,组成一个新的类型。使用结构体分两步走:先定义结构体类型(设计“模板“),再声明结构体变量(根据模板创建具体的“实例“)。
第一步:定义结构体类型。 这一步的作用是告诉编译器这个新类型“长什么样“——包含哪些成员,各自是什么类型。语法如下:
struct 结构体名
{
数据类型 成员名1;
数据类型 成员名2;
数据类型 成员名3;
...
};
其中 struct 是关键字,意思是“接下来定义的是一个结构体类型“。结构体名是你给这个新类型起的名字,命名规则和变量名一样。花括号 { } 中是成员列表,每个成员和普通变量声明一样,以分号结尾。最后的分号 ; 不能忘记——这是初学者常犯的错误,因为花括号后面跟分号不符合多数其他语境的习惯。
第二步:声明结构体变量。 定义好类型之后,就可以像使用 int 或 float 一样用它来声明变量:
struct 结构体名 变量名;
将以上两步放在一起看一个完整示例:
// 第一步:定义结构体类型(设计模板)
struct Student
{
char name[20]; // 姓名
int age; // 年龄
float score; // 成绩
}; // 注意这个分号!
// 第二步:声明结构体变量(创建实例)
struct Student stu1; // stu1 是一个 Student 类型的变量
struct Student stu2; // stu2 是另一个 Student 类型的变量
这样,stu1 和 stu2 各自都拥有了 name、age、score 这三个成员,就像两个独立的快递包裹,每个包裹里都有这三样东西。它们互不影响——修改 stu1 的 age 不会改变 stu2 的 age。
1.3 结构体变量的初始化
有了结构体变量之后,第一件事自然是给它赋初始值。结构体支持在声明的同时用花括号 { } 按成员定义的顺序列出初始值:
#include <stdio.h>
struct Student
{
char name[20];
int age;
float score;
};
int main(void)
{
// 声明并初始化结构体变量
struct Student stu1 = {"小明", 18, 92.5};
// 也可以先声明,再逐个赋值
struct Student stu2;
(void)stu2; // 本节只演示声明,明确暂不读取未初始化成员
// stu2.name = "小红"; // 错误!字符数组不能这样赋值
// 后面会学到用 strcpy 给字符串成员赋值
// 输出结构体成员
printf("姓名:%s\n", stu1.name);
printf("年龄:%d\n", stu1.age);
printf("成绩:%.1f\n", stu1.score);
return 0;
}
输出:
姓名:小明
年龄:18
成绩:92.5
关于初始化字符串成员:在声明时初始化,可以用
char name[20] = "小明"这种形式。但如果已经声明了结构体变量,再想修改字符串成员,就不能直接用=赋值了,需要用strcpy函数。本章第二节会详细讨论这个问题。
补充:指定初始化器——按成员名赋值
上面展示的是按顺序初始化——花括号里的值必须严格按照结构体定义中成员的先后顺序排列。这种方式的缺点是:你必须记住每个成员的位置;如果结构体有十几个成员而你只想初始化其中三个,中间那些你不需要的成员也得在花括号里留出位置,非常不方便。
C 语言从 C99 标准开始,提供了一种更灵活的写法:指定初始化器(Designated Initializer)。它的语法是 .成员名 = 值,直接在花括号里点名赋值:
struct Student stu1 = {.name = "小明", .age = 18, .score = 92.5};
和按顺序初始化相比,指定初始化器有四个明显的好处:
(1)不依赖顺序。 你可以按任意顺序列出成员,代码的含义完全不受影响:
// 下面两种写法完全等价
struct Student stu1 = {.name = "小明", .age = 18, .score = 92.5};
struct Student stu2 = {.score = 92.5, .name = "小明", .age = 18}; // 顺序不同,结果一样
(2)自文档化。 看到 {.score = 92.5},读者不需要翻到结构体定义去数“第三个成员是什么“——成员名已经直接写在了值旁边。这在结构体成员较多(十几个甚至更多)时尤其有价值。
(3)可以只初始化部分成员。 未指定的成员会被自动初始化为 0(数值类型)或空(字符数组)。这个特性在只需要关心少数几个字段时非常实用:
struct Student stu3 = {.name = "小红", .score = 88.0};
// age 自动初始化为 0
注意:这种“未指定成员自动归零“的特性只在使用了指定初始化器时才成立。如果完全不用花括号初始化,局部结构体变量的成员值是未定义的(垃圾值)。
(4)更安全。 当结构体定义发生变更时——比如在中间插入一个新成员——按顺序初始化的代码可能出现“值填错了位置“的隐蔽 bug;而指定初始化器通过成员名精确对应,不受成员排列顺序变化的影响。
下面是一个完整的对比示例:
#include <stdio.h>
struct Student
{
char name[20];
int age;
float score;
};
int main(void)
{
// 风格一:按顺序初始化(传统方式)
struct Student stu1 = {"小明", 18, 92.5};
// 风格二:指定初始化器(推荐方式)
struct Student stu2 = {.name = "小明", .age = 18, .score = 92.5};
// 指定初始化器:只初始化部分成员
struct Student stu3 = {.name = "小红", .score = 88.0};
// age 未指定,自动初始化为 0
printf("stu1:%s, %d 岁, %.1f 分\n", stu1.name, stu1.age, stu1.score);
printf("stu2:%s, %d 岁, %.1f 分\n", stu2.name, stu2.age, stu2.score);
printf("stu3:%s, %d 岁, %.1f 分\n", stu3.name, stu3.age, stu3.score);
return 0;
}
输出:
stu1:小明, 18 岁, 92.5 分
stu2:小明, 18 岁, 92.5 分
stu3:小红, 0 岁, 88.0 分
选择建议:对于只有两三个成员的小结构体,按顺序初始化写起来更短;但对于成员较多、或者你只关心其中几个成员的结构体,指定初始化器明显更清晰、更安全。在实际项目中,指定初始化器越来越成为主流选择——它让代码的可读性和可维护性都上了一个台阶。
1.4 访问结构体成员:点运算符 .
定义了结构体变量并初始化之后,接下来的问题就是如何读取或修改其中的某个具体成员。答案是使用点运算符 .:
结构体变量名.成员名
点运算符就像一把钥匙,用来打开结构体这个“包裹“,取出里面的某个具体东西。通过它,你可以对结构体的任意成员进行读值和赋值操作:
struct Student stu1;
stu1.age = 19; // 给 age 成员赋值
stu1.score = 88.5; // 给 score 成员赋值
printf("%d\n", stu1.age); // 读取 age 成员的值
下面看一个完整的示例,演示成员的访问和修改:
#include <stdio.h>
struct Student
{
char name[20];
int age;
float score;
};
int main(void)
{
struct Student stu1 = {"小明", 18, 92.5};
// 使用点运算符访问成员
printf("姓名:%s\n", stu1.name);
printf("年龄:%d\n", stu1.age);
printf("成绩:%.1f\n", stu1.score);
// 修改成员的值
stu1.age = 19;
stu1.score = 95.0;
printf("\n--- 修改后 ---\n");
printf("年龄:%d\n", stu1.age);
printf("成绩:%.1f\n", stu1.score);
return 0;
}
输出:
姓名:小明
年龄:18
成绩:92.5
--- 修改后 ---
年龄:19
成绩:95.0
二、结构体进阶
上一节我们学会了定义单个结构体变量,并访问它的成员。这解决了“把相关属性打包在一起“的基本问题。但在实际编程中,我们很少只处理一条记录——一个班级有几十个学生,一个书架上有几百本书,一个系统中有成千上万个用户。因此,结构体必须和数组、函数配合使用,才能发挥真正的威力。
本节讨论结构体的四种进阶用法:
- 结构体数组:批量管理多条结构相同的记录
- 结构体与函数:传参、返回值的配合
- 字符串成员的正确赋值方式
- 结构体的嵌套:结构体里面放结构体
2.1 结构体数组——批量管理复杂数据
结构体和数组可以组合使用。结构体数组就是“一组结构体“,每个数组元素都是一个完整的结构体变量。这种组合非常适合管理多条结构相同的记录——比如一个班的所有学生:
#include <stdio.h>
struct Student
{
char name[20];
int age;
float score;
};
int main(void)
{
// 结构体数组:3 个学生
struct Student students[3] = {
{"小明", 18, 92.5},
{"小红", 19, 88.0},
{"小刚", 18, 95.5}
};
// 用循环遍历所有学生
for (int i = 0; i < 3; i++)
{
printf("学生 %d:%s,年龄 %d,成绩 %.1f\n",
i + 1, students[i].name, students[i].age, students[i].score);
}
return 0;
}
输出:
学生 1:小明,年龄 18,成绩 92.5
学生 2:小红,年龄 19,成绩 88.0
学生 3:小刚,年龄 18,成绩 95.5
访问方式分析:
students→ 数组名,代表一整排储物柜students[i]→ 第 i 个抽屉,里面是一个完整的结构体变量(整个学生信息包裹)students[i].name→ 打开第 i 个抽屉,拿出里面的name成员
通过 for 循环遍历数组下标,就能批量处理每一条记录。
提示:结构体数组同样可以使用指定初始化器。当每条记录的字段较多时,用
.成员名 = 值能让每个元素的内容一目了然:struct Student students[3] = { {.name = "小明", .age = 18, .score = 92.5}, {.name = "小红", .age = 19, .score = 88.0}, {.name = "小刚", .age = 18, .score = 95.5} };
2.2 结构体与函数
结构体数组解决了“多条记录“的管理问题,但实际程序还需要对记录执行各种操作——打印、排序、筛选、计算统计值等等。这些操作通常应该封装在函数中,而不是全部写在 main 里。这自然引出了一个问题:结构体变量能否像普通变量一样在函数之间传递?
答案是肯定的。
2.2.1 把结构体传递给函数
结构体变量可以作为函数参数传递,用法与 int、float 等基本类型完全一致:
#include <stdio.h>
struct Student
{
char name[20];
int age;
float score;
};
// 函数:打印学生信息(接收结构体参数)
void print_student(struct Student stu)
{
printf("姓名:%s,年龄:%d,成绩:%.1f\n", stu.name, stu.age, stu.score);
}
int main(void)
{
struct Student stu1 = {"小明", 18, 92.5};
struct Student stu2 = {"小红", 19, 88.0};
print_student(stu1); // 传递整个结构体
print_student(stu2);
return 0;
}
输出:
姓名:小明,年龄:18,成绩:92.5
姓名:小红,年龄:19,成绩:88.0
注意:上面的代码中,结构体是以传值的方式传递的。这意味着函数内部拿到的是一个副本——如果函数修改了
stu的成员,不会影响调用处的原始变量。这和普通变量的传值是一样的道理。当结构体较大时,复制整个结构体会消耗时间和内存;这个问题将在指针章节中得到解决。
2.2.2 用结构体作返回值
函数不仅可以接收结构体参数,也可以返回一个结构体。这在需要“创建并返回一条新记录“的场景中非常自然:
#include <stdio.h>
struct Student
{
char name[20];
int age;
float score;
};
void print_student(struct Student stu)
{
printf("姓名:%s,年龄:%d,成绩:%.1f\n", stu.name, stu.age, stu.score);
}
// 函数:创建一个学生并返回
struct Student create_student(void)
{
struct Student new_stu = {"新同学", 18, 80.0};
return new_stu; // 返回结构体
}
int main(void)
{
struct Student stu = create_student();
print_student(stu); // 姓名:新同学,年龄:18,成绩:80.0
return 0;
}
2.3 给字符串成员赋值:strcpy 的使用
在结构体与函数配合使用的过程中,有一个细节此前被刻意跳过了:字符串成员的赋值问题。前面提到,结构体声明时可以直接用花括号初始化字符串成员。但声明之后,如果要修改字符串成员,就不能直接用 = 了:
struct Student stu1;
stu1.name = "小明"; // 错误!字符数组不能被赋值
这是因为 C 语言中的字符数组是一个固定内存区域,数组对象不是可修改的左值(lvalue)——你不能把一个字符串常量直接“覆盖“给它。正确的做法是使用 strcpy 函数(需要 #include <string.h>):
#include <stdio.h>
#include <string.h>
struct Student
{
char name[20];
int age;
float score;
};
int main(void)
{
struct Student stu1;
// 用 strcpy 给字符串成员赋值
strcpy(stu1.name, "小明");
stu1.age = 18;
stu1.score = 92.5;
printf("姓名:%s,年龄:%d,成绩:%.1f\n", stu1.name, stu1.age, stu1.score);
// 修改名字
strcpy(stu1.name, "大明");
printf("修改后姓名:%s\n", stu1.name);
return 0;
}
strcpy 函数说明:
strcpy(目标字符数组, 源字符串);
- 把“源字符串“的内容(包括末尾的
'\0')复制到“目标字符数组“中 - 需要
#include <string.h> - 和直接操作数组一样,要确保目标数组足够大,否则会发生越界
2.4 结构体的嵌套——结构体里放结构体
到目前为止,结构体的成员都是基本类型(int、float、char[] 等)。但现实世界中的数据往往是分层的:一个学生不仅有姓名和成绩,还有一个出生日期,而日期本身又由年、月、日组成;一本书不仅有书名,还有一个出版社信息,而出版社又有名称和地址。这种层次结构自然要求结构体的嵌套——一个结构体的成员可以是另一个结构体。
下面以学生和出生日期为例来演示:
#include <stdio.h>
// 日期结构体
struct Date
{
int year;
int month;
int day;
};
// 学生结构体(嵌套了日期结构体)
struct Student
{
char name[20];
int age;
float score;
struct Date birthday; // 嵌套结构体成员
};
int main(void)
{
struct Student stu1 = {"小明", 18, 92.5, {2008, 3, 15}};
printf("姓名:%s\n", stu1.name);
printf("出生日期:%d 年 %d 月 %d 日\n",
stu1.birthday.year, // 多级点运算符
stu1.birthday.month,
stu1.birthday.day);
return 0;
}
输出:
姓名:小明
出生日期:2008 年 3 月 15 日
多级访问分析:stu1.birthday 拿到的是 birthday 这个成员,它本身是一个 struct Date 类型的变量;再通过 .year 从这个日期变量中拿到 year 成员。这就像打开一个大包裹,里面还有一个小包裹,需要再打开一次。嵌套的层数没有限制,但层次过深会使代码的可读性下降——通常在两层以内是合理的。
嵌套结构体的指定初始化器。对于嵌套结构体,指定初始化器的优势更加突出——它不需要记住内层成员的排列顺序,而且一眼就能看出每个值属于哪个字段:
// 按顺序初始化(原始写法)——需要记住 {年, 月, 日} 的顺序
struct Student stu1 = {"小明", 18, 92.5, {2008, 3, 15}};
// 指定初始化器——每个值都标注了所属成员,层次关系一目了然
struct Student stu2 = {
.name = "小明",
.age = 18,
.score = 92.5,
.birthday = {.year = 2008, .month = 3, .day = 15}
};
// 也可以只初始化部分嵌套成员,未指定的自动为 0
struct Student stu3 = {
.name = "小红",
.birthday = {.year = 2009, .month = 6}
// birthday.day 自动为 0,age 和 score 也自动为 0
};
可以看到,指定初始化器的 . 层层嵌套,和访问成员时的 stu1.birthday.year 形成了完美的对称——初始化的写法和读取的写法在结构上是一致的。这种对称感让代码的意图非常清晰,阅读时几乎不需要思考。
三、typedef —— 给类型起别名
前面的两节,我们一直在和各种类型打交道——int、float、char、struct Student……每一次声明变量,都要把类型的完整名字写出来。对于基本类型来说,这倒不算什么负担;但对于结构体,每次声明变量都要在前面加一个 struct 关键字,写久了难免觉得啰嗦。写 struct Student stu1; 总不如写 Student stu1; 来得直接。
这就引出了本节的主题——typedef。它的作用是给已有的类型起一个新名字(别名)。有了它,你可以把冗长的类型名简化,也可以给抽象的类型赋予一个更具可读性的名称。
关键理解:
typedef不创建任何新类型——它只是给现有的类型贴上另一张标签。就像一个人既有本名又有绰号,叫哪个名字指的都是同一个人。
3.1 基本类型别名
先从一个最简单的例子入手。假如你正在写一个和年龄相关的程序,用 int 来存储年龄。但代码中到处都是 int age,光看类型名并不能立刻传达“这是一个年龄值“的语义。你可以用 typedef 给它取一个更有意义的名字:
typedef int Age;
Age student_age = 18; // 等价于 int student_age = 18;
Age teacher_age = 35; // 等价于 int teacher_age = 35;
经过 typedef int Age; 之后,Age 就成了 int 的别名。此后你用 Age 声明变量,和用 int 声明变量完全等价——编译器会把它们当作同一个类型来处理。
如何阅读 typedef 声明? typedef 的语法乍看有些别扭,但它有一个简单的阅读法则:
把
typedef去掉,剩下的部分就是一个普通的变量声明;而这个声明的变量名,就是新类型的别名。
以 typedef int Age; 为例:
- 去掉
typedef,得到int Age;→ 一个普通变量声明,表示“Age是一个int类型的变量“ - 加上
typedef→ 含义变为“Age是int类型的别名“,不再是变量
换句话说,typedef 把本该是变量名的位置,变成了类型名。
再看几个例子巩固这个阅读法则:
typedef unsigned long size_t; // unsigned long 的别名
typedef char String[100]; // 长度为 100 的字符数组的别名
typedef int* IntPtr; // int* 的别名
按照阅读法则:去掉 typedef,char String[100] 声明了一个长度为 100 的字符数组变量 String;加上 typedef,String 就成了“长度为 100 的字符数组“这一类型的别名。之后写 String name; 就等价于写 char name[100];。同样,IntPtr 就是 int* 的别名,之后写 IntPtr p; 等价于 int* p;。
3.2 typedef 与结构体——让代码告别 struct 前缀
typedef 最频繁的应用场景,就是和结构体搭配使用。回顾一下前面写的所有代码,每次声明结构体变量都要写完整的 struct Student:
struct Student stu1;
struct Student stu2;
struct Student create_student(void);
void print_student(struct Student stu);
这几个字反复出现,写起来相当繁琐。如果你接触过 C++ 或 Java,你会发现那些语言中声明结构体或类完全不需要 struct 前缀——直接写类型名就行了。C 语言虽然做不到这一点,但借助 typedef 可以达到同样的效果。
使用 typedef 给结构体起别名的做法有两种风格。
方式一:分开写。 先定义结构体类型,再用 typedef 给它起别名:
struct Student
{
char name[20];
int age;
float score;
};
typedef struct Student Student; // 给 struct Student 起别名为 Student
此后,你可以直接用 Student 来代替 struct Student:
Student stu1 = {.name = "小明", .age = 18, .score = 92.5}; // 等价于 struct Student stu1 = ...
Student students[3] = {{...}, {...}, {...}}; // 等价于 struct Student students[3] = ...
void print_student(Student stu) { ... } // 等价于 struct Student stu
这种写法的好处是思路清晰:定义类型归定义类型,起别名归起别名。而且 struct Student 这个名字依然可用——两种写法都可以,你可以在新旧代码之间平滑过渡。
方式二:合在一起写(最常见)。 C 语言允许在定义结构体的同时使用 typedef,一步到位:
typedef struct
{
char name[20];
int age;
float score;
} Student;
注意这里花括号前面没有结构体名(也可以有,但写了也用不大到),花括号后面紧跟的是别名。这一句同时做了三件事:定义了一个匿名的结构体类型,给它起别名为 Student,之后就能直接用 Student 声明变量。这是实际项目中最常见的写法,几乎每一个 C 程序员都会这样写:
Student stu1 = {.name = "小明", .age = 18, .score = 92.5};
Student stu2;
void print_student(Student stu)
{
printf("姓名:%s,年龄:%d,成绩:%.1f\n", stu.name, stu.age, stu.score);
}
没有了 struct 前缀的干扰,代码读起来清爽了许多。
两种方式的对比与取舍:
| 方式 | 写法 | 优点 | 缺点 |
|---|---|---|---|
| 分开写 | struct Student { ... };typedef struct Student Student; | 有显式的结构体标签名,可以在结构体内部引用自身(如链表的自引用) | 需要两行,略啰嗦 |
| 合一起 | typedef struct { ... } Student; | 简洁,一步到位 | 无标签名,不能自引用 |
在后面的指针和链表章节中,你会遇到需要在结构体内部包含一个指向自身类型的指针的情况。这时必须使用方式一(保留结构体标签名),因为 typedef 创建的别名在结构体定义完成之前还不可用:
typedef struct Node
{
int data;
struct Node* next; // 这里只能用 struct Node,别名 Node 还没生效
} Node;
现阶段你暂时不需要关心自引用的问题——在学习链表之前,放心使用方式二的合写形式即可,它会让你的代码干净很多。
四、动手练习
#include <stdio.h>
#include <string.h>
// 用 typedef 定义图书结构体(合写方式)
typedef struct
{
char title[50];
char author[30];
int pages;
float price;
} Book;
int main(void)
{
// 练习1:结构体数组存储图书信息(指定初始化器写法,字段含义一目了然)
Book books[3] = {
{.title = "C语言入门", .author = "张三", .pages = 320, .price = 45.5},
{.title = "数据结构", .author = "李四", .pages = 450, .price = 59.0},
{.title = "算法导论", .author = "王五", .pages = 600, .price = 79.5}
};
printf("===== 图书信息表 =====\n");
for (int i = 0; i < 3; i++)
{
printf("书名:%s\n", books[i].title);
printf("作者:%s\n", books[i].author);
printf("页数:%d\n", books[i].pages);
printf("价格:%.1f 元\n", books[i].price);
printf("----------------------\n");
}
// 练习2:找出最贵的书
int max_index = 0;
for (int i = 1; i < 3; i++)
{
if (books[i].price > books[max_index].price)
{
max_index = i;
}
}
printf("最贵的书是:《%s》,价格 %.1f 元\n\n",
books[max_index].title, books[max_index].price);
// 练习3:嵌套结构体——学生与地址(使用 typedef)
typedef struct
{
char city[20];
char street[50];
} Address;
typedef struct
{
char name[20];
int age;
Address addr; // 注意:用别名声明,不需要 struct 前缀
} Person;
// 练习3:嵌套结构体的指定初始化器——层次关系非常清晰
Person person1 = {
.name = "小明",
.age = 20,
.addr = {.city = "上海", .street = "南京路100号"}
};
printf("姓名:%s\n", person1.name);
printf("年龄:%d\n", person1.age);
printf("城市:%s\n", person1.addr.city);
printf("街道:%s\n", person1.addr.street);
return 0;
}
第六章 C语言的内存模型
提起 C 语言,很多人的第一反应就是“指针“——它既是 C 语言最强大的武器,也是让无数初学者头疼的难题。你可能已经听说过指针的大名,甚至隐隐有些畏惧。但请放心:指针并没有那么神秘,它本质上操作的不过是内存地址而已。要真正理解指针,你首先需要理解一个更基础的问题——变量到底是“放“在哪里的。
回顾此前各章的学习,我们一直在和变量打交道:声明一个 int x,给变量赋值,把变量传给函数……当时我们反复提到“存储“这个词——变量是用来存储数据的容器。但你有没有想过这些更底层的问题:
- 变量究竟存储在计算机的什么地方?
- 为什么有的变量出了花括号就消失,有的却能一直存在?
scanf("%d", &x)里的&符号到底在做什么?- 既然所有变量都存在内存里,那它们之间不会互相打架吗?
这些问题的答案,都指向同一个核心主题——C 语言的内存模型。
理解内存模型,是你从“会写 C 代码“走向“真正理解 C 语言“的关键一步。本章将依次讲解:
- 内存地址直觉:把地址理解为一排编号格子——这是理解一切的基础
- 物理内存:程序运行的物理空间——以及操作系统面临的难题
- 虚拟内存:密室逃脱店的故事——操作系统如何用“逻辑编号映射“的思路解决冲突
- 典型进程内存布局:栈、动态分配区、静态数据和代码映射——理解常见实现,同时区分语言标准与平台细节
- 局部变量与全局变量:比较作用域、链接和存储期,而不是死记某个物理地址
static关键字:如何改变对象的存储期或标识符的链接属性
当你学完这一章,指针就不再是凭空出现的魔法——它只不过是一个存着地址的变量,而你已经知道了地址通向哪里。
一、内存概念
1. 内存地址——送外卖得知道门牌号
从第一章开始,你就在用 & 这个符号了。scanf("%d", &x) 里的 &x——它的意思是“取 x 的地址“。你可能记住这条规则用到了现在,但心里始终有个疑问:地址,到底是什么?
打个比方:你给客人送外卖,得知道客人住在哪——几号楼、几单元、几零几。这个门牌号,就是客人家的地址。没有地址,外卖送不到。
CPU 访问对象时也需要定位信息。在 C 语言抽象机中,指针可以表示对象或函数的位置;在常见机器上,它通常对应进程虚拟地址空间中的地址表示。门牌号是有用的直觉,但不要把 C 指针简单等同于未经转换的物理内存编号。
2. 物理内存——程序赖以运行的硬件
上一节说的那些位置,物理上就存在于电脑主板上的内存条里。
什么是物理内存。 你买电脑或手机时,配置单上一定见过这样的写法:笔记本 “16GB + 512GB”,手机 “12GB + 256GB”。前面的 16GB、12GB 就是物理内存(Physical Memory),后面的 512GB、256GB 是硬盘。
这里所说的物理内存通常指系统 RAM(Random Access Memory,随机存取存储器)。运行中的代码和数据会通过缓存、内存控制器和虚拟内存映射等机制被处理器访问。磁盘或闪存主要负责持久化存储,程序通常通过操作系统 I/O 或内存映射来访问其中的数据。
物理内存有四个关键特点:
- 延迟低、带宽高:RAM 通常比持久化存储更适合作为程序运行时工作区,但具体差距随硬件而变
- 容量相对有限:个人设备的 RAM 通常少于其持久化存储;具体容量没有固定范围
- 掉电就没了:关机之后,内存里的数据全部清空——因此代码要“保存到文件“,文件存在硬盘上,断电不丢
- 由系统统一管理:多个进程共享机器的内存资源,但通常通过各自的虚拟地址空间进行隔离
💡 内存(RAM) vs 硬盘(Storage)
内存(RAM) 硬盘 / 闪存 作用 程序运行时的临时工作空间 长期保存文件、照片、软件 速度 极快(CPU 可直接读写) 较慢(需要 I/O 操作) 断电后 数据全部消失 数据依然保留 容量 通常相对较小 通常相对较大
物理内存带来的管理难题。 这么多程序共用一条物理内存——如果不加管理,程序 A 往某个格子写了数据,程序 B 不知道,也往同一个格子写,程序 A 的数据就被踩掉了。一个程序崩溃时乱写的垃圾数据可能搞坏另一个程序的内存,恶意程序甚至可以翻看公共格子偷走你的密码。
操作系统必须解决这个问题:怎么让多个程序安全、高效地共用同一块物理内存?
以密室逃脱店为例来理解
假如你开了一家密室逃脱店。你租了一层楼——就这么大,多一平米都没有。
你的密室主题很火,每天来好几批客人。最简单的想法:整层打通,做成一个大密室,所有客人都进这一个房间。
你很快发现了问题:
- 一批客人翻箱倒柜——打开柜子拿线索 A,顺手把线索 B 塞进另一个抽屉。这批人走了,下一批进来,看到的不是初始状态,是上一批留下的烂摊子。 道具位置全乱了,抽屉里多了一堆不该出现的东西
- 两批客人同时挤在里面——你翻你的柜子,我翻我的抽屉。A 队刚找到的关键道具被 B 队当成垃圾扔了。一个队把柜子弄坏了,另一个队要用的线索恰好锁在那个柜子里
同一个空间,所有人直接共享 = 互相干扰、互相破坏。一次只接待一批?后面的客人全堵门口排队。
这跟操作系统面对的问题,结构上一模一样:
| 密室逃脱店 | 计算机 |
|---|---|
| 一层楼的物理空间,所有客人共用 | 物理内存——所有程序共用一条内存条 |
| 一批客人 | 一个程序 |
| 客人翻道具、解谜、改布局 | 程序读写内存格子里的数据 |
| 大房间里互相踩踏 | 程序直接操作物理内存 → 踩数据、崩溃传播、隐私泄露 |
不加管理,直接让程序操作物理内存,就像让所有客人挤在一间密室里——谁也玩不好。
操作系统的答案和密室老板一样:给每组客人一套独立的编号体系。 让每个程序只看自己的道具编号,背后由一张总平面图负责把编号翻译到实际的物理位置——这就是虚拟内存。
3. 虚拟内存——每个程序都有自己的独立空间
什么是虚拟内存
虚拟内存(Virtual Memory)是现代操作系统常见的一种内存管理机制。它让每个进程面对一套自己的虚拟地址空间。这个空间在编号上覆盖一个很大的范围,但并非其中每个地址都有效:零地址附近通常故意不映射,中间也可能存在空洞,代码、共享库和动态内存的位置还可能受 ASLR 影响。
对普通程序来说,它主要操作虚拟地址,不需要知道数据当前对应哪一页物理内存。有效的虚拟地址区间可以在程序看来连续,即使其背后的物理页并不连续;没有建立映射的虚拟地址则不能访问。
虚拟内存如何工作
程序读写数据时,给出的是一个虚拟地址。但数据实际存放在物理内存条上的某个物理地址。虚拟地址到物理地址的翻译,由 CPU 内部的一个专用硬件自动完成——MMU(Memory Management Unit,内存管理单元)。
MMU 翻译的依据是操作系统维护的一张页表(Page Table)。页表记录了“程序 A 的虚拟地址 X → 物理地址 Y“这样的映射关系。翻译过程如下:
- 程序给出虚拟地址:“我要读写 100 号”
- MMU 查页表,把 100 翻译成物理地址 38492
- CPU 去物理内存条上真正读写 38492 号位置
- 程序从头到尾不知道 38492 的存在
几个关键术语:
- 虚拟地址(Virtual Address):程序看到的地址编号
- 物理地址(Physical Address):内存条上的真实位置编号
- MMU:CPU 内部的硬件翻译器,自动完成地址转换
- 页表(Page Table):操作系统维护的映射表,记录每个虚拟地址对应的物理地址
虚拟内存的设计目的
虚拟内存要达成两个核心目标:
- 隔离:不同进程默认拥有不同的地址映射,同一个虚拟地址在两个进程中通常对应不同的物理位置。操作系统也可以显式建立共享内存映射,所以“隔离”是默认机制,不是绝对禁止共享。
- 简化:程序使用统一的虚拟地址模型,不必自行协调物理内存位置。具体布局由操作系统、加载器、ABI 和编译工具链共同决定,并不保证“代码固定在低地址、栈固定在高地址”。
💡 交换(Swap)
支持交换或分页文件的操作系统,可以把部分暂时不用的内存页写入持久化存储,腾出 RAM 给活跃页面。虚拟地址通常保持不变,但再次访问可能触发缺页并产生明显延迟。具体是否启用、如何实现由操作系统配置决定。
虚拟地址空间的大小
32 位指针若使用完整的 32 位地址表示,理论上可区分 2³² 个字节地址,但单个进程真正可用的范围还会受操作系统划分和 ABI 限制。64 位架构的理论编号空间更大,实际硬件和操作系统通常只实现其中一部分。可移植 C 程序不应依赖某个固定的虚拟地址空间大小。
以密室逃脱店为例来理解
还记得密室逃脱店老板的困境吗?他的解决方案——给每组客人一套独立的道具编号体系——就是虚拟内存的核心思路。
老板给每个房间放进了完全相同的一套密室主题。同样外观的道具、同样的线索摆放方式、同样的陈设布局。关键设计是:每个房间里的道具都有本地编号——“1号抽屉”“2号柜子”“3号暗门”……每个房间的编号体系完全一致,但所有这些编号都是逻辑上的,只在这间房里有效。
现在分别从两个视角来看:
玩家视角——程序的视角:
- 每批客人走进房间,看到标准初始状态——道具在原位,线索没被动过
- 客人按线索操作时只看本地编号:“打开 3 号暗门”——他不知道也不需要知道,这条指令最终落在大楼的哪面墙、哪块地砖上
- 隔壁有没有客人弄坏道具、有没有客人提前走了——完全无关
- 通关离开后,老板重置房间状态,给下一批客人使用(内存回收)
老板视角——操作系统的视角:
- 老板手里有一张楼层总平面图(页表)。A 房的“3 号暗门“实际对应大楼北侧第 5 块区域,B 房的“3 号暗门“实际对应大楼南侧第 11 块区域。同样编号,物理位置完全不同
- 客人说“开 3 号暗门“ → 老板查平面图找到真实位置 → 告诉客人去哪操作(MMU 翻译)
- 客人来了 → 给一套干净编号体系。客人走了 → 回收编号,重置对应区域(内存回收)
- 某间房客人暴力破坏(程序崩溃)→ 没事,不影响其他房间
- 道具太多摆不下 → 暂时不用的搬进地下仓库(swap)
映射回计算机:
| 密室逃脱店 | 计算机 |
|---|---|
| 房间内的本地编号(1号抽屉、2号柜子……) | 虚拟地址——程序看到的编号 |
| 大楼实际的墙、地砖位置 | 物理地址——内存条上的真实位置 |
| 老板手里的楼层总平面图 | 页表 |
| 老板查图,把房间编号翻译成实际位置 | MMU 翻译虚拟地址 → 物理地址 |
| 各组客人互不干扰 | 隔离——不同进程的地址空间相互独立 |
| 客人通关后老板重置房间 | 程序结束,操作系统回收内存 |
| 搬道具进地下仓库 | Swap——交换到硬盘 |
⚠️ 比喻的局限——请务必注意
密室逃脱店用房间来隔离客人,每个房间的物理面积必然小于整层楼。但计算机中的虚拟地址范围可以大于实际安装的 RAM,而且大量地址可以暂时不映射到任何物理页。
这不是比喻的失误,而是现实世界里任何物理比喻都绕不开的限制——你不可能隔出一间比整层楼还大的房间。虚拟内存的本质不是物理切割,而是逻辑映射:你程序里看到的地址永远是逻辑编号,MMU + 页表在背后默默把它翻译成物理内存上的真实位置。记住这一点就好——比喻只是帮你建立直觉,不代表物理世界的约束同样适用于计算机。
串起来:
int score = 95;——它到底在哪?
- 对象大小:
score占sizeof(int)个字节;这个值由实现决定,不固定假设为 4- 程序视角:
&score取得指向该对象的指针,常见实现把它表示为虚拟地址- 平台映射:操作系统与硬件负责把有效虚拟页映射到物理存储,程序不应依赖具体物理位置
一句话:让每个程序以为自己独占整台电脑的内存——这就是虚拟内存的设计初衷。
在具有进程和虚拟内存的常见宿主系统上,每个进程通常拥有自己的虚拟地址空间。裸机和部分嵌入式环境可能没有这种机制,因此它不是 C 语言本身提供的保证。
在常见桌面和服务器系统上,编译器、链接器、加载器与运行库会按照平台 ABI 组织代码和数据。下面介绍的是典型实现中的进程内存布局,它很有助于理解程序运行,但不是 C 语言标准强制要求的物理分区。
4. C 程序的典型内存布局——四类常见区域
为什么要分区域?因为你程序里的数据,性格各不相同:
- 临时的数据:函数里算个中间结果,算完就不需要了。这类数据应该自动清理,不能一直占着空间
- 持久的数据:记录”程序总共被打开了几次”的计数器,从启动到退出都不能丢
- 大小未知的数据:用户输入了一行文字,多长事先不知道。编译时没法预留——你不可能猜”用户大概输入 100 个字”然后留 100 个格子。必须运行时按需分配
- 代码本身:编译好的机器指令,运行时不应该被意外修改——否则程序逻辑就乱了
如果把这些性格迥异的数据全混在一起管理,要么浪费空间,要么互相干扰。不同的需求,推动了不同的分区。
说明:下面的四区模型是常见操作系统(Windows、Linux、macOS 等)上 C 程序的典型内存布局。它不是 C 语言标准强制规定的,但理解它对于学习指针、动态内存和后续章节至关重要——就像你不需要知道 CPU 内部的晶体管怎么排布,但需要知道 CPU 有运算器和控制器一样。
4.1 全景图:四个区域的位置关系
在动手写代码之前,先在脑子里建立一张”地图”。一个典型 C 程序的虚拟地址空间,从高地址到低地址,四类区域依次排列:
- 高地址段 → 栈区(Stack):向低地址方向增长。存放局部变量、函数形参、返回地址。容量有限,自动管理,速度极快。
- 中间段 → 未映射区域:栈和堆之间的”安全距离”,防止两者互相撞上。
- 中间段 → 堆区(Heap):向高地址方向增长。存放
malloc申请的动态内存。空间很大,手动管理,速度较慢。 - 低地址段 → 静态存储区(.data / .bss):存放全局变量和
static变量,贯穿整个程序生命周期。 - 低地址段 → 代码区(Text):存放编译后的机器指令,只读。
几个关键观察:
- 栈从高地址向低地址增长,就像一叠从上往下放的盘子
- 堆从低地址向高地址增长,就像仓库里从门口开始往里堆货
- 栈和堆相向而行,中间的未映射区域是”缓冲地带”——如果栈或堆增长得太疯狂,它们会撞到一起,但操作系统通常会在越界时直接终止程序,而不是让它们真的互相覆盖
- 代码区和静态存储区在低地址段,布局在程序启动时就基本确定了
下面这张图用另一种视觉风格展示了同样的布局:
有了这张地图,接下来逐个认识四个区域。
4.2 栈区(Stack)—— 自动管理的”叠盘子”
栈区是你写 C 代码时打交道最多的区域。你到目前为止声明的几乎所有变量——函数里的 int x、float y、char name[20]——都住在栈上。
栈上放了什么?
具体来说,以下这些东西通常都在栈区:
- 局部变量:你在函数体(
{ })内部声明的变量,比如int a = 10; - 函数的形参:函数参数列表里的变量,比如
void func(int x, float y)中的x和y - 返回地址:函数执行完后,CPU 需要知道”回哪儿去”——这个返回地址也保存在栈上
- 寄存器的备份值:函数可能会用到一些 CPU 寄存器,调用之前的值需要暂存起来
一言蔽之:每次函数调用,都会在栈上开辟一块空间,把这次调用需要的所有”家当”——参数、局部变量、返回地址——打包放进去。这块空间叫做栈帧(Stack Frame)。
栈的工作方式:盘子的故事
栈的管理方式,和你在厨房洗碗时叠盘子一模一样:
- 洗完一个盘子,叠在最上面
- 要用盘子时,从最上面取
- 最后放上去的,最先拿下来——这正是”后进先出”(LIFO,Last In First Out)
函数调用完美契合这个模式。来看一个具体例子——三层函数嵌套调用:
#include <stdio.h>
void func_b(int num)
{
int result = num * 2; // func_b 的局部变量
printf("func_b: %d\n", result);
} // func_b 返回 → 它的栈帧被"拿掉"
void func_a(int x, int y)
{
int sum = x + y; // func_a 的局部变量(形参 x, y 也在栈上)
func_b(sum); // 调用 func_b → func_b 的栈帧叠上去
printf("func_a 完成\n");
} // func_a 返回 → 它的栈帧被"拿掉"
int main(void)
{
int a = 10; // main 的局部变量
func_a(a, 20); // 调用 func_a → func_a 的栈帧叠上去
return 0;
} // main 返回 → 它的栈帧被"拿掉"
程序运行时,栈的变化过程如下:
- ① main 开始执行:在栈上创建
main的栈帧,里面有局部变量a = 10 - ② main 调用
func_a(10, 20):在main的栈帧上面叠放func_a的栈帧,里面有形参x = 10, y = 20和局部变量sum = 30 - ③ func_a 调用
func_b(30):在func_a的栈帧上面再叠放func_b的栈帧,里面有形参num = 30和局部变量result = 60 - ④ func_b 返回:
func_b的栈帧从栈顶整个移除,控制权交还给func_a,func_a继续执行 - ⑤ func_a 返回:
func_a的栈帧也被移除,控制权交还给main
每次函数返回,它在栈上的”家当”就自动清空,不需要你写任何代码来释放——这就是”自动管理”的含义。
栈的特点
(1)容量有限。 栈的大小由操作系统和编译器预先确定,通常在 1 MB 到 8 MB 之间(具体数值因平台而异)。这意味着:
- 声明一个超大的局部数组会让栈直接爆掉(栈溢出,Stack Overflow)
- 递归调用如果深不见底,每一层都叠一个新栈帧,叠到一定数量也会溢出
// 危险示例:局部数组太大,可能导致栈溢出
void bad_function(void)
{
int huge[1000000]; // 100 万个 int ≈ 4 MB,很可能超过栈容量
// ...
}
// 危险示例:无限递归
void infinite_recursion(void)
{
infinite_recursion(); // 永远不返回,栈帧不断叠上去,直到溢出
}
栈溢出通常导致程序直接崩溃——这是操作系统在保护你的系统不被一个跑飞的程序搞垮。
(2)速度极快。 栈的操作只需要一条 CPU 指令:移动栈指针(Stack Pointer,一个记录栈顶位置的寄存器)。叠盘子 = 栈指针往下移一格;取盘子 = 栈指针往上移一格。没有搜索、没有查找、没有记账——纯粹的指针加减。这是整个计算机系统中最快的内存管理方式。
(3)自动管理。 你不必手动释放栈上的变量——函数返回时,编译器自动生成的代码会把栈指针移回调用前的位置,”销毁”栈帧里的一切。你只管声明变量,编译器包办生老病死。
4.3 堆区(Heap)—— 按需申请的”储物仓库”
栈很好——又快又自动化——但它有几个根本性的局限。堆区正是为了弥补这些局限而存在的。
为什么光有栈不够?
栈搞不定的三种典型场景:
场景一:编译时不知道要多少空间。 栈上的空间在编译时就确定了——编译器看到 int arr[100] 就预留 100 个 int 的大小。但如果用户运行程序时才告诉你”我要处理 5000 条数据”,编译器事前根本不知道,怎么预留?
int n;
scanf("%d", &n);
int arr[n]; // 可变长数组(VLA)在某些 C 标准下可用,但不推荐
// 而且 VLA 通常也在栈上分配,n 太大就会栈溢出
场景二:数据需要比函数调用活得更久。 栈上的数据,函数一返回就没了。但如果一个函数负责”创建一份数据”,另一个函数稍后”处理这份数据”——这份数据就不能跟着创建它的函数一起死掉。
// 错误示范:返回局部变量的地址(后面学指针时会深入讲)
// 函数返回后,栈帧被销毁,局部变量不复存在
场景三:数据量太大,栈根本装不下。 即使编译时知道大小,如果要处理 100 万条记录(几 MB 甚至几十 MB),栈的容量也远远不够。
这三种场景的共同答案是:堆区。堆区允许你在程序运行时,按需向操作系统申请任意大小的内存,用完之后手动归还。
堆的工作方式:储物仓库的故事
把堆想象成一个大型储物仓库,操作系统是仓库管理员。你需要存放东西时:
- 申请:告诉管理员”我需要一个能放 N 件货物的货架”——这是
malloc(N)做的事 - 拿到货架号:管理员在仓库里找到合适的空位,给你一个货架编号(指针),之后你凭这个编号存取货物
- 存取:拿着货架号去对应位置放东西、取东西
- 退租:用完了,告诉管理员”这个货架我不需要了”——这是
free()做的事
关键区别在于:栈上的盘子,你只管用,服务员自动收;堆上的仓库,你自己申请,也必须在用完后自己退租。 忘了退租?仓库空间被占着不还,这就叫内存泄漏。
来看一段代码,直观感受堆的用法:
#include <stdio.h>
#include <stdlib.h> // malloc 和 free 需要这个头文件
int main(void)
{
int n;
printf("请输入学生人数:");
scanf("%d", &n);
// 在堆上申请 n 个 int 的空间(运行时才知道 n 是多少)
int* scores = (int*)malloc(n * sizeof(int));
// 安全检查:malloc 可能失败(比如内存不够了)
if (scores == NULL)
{
printf("内存分配失败!\n");
return 1;
}
// 像普通数组一样使用堆上的空间
for (int i = 0; i < n; i++)
{
scores[i] = (i + 1) * 10; // 填入数据
printf("学生 %d 的成绩:%d\n", i + 1, scores[i]);
}
// ★ 关键一步:用完之后归还空间
free(scores);
return 0;
}
提示:
malloc和free的详细用法将在第十章中展开讨论。这里你只需要建立两个直觉:(1) 堆是”需要时申请,用完归还”的内存区域;(2)scores这个指针变量本身在栈上,但它指向的数据在堆上——指针只是一张写着货架号的小纸条。
堆的特点
(1)容量大。 堆可以利用几乎整个虚拟地址空间——动辄几 GB,远超栈的几 MB。处理大量数据时,堆是不二之选。当然,实际能分配的量受物理内存 + 交换空间总和限制,不是无限的。
(2)速度较慢。 和栈的”移动指针就搞定”不同,malloc 需要在仓库里找到一个合适的空闲货架,登记为”已占用”,返回货架号;free 则需要把货架标记回”空闲”,还可能合并相邻的空闲区域。这些”查找 + 记账”操作让堆分配比栈分配慢上一到两个数量级。一般情况下不必过度担心这个差距,但在性能敏感的代码中值得留意。
(3)手动管理。 这是堆区别于栈的最关键特征——程序员掌握生杀大权。申请了忘记释放 → 内存泄漏(memory leak),程序占用的内存越来越多;释放了还继续用 → 悬空指针(dangling pointer),行为不可预测,查 bug 查到怀疑人生。这也是为什么很多现代化语言(Java、Python、Go 等)引入了垃圾回收(Garbage Collection,GC)来自动管理堆内存——但在 C 语言中,你既是国王,也是守夜人。
(4)灵活性最高。 申请大小在运行时决定,释放时机由你掌控,数据可以跨函数存活。堆是 C 语言强大表达能力的来源之一——链表、树、图等所有”编译时不知道有多大”的数据结构,都建立在堆上。
4.4 静态存储区 —— 贯穿始终的”铭牌”
栈是临时的便签纸,堆是按需租用的仓库——那有没有一块空间,从程序启动到结束,始终稳稳当当地放在那里?有,这就是静态存储区。
谁住在静态存储区?
- 全局变量:在所有函数之外定义的变量
static局部变量:在函数内部用static修饰的变量(作用域在函数内,但存储位置在这里)- 字符串字面量:代码里直接写的
”Hello”、”请输入”等,通常放在只读数据区,属于静态存储期
静态存储区的内部划分:.data 与 .bss
静态存储区内部其实分两个”片区”,区别在于有没有显式给出初始值:
- .data 段:存放已初始化的全局变量和
static变量,比如int count = 100;。初始值(100)存储在可执行文件中,程序启动时直接加载到内存。 - .bss 段:存放未初始化(或初始化为 0)的全局变量和
static变量,比如int flag;。可执行文件中只记录”这里需要 N 个字节”,程序启动时操作系统直接给一块清零的内存。
用铭牌的比喻来说:
- .data 是”刻好字的铭牌”——初始值清清楚楚地刻在上面,可执行文件中存着这些值
- .bss 是”空白铭牌”——铭牌的位置留好了,但上面什么字都没刻。操作系统启动程序时,直接给一块清零的内存即可,不需要在可执行文件中存一大堆零
为什么 .bss 很重要? 如果你的程序声明了
int big_table[1000000] = {0};,一千万个零不需要存到可执行文件中——可执行文件只需要记录”这块要 400 万字节”,启动时操作系统给一块清零的内存。如果这些零全存进文件,可执行文件的体积会白白增大。
静态存储区的特点
- 生命周期最长:从程序启动到结束,始终存在
- 自动零初始化:未显式初始化的变量自动归零(不像栈上的局部变量,不初始化就是垃圾值)
- 编译时确定大小:编译器在编译时就能算出需要多少空间,不需要运行时动态决定
4.5 代码区(Text)—— 只读的”工作手册”
代码区存放的是你写的 C 代码被编译器翻译成的机器指令。它有三个关键特征:
(1)只读。 现代操作系统把代码区映射为不可写——任何试图修改代码区的操作都会导致程序崩溃。这是出于两个原因:
- 安全:防止恶意代码注入。如果代码区可写,攻击者可能通过缓冲区溢出等手段把恶意指令写进去
- 防错:防止程序中的野指针意外改写代码逻辑。一个乱飞的指针改写了数据段可能只导致计算结果出错,改写了代码段则可能导致完全不可预测的行为
(2)共享。 如果你同时打开了三个终端窗口,每个都运行着同一个程序,操作系统可以让它们共享同一份代码区的物理内存——因为代码只读不写,共用不会出问题。这节省了大量内存。
(3)位置由构建工具链决定。 代码在虚拟地址空间中的具体位置由编译器、链接器和加载器共同确定,并受到地址空间布局随机化(ASLR,Address Space Layout Randomization)等安全机制的影响——每次运行时,代码区的实际地址可能不同,这也是出于安全考虑。
4.6 贯穿示例:一个程序的内存全貌
讲了四个区域,现在用一个完整的程序把它们串起来。请仔细看下面的代码和注释——每一个变量属于哪个区域,都标注清楚了:
#include <stdio.h>
#include <stdlib.h>
// ★ 静态存储区 (.data) —— 显式初始化了,初始值"Hello"存在可执行文件中
char greeting[20] = "Hello, World!";
// ★ 静态存储区 (.bss) —— 未显式初始化,启动时自动清零
int access_count;
// 形参 name 在栈区 | 形参 age 在栈区
void print_info(char* name, int age)
{
// ★ 栈区 —— 局部变量
int year = 2026 - age; // 根据年龄推算出生年份
// ★ 静态存储区 (.bss) —— static 局部变量,只在这个函数里可见
// 但生命周期贯穿整个程序,且自动初始化为 0
static int call_times;
call_times++; // 每次调用 +1
access_count++; // 全局变量,也 +1
printf("%s 今年 %d 岁,出生于 %d 年\n", name, age, year);
printf(" (print_info 被调用了 %d 次,全局访问计数 %d)\n",
call_times, access_count);
}
int main(void)
{
// ★ 栈区 —— main 的局部变量
int student_count;
// ★ 栈区 —— 指针变量 p 本身在栈上
// ★ 堆区 —— p 指向的内存块在堆上(运行时分配)
int* p = (int*)malloc(3 * sizeof(int));
if (p == NULL) return 1;
p[0] = 85;
p[1] = 92;
p[2] = 78;
student_count = 3;
print_info("小明", 20); // 字符串字面量 "小明" → 静态存储区(只读)
print_info("小红", 19);
print_info("小刚", 18);
for (int i = 0; i < student_count; i++)
printf("学生 %d 的成绩:%d\n", i + 1, p[i]);
free(p); // ★ 归还堆上的内存
return 0;
}
梳理一下这个程序的内存分布:
| 代码中的东西 | 所在区域 | 为什么 |
|---|---|---|
greeting[20] | 静态存储区 (.data) | 在所有函数外定义,且显式初始化了 |
access_count | 静态存储区 (.bss) | 在所有函数外定义,未显式初始化 |
print_info 的形参 name, age | 栈区 | 函数参数,每次调用都被”叠”到栈帧上 |
print_info 的 year | 栈区 | 函数内的局部变量 |
print_info 的 call_times | 静态存储区 (.bss) | static 局部变量,未显式初始化 |
main 的 student_count | 栈区 | main 函数内的局部变量 |
main 的指针 p 本身 | 栈区 | main 函数内的局部变量 |
p 指向的 3 个 int | 堆区 | 通过 malloc 申请 |
”小明” 等字符串字面量 | 静态存储区(只读) | 直接写在代码里的字符串常量 |
print_info、main 的机器指令 | 代码区 | 编译后的机器码 |
运行时,栈的变化(只看 main → print_info 部分):
- main 开始执行:栈上有
main的栈帧,包含student_count和指针变量p(p本身在栈上,但它指向的 3 个 int 在堆上) - 调用
print_info("小明", 20):在main的栈帧上方叠放print_info的栈帧,包含形参name、age和局部变量year - print_info 返回:
print_info的栈帧被整个移除,main的栈帧恢复为栈顶
关键观察:函数调用期间,堆上的数据 [85][92][78] 始终存在——它不受栈帧叠放和移除的影响,只有显式调用 free() 才会被回收。
四个区域讲完了。但说实话,在日常写 C 代码时,你直接感知到的主要是其中两个:堆和栈。代码区的指令是编译器生成的,你不用管;静态存储区的全局变量定义了就放那儿,也不需要特别操作。而你每写一个局部变量,都在用栈;每调用一次 malloc,都在用堆。
这两个区域的差异,决定了你应该把什么样的数据放在哪里。下面这张对比表把核心区别一次性说清楚:
| 对比维度 | 栈(Stack) | 堆(Heap) |
|---|---|---|
| 管理方式 | 编译器自动管理,函数进入时分配、返回时释放 | 程序员手动管理,malloc 申请、free 释放 |
| 存放内容 | 局部变量、函数形参、返回地址 | 动态分配的内存块 |
| 容量 | 有限,通常 1~8 MB | 很大,可利用几乎整个虚拟地址空间(数 GB) |
| 速度 | 极快(移动栈指针即可,一条 CPU 指令) | 较慢(需要查找空闲块 + 登记管理信息) |
| 生命周期 | 由函数调用决定,函数返回即销毁 | 由程序员决定,从 malloc 到 free |
| 分配时机 | 编译时确定布局,运行时自动分配 | 运行时按需分配,大小可以在运行时决定 |
| 典型问题 | 栈溢出(递归过深、局部数组过大) | 内存泄漏(忘记 free)、碎片化、悬空指针 |
| 适用场景 | 大小已知、生命周期短的小数据 | 大小未知、需要跨函数存活的大数据 |
选择口诀:
数据小而短命 → 栈(声明一个局部变量就行) 数据大或长寿 → 堆(用
malloc申请,用完记得free)
这个口诀覆盖了 90% 的日常决策。随着学习的深入,你会对”什么时候用堆、什么时候用栈”形成肌肉记忆——但在此之前,这张表和这个口诀就是你的指南针。
补充说明:虚拟地址空间的四区模型是常见桌面/服务器系统的典型实现,不是 C 语言标准的强制规定。但在学习阶段,用它来建立内存直觉是完全合适的——就像学物理时用”电子绕核旋转”的模型,虽然不完全精确,但它能帮你理解和预测大部分现象。后续章节中局部变量、指针、动态内存等概念,都会反复用到本章建立的这张”内存地图”。
接下来,就从你最常打交道的两种变量——局部变量和全局变量——入手,深入理解它们和内存分区的关系。
二、局部变量与全局变量
1. 作用域 —— 决定了你能看到谁
在讲局部变量和全局变量之前,必须先理解一个更基础的概念:作用域(scope)。
C 语言里的一对 { },不仅仅是“函数体的边界“——它定义了一个作用域。在 { } 内部声明的变量,只在这个 { } 内部可见。出了这个 { },外面就看不到里面的变量了。
#include <stdio.h>
void func(void)
{
int a = 10; // a 的作用域就是这个 { } 内部
printf("%d\n", a); // 使用 a,避免示例只声明不用
} // 离开这里后,自动对象 a 的生命周期结束
int main(void)
{
func();
// 这里无法访问 a——a 的作用域只在 func 的 { } 里面
return 0;
}
嵌套的 { } 也遵循同样的规则:内层可以看到外层的变量,外层看不到内层的变量。
#include <stdio.h>
int main(void)
{
int x = 1; // x 的作用域是整个 main 的 { }
{ // 嵌套的代码块
int y = 2; // y 的作用域只在这个内层 { } 里
printf("%d %d", x, y); // ✅ 内层可以访问外层的 x 和自己的 y
}
// printf("%d", y); // 错误示范:y 已超出作用域,取消注释会编译失败
return 0;
}
💡 作用域 vs 生命周期
作用域决定名字在源代码中的可见范围,存储期和执行流程共同决定对象的生命周期。普通自动对象通常在离开相应块时结束生命周期,但
static局部变量即使名字离开作用域,对象仍然存活。第十二章会系统区分这些概念。
2. 局部变量——栈区的居民
回顾你到目前为止写过的所有 C 程序,你声明变量时一定是在某个函数内部写的:
void func(void)
{
int a = 10; // 写在函数 func 的内部
double b = 3.14;
}
像这样定义在函数或嵌套块内部的标识符具有块作用域,通常称为局部变量。离开块后,这个名字不可见;若对象具有自动存储期,其生命周期也会结束,而 static 局部对象则继续存活。
局部自动对象在常见未优化实现中经常位于调用栈,也可能被放进寄存器或被优化掉。下面先用“栈帧”模型建立直觉,再牢记它不是 C 标准规定的唯一实现方式。
#include <stdio.h>
void func(void)
{
int a = 10; // func 被调用 → a、b、c 叠到栈顶
double b = 3.14;
char c = 'X';
printf("%d %.2f %c\n", a, b, c); // 读取三个对象,展示其值
} // func 结束 → a、b、c 从栈顶拿走,全部销毁
int main(void)
{
func();
// 到这里,a、b、c 已经不存在了
// 如果再次调用 func(),会在栈顶重新叠三块全新的变量
return 0;
}
从这个例子可以看出栈上局部变量的几个关键行为:
- 进入块时开始、离开块时结束:这里的自动对象无需手动释放;编译器负责实现其存储管理
- 每次调用对应新的对象实例:连续调用两次
func(),两次调用中的a、b、c不是同一生命周期的对象 - 避免巨型自动数组:调用栈容量有限且因环境而异,过大的自动对象可能导致栈耗尽
3. 全局变量——静态存储区的居民
到目前为止,你声明的所有变量都写在函数内部(局部变量)。但 C 语言允许你在所有函数之外定义变量——这种变量叫做全局变量(global variable)。全局变量不归任何一个函数私有,而是整个程序的“公共财产“。
与局部变量不同,全局变量住在静态存储区。它们就像刻着名字的铭牌,从程序启动的那一刻起就钉在那里,直到程序结束才会被取下。
#include <stdio.h>
int global_counter = 0; // 在所有函数外面定义 → 全局变量,在静态存储区
void count(void)
{
global_counter++; // 任何函数都可以访问它
printf("count = %d\n", global_counter);
}
int main(void)
{
count(); // count = 1
count(); // count = 2
count(); // count = 3
return 0;
}
注意 global_counter 只初始化了一次(= 0),之后每次 count() 调用修改的都是同一个变量,所以它从 1 一直累加到 3。如果它是局部变量,每次调用 count() 都会重新创建一个新的、从 0 开始的变量——这就是存储位置不同带来的行为差异。
文件作用域变量的名字从声明处开始在相应作用域内可见。非 static 的文件作用域定义通常具有外部链接,其他翻译单元可通过兼容的 extern 声明引用;这并不意味着无需声明就能被任意函数直接使用。
4. 局部变量与全局变量对照
| 局部变量 | 全局变量 | |
|---|---|---|
| 定义位置 | 函数 /{ } 内部 | 所有函数之外 |
| 典型实现位置 | 常见于栈帧,也可能在寄存器中 | 常见于数据段或 BSS 类映射 |
| 生命周期 | 自动对象通常随块执行开始和结束 | 贯穿整个程序执行 |
| 名字的可见性 | 从声明处到所在块末尾 | 从声明处到文件作用域末尾;跨文件需链接与声明 |
| 未初始化时 | 值不确定,读取可能是未定义行为 | 按规则零初始化 |
| 管理方式 | 自动管理(编译器) | 自动管理(编译器) |
这张表真正要揭示的是:应先从声明方式判断对象的存储期,再理解常见实现如何安排存储。不要反过来仅凭某次打印出的地址猜测标准语义。
三、static 关键字——改变存储位置的“开关“
通过上一节的对照表,你可能会产生一个疑问:如果我想让一个局部变量也拥有全局变量那样的“长寿命“——在多次函数调用之间保持它的值——但又不想让它变成全局变量被所有函数访问,有没有办法?
C 语言提供了 static 关键字来满足这种需求。用于块作用域对象时,它把对象的存储期改为静态存储期,但名字仍保持块作用域。
1. static 局部变量——住在静态区的“局外人“
在局部变量前面加上 static,会把它从自动存储期改为静态存储期。这带来的关键行为变化是:对象只初始化一次,之后每次函数调用都沿用上一次结束时的值。 至于它在可执行文件和内存中的具体区域,由实现决定。
#include <stdio.h>
void count(void)
{
static int counter = 0; // 静态存储期:只初始化一次,值在调用间保留
counter++;
printf("counter = %d\n", counter);
}
int main(void)
{
count(); // counter = 1
count(); // counter = 2
count(); // counter = 3
return 0;
}
对比普通局部变量和 static 局部变量:
| 普通局部变量 | static 局部变量 | |
|---|---|---|
| 存储期 | 自动存储期(通常在栈帧中实现) | 静态存储期 |
| 初始化时机 | 每次执行到声明时进行初始化 | 程序启动前完成静态初始化 |
| 值在调用之间 | 不保留(每次都是新的) | 保留(沿用上次的值) |
| 作用域 | 定义它的 { } 内 | 定义它的 { } 内(不变) |
| 生命周期 | 进入 { } 时创建,离开时销毁 | 程序启动时创建,程序结束时销毁 |
这里有一个非常重要的概念需要格外注意:变量的作用域(在哪里能访问它)和生命周期(它能活多久)是两个不同且相互独立的概念。static 局部变量就是最典型的例子——它的作用域仅限于函数内部,出了函数就无法直接访问它,但它的生命周期却贯穿整个程序。
此外,和全局变量一样,static 局部变量如果没有显式初始化,也会被自动初始化为 0。
2. static 全局变量——限制可见范围
static 关键字也可以用在全局变量上,但它的作用完全不同:它不是改变存储位置(全局变量本来就在静态存储区),而是限制可见范围。
普通的全局变量可以被程序中的所有文件访问(前提是其他文件用 extern 声明)。但如果给全局变量加上 static,它就变成了文件内部私有——只有当前 .c 文件中的函数能够访问它,其他文件看不到。
// file1.c
static int internal_counter = 0; // 只能在本文件内访问
void increment(void)
{
internal_counter++;
}
// file2.c
// extern int internal_counter; // 错误示范:无法引用 file1.c 中具有内部链接的对象
这个特性对于大型项目的模块化开发非常重要:它让你可以把只在当前文件内部使用的数据隐藏起来,避免被其他文件意外修改。这称为信息隐藏,是编写可靠、可维护的 C 代码的重要实践。
3. static 总结
static 关键字在不同语境下有不同的含义,但核心思想是一致的——限制与持久化:
| 用法 | 作用 | 关键词 |
|---|---|---|
static 局部变量 | 赋予静态存储期,值在调用之间保持 | 持久化 |
static 全局变量 | 可见范围从“所有文件“缩小到“当前文件“ | 限制 |
记忆时应按语境区分:块作用域对象上的 static 改变存储期;文件作用域声明上的 static 赋予内部链接。二者都不等价于“地址固定”,也不应从英文名称反推更多语言保证。
第七章 指针
本章要点
指针是 C 语言中最具标志性、也最需要静下心来仔细理解的概念之一。
此前各章中,我们使用的变量都是直接存储数据值——整数、小数、字符——程序通过变量名来读取或修改这些值。但 C 语言提供了更底层的视角:每一个变量在内存中都占据着一块空间,拥有一个独一无二的地址。指针,正是用来存储和操作这些地址的特殊类型。
本章内容安排:
- 从地址的存储问题出发,引出指针的基本概念
- 指针变量的声明、指针类型(
int*、char*等)、取地址运算(&)、解引用运算(*) - 指针的几个关键特性——指针与地址的关系、指针的运算规则
- 指针安全:空指针与野指针
- 初步介绍二级指针
理解指针,是理解 C 语言内存模型的关键。数组的遍历、函数参数的传递、动态内存的分配,无一不建立在指针之上。扎实地掌握本章的内容,是学好后续各章的前提。
一、指针基础
1. 从一个问题出发:地址怎么存?
C 语言中有一个专门的运算符 &,叫做取地址运算符。把它写在一个变量的前面,就能拿到这个变量在内存中的地址:
int a = 100;
? p = &a; // ? 应该是什么类型?
&a 取出了变量 a 的地址。现在的问题是:用什么类型来接收这个地址?a 是 int 类型,用 int 来声明。100 是 int 类型,也可以直接赋给 int 变量。那 &a——一个变量的地址——它是什么类型?
答案是 int*,读作“int-star“。这就是 C 语言中的指针类型。把 ? 换成 int*,就得到了正确的代码:
#include <stdio.h>
int main(void)
{
int a = 100;
int* p = &a; // p 是 int* 类型,用来存 a 的地址
printf("a 的值是:%d\n", a);
printf("a 的地址是:%p\n", (void *)p);
return 0;
}
程序运行后,屏幕上会输出类似这样的结果:
a 的值是:100
a 的地址是:000000BFCB5FFBBC
第一行是变量 a 中存储的值,第二行那个十六进制数就是变量 a 在内存中的地址——操作系统为 a 分配的存储位置的门牌号。& 做的事情很简单:你告诉它一个变量名,它就把这个变量在内存中的地址返回给你。int* 做的事情也很明确:作为指针类型,专门用来存储这种地址。
2. 指针类型
int* 这个类型是怎么来的?它不是凭空出现的——它是基于已有的类型 int,加上 * 组合而成的。* 的含义是“指向……的指针“:
int+*→int*(指向int的指针类型)char+*→char*(指向char的指针类型)float+*→float*(指向float的指针类型)
前面的是基本类型,后面学到结构体之后,也一样可以组合——比如 struct Student + * → struct Student*。任何已有类型,加上 *,就得到对应的指针类型。
声明指针类型变量的基本格式为:
数据类型 *指针变量名;
int *p; // p 是 int* 类型,专门用来存 int 变量的地址
float *q; // q 是 float* 类型,专门用来存 float 变量的地址
char *r; // r 是 char* 类型,专门用来存 char 变量的地址
int *p; 应当读作“p 是 int 的指针“——如果顺着 p 里存的地址去找,会在那个地址上找到一个 int 类型的值。
那么,为什么指针需要区分类型?简单说:编译器需要知道顺着地址找到数据后,该按什么规则来解读它。
int *p 告诉编译器两件事:
- 从 p 中取出地址
- 从那个地址开始,按
int的格式读取和解释数据
至于具体读几个字节、各类型占多大空间,我们放到下一节用 sizeof 实测来看。
3. 指针变量
指针变量到底占多大空间?在你自己的机器上运行下面这段程序,直接看结果:
#include <stdio.h>
int main(void)
{
int a = 100;
int* p = &a;
char* r;
double* q;
printf("int 占 %zu 字节\n", sizeof(a));
printf("int* 占 %zu 字节\n", sizeof(p));
printf("char* 占 %zu 字节\n", sizeof(r));
printf("double* 占 %zu 字节\n", sizeof(q));
return 0;
}
64 位系统上的典型输出:
int 占 4 字节
int* 占 8 字节
char* 占 8 字节
double* 占 8 字节
从这几行输出中,可以得出三个重要结论:
① 指针变量本身占用内存空间,它是一个实实在在的变量。 你看,int* 打印出来占了 8 字节——它和 int、char 一样,在内存里有自己的位置,有自己独立的地址(用 &p 可以取到)。它不是一个“标签“或“别名“,而是一个正儿八经的变量。
② 指针是一种独立的类型。 int 和 int* 是不同的类型——一个存整数,一个存地址。即使在同一平台上 int 和 int* 碰巧大小相同,它们也仍然是截然不同的类型,编译器会对它们进行不同的类型检查。
③ 常见平台上的对象指针通常大小相同,但这不是 C 标准的普遍保证。 在本机示例中,int *、char *、double * 都是 8 字节,这是当前 ABI 的选择。可移植代码应以各自的 sizeof 结果为准,尤其不能进一步推断函数指针也一定具有相同表示。
注意区分:这里打印的是指针变量本身的大小;
sizeof(*p)才是被指向类型的大小。两者属于不同层面。教程展示的 8 字节只是当前 64 位环境中的典型结果,不应写进依赖可移植性的程序逻辑。
搞清楚了指针变量占多大空间,下一个问题是:它和被指向的变量之间是什么关系?仍然用比喻来理解——
一个普通变量(比如 int a = 100)的盒子里装的是具体的数据值 100。而一个指针变量(比如 int* p = &a)的盒子里装的不是数据本身,而是另一个变量的地址——一张写着“你要找的数据在某某位置“的纸条:
int a = 100; // 普通变量:盒子里装的是 100
int* p = &a; // 指针变量:盒子里装的是 a 的地址
在内存中,这种关系如下。注意两个关键事实:
p和a是两个独立的变量,各自占据不同的内存空间(a在 0x1000,p在 0x2000)p里面存的是a的地址(0x1000),顺着这个地址就能找到a,进而拿到100
从上面的图和代码中,可以再确认两个根本认识:
- 指针变量和它指向的变量是两个独立的东西。
p和a住在不同的内存地址,各自有各自的值。只不过p的值恰好是a的地址,通过这个地址可以间接找到a。 - 指针变量里存的是地址,不是普通数据。 普通变量装数据值,指针变量装门牌号——这就是指针和普通变量最根本的区别。
要让一个指针变量指向某个变量,先用 & 获取那个变量的地址,再把这个地址赋值给指针变量:
int a = 100; // 声明普通变量 a
int *p; // 声明指针变量 p
p = &a; // 把 a 的地址赋值给 p(p 现在"指向"了 a)
也可以将声明和赋值合并为一步:
int a = 100;
int *p = &a; // 声明并初始化:p 指向 a
这里需要特别注意一个初学者最容易混淆的地方:声明中的 * 和表达式中的 * 含义不同。
int *p; // ① 这里的 * 是类型声明的一部分——表示 p 是指针变量
p = &a; // 赋值时 p 前面没有 *——把地址直接赋给 p 本身
*p = 100; // ② 这里的 * 是解引用运算符——表示"p 指向的那个变量"
关键区别:
- 声明语句中的
*:是类型标记,读作“p 是 int 的指针“。指针变量的名字就是p,不是*p。 - 执行语句中的
*:是解引用运算符,意思是“顺着 p 里的地址找到那个变量“。
关于书写风格:int* p;(星号贴近类型)和 int *p;(星号贴近变量名)两种写法语法上都正确,选择哪一种只是风格问题。C 语言惯用写法是将星号贴近变量名,但本书采用贴近类型名的写法,更能体现“int* 是一个整体类型“的含义。
二、指针操作
理解了指针变量的声明和取地址操作之后,下一个自然的问题是:既然指针变量里装的是地址,我们该如何通过这个地址去访问它所指向的数据?答案是解引用运算,这是指针操作中最核心的一步。掌握了它,才算真正理解了指针的作用。
1. 解引用运算符 *——通过指针访问数据
指针变量存储了地址。如果你拿到了地址,怎么获取那个地址上存放的数据呢?这就需要用到解引用运算符 *(也叫“间接访问运算符“)。它的基本规则为:
*指针变量名 // 获取这个指针所指向的变量的值
这里的 * 是一个运算符,表示“顺着这个地址,去找到那个盒子里的内容“。*p 的意思就是“p 指向的那个变量“。
#include <stdio.h>
int main(void)
{
int a = 100;
int *p = &a; // p 指向 a
printf("a 的值:%d\n", a); // 100
printf("*p 的值:%d\n", *p); // 100(通过 p 找到 a 的值)
// 通过 *p 也可以修改 a 的值
*p = 200;
printf("--- 通过 *p 修改后 ---\n");
printf("a 的值:%d\n", a); // 200
printf("*p 的值:%d\n", *p); // 200
return 0;
}
输出:
a 的值:100
*p 的值:100
--- 通过 *p 修改后 ---
a 的值:200
*p 的值:200
*p = 200; 这行代码的含义是:顺着 p 里存的地址,找到那个变量,把它的值改成 200。因为 p 指向的是 a,所以实际上就是把 a 改成了 200。解引用让指针不止于“看一看“——通过它,我们可以间接地读取、甚至修改另一个变量的值,这正是指针强大之处的直接体现。
* 的两种角色
在 C 语言中,* 符号在不同的上下文中承担着不同的含义,理解这两种角色的区别,是指针学习的关键一步:
int *p; // ① 声明语句中的 *:表示"p 是指针类型"
p = &a; // 把 a 的地址赋值给 p
*p = 100; // ② 执行语句中的 *:解引用,"p 指向的那个变量"
| 出现位置 | 符号 | 含义 |
|---|---|---|
声明语句:int *p; | * | 类型标记:p 是指针 |
执行语句:*p = 10; | * | 解引用运算符:p 指向的变量 |
执行语句:&a | & | 取地址运算符:a 的地址 |
你可以这样记忆:声明里的 * 是“招牌“——它告诉编译器 p 是哪种变量;执行语句里的 * 是“开门“——顺着门牌号找到对应的盒子,打开它,取东西或者放东西。
2. 指针的几个特性
理解了指针变量的声明和解引用之后,有必要进一步了解指针变量本身所具备的几个基本特性。这些特性决定了指针变量在实际编程中的行为方式。
特性一:指针变量和被指向的变量是两个独立的东西
int a = 100;
int *p = &a;
// p 和 a 是两个不同的变量,有各自的地址
printf("a 的地址:%p\n", (void *)&a);
printf("p 的地址:%p\n", (void *)&p); // p 自己也有地址!
// p 的值 等于 a 的地址
printf("p 的值(即 a 的地址):%p\n", (void *)p);
p 本身也是一个变量,住在内存的某个位置,所以 p 也有自己的地址。&p 得到的是 p 自己的门牌号,这和 p 里面存的值(a 的门牌号)是完全不同的两回事。明确这一点,对于后续理解二级指针和指针的指针运算至关重要。
特性二:一个指针可以先后指向不同的变量
int a = 10, b = 20;
int *p;
p = &a;
printf("*p = %d\n", *p); // 10
p = &b; // p 改为指向 b
printf("*p = %d\n", *p); // 20
指针变量也是变量,它里面存的值(地址)可以像普通变量一样被重新赋值。这一特性使得指针在遍历数组或处理链表等数据结构时非常灵活。
特性三:多个指针可以指向同一个变量
int a = 100;
int *p1 = &a;
int *p2 = &a;
*p1 = 200; // 通过 p1 修改 a
printf("*p2 = %d\n", *p2); // 200,通过 p2 也能看到修改后的值
因为 p1 和 p2 都指向同一个变量 a,通过任意一个指向它的指针修改它的值,另一个指针也能“看到“变化。就像同一间房子挂了两把钥匙,不管用哪一把开门,进去的都是同一间房。
3. 指针的算术运算
指针变量里存的是一个内存地址。从底层来看,地址就是一串数字——既然它长得像数字,那能不能对它做加减法?C 语言的回答是:可以,但最好不要把它理解成数学意义上的“算术“。
指针的“算术运算“,本质上并不是在算数字,而是在移动位置。 你脑海中应该浮现的画面是:指针像一根在内存中游走的箭头——加 1 就是箭头往高地址方向挪一格,减 1 就是往低地址方向退一格。下面分别来看指针的向前移动(递增)和向后移动(递减)。
3.1 指针的递增——往高地址方向移动
指针加一个整数 i,含义是从当前位置出发,向高地址方向移动 i 个元素:
int arr[] = {10, 20, 30, 40, 50};
int *p = &arr[1]; // p 指向 arr[1](值为 20)
printf("*p = %d\n", *p); // 20
printf("*(p+1) = %d\n", *(p + 1)); // 30——向高地址方向移动 1 个元素
printf("*(p+3) = %d\n", *(p + 3)); // 50——向高地址方向移动 3 个元素
递增操作的常见写法:
| 写法 | 含义 |
|---|---|
p + i | 从 p 当前位置出发,向高地址方向移动 i 个元素(p 本身不变) |
p++ / ++p | p 自身向高地址方向移动 1 个元素(移动后 p 指向下一个元素) |
3.2 指针的递减——往低地址方向移动
与递增对应,指针减一个整数 i,含义是从当前位置出发,向低地址方向移动 i 个元素:
int arr[] = {10, 20, 30, 40, 50};
int *p = &arr[3]; // p 指向 arr[3](值为 40)
printf("*p = %d\n", *p); // 40
printf("*(p-1) = %d\n", *(p - 1)); // 30——向低地址方向移动 1 个元素
printf("*(p-2) = %d\n", *(p - 2)); // 20——向低地址方向移动 2 个元素
递减操作的常见写法:
| 写法 | 含义 |
|---|---|
p - i | 从 p 当前位置出发,向低地址方向移动 i 个元素(p 本身不变) |
p-- / --p | p 自身向低地址方向移动 1 个元素(移动后 p 指向前一个元素) |
此外,两个同类型指针相减(p2 - p1)的结果是它们之间的元素个数(差了多少个元素,不是差了多少字节)。这在计算数组区间长度时很常用。
3.3 为什么 p+1 而不是 p+4?——自动缩放机制
看到这里,你心里可能有一个疑问:上面说过 int 占 4 个字节,那按道理下一个 int 应该从 4 个字节之后开始——为什么写 p + 1 就能跳到下一个元素,而不是写 p + 4?
答案藏在指针的类型里。当编译器看到 p + 1 时,它会做一件事:根据 p 指向的类型,自动把 1 换算成正确的字节偏移量。这个机制叫自动缩放——你只需要用“元素的个数“来思考(往前走几个元素),编译器在背后帮你把“元素个数“翻译成“字节偏移量“。
换句话说,p + 1 中的 1 不是 1 个字节,而是 1 个元素。一个元素占多少字节,取决于 p 的类型:
int*的p + 1→ 地址增加sizeof(int)字节(通常是 4)char*的p + 1→ 地址增加sizeof(char)字节(固定是 1)double*的p + 1→ 地址增加sizeof(double)字节(通常是 8)
下面的程序可以让你直观地看到这种差异:
#include <stdio.h>
int main(void)
{
int arr_int[] = {10, 20, 30};
char arr_char[] = {'A', 'B', 'C'};
double arr_dbl[] = {1.0, 2.0, 3.0};
int *pi = &arr_int[0];
char *pc = &arr_char[0];
double *pd = &arr_dbl[0];
printf("int* :pi = %p, pi + 1 = %p(向高地址移动了 %zu 字节)\n",
(void *)pi, (void *)(pi + 1), (size_t)((char *)(pi + 1) - (char *)pi));
printf("char* :pc = %p, pc + 1 = %p(向高地址移动了 %zu 字节)\n",
(void *)pc, (void *)(pc + 1), (size_t)((char *)(pc + 1) - (char *)pc));
printf("double* :pd = %p, pd + 1 = %p(向高地址移动了 %zu 字节)\n",
(void *)pd, (void *)(pd + 1), (size_t)((char *)(pd + 1) - (char *)pd));
return 0;
}
典型输出(64 位系统):
int* :pi = 0x7fff...,pi + 1 = 0x7fff...(向高地址移动了 4 字节)
char* :pc = 0x7fff...,pc + 1 = 0x7fff...(向高地址移动了 1 字节)
double* :pd = 0x7fff...,pd + 1 = 0x7fff...(向高地址移动了 8 字节)
三种指针走的字节数完全不同,但效果完全一致——都恰好跨过了一个自己所指向类型的元素。这就是自动缩放的价值:你只管按“元素个数“来思考(往前走 1 个元素),编译器和硬件帮你搞定底层的字节计算。递减方向同样受自动缩放支配——p - 1 对于 int* 是往回退 4 字节,对于 char* 是退 1 字节,以此类推。
⚠️ 安全提醒: 无论递增还是递减,指针的移动范围都不能越出同一数组的边界(可以指向数组末尾之后紧邻的那个假想位置,但不能对其解引用)。对一个孤立的普通变量取地址后做加减,移动后的指针指向哪里完全不可预期——对这样的地址解引用属于未定义行为。编译器不会帮你检查越界,这是你自己的责任。
小结:把指针运算理解为“指针的移动“。 回到本节开头的那句话——普通数字的加减法是数学计算,而指针的加减法更像是在内存中游走。你告诉指针“往前走几个元素“或“往后退几个元素“,它就会根据自己指向的类型,自动迈出正确的步长,准确定位到目标元素的位置。这也正是 arr[i] 和 *(arr + i) 完全等价的根本原因——arr[i] 在编译器眼里就是“从数组开头出发,向高地址方向移动 i 步,然后取出那个位置的值“。指针与数组的这种深层关系,会在第十一章中完整展开。
三、指针安全与进阶
理解了指针变量的基本声明和操作之后,还有两个话题需要在深入学习之前弄清楚:
- 指针安全:空指针和野指针——帮你避免程序中最常见的一类运行时错误
- 指针进阶:二级指针——为后续指针与数组、指针与函数的组合使用打开一扇门
1. 空指针和野指针
空指针(NULL)
如果你声明了一个指针变量但暂时不知道让它指向谁,正确的做法是给它赋一个 NULL 值:
int *p = NULL; // p 现在不指向任何变量
NULL 是空指针常量,表示“不指向任何对象或函数”。判空可以排除空指针,但不能证明一个非空指针仍然有效:它还可能已经悬空、越界,或来自错误的地址计算。
if (p != NULL)
{
*p = 100; // 仅当程序还能保证 p 指向一个存活的 int 对象时才安全
}
NULL 需要包含头文件 <stddef.h> 或 <stdio.h> 或 <stdlib.h> 才能使用(实际上三者都会间接引入它)。
野指针
未初始化的自动指针对象具有不确定值。不要把它理解成一个可观察、可利用的“随机地址”——读取这个不确定指针并拿去解引用,本身就会进入未定义行为。常见错误如下:
int *p; // 声明了指针但没有初始化
*p = 100; // 危险!p 未初始化,读取并解引用会产生未定义行为
这有多危险?不妨打个比方:
你随手拿了一张便签,上面原来就写着某个门牌号。你以为上面写的是“第 3 号文件柜“,但它其实是别人留下的废旧便签,上面写的可能是“总经理保险柜 888 号“。
你顺着这个地址去存东西,就等于把数据写到了完全不该碰的地方。可能的后果:
- 轻则:修改了系统关键数据,程序直接崩溃
- 重则:被攻击者利用,引入安全漏洞
避免野指针的方法很明确:声明指针变量时必须初始化。如果暂时不想让它指向某个具体的变量,就初始化为 NULL。
int *p = NULL; // 好习惯:明确表示现在不指向任何地方
2. 指向指针的指针——初步了解
既然指针变量本身也有地址,那么能不能用一个指针来指向另一个指针呢?当然可以,这就是二级指针。
int a = 100;
int *p = &a; // p 指向 a
int **pp = &p; // pp 指向 p(pp 是二级指针)
在内存中:
访问方式:
printf("%d\n", a); // 100
printf("%d\n", *p); // 100(一次解引用:找到 a)
printf("%d\n", **pp); // 100(两次解引用:先找到 p,再找到 a)
二级指针目前只需要知道“存在这个东西“即可,不必深究。后续学习指针与数组、指针与函数之间的关系时,会自然地用到它。
四、动手练习
#include <stdio.h>
int main(void)
{
// 练习1:基本的指针操作
int x = 42;
int *p = &x;
printf("=== 练习1:基本操作 ===\n");
printf("x 的值:%d\n", x);
printf("p 的值(x 的地址):%p\n", (void *)p);
printf("*p 的值:%d\n", *p);
// 通过指针修改 x
*p = 99;
printf("\n通过 *p = 99 修改后:\n");
printf("x 的值:%d\n", x);
printf("*p 的值:%d\n\n", *p);
// 练习2:指针自己的地址
printf("=== 练习2:指针自己的地址 ===\n");
printf("x 的地址:%p\n", (void *)&x);
printf("p 的地址:%p\n", (void *)&p);
printf("p 自己的地址和它存的值是不同的!\n\n");
// 练习3:一个指针先后指向不同变量
int a = 10, b = 20;
int *ptr;
printf("=== 练习3:改变指针的指向 ===\n");
ptr = &a;
printf("ptr 指向 a 时,*ptr = %d\n", *ptr);
ptr = &b;
printf("ptr 指向 b 时,*ptr = %d\n\n", *ptr);
// 练习4:二级指针简单演示
int num = 5;
int *p1 = #
int **p2 = &p1;
printf("=== 练习4:二级指针 ===\n");
printf("num = %d\n", num);
printf("*p1 = %d\n", *p1);
printf("**p2 = %d\n", **p2);
return 0;
}
第八章 结构体指针
本章要点
第五章学习了结构体——用 . 运算符访问成员。但如果手里拿的不是结构体本身,而是指向结构体的指针,该怎么操作?
本章回答这个问题。核心内容就一个运算符:
- 箭头运算符
->:p->name等价于(*p).name,专门用于通过结构体指针访问成员 - 结构体变量用
.,结构体指针用->——不能混用 - 结合指针运算遍历结构体数组
-> 是 C 语言中使用频率最高的运算符之一。掌握了它,后续章节中链表、动态数据结构的大量代码才能顺畅读写。
前面学过,用 . 访问结构体成员:stu.name。但如果手里拿的是指向结构体的指针而非结构体本身,该怎么办?
根据指针的基本原理,需要先解引用再取成员:
struct Student *p = &stu;
printf("%s\n", (*p).name); // 先 *p 得到结构体,再 .name
这种写法括号加星号加点的组合很繁琐。C 语言为此提供了箭头运算符 ->:
p->name // 等价于 (*p).name
-> 左侧是结构体指针,右侧是成员名,两者之间不能有空格。读作“p 所指向的那个结构体的 name 成员“。
#include <stdio.h>
struct Student
{
char name[20];
int age;
float score;
};
int main(void)
{
struct Student stu = {"小明", 18, 92.5};
struct Student *p = &stu;
printf("姓名:%s\n", p->name); // 读取
p->age = 19; // 修改
p->score = 95.0;
printf("年龄:%d,成绩:%.1f\n", p->age, p->score);
// 遍历结构体数组
struct Student students[3] = {
{"小明", 18, 92.5},
{"小红", 19, 88.0},
{"小刚", 18, 95.5}
};
for (struct Student *ptr = students; ptr < students + 3; ptr++)
printf("%s %d %.1f\n", ptr->name, ptr->age, ptr->score);
return 0;
}
规则只有一条:结构体变量用 .,结构体指针用 ->,不能混用。
第九章 函数指针
在前面几章中,我们已经学习了函数的定义与调用,掌握了如何将一段逻辑封装起来并通过函数名反复使用。然而,函数名本身究竟是什么?它仅仅是一个符号吗?本章将回答这个问题,并由此引出一个重要的概念——函数指针。
函数指针允许我们将函数本身当作一种“数据“来存储和传递。掌握了函数指针,你就能写出更加灵活和通用的代码:同一个函数可以根据传入的不同函数指针,表现出完全不同的行为。这种技术被称为回调(Callback),是 C 语言中实现可扩展程序设计的重要手段。
本章内容:
- 函数指针的声明、赋值与调用
- 回调函数的应用场景与设计模式
- 动手练习
一、函数指针基础
1. 函数名到底是什么?
在第一章,我们已经学会了如何定义和调用函数:
#include <stdio.h>
int add(int a, int b)
{
return a + b;
}
int main(void)
{
int result = add(3, 5); // 调用 add 函数
printf("3 + 5 = %d\n", result);
return 0;
}
这个写法你已经非常熟悉了:函数名(参数)。但请你停下来思考一个问题:add 是什么?它难道仅仅是一个名字吗?我们之前学过,变量名代表某块内存空间里的数据,而变量本身有地址,可以用 & 取出来。那么,函数能不能也被“定位“呢?答案是肯定的——让我们尝试把函数名保存到一个函数指针里,然后通过这个指针调用函数:
#include <stdio.h>
int add(int a, int b)
{
return a + b;
}
int main(void)
{
int (*p)(int, int) = add;
printf("可以把 add 保存到函数指针 p 中,并通过 p 调用:%d\n", p(3, 5));
return 0;
}
输出:
可以把 add 保存到函数指针 p 中,并通过 p 调用:8
你会发现,函数名也可以被当作“函数的位置“来使用。事实上,add 在很多表达式中会自动转换为指向这个函数的指针,就像数组名在很多表达式中会退化为数组首元素地址一样。在代码区中,编译器把 add 函数的机器指令放在某个位置,函数指针保存的就是这个函数入口位置。
既然函数名可以转换成函数指针,我们就可以把这个位置存到一个指针变量里,然后通过这个指针来调用函数——这就是函数指针。
2. 函数指针的声明
函数指针是一种特殊的指针,它指向的是函数,而不是普通数据。声明函数指针时,需要描述出这个函数的返回值类型和参数类型。其基本语法如下:
返回值类型 (*指针名)(参数类型列表);
让我们拆解这个语法:
返回值类型:所指函数的返回类型。(*指针名):星号表示这是一个指针,括号必须加,否则含义完全不同。(参数类型列表):所指函数的参数类型,只写类型,参数名可写可不写。
例如:
int (*p_func)(int, int); // p_func 是一个指针,指向"返回值为 int、接受两个 int 参数"的函数
读法是:p_func 是一个函数指针,指向的函数接收两个 int,返回 int。这行声明之后,p_func 就可以存放那些满足这个“签名“的函数的地址,比如前面的 add 函数(int add(int, int))就符合这个签名。
这里有一个关键的语法细节需要特别注意:声明函数指针时,(*指针名) 的括号绝对不能省略。试比较以下两种写法:
int (*p_func)(int, int); // 函数指针:p_func 是指针,指向"返回 int、接受两个 int"的函数
int *p_func(int, int); // 普通函数:p_func 是函数,返回 int *
在 C 语言里,() 的优先级比 * 高。如果不加括号,p_func 先与 (int, int) 结合,含义变成“一个名为 p_func 的函数,它返回 int *“。而加了括号 (*p_func),先让星号与名字结合,表示“这是一个指针”,然后再指向函数。这个语法稍显绕口,但你只需要记住一条规则:声明函数指针时,星号两边必须加括号。
💡 Tips:函数指针声明的推导思路
函数指针的语法初看有些绕,但你可以用以下三步从普通函数声明推导出来。以
int add(int a, int b);为例:
- 给函数名加上
*:int *add(int a, int b);- 把
*add看作一个整体,用括号括起来:int (*add)(int a, int b);- 去掉形参名,只保留类型:
int (*add)(int, int);这样就从普通函数声明得到了函数指针声明。第一步理解成“要声明一个指向函数的指针“,第二步用括号保证
*先与名字结合(否则()的高优先级会让add
3. 给函数指针赋值并调用
声明函数指针后,把函数名直接赋值给它即可:
#include <stdio.h>
int add(int a, int b)
{
return a + b;
}
int subtract(int a, int b)
{
return a - b;
}
int main(void)
{
// 声明函数指针,并让它指向 add
int (*p_func)(int, int);
p_func = add; // 直接用函数名赋值,不需要 &(写了 &add 也可以,效果一样)
// 通过函数指针调用 add
int result = p_func(10, 5); // 等价于 add(10, 5)
printf("10 + 5 = %d\n", result);
// 改为指向 subtract
p_func = subtract;
result = p_func(10, 5); // 等价于 subtract(10, 5)
printf("10 - 5 = %d\n", result);
return 0;
}
输出:
10 + 5 = 15
10 - 5 = 5
关于赋值和调用,有两点值得注意:
- 赋值:直接用函数名即可(
p_func = add),写成p_func = &add效果相同,因为函数名在表达式中会自动转换为函数指针 - 调用:写
p_func(参数)即可直接调用所指的函数,写成(*p_func)(参数)也是正确的,但p_func(参数)更为简洁
理解了函数指针的声明、赋值和调用之后,一个自然的问题是:函数指针的真正价值在哪里?
二、函数指针的应用
函数指针,归根结底也是一种类型。就像 int* 是指向 int 的指针类型、char* 是指向 char 的指针类型一样,int (*)(int, int) 就是指向“接收两个 int、返回 int“的函数的指针类型。第七章讲过,类型的用途就是用来声明变量、接收值、作为函数形参——函数指针类型也不例外。
你有没有想过这样一种场景:你想写一个函数 operate,让它对两个数做某种操作。但这个操作不由 operate 自己决定,而是由调用者来指定——传加法函数就做加法,传乘法函数就做乘法。换句话说,你需要在函数的形参里接收“另一个函数“。用什么类型来接收?
答案就是函数指针类型。
先看一个例子,直观感受一下:
#include <stdio.h>
int add(int a, int b) { return a + b; }
int multiply(int a, int b) { return a * b; }
// operate 的第三个参数是函数指针类型
// int (*operation)(int, int) 读作:
// operation 是一个指针,指向"接收两个 int、返回 int"的函数
void operate(int x, int y, int (*operation)(int, int))
{
int result = operation(x, y);
printf("结果:%d\n", result);
}
int main(void)
{
operate(10, 20, add); // 传入 add,结果 30
operate(10, 20, multiply); // 传入 multiply,结果 200
return 0;
}
输出:
结果:30
结果:200
分析这段代码:operate 函数本身完全不知道要对 x 和 y 做什么操作——它只负责调用 operation 并打印结果。具体的行为——加还是乘——由 main 函数通过传入不同的函数指针来决定。这就是函数指针作为类型的核心用途:让函数的行为由调用者来定制。
1. 回调函数
上面这种“把函数指针传给另一个函数,让它在合适的时机反过来调用“的用法,有一个专门的名字——回调函数(Callback)。被传入的那个函数(比如 add、multiply)叫做回调函数;接收函数指针、在内部调用它的那个函数(比如 operate)叫做调用方。
为什么需要回调函数?它解决的是一个非常实际的问题:分离“做什么“和“怎么做“。假设你要对一个数组中的每个元素做某种处理——翻倍、平方、加 10……如果没有回调函数,每种处理你都得写一个几乎一样的遍历函数,只有中间一行处理逻辑不同:
// 没有回调函数时:每种操作都要写一个独立的遍历函数(示意伪代码)
void double_all(int arr[], int size) { for (int i = 0; i < size; i++) arr[i] *= 2; }
void square_all(int arr[], int size) { for (int i = 0; i < size; i++) arr[i] = arr[i] * arr[i]; }
void add_ten_all(int arr[], int size) { for (int i = 0; i < size; i++) arr[i] += 10; }
// 每增加一种操作,就要复制粘贴一整个函数……
有了回调函数,遍历的逻辑只写一次,具体的操作由调用者通过函数指针传入:
#include <stdio.h>
// for_each 负责遍历数组,对每个元素调用 func
// func 的类型是 void (*)(int *) —— 指向"接收 int*、无返回值"的函数
void for_each(int arr[], int size, void (*func)(int *))
{
for (int i = 0; i < size; i++)
{
func(&arr[i]); // 回调:把元素的地址交给 func 处理
}
}
// 各种回调函数:签名必须是 void (int *)
void double_value(int *x) { *x = *x * 2; }
void square_value(int *x) { *x = (*x) * (*x); }
void add_ten(int *x) { *x = *x + 10; }
int main(void)
{
int numbers1[] = {1, 2, 3, 4, 5};
int numbers2[] = {1, 2, 3, 4, 5};
int numbers3[] = {1, 2, 3, 4, 5};
printf("原数组:");
for (int i = 0; i < 5; i++) printf("%d ", numbers1[i]);
printf("\n");
// 传入不同的回调函数,同一个 for_each 表现出完全不同的行为
for_each(numbers1, 5, double_value);
printf("翻倍后:");
for (int i = 0; i < 5; i++) printf("%d ", numbers1[i]);
printf("\n");
for_each(numbers2, 5, square_value);
printf("平方后:");
for (int i = 0; i < 5; i++) printf("%d ", numbers2[i]);
printf("\n");
for_each(numbers3, 5, add_ten);
printf("加10后:");
for (int i = 0; i < 5; i++) printf("%d ", numbers3[i]);
printf("\n");
return 0;
}
输出:
原数组:1 2 3 4 5
翻倍后:2 4 6 8 10
平方后:1 4 9 16 25
加10后:11 12 13 14 15
注意看 for_each 的第三个参数:void (*func)(int *)——这就是一个函数指针类型。它告诉编译器:func 是一个指针,指向的函数接收一个 int* 参数,没有返回值。调用者传入的 double_value、square_value、add_ten 都满足这个签名,所以都可以赋给 func。for_each 不需要知道传入的函数到底做了什么,它只负责在合适的时机调用 func(&arr[i])——这就是回调。
经典应用:自定义排序
C 标准库里的 qsort 是回调机制的经典范例。它可以对任意类型的数组排序,但怎么比较两个元素,由你通过函数指针告诉它:
#include <stdio.h>
#include <stdlib.h>
int compare_int(const void *a, const void *b)
{
int ia = *(const int *)a;
int ib = *(const int *)b;
if (ia < ib) return -1;
if (ia > ib) return 1;
return 0;
}
int main(void)
{
int arr[] = {42, 7, 99, 3, 15};
size_t size = sizeof arr / sizeof arr[0];
qsort(arr, size, sizeof(int), compare_int);
printf("排序后:");
for (size_t i = 0; i < size; i++)
{
printf("%d ", arr[i]);
}
printf("\n");
return 0;
}
输出:
排序后:3 7 15 42 99
qsort 的第四个参数类型是 int (*)(const void *, const void *)——一个函数指针类型。compare_int 就是我们传入的回调函数。qsort 在排序过程中,每次需要比较两个元素时,都会通过这个函数指针调用 compare_int。这种“框架提供机制,用户提供策略“的设计模式,正是回调函数最核心的价值所在。
以上我们学习了函数指针的声明与赋值、函数指针作为类型的核心用途、回调函数的设计模式。下面通过一个综合练习来巩固这些知识。
三、动手练习
#include <stdio.h>
// 回调函数类型:接收两个 int,返回 int
typedef int (*Operation)(int, int);
// 加法
int add(int a, int b) { return a + b; }
// 乘法
int multiply(int a, int b) { return a * b; }
// 取最大值
int max(int a, int b) { return a > b ? a : b; }
// 一个通用的"计算"函数,接收两个数和一种操作
int compute(int x, int y, Operation op)
{
return op(x, y);
}
int main(void)
{
int a = 12, b = 6;
printf("计算 a = %d, b = %d\n\n", a, b);
// 传入不同的回调函数
printf("加法结果:%d\n", compute(a, b, add));
printf("乘法结果:%d\n", compute(a, b, multiply));
printf("最大值结果:%d\n", compute(a, b, max));
return 0;
}
第十章 堆内存使用与内存分配
本章要点
第六章讲过,栈内存由编译器自动管理——函数进入时分配,返回时释放。但栈有两个局限:大小必须在编译时确定,函数返回后数据就消失。
当你需要运行时决定内存大小或让数据活得比函数更久时,栈就无能为力了。这正是堆内存(heap memory)的用武之地。本章涵盖以下内容:
- 堆与栈的区别——自动管理 vs 手动管理
- 四个核心函数:
malloc(申请)、free(释放)、calloc(申请并清零)、realloc(调整大小) - 内存安全:内存泄漏、悬空指针、常见错误及其规避
- 实践:动态数组与动态结构体
堆内存是 C 语言从“小程序“迈向“实用程序“的分水岭。没有它,你无法处理用户输入的不定长文本、无法构建链表和树、无法写出真正灵活的程序。
一、堆内存概念
第六章介绍过常见宿主实现的调用栈、动态分配区、静态数据和代码映射。本章聚焦 C 标准库提供的动态内存管理接口。
- 自动对象:生命周期通常随所在块的执行自动开始和结束
- 动态分配对象:由
malloc等函数创建,并由程序在合适时机调用free;实现内部可能复用已有内存,也可能向操作系统请求更多资源
操作堆内存靠四个声明在 <stdlib.h> 中的函数:malloc、calloc、realloc、free。下面逐一学习。
二、内存分配函数
1. malloc —— 申请堆内存
malloc 是 “memory allocation”(内存分配)的缩写。它向 C 运行库的分配器请求一块指定大小、满足基本对齐要求的连续存储,并返回指向该区域开头的指针。其函数原型为:
void *malloc(size_t size);
参数 size 表示要申请的字节数。返回值是一个 void * 类型的指针:申请成功时,该指针指向分配好的内存开头;如果系统内存不足导致申请失败,则返回 NULL。在 C 语言中,void *(无类型指针)可以自动转换为其他对象指针类型,因此通常无需显式的强制类型转换,直接将返回值赋给对应类型的指针变量即可。
堆内存最核心的特点:运行时确定大小。 回忆一下第三章的数组——声明一个普通数组时,大小必须是一个编译时就能确定的常量:int scores[5]; 中的 5 是写死在代码里的,程序运行之后无法改变。但现实中很多场景你事先并不知道需要多大的空间——比如用户想输入多少条记录、文件里有多少行数据、网络传输的消息有多长。堆内存正是为这类需求设计的:你可以在程序运行时根据实际条件(比如 scanf 读取到的用户输入)动态计算出需要的字节数,然后交给 malloc 去分配。也就是说,数组的大小由“写代码时写死“变成了“跑起来之后按需决定“——这是堆内存和栈数组最本质的区别。
下面是一个完整的使用示例——注意数组的大小 n 来自用户输入,编译时完全不知道具体值:
#include <stdio.h>
#include <stdint.h> // SIZE_MAX
#include <stdlib.h> // malloc, free
int main(void)
{
int n;
printf("请输入学生人数:");
if (scanf("%d", &n) != 1 || n <= 0 || (size_t)n > SIZE_MAX / sizeof(int))
{
printf("人数无效或所需空间过大\n");
return 1;
}
// 在堆上申请存放 n 个 int 的空间
int *scores = malloc((size_t)n * sizeof(int));
// 使用返回值前必须检查是否申请成功
if (scores == NULL)
{
printf("内存分配失败!\n");
return 1; // 异常退出
}
// 把这块空间当作数组来用
for (int i = 0; i < n; i++)
{
printf("请输入第 %d 个学生的成绩:", i + 1);
if (scanf("%d", &scores[i]) != 1)
// if (scanf("%d", scores + i) != 1) // 指针写法:与上行等价
{
fprintf(stderr, "第 %d 个成绩不是有效整数。\n", i + 1);
free(scores);
return 1;
}
}
printf("\n你输入的成绩:");
for (int i = 0; i < n; i++)
{
printf("%d ", scores[i]);
// printf("%d ", *(scores + i)); // 指针写法:与上行等价
}
printf("\n");
// 用完后必须释放
free(scores);
scores = NULL;
return 0;
}
这段代码展示了几个值得注意的细节:
n * sizeof(int)计算字节数:例如n = 50、sizeof(int)为 4 时,申请 200 字节。sizeof确保在不同平台上都能算出正确的值,不要手写数字。malloc返回void *,自动转为int *:scores可以像普通int数组一样通过scores[i]访问——这正是动态内存最直观的用法。- 必须检查返回值:如果
scores == NULL,说明分配失败(系统内存不足等),此时直接使用该指针将导致程序崩溃。 - 用完后必须
free释放:和普通数组最大的区别——堆内存的生命周期由你控制,不用了要手动归还,否则造成内存泄漏。
💡 提示:为什么指针也能用
[ ]?你可能注意到了——
scores明明是一个指针(int *),却可以用scores[i]这种下标方式来访问。初学者容易误以为[ ]是“数组专用“的语法,其实不然。在 C 语言中,p[i]和*(p + i)是完全等价的两种写法——编译器看到p[i],内部会把它翻译成*(p + i),然后按指针算术运算找到对应地址。也就是说,[ ]本质上只是指针运算的一种简写形式,数组能用它,指针也能用它。这项机制是 C 语言数组和指针深层关系的基石,第十一章会专门展开讲解。目前只需要知道:堆上malloc出来的指针,放心用[ ]就行了。
2. free —— 释放堆内存
堆内存的生命周期完全由程序员控制。当你不再需要某块通过 malloc、calloc 或 realloc 获得的动态内存时,必须调用 free 将其归还给系统,否则这块内存将一直被占用直到程序结束。free 的函数原型为:
void free(void *ptr);
ptr 必须是空指针,或仍指向此前由兼容分配函数返回且尚未释放的分配对象开头;传入内部地址或重复释放都会产生未定义行为。调用 free 后对象生命周期结束,原指针及其别名都不能再用于访问该对象。将当前所有者指针置为 NULL 有助于避免本变量被误用,但不会自动清除其他别名。
free(scores);
scores = NULL; // 现在 scores 明确表示不指向任何有效内存
此后如果再对 scores 解引用,就是对空指针解引用,仍然属于未定义行为;但它通常比悬空指针更容易被调试器、运行时检查或崩溃现场定位出来。
3. calloc —— 申请并清零
calloc 与 malloc 功能相似,但有两个重要区别:
-
参数形式不同:
calloc接受两个参数,分别表示元素个数和每个元素的大小,其原型为:void *calloc(size_t num, size_t size); -
calloc会将分配区域的所有位清零:而malloc返回的区域没有初始化,不能在写入前读取。对本章常见平台上的整数计数器,全零位表示数值 0;但 C 标准并不承诺全零位对所有类型都等于其语义上的零值或空指针,因此不要把calloc当成任意对象的通用初始化器。
calloc具有清零语义,但它与malloc的实际性能取决于分配器和操作系统实现;不要在未测量时断言哪一个一定更快。
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int n = 5;
// calloc 申请并清零
int *arr = calloc((size_t)n, sizeof(int));
if (arr == NULL)
{
printf("内存分配失败!\n");
return 1;
}
// 由于 calloc 清零,所有元素初始为 0
printf("calloc 分配后的数组:");
for (int i = 0; i < n; i++)
{
printf("%d ", arr[i]); // 输出 0 0 0 0 0
}
printf("\n");
free(arr);
arr = NULL;
return 0;
}
两种函数的对比如下:
malloc | calloc | |
|---|---|---|
| 参数 | 总字节数 | 元素个数 + 每个元素大小 |
| 初始化 | 不初始化,写入前不可读取 | 所有位清零 |
| 性能 | 取决于实现与使用方式 | 取决于实现与使用方式 |
需要全零位初始状态时选
calloc;马上逐元素写入时通常选malloc。性能差异应以目标平台测量结果为准。
4. realloc —— 调整已分配内存的大小
在实际编程中,经常遇到需要调整已分配内存大小的情况——比如一开始申请了 10 个元素的空间,后来发现需要扩展到 20 个。realloc 函数正是为此设计的,它可以在保留原有数据的前提下调整内存块的大小。其原型为:
void *realloc(void *ptr, size_t new_size);
其中 ptr 是此前通过 malloc、calloc 或 realloc 获得的指针,new_size 是新的总字节数。
关键结论:成功返回的地址可能与原来的 ptr 不同。 realloc 会保留新旧大小中较小范围内的原数据,但标准不规定分配器必须采用原地扩展还是搬迁策略,因此不能假设地址不变。new_size == 0 的历史规则较复杂,本教程只传入正数。
使用 realloc 时有一个极其重要的安全规则:必须用一个临时指针接收返回值,确认非 NULL 后再赋值给原指针。如果直接写 ptr = realloc(ptr, new_size),当扩展失败时 realloc 返回 NULL,原指针的值就被覆盖了——原有的内存地址既无法继续使用,也无法释放,造成内存泄漏。安全的模式如下:
int *temp = realloc(ptr, new_size);
if (temp == NULL)
{
// 扩展失败,但 ptr 仍然有效,可以继续用原数据或释放
}
else
{
ptr = temp;
}
下面是一个完整的扩展示例:
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int n = 3;
// 先申请 3 个 int
int *arr = malloc((size_t)n * sizeof(int));
if (arr == NULL) return 1;
arr[0] = 10; arr[1] = 20; arr[2] = 30;
printf("原始数组:%d %d %d\n", arr[0], arr[1], arr[2]);
// 扩大到 5 个 int
n = 5;
int *temp = realloc(arr, (size_t)n * sizeof(int));
if (temp == NULL)
{
printf("内存扩展失败,原数据仍保留在 arr 中\n");
free(arr); // 还是要释放
return 1;
}
arr = temp; // 用新指针更新 arr
// 新增的两个元素手动赋值
arr[3] = 40;
arr[4] = 50;
printf("扩展后数组:");
for (int i = 0; i < n; i++)
{
printf("%d ", arr[i]);
}
printf("\n");
free(arr);
arr = NULL;
return 0;
}
至此,我们已经介绍了操作堆内存的四个核心函数。掌握了它们的用法,就拥有了灵活管理内存的能力。然而,这份灵活性也伴随着不可忽视的风险——动态内存管理中最困难的不是如何使用这些函数,而是如何安全地使用它们。接下来,我们将讨论两个最常见也是最危险的内存管理陷阱。
三、内存安全
动态内存赋予了你极大的灵活性,但也引入了两个需要时刻警惕的陷阱:内存泄漏和悬空指针。下面逐一拆解。
1. 内存泄漏(Memory Leak)
内存泄漏是指申请了堆内存但用完后没有释放。
打个比方:你向仓库租了一个储物间存放物品,项目结束后你离开了,却没有办理退租手续。仓库管理员以为你还要继续使用,这间房就一直被你占着。久而久之,仓库里堆满了无人使用的“幽灵房间“,真正有需求的租客却无房可租。
在程序中,内存泄漏的后果:
- 轻则:进程占用内存持续增长,拖慢系统性能
- 重则:耗尽可用内存,导致程序甚至整个系统崩溃
void leak_example(void)
{
int *p = malloc(100 * sizeof(int));
// 忘记 free(p); 若同时丢失最后一个指针,这块内存在本次进程中将无法再释放
}
2. 悬空指针(Dangling Pointer)
悬空指针是另一个维度的危险:你已经把租用的储物间退还了(调用了 free),但通讯录上还记着那个房间的门牌号。下次你按着门牌号去使用那个房间时,它可能已经被分配给了别人,里面存放着完全不同的物品——甚至房间本身已经被拆除。
根据产生方式的不同,悬空指针可以分为以下两种情况。
情况一:同一指针 free 后继续使用
这是最简单的情形——自己申请、自己释放、释放之后忘了这回事又去用:
int *p = malloc(sizeof(int));
*p = 42;
free(p); // 房间已退租
*p = 100; // 危险!悬空指针,操作已释放的内存
这种情形相对容易察觉——毕竟 p 是自己亲手 free 的,代码相邻,排查时多扫一眼就能发现。
情况二:多个指针指向同一块内存,其中一个释放后另一个悬空
这才是悬空指针最隐蔽、最危险的形态。你通过指针 A 释放了内存,但指针 B 毫不知情,仍然持有着那片已经归还的地址:
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
// 申请一块堆内存
int *owner = malloc(sizeof(int));
if (owner == NULL) return 1;
*owner = 100;
// 另一个指针也指向同一块内存
int *alias = owner;
printf("owner 指向:%p,值 = %d\n", (void *)owner, *owner);
printf("alias 指向:%p,值 = %d\n", (void *)alias, *alias);
// owner 决定释放这块内存
free(owner);
owner = NULL; // owner 自己安全了
// 但是 alias 完全不知情!
// alias 现在是一个悬空指针——它指向的内存已经被回收
printf("owner = %p(已置 NULL)\n", (void *)owner);
printf("alias = %p(悬空!仍指向已释放的内存)\n", (void *)alias);
// 如果接下来对 alias 解引用,行为不可预测:
// *alias = 200; // 危险!可能崩溃,也可能默默破坏其他数据
// 正确做法:要么在 free 前同步通知所有指向者,
// 要么在一开始就明确"谁拥有这块内存的所有权"
return 0;
}
运行这段代码,owner 和 alias 打印出的地址值相同——它们指向同一分配对象。free(owner) 执行后,该对象的生命周期结束,分配器可以复用这块存储;alias 随即成为悬空指针。它通常仍保存旧的地址表示,但不能再解引用、参与指针运算或传给 free。仅用 if (alias) 不能判断对象是否仍然有效。
这个例子揭示了一条重要的设计规则:在存在多个指针指向同一块堆内存时,必须明确谁是这块内存的“所有者“,只有所有者有权 free。其他指针都是“借用者“,在所有者释放之后,借用者必须被同步置为 NULL,或者从逻辑上保证不再使用。这条规则在链表、树等数据结构中尤为重要——一个节点被删除时,所有指向它的“前驱“指针都需要被正确更新。
对悬空指针的解引用可能导致程序崩溃,也可能不崩溃但产生诡异的数据错误——后者往往更难排查。防御悬空指针的核心策略是:free 后立即将指针置为 NULL(free(p); p = NULL;)。如果多个指针指向同一块内存,释放后必须将所有指向它的指针都置为 NULL,这在实际项目中需要非常小心的设计。
3. 常见错误一览
除了上述两大核心陷阱之外,在实际编程中还有若干细节容易出错。下表归纳了最常见的几种情况,建议在每次使用动态内存时对照检查:
| 错误 | 描述 | 后果 |
|---|---|---|
忘记检查 malloc 返回值 | 分配失败却继续使用 NULL 指针 | 程序崩溃 |
忘记 free | 不再使用的内存不释放 | 内存泄漏 |
重复 free | 同一指针释放两次 | 程序崩溃或不可预测行为 |
free 后继续使用 | 悬空指针 | 不可预测,严重安全隐患 |
realloc 不检查返回值 | 扩展失败却把原指针覆盖 | 内存泄漏 + 数据丢失 |
| 越界访问动态内存 | 写入超出分配范围的地址 | 破坏堆结构,程序崩溃 |
这些规则背后有一条根本原则:堆内存的生命周期由程序员全权负责。它不会在函数返回时自动释放,不会在超出作用域时自动清理——从 malloc 的那一刻起,直到 free 的那一刻止,你手中的每一块动态内存都必须有明确且唯一的“归宿“。理解了这一点,就抓住了动态内存管理的核心。
四、动手练习
1. 动态数组与动态结构体
动态内存最常见的应用场景是创建动态数组和动态结构体。动态数组允许程序在运行时根据用户输入或其他条件决定数组的实际大小,其典型模式如下:
int n;
if (scanf("%d", &n) != 1 || n <= 0 ||
(size_t)n > SIZE_MAX / sizeof(int)) return 1;
int *arr = malloc((size_t)n * sizeof(int));
if (arr == NULL) return 1;
// 在 [0, n) 范围内使用 arr[i] ...
free(arr);
结构体本身也可以动态创建,这在处理数量不确定的记录时非常实用。下面的例子展示了如何动态分配一个结构体数组,并通过指针访问其成员:
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
struct Student
{
char name[20];
int age;
float score;
};
int main(void)
{
int n;
printf("请输入学生人数:");
if (scanf("%d", &n) != 1 || n <= 0 ||
(size_t)n > SIZE_MAX / sizeof(struct Student))
{
fprintf(stderr, "人数无效或所需空间过大。\n");
return 1;
}
// 动态分配一个结构体数组
struct Student *students = malloc((size_t)n * sizeof(struct Student));
if (students == NULL)
{
printf("内存分配失败!\n");
return 1;
}
// 使用结构体指针访问成员
for (int i = 0; i < n; i++)
{
printf("请输入第 %d 个学生的姓名:", i + 1);
if (scanf("%19s", students[i].name) != 1)
{
free(students);
return 1;
}
printf("年龄:");
if (scanf("%d", &students[i].age) != 1)
{
free(students);
return 1;
}
printf("成绩:");
if (scanf("%f", &students[i].score) != 1)
{
free(students);
return 1;
}
}
printf("\n===== 学生信息 =====\n");
for (int i = 0; i < n; i++)
{
printf("%s,年龄 %d,成绩 %.1f\n",
students[i].name, students[i].age, students[i].score);
}
free(students);
students = NULL;
return 0;
}
2. 综合练习
下面的程序综合运用了 malloc、calloc 和 realloc,展示了动态数组的创建、求和、求平均值以及动态扩展等操作。
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
int main(void)
{
// 练习1:动态数组求和与平均值
int n;
printf("请输入数字个数:");
if (scanf("%d", &n) != 1 || n <= 0 ||
(size_t)n > SIZE_MAX / sizeof(double))
{
printf("数字个数必须大于 0\n");
return 1;
}
double *arr = malloc((size_t)n * sizeof(double));
if (arr == NULL) return 1;
double sum = 0.0;
printf("请输入 %d 个数字:\n", n);
for (int i = 0; i < n; i++)
{
if (scanf("%lf", &arr[i]) != 1)
{
free(arr);
return 1;
}
sum += arr[i];
}
printf("总和 = %.2f\n", sum);
printf("平均值 = %.2f\n", sum / n);
free(arr);
arr = NULL;
// 练习2:动态扩展数组
int cap = 3;
int *nums = malloc((size_t)cap * sizeof(int));
if (nums == NULL) return 1;
nums[0] = 1; nums[1] = 2; nums[2] = 3;
printf("当前数组大小 %d,内容:%d %d %d\n", cap, nums[0], nums[1], nums[2]);
int new_cap = 6;
int *temp = realloc(nums, (size_t)new_cap * sizeof(int));
if (temp == NULL)
{
printf("扩展失败\n");
free(nums);
return 1;
}
nums = temp;
cap = new_cap;
nums[3] = 4; nums[4] = 5; nums[5] = 6;
printf("扩展后大小 %d,内容:", cap);
for (int i = 0; i < cap; i++) printf("%d ", nums[i]);
printf("\n");
free(nums);
nums = NULL;
return 0;
}
第十一章 指针和数组的关系
本章要点
在第十章中,我们用 malloc 动态分配了一段内存,返回的是一个 int * 类型的指针,却可以像数组一样用 scores[i] 来访问它。这个现象自然而然引出一个问题:指针和数组之间,到底有什么关系? 再加上此前我们就注意到数组名在多数场合表现得像一个地址——这两条线索指向同一个方向:指针和数组在 C 语言中有着深层的关联。
在此之前,我们分别学习了数组(第三章)和指针(第七章)。数组是一片连续内存,用下标访问;指针存地址,用 * 间接访问。本章把这两个概念放在一起,彻底梳理清楚它们的关系。
本章把指针与数组的关系彻底梳理清楚。具体涵盖以下内容:
- 数组名和指针的关系——数组名是不是指针?退化机制、
[ ]的本质、sizeof与取地址的差异 - 数组和
malloc出的指针的关系——为什么堆内存也能当数组用?栈数组与堆内存的对比
理解了这些,你便能在两种表示之间自如切换,也为后续章节理解生命周期和动态内存打下基础。
一、数组名和指针的关系
1. 数组名——多数场景会”退化”为指针
当你写下 int arr[5]; 时,arr 这个名字代表的是整个数组——5 个 int 连在一起的一整块空间。然而,在绝大多数表达式中(sizeof 和 & 是少数例外),编译器会自动将 arr 转换为数组首元素的地址。这个行为通常被称为数组名到指针的”退化”(decay)。
int arr[5] = {10, 20, 30, 40, 50};
// arr 在表达式中退化为首元素地址,所以这两行打印的值相同
printf("arr = %p\n", (void *)arr);
printf("&arr[0] = %p\n", (void *)&arr[0]);
要注意的是,arr 退化后得到的是一个地址值,但 arr 本身并不是一个指针变量——它没有自己独立的存储空间来存这个地址。它只是数组的名字,在需要参与运算时被编译器”翻译”成了首元素地址。因此数组名不能像指针变量那样被重新赋值:
int arr[5];
int *p = arr; // 正确:把数组首地址赋给指针 p
arr = p; // 错误!数组名不能放在等号左边被赋值
一个有效的心理模型:你可以把
arr想象成一个固定不变的首元素地址——它的值永远是数组开头的位置,你不能修改它,但你可以读它、用它参与运算(arr + 1、arr[2]等等)。这个简化模型足够应付绝大多数初学场景。更精确的理解(数组名标识数组对象、在表达式中发生退化)会随着使用经验的积累自然建立起来,目前不必强求。
2. 数组名和指针的本质区别
上面我们看到,数组名退化后在表达式中表现得”像”一个指针。那么,数组名到底是不是指针?
答案很明确:不是。 arr 是数组的名字,p 是指针变量,两者在本质上完全不同:
- 数组是一块数据的容器——
int arr[5]在栈上分配了 5 个int连在一起的一整片空间,arr是这片空间的名字。 - 指针是一个寻址的工具——
int *p是一个独立的 8 字节变量,里面存着一张写着”你要找的数据在某某位置”的纸条。
正因为数组名不是指针变量,它们在具体行为上产生了实际的差异。下面逐一来看。
差异一:sizeof 的结果不同
int arr[5] = {1, 2, 3, 4, 5};
int *p = arr;
printf("sizeof(arr) = %d\n", (int)sizeof(arr)); // 20(整个数组的字节数)
printf("sizeof(p) = %d\n", (int)sizeof(p)); // 8(指针本身的字节数,64位系统)
sizeof(arr):得到整个数组占用的内存大小——5 * sizeof(int),大多数平台上为 20 字节。sizeof是少数数组名不退化的例外之一,此时arr代表的是整片数组空间。sizeof(p):得到指针变量本身的大小,64 位系统上通常是 8 字节,与它指向什么数据毫无关系。
这个差异在将数组作为函数参数传递时尤为关键,后续学习函数与数组配合时自然会遇到。
差异二:取地址的行为不同
int arr[5];
printf("arr = %p\n", (void *)arr); // 首元素地址
printf("&arr = %p\n", (void *)&arr); // 整个数组的地址
// 这两个数值相同,但类型不同!
虽然 arr 和 &arr 打印出的地址值相同,但它们的类型截然不同:arr 在表达式中退化为 int *(指向首元素的指针),而 &arr 的类型是 int (*)[5](指向整个数组的指针)。这种类型差异会影响指针算术运算——对 arr 加 1 会前进 1 个 int 的距离,而对 &arr 加 1 则会前进整个数组的距离(即 5 * sizeof(int) 字节)。
对比指针变量:
int *p = arr;
printf("p = %p\n", (void *)p); // p 的值:arr 的首地址
printf("&p = %p\n", (void *)&p); // 指针变量 p 自己的地址(和 p 的值完全不同!)
p 是一个独立的指针变量,它有自己的存储空间。p 的值是数组的首地址,而 &p 是 p 这个变量自身在内存中的地址——两者毫不相干。
差异三:数组名不可修改
int arr[5];
int *p = arr;
p++; // 正确:p 是变量,可以改变指向
arr++; // 错误!数组名不能自增
数组名代表整块数组空间本身,它不是一个单独存放地址的变量——这才是它不能放在赋值号左边、也不能执行 ++ 的根本原因。指针变量则不同,它是一个实实在在的变量,里面存了一个地址,可以随时改写成另一个地址,指向不同的数据。
二、数组和 malloc 出的指针的关系
第一部分回答了”数组名是不是指针”——不是。现在来看另一个同样重要的问题:malloc 返回的明明是一个指针,为什么能把它当数组用?
回忆一下第十章中这个程序片段——我们让用户输入学生人数 n,然后 malloc 了一块内存来存放成绩,紧接着就用 scores[i] 访问了它:
int n;
printf("请输入学生人数:");
scanf("%d", &n);
int *scores = malloc((size_t)n * sizeof(int));
if (scores == NULL) return 1;
// 像使用数组一样使用 scores!
for (int i = 0; i < n; i++)
{
printf("请输入第 %d 个学生的成绩:", i + 1);
scanf("%d", &scores[i]); // ← scores 明明是指针,却用了 [i]
}
for (int i = 0; i < n; i++)
{
printf("%d ", scores[i]); // ← 这里也是
}
scores 是一个 int * 类型的指针变量,为什么能对它写 scores[i]?要回答这个问题,得先从 [ ] 这个符号本身说起。
1. [ ] 的真相——为什么指针也能用下标访问?
这背后的机制其实在第七章已经埋下了:p[i] 和 *(p + i) 是完全等价的。 指针可以加一个整数做移动——p + i 就是从 p 当前位置向高地址方向移动 i 个元素。编译器看到 p[i],内部就是把它翻译成 *(p + i) 来处理的。
换句话说,上面第十章代码中的 scores[i],在编译器眼里就是 *(scores + i)。而 scores + i 正好指向第 i 个 int 的位置——这一切和 scores 是不是”数组”毫无关系,它只要求 scores 是一个指针、且它指向的内存是连续排列的。
[ ] 只是指针算术运算的一种简写形式,从来就不是数组的”专利”。
2. 指针算术运算——为什么 arr[i] 和 *(arr + i) 等价
理解了 [ ] 的本质,再来看数组名。数组名在表达式中会退化为首元素地址,那么”第 i 个元素”的地址显然可以通过首地址加上偏移量算出:
首地址 + 偏移量 → 第 i 个元素的地址
C 语言的指针加法会自动根据指向的类型进行缩放。例如 int *p 加 1,地址值实际增加的是 sizeof(int) 个字节(通常是 4),正好跳过一个 int。
int arr[5] = {10, 20, 30, 40, 50};
// 下面两行完全等价
printf("%d\n", arr[2]); // 输出 30
printf("%d\n", *(arr + 2)); // 输出 30
arr[i] 的本质就是 *(arr + i)——这不是巧合,而是 C 语言标准规定的语义。编译器看到 arr[i],先让 arr 退化为首地址,再按 *(首地址 + i) 的方式去访问内存。同样的逻辑也适用于任何指向连续内存的指针。
回到第十章的 scores:scores[i] 被翻译成 *(scores + i),而 scores + i 正是通过 malloc 分配的那块堆内存中第 i 个 int 的位置。这也正是第十章代码中,注释里写下 scores + i 和 *(scores + i) 的原因——它们和 &scores[i]、scores[i] 分别是完全等价的写法。
3. 用指针遍历数组
[i] = *(p + i) 意味着遍历同一段连续内存可以有多种写法。直接用第十章的 scores 为例,三种方式的对比一目了然:
// 沿用第十章的变量:int *scores, int n
// scores 指向堆上一段连续的 int 空间
// 方法1:下标法(最常用,第十章原版写法)
for (int i = 0; i < n; i++)
{
printf("%d ", scores[i]);
}
// 方法2:指针偏移法(等价写法)
for (int i = 0; i < n; i++)
{
printf("%d ", *(scores + i)); // scores[i] == *(scores + i)
}
// 方法3:移动指针法(p 自己往前走)
int *p = scores; // p 指向首元素
for (int i = 0; i < n; i++)
{
printf("%d ", *p);
p++; // p 移动到下一个元素
}
三种写法输出完全一致,各自适用的场景也不同:
- 下标法:最直观——明确表达了”访问第几个元素”的语义,推荐日常使用
- 指针偏移法:更贴近底层真相——
scores + i算出地址,*取出值,有助于理解编译器的视角 - 移动指针法:在字符串处理、链表遍历等算法中非常常见,是 C 语言惯用的遍历模式
4. malloc 指针为什么能当数组用?
现在可以完整回答了。第三章讲过,数组的本质是一段连续排列的内存空间——元素一个接一个,中间没有缝隙。而第十章中 malloc 做的事情,恰恰也是在内存中划出一块连续的区域并返回其起始地址。
对比一下第三章的栈数组和第十章的堆内存:
栈数组:int arr[5]; → 栈上分配 5 个连续的 int
堆内存:int *scores = malloc(n * sizeof(int)); → 堆上分配 n 个连续的 int
虽然一个在栈上、一个在堆上,但它们在内存中的排布方式是完全一样的——都有一个首地址,后面紧挨着一排同类型的元素。而 [i] 只是”从首地址出发、向高地址移动 i 步、取出那个位置的值”的简写形式——内存在栈上还是堆上,丝毫不影响这个机制的运作。
5. 栈数组和堆内存的对比
相似性说完了——那它们在使用上到底有什么区别?回到第十章的 scores 和第三章的 int arr[5],对比一下:
栈数组 int arr[5] | malloc 指针(第十章 scores) | |
|---|---|---|
| 大小决定时机 | 编译时写死,5 必须写常量 | 运行时按需决定,n 来自 scanf |
| 生命周期 | 自动管理,离开 { } 就释放 | 手动管理,不 free 就永远占着 |
sizeof 结果 | 整个数组的字节数(20) | 指针变量本身的大小(8 字节) |
| 能否改变大小 | 不能 | 可以通过 realloc 调整 |
一句话总结:栈数组和 malloc 指针在”用下标访问元素”这件事上用法完全一致,但在”从哪来、活多久、占多大”这些维度上是两套完全不同的规则。 这也正是第三章(固定数组)和第十章(动态内存)各自独立成章的缘由——它们解决的是同一个问题(管理一组同类型数据),但适用的场景和掌控的复杂度完全不同。
三、动手练习
#include <stdio.h>
int main(void)
{
// 练习1:用三种不同方式输出数组
int arr[6] = {1, 3, 5, 7, 9, 11};
printf("练习1:三种遍历方式\n");
// 方式1:下标法
for (int i = 0; i < 6; i++) printf("%d ", arr[i]);
printf("\n");
// 方式2:指针偏移法
for (int i = 0; i < 6; i++) printf("%d ", *(arr + i));
printf("\n");
// 方式3:移动指针法
int *p = arr;
for (int i = 0; i < 6; i++) printf("%d ", *p++);
printf("\n\n");
// 练习2:验证 sizeof 的区别
printf("练习2:sizeof 区别\n");
printf("sizeof(arr) = %d(整个数组)\n", (int)sizeof(arr));
printf("sizeof(p) = %d(指针本身)\n\n", (int)sizeof(p));
// 练习3:指针数组和数组指针的简单演示
int a = 10, b = 20, c = 30;
// 指针数组:数组里存指针
int *ptr_arr[3] = {&a, &b, &c};
printf("练习3:指针数组\n");
for (int i = 0; i < 3; i++)
{
printf("ptr_arr[%d] 指向的值:%d\n", i, *ptr_arr[i]);
}
// 数组指针:指向整个数组
int matrix[3] = {100, 200, 300};
int (*arr_ptr)[3] = &matrix;
printf("\n数组指针:通过 (*arr_ptr)[i] 访问\n");
for (int i = 0; i < 3; i++)
{
printf("(*arr_ptr)[%d] = %d\n", i, (*arr_ptr)[i]);
}
return 0;
}
第十二章 生命周期
本章要点
第六章介绍了常见进程布局,并强调 C 标准真正规定的是对象的存储期等语义。本章继续回答“对象的存储何时存在、指针何时仍然有效”。
生命周期就是变量从被创建到被销毁的全过程。本章从这个角度出发,先简要回顾作用域,然后引出生命周期的三种形态,最后通过综合案例和方法论把两个概念串起来。具体涵盖:
- 作用域简要回顾(第六章已详细讲过,此处只做提炼)
- 生命周期的三种存储期:自动、静态、动态——创建和销毁的时机各有什么不同
- 作用域与生命周期脱钩的场景——
static局部变量 - 综合案例分析:用生命周期视角跟踪变量的“生与死“
- 方法论:如何选择堆还是栈
一、作用域——简要回顾
第六章在讲局部变量时已经引入了作用域:{ } 就是变量的“辖区“,出了辖区变量名就不可用了,内层可以看到外层的变量,外层看不到内层的变量。
C 语言中作用域有四种形式——块作用域({ } 内部)、文件作用域(所有函数之外)、函数原型作用域(函数声明中的参数列表内)和函数作用域(仅适用于 goto 标签)——但归根结底,作用域是一个编译时的概念:编译器用它来检查“你能不能在这里用这个变量名“。
关键认知:作用域只管名字能不能用,不管数据还活没活着。static 局部变量出了 { } 名字不能用了,但数据还活着。这个差异,引出了本章真正的主题。
二、生命周期——变量什么时候“活着“
先理解“生命周期“到底在说什么
存储期(storage duration)决定对象所需存储在何时保留;生命周期(lifetime)描述对象在何时存在并可按其类型访问。二者紧密相关,但不是同一个术语。本章会在不混淆标准概念的前提下,用“生与死”帮助理解。
接下来,我们以生命周期视角重新分析第六章的三个内存区域。什么叫以生命周期视角?就是以变量为主体,关注它什么时候被创建、什么时候被销毁。
本章重点比较三类常见情形:自动存储期、静态存储期,以及由 malloc 等函数获得的已分配存储期。调用栈和“堆”是常见实现术语,标准语义应以前述存储期为准。
1. 自动存储期——随块执行开始和结束
多数定义在块内部、没有 static 等存储类说明符的对象具有自动存储期(automatic storage duration)。常见实现可能使用调用栈或寄存器,程序员不需要也不能对这些对象调用 free。
开始时机:执行进入相应块时,对象的存储得到保留;变长数组的规则更细,本章不展开。
结束时机:执行离开相应块时,对象生命周期结束,其指针不能再解引用。
下面用一个稍复杂的例子来观察整个过程:
#include <stdio.h>
void inner(void)
{
int c = 30; // ③ c 的生命周期开始
printf("inner: c = %d\n", c);
} // ④ 离开 inner,c 销毁
void outer(void)
{
int b = 20; // ② b 的生命周期开始
printf("outer: b = %d\n", b);
inner();
// inner 返回后,c 已经不存在了
printf("outer: b = %d\n", b);
} // ⑤ 离开 outer,b 销毁
int main(void)
{
int a = 10; // ① a 的生命周期开始
printf("main: a = %d\n", a);
outer();
printf("main: a = %d\n", a);
return 0;
} // ⑥ 离开 main,a 销毁
从语言语义看,对象生命周期按嵌套调用顺序开始和结束;常见调用栈实现会相应地按后进先出管理栈帧。优化后的实际存储位置不影响这一语义。
对这里的普通自动对象而言,离开相应块后生命周期结束。此后即使某个指针还保留旧地址表示,也不能再通过它访问对象。这就是为什么不能返回指向普通局部对象的指针供调用者解引用。
2. 静态存储期——程序全程存在的“常驻居民“
具有静态存储期(static storage duration)的对象,其存储持续整个程序执行过程。常见实现把它们放入数据段、BSS 或只读数据映射,但标准不规定具体段名。
哪些变量具有静态存储期?两类:
- 全局变量:定义在所有函数外部的变量。即使没加
static关键字,它们也是静态存储期。 static修饰的局部变量:定义在函数内部但加了static关键字。
全局变量很简单——文件作用域 + 静态存储期,整个程序运行期间随处可用。真正值得细说的是 static 局部变量,因为它展示了作用域和生命周期可以“脱钩“:
#include <stdio.h>
void count_calls(void)
{
static int count = 0; // 静态初始化一次,值在调用之间保留
count++;
printf("该函数已被调用 %d 次\n", count);
}
int main(void)
{
count_calls(); // 输出:该函数已被调用 1 次
count_calls(); // 输出:该函数已被调用 2 次
count_calls(); // 输出:该函数已被调用 3 次
return 0;
}
逐次调用时发生了什么:
| 调用次数 | 进入 count_calls 时 | count 的值 | 离开后 |
|---|---|---|---|
| 第 1 次 | count 已完成静态初始化,然后自增为 1 | 1 | count 还在 |
| 第 2 次 | 不重新初始化,count 保持上次的值 1,自增为 2 | 2 | count 还在 |
| 第 3 次 | 同上,自增为 3 | 3 | count 还在 |
count 的静态初始化在程序启动阶段完成一次,并不会在每次调用时重复。普通自动局部对象每次进入块都会形成新的生命周期,而该 static 对象贯穿整个程序执行。
再强调一遍:static 只改变生命周期,不改变作用域。count 仍然只能在 count_calls 函数内部通过名字访问——你在 main 里写 count 会编译报错。但这个变量的“寿命“却覆盖了整个程序运行期。
3. 已分配存储期——由分配与释放控制
通过 malloc、calloc、realloc 获得的存储具有已分配存储期(allocated storage duration,口语中常称动态内存)。其生命周期需要程序显式管理:
- 出生:调用
malloc/calloc成功返回的那一刻 - 死亡:调用
free的那一刻 - 如果丢失最后一个指针且未调用
free:在进程余下时间里无法再释放,形成内存泄漏
动态存储期在第十章已经详细讨论过,这里不再重复函数的用法,只强调它和另外两种存储期的本质区别:自动和静态的回收时机是语言规则定死的,动态的回收时机是你决定的。 这个“自由“恰恰是 C 语言内存管理中最容易出错的地方。
4. 三种存储期对比
| 自动存储期 | 静态存储期 | 动态存储期 | |
|---|---|---|---|
| 常见实现 | 栈帧或寄存器 | 数据/只读映射 | 运行库动态分配区域 |
| 创建时机 | 进入 {} 时 | 程序启动时 | malloc 成功时 |
| 销毁时机 | 离开 {} 时(自动) | 程序结束时(自动) | free 时(手动) |
| 由谁管理 | 编译器 | 编译器 | 程序员 |
| 典型变量 | 普通局部变量、函数参数 | 全局变量、static 变量 | malloc 分配的内存 |
| 跨函数传递 | 生命周期内可以;不得逃逸后使用 | 可以 | 释放前可以 |
三类存储期各有一套管理规则,但不应把它们机械等同于固定的物理分区。接下来把作用域、生命周期和指针有效性放在一起分析。
三、综合案例分析
案例一:返回局部变量的地址(经典错误)
#include <stdio.h>
#if 0
int *get_value(void)
{
int n = 42;
return &n; // 返回局部变量 n 的地址
}
#endif
int main(void)
{
// 错误示范已由 #if 0 禁用:不能解引用指向已结束生命周期对象的指针。
return 0;
}
逐步分析:
get_value被调用,自动对象n的生命周期开始,值为 42。return &n试图把指向n的指针返回给调用者。get_value返回时,n的生命周期结束。- 旧指针不再指向存活对象,随后解引用会产生未定义行为。
这个错误的本质是:指针指向的内存的生命周期短于指针本身的使用期。 n 的生命周期在函数返回时结束,但 p 还想继续用它。这类错误编译器通常只会给一个警告,不会阻止编译,运行时也不一定立即崩溃——“有时能跑、有时炸“正是它危险的原因。
案例二:静态局部变量——安全返回地址
#include <stdio.h>
int *get_counter(void)
{
static int count = 0;
count++;
return &count; // 返回静态局部变量的地址
}
int main(void)
{
int *p1 = get_counter(); // count = 1
int *p2 = get_counter(); // count = 2
printf("%d %d\n", *p1, *p2); // 输出 2 2
return 0;
}
逐步分析:
- 第一次调用前,
count已完成静态初始化;调用时自增为 1并返回其地址。 get_counter返回——count的作用域结束了(名字不可见了),但它的生命周期没有结束,静态存储区的数据还在。- 第二次调用:
count的名字重新可见,值保持为 1,自增为 2,返回地址。 p1和p2指向的是同一块静态存储区地址,那块地址始终有效。所以*p1和*p2都输出 2——因为count只有一个,两次调用操作的是同一个变量。
这个案例展示了作用域和生命周期分离的含义:名字只是“访问权限“,生命周期才是“存在与否“。count 的名字在函数返回后暂时不可见,但它本身一直活着。
案例三:指针变量 vs 它指向的内存
#include <stdio.h>
#include <limits.h>
#include <stdint.h>
#include <stdlib.h>
int *create_array(size_t n)
{
if (n > SIZE_MAX / sizeof(int) || n > (size_t)INT_MAX / 10) return NULL;
int *arr = malloc(n * sizeof(int));
if (arr == NULL) return NULL;
for (size_t i = 0; i < n; i++)
arr[i] = (int)i * 10;
return arr; // 返回分配对象的地址
}
int main(void)
{
int *data = create_array(5);
if (data == NULL) return 1;
for (size_t i = 0; i < 5; i++)
printf("%d ", data[i]); // 输出 0 10 20 30 40
free(data); // 用完后释放
return 0;
}
这个案例中其实有两个不同生命周期的东西:
| 东西 | 存储位置 | 存储期 | 何时销毁 |
|---|---|---|---|
arr(指针变量本身) | 常见于栈帧 | 自动存储期 | create_array 返回时 |
arr 指向的分配对象 | 动态分配区 | 已分配存储期 | free(data) 时 |
create_array 返回后,局部指针变量 arr 的生命周期结束,但它保存的指针值已经作为返回值传出。main 中的 data 接收该值,因此可以在 free 前继续访问分配对象。一个指针变量的生命周期结束,并不会自动结束它所指分配对象的生命周期。
对比案例一:n 是自动对象,函数返回时其生命周期结束;案例三中的 arr 虽然也是自动对象,但它指向的是独立的分配对象,后者直到 free 才结束生命周期。判断指针是否有效,要看被指向对象,而不是只看指针变量本身。
案例四:多级指针与生命周期
再往深一层——如果函数需要修改调用者的指针,该怎么办?这涉及指针的指针,生命周期分析需要多绕一层:
#include <stdio.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
bool allocate(int **pp, size_t n)
{
if (pp == NULL || n > SIZE_MAX / sizeof(int)) return false;
*pp = malloc(n * sizeof(int)); // 修改调用者的指针
return *pp != NULL;
}
int main(void)
{
int *data = NULL;
if (!allocate(&data, 3)) return 1;
data[0] = 10; data[1] = 20; data[2] = 30;
for (int i = 0; i < 3; i++)
printf("%d ", data[i]);
free(data);
return 0;
}
生命周期分析:
data是main中的自动对象,离开相应块时生命周期结束data指向的对象具有已分配存储期,在free时结束生命周期pp是allocate的形参,也是自动对象*pp就是data——通过解引用pp,allocate修改了main中的data
pp 销毁了没关系,它只是临时用来“找到 data“的——关键操作 *pp = malloc(...) 已经把堆地址写进了 main 的 data 变量里,而 data 的生命周期还没结束。指针可以换来换去传递,只要最终指向的数据在目标生命周期内有效就行。
这四个案例聚焦同一条规律:形成悬空指针的原因,是被指向对象的生命周期已经结束。指针还可能因未初始化、为空、越界或类型不匹配而无效,因此每次使用前都要确认来源、边界、类型和生命周期。
四、内存管理——基于生命周期的方法论
理解了作用域和生命周期,我们现在可以正面回答一个更底层的问题:什么是内存管理?
内存管理就是确保程序中每一块内存的生命周期和它的使用需求精确匹配:
- 不早:使用的时候内存必须还在(不能访问已释放的变量)
- 不晚:不用的时候内存必须释放(不能占着不还,造成泄漏)
- 不混:每块内存有明确的“责任人“——谁分配、谁释放、谁使用,要说得清楚
这三个要求听起来简单,但在成百上千行的程序中,不借助一套系统性的思考方法,几乎不可能做到零差错。C 语言给出的设计框架其实非常清晰。面对一块数据,依次搞清楚三件事:
第一步:使用自动对象还是动态分配?
这是最优先的决策——选择不同的内存区域,意味着完全不同的管理方式。判据很直接:
- 普通局部变量、数组:大小固定、只在当前作用域使用时,优先声明为自动对象,例如
int x;或int arr[100];。常见实现会把其中一部分放在调用栈中,也可能放入寄存器或被优化掉;关键性质是离开作用域后生命周期自动结束。 - 动态内存分配:大小运行时才能确定,或者数据需要在函数返回后继续存活(传给调用者)→ 用堆。调用
malloc/calloc申请第十章已详细讲过,这里不再重复。
一条实用原则:能用作用域清晰的自动对象解决时,不必引入动态分配。
- 自动对象:生命周期由执行范围管理,不会形成动态内存泄漏
- 动态分配:需要明确所有权和
free路径,适合确有运行期容量或跨作用域生命周期需求的情况
很多初学者学会 malloc 之后到处用,觉得动态分配才“高级“——恰恰相反。在不需要动态存储期的场景下用 malloc,不是高级,是给自己找麻烦。
第二步:谁,在什么时候创建了变量?
这是变量生命周期的开始。创建者和创建时机取决于你第一步选了哪种内存:
- 自动对象:执行进入相应块时开始生命周期,编译器选择寄存器、栈帧或其他等价实现。比如
int a = 10;不需要手动分配或释放。 - 静态存储区变量:编译器和运行时系统在程序启动时统一分配。全局变量和
static变量在main函数执行之前就已经存在了。 - 动态分配内存:调用
malloc/calloc时,C 运行库的分配器提供一块满足要求的存储;分配器可能复用已有内存,也可能再向操作系统申请资源。该对象一直存活到被free,或进程终止为止。
第三步:谁,在什么时候销毁了变量?
这是变量生命周期的结束。销毁者和销毁时机同样因存储期而异:
- 自动对象:离开相应块时生命周期结束。你不需要、也不能对它调用
free;让指针逃逸并在之后解引用会产生未定义行为。 - 静态存储区变量:运行时系统在程序结束时统一回收。全局变量和
static变量从程序启动活到程序退出,整个过程中你不需要管。 - 堆内存:你调用
free的那一刻销毁。这是 C 语言内存管理中唯一需要你主动执行的释放操作。忘了free就是内存泄漏;free之后继续用就是悬空指针。
把这三步连在一起,就是 C 语言内存管理的全貌:
先选择合适的存储期 → 再确认对象何时开始和结束生命周期 → 最后保证每次访问都发生在有效期内。
日常编程中养成一个习惯:每写一个变量声明或 malloc 调用,心里默念一遍——它什么时候死的?谁杀的? 这两个问题有了明确答案,悬空指针和内存泄漏就离你很远。整个指针篇到此收束于这条准则——希望它成为你今后写每一行 C 代码时的底层直觉。
第十三章 共用体与枚举
本章要点
第五章学习的结构体能将多个不同类型的数据打包,每个成员拥有独立的内存空间。本章讨论另外两种构造类型:共用体和枚举。
- 共用体(union):多个成员共享同一块内存,同一时刻只能存一个成员的值
- 枚举(enum):给整型常量起有意义的名字,替换“魔法数字“
- 两者组合成**“带标签的联合体”**——用枚举标记共用体当前存的是哪种类型
一、共用体
1. 共用体(union)
1.1 从一种“多功能“储物格说起
结构体可以把不同类型的数据同时打包在一起,每个成员都有自己独立的空间。但有时我们需要的是一个“多合一“的存储单元:同一块空间,在不同的时候可以存放不同类型的数据,但一次只能容纳一种。
设想这样一个场景:一个变量在程序运行期间,有时需要存储整数,有时需要存储浮点数,有时需要存储字符——但这三种需求绝不会同时出现。如果用结构体来实现,三个成员各自占用各自的空间,会造成不必要的浪费。
共用体正是为这种场景设计的:所有成员共享同一块内存,你往其中写入一个新成员的值,就会覆盖掉旧成员的内容。
1.2 语法基本规则
共用体的定义语法和结构体非常相似,只需将关键字 struct 替换为 union:
union 共用体名
{
数据类型 成员1;
数据类型 成员2;
...
};
声明变量同样延续了结构体的方式:
union 共用体名 变量名;
成员访问也使用点运算符 .:
变量名.成员名
1.3 语法示例
#include <stdio.h>
union Data
{
int i;
float f;
char c;
};
int main(void)
{
union Data d;
d.i = 100;
printf("d.i = %d\n", d.i); // 100
d.f = 3.14f;
printf("d.f = %f\n", d.f); // 3.140000
// 注意:此时 d.i 的值已经无效了,下面不要再读取 d.i
d.c = 'A';
printf("d.c = %c\n", d.c); // A
// 同理,写入 d.c 后,d.f 也不再是当前有效成员
return 0;
}
输出:
d.i = 100
d.f = 3.140000
d.c = A
这段代码没有在写入新成员后继续读取旧成员,因为那样的结果不可移植。它想说明的关键事实是:共用体成员共享同一块空间,写入一个成员后,应当只把最后写入的那个成员视为当前有效成员。
注意:共用体本身并不记录当前存储的是哪一个成员,这一信息需要程序员自行维护——要么凭逻辑记住,要么额外设置一个标记变量来区分当前有效的数据类型。这正是共用体与枚举最自然的结合点:用枚举标记类型,用共用体承载数据,构成**“带标签的联合体”**。下面就来讨论枚举。
二、枚举
1. 枚举(enum)
1.1 从“魔法数字“说起
在之前的程序中,我们经常会使用整数来表示某种状态或选项:
int day = 1; // 1 表示星期一?还是星期日?
if (day == 1) { ... }
这类没有任何说明的裸数字被称为“魔法数字“(magic number)。随着时间的推移,连编写者自己都可能忘记这些数字的含义,更不用说其他人阅读代码时的困惑了。枚举的用途就是为一组相关联的整型常量赋予有意义的名字,让代码的含义一目了然。
1.2 语法基本规则
enum 枚举名
{
标识符1,
标识符2,
标识符3,
...
};
关于枚举值的默认行为,有两条简单规则需要记住:
- 不手动指定:第一个标识符默认值为
0,其后的标识符依次加1 - 手动指定某个值:后续未指定的标识符会在此基础上继续递增
声明枚举变量的语法与结构体类似:
enum 枚举名 变量名;
1.3 语法示例
#include <stdio.h>
// 定义星期枚举
enum Weekday
{
MONDAY, // 0
TUESDAY, // 1
WEDNESDAY, // 2
THURSDAY, // 3
FRIDAY, // 4
SATURDAY, // 5
SUNDAY // 6
};
int main(void)
{
enum Weekday today = WEDNESDAY;
printf("today = %d\n", today); // 2
if (today == WEDNESDAY)
{
printf("今天是星期三\n");
}
return 0;
}
输出:
today = 2
今天是星期三
1.4 手动指定值
枚举的值也可以由程序员手动控制。既可以为所有成员全部指定,也可以只指定其中一部分,其余成员自动递增:
enum Color
{
RED = 1,
GREEN = 5,
BLUE = 10
};
// 或者让部分自动递增
enum ErrorCode
{
OK = 0,
FILE_NOT_FOUND, // 1
PERMISSION_DENIED, // 2
UNKNOWN = 99
};
第一个未被手动指定的成员从 0 开始,其他未指定的成员在前一个已确定成员的值基础上加 1。
1.5 枚举的优点
使用枚举带来的好处体现在三个层面:
- 可读性:
if (today == FRIDAY)远比if (today == 4)清晰,阅读代码的人无需记忆数字与含义的对应关系。 - 组织性:枚举将一组相关的常量集中定义在一起,便于统一管理和修改,不会散落各处。
- 类型提示:枚举是与实现所选整数类型兼容的独立类型。使用枚举变量能表达取值所属的集合;混用不同枚举时,一些编译器也会给出警告。
1.6 枚举与 switch 的经典配合
枚举与 switch 语句是一对天然的搭档。枚举提供命名清晰的常量,switch 则在不同分支中分派对应的处理逻辑:
enum Weekday today = SATURDAY;
switch (today)
{
case MONDAY:
case TUESDAY:
case WEDNESDAY:
case THURSDAY:
case FRIDAY:
printf("工作日\n");
break;
case SATURDAY:
case SUNDAY:
printf("周末\n");
break;
default:
break;
}
三、动手练习
#include <stdio.h>
// 定义表示图形的共用体
union Shape
{
double radius; // 圆:半径
struct { double w, h; } rect; // 矩形:宽和高
};
// 定义图形类型枚举
enum ShapeType { CIRCLE, RECTANGLE };
int main(void)
{
// 练习1:共用体与枚举配合
enum ShapeType type = CIRCLE;
union Shape s;
if (type == CIRCLE)
{
s.radius = 5.0;
printf("圆形,半径 = %.2f, 面积 = %.2f\n", s.radius, 3.14 * s.radius * s.radius);
}
else if (type == RECTANGLE)
{
s.rect.w = 3.0;
s.rect.h = 4.0;
printf("矩形,宽 = %.2f, 高 = %.2f, 面积 = %.2f\n", s.rect.w, s.rect.h, s.rect.w * s.rect.h);
}
// 练习2:用枚举表示月份,输出月份天数
enum Month { JAN=1, FEB, MAR, APR, MAY, JUN, JUL, AUG, SEP, OCT, NOV, DEC };
enum Month m = MAR;
printf("\n%d 月有 ", m);
switch (m)
{
case JAN: case MAR: case MAY: case JUL: case AUG: case OCT: case DEC:
printf("31 天\n"); break;
case APR: case JUN: case SEP: case NOV:
printf("30 天\n"); break;
case FEB:
printf("28 或 29 天\n"); break;
default:
printf("无效月份\n");
}
return 0;
}
第十四章 宏
本章要点
在第一章中,我们第一次见到 #include 时提到过“预处理“——在编译之前,预处理器会先对源文件做一遍扫描。#include 把头文件内容复制过来,而本章要介绍的 #define,做的是另一件事:定义宏。
宏的核心机制是编译前的文本替换——在代码被真正编译之前,把宏名替换成定义的内容。本章涵盖以下内容:
- 最简单的宏:用
#define给常量值起名 - 带参数的宏(宏函数):像函数一样接受参数,但本质仍是文本替换
- 宏的本质与陷阱:替换发生在编译前、没有类型检查、运算符优先级问题
- 宏 vs 函数的取舍
理解宏的关键不在于记语法,而在于时刻意识到:宏不是 C 代码,宏是给预处理器看的文本替换规则。
一、最简单的宏
1.1 语法
#define 最基础的用法,是给一个固定不变的值起一个名字:
#define 宏名 替换内容
两点注意:
- 末尾没有分号——预处理指令自成一行,不受 C 语句标点规则约束
- 宏名习惯用全大写加下划线(
MAX_SIZE、BUFFER_LEN)——这不是语法要求,而是社区约定,让读代码的人一眼就能分辨“这是宏“还是“变量“
1.2 一个完整的例子
#include <stdio.h>
#define MAX_SIZE 100
#define PI 3.1415926
int main(void)
{
int scores[MAX_SIZE]; // int scores[100];
for (int i = 0; i < MAX_SIZE; i++) // i < 100;
scores[i] = i;
double radius = 5.0;
double area = PI * radius * radius;
printf("数组大小 = %d\n", MAX_SIZE);
printf("面积 = %.2f\n", area);
return 0;
}
预处理阶段会把这里的 MAX_SIZE 替换为 100,把 PI 替换为 3.1415926。在需要整数常量表达式的场景(例如文件作用域数组长度、case 标签)中,值合适的宏可以使用,而 C 语言中的块作用域 const int 并不自动成为整数常量表达式。支持 VLA 的实现允许块作用域数组使用运行期长度,但本教程不依赖这一可选特性。
二、带参数的宏
#define 还可以在宏名后面加一对括号,里面写上参数——这种宏看起来像函数调用,大家习惯叫它“宏函数“。但它并不是什么新语法,仍然是 #define,仍然做文本替换,只是替换的时候会把实参文本填到形参的位置。
宏名和左括号之间不能有空格——#define SQUARE (x) 会被理解成无参宏 SQUARE,替换内容是 (x),和带参数的 SQUARE(x) 完全不同。
#include <stdio.h>
#define SQUARE(x) ((x) * (x))
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int main(void)
{
printf("%d\n", SQUARE(5)); // ((5) * (5)) → 25
printf("%d\n", MAX(3, 7)); // ((3) > (7) ? (3) : (7)) → 7
printf("%f\n", SQUARE(1.5)); // ((1.5) * (1.5)) → 2.25
return 0;
}
SQUARE(5) 被替换成 ((5) * (5))——参数 5 填入了宏体中 x 的位置。
看起来和函数调用一样,但它不是函数调用:没有参数压栈、没有跳转、没有返回值传递。预处理器做的只是把实参文本填入宏体中形参的位置。至于这个“文本替换“的本质会带来什么问题,下一节集中讨论。
三、宏的本质:编译前的文本替换
上面两节学了最基本的宏和带参数的宏。现在来回答那个根本问题:宏到底是什么?
答案就一句话——宏是编译前的文本替换。预处理器不解析 C 语法,不检查类型,不做任何语义分析,它只做一件事:找到宏名,贴上宏体。
明白了这一点,前面那些“为什么要加括号““为什么 a++ 会出事”“为什么多语句要用 do-while(0)”——全部可以推导出来。
3.1 用 -E 亲眼看看
在讲各种陷阱之前,先掌握一个最有力的工具。写一个程序:
// macro_demo.c
#include <stdio.h>
#define PI 3.14159
#define SQUARE(x) ((x) * (x))
int main(void)
{
double r = 2.0;
double area = PI * SQUARE(r);
printf("面积 = %f\n", area);
return 0;
}
在终端中执行:
clang -E macro_demo.c # Clang 用户
# 或
gcc -E macro_demo.c # GCC 用户
-E 告诉编译器:只做预处理,不要编译。翻到输出末尾,main 函数变成了:
int main(void)
{
double r = 2.0;
double area = 3.14159 * ((r) * (r));
printf("面积 = %f\n", area);
return 0;
}
PI 不在了,变成了 3.14159。SQUARE(r) 不在了,变成了 ((r) * (r))。编译器根本不知道 PI 和 SQUARE 曾经存在过——它只看到替换完毕的纯 C 代码。
从今以后,你写的每一个宏都可以这样亲眼验证。 下面要讲的括号陷阱、副作用陷阱、多语句问题,用
-E展开一看,真相立即大白。
3.2 括号陷阱
回头看 SQUARE 的定义,括号似乎太多了:((x) * (x))。能不能简化成 x * x?做个实验:
#include <stdio.h>
#define SQUARE_BAD(x) x * x
#define SQUARE_GOOD(x) ((x) * (x))
int main(void)
{
printf("SQUARE_BAD(1+2) = %d\n", SQUARE_BAD(1 + 2));
printf("SQUARE_GOOD(1+2) = %d\n", SQUARE_GOOD(1 + 2));
printf("100 / SQUARE_BAD(2) = %d\n", 100 / SQUARE_BAD(2));
printf("100 / SQUARE_GOOD(2) = %d\n", 100 / SQUARE_GOOD(2));
return 0;
}
输出:
SQUARE_BAD(1+2) = 5
SQUARE_GOOD(1+2) = 9
100 / SQUARE_BAD(2) = 100
100 / SQUARE_GOOD(2) = 25
用 -E 展开,真相一目了然:
SQUARE_BAD(1+2) → 1 + 2 * 1 + 2 // 乘法优先:1+(2×1)+2 = 5
SQUARE_GOOD(1+2) → ((1 + 2) * (1 + 2)) // 括号先算:3×3 = 9
100 / SQUARE_BAD(2) → 100 / 2 * 2 // 左到右:(100/2)×2 = 100
100 / SQUARE_GOOD(2) → 100 / ((2)*(2)) // 括号先算:100/4 = 25
根因就是文本替换:参数表达式被原样粘贴到宏体中。1+2 贴到 x * x 里变成 1+2*1+2,乘法优先于加法,计算顺序全乱了。外层括号同样不可省略——100 / SQUARE_BAD(2) 变成 100 / 2 * 2,除法先于宏体内的乘法执行。
铁律:每个参数加括号,整体再加一层括号。
3.3 副作用陷阱
即便加够了括号,还有另一个根因相同的问题:
#include <stdio.h>
#define SQUARE(x) ((x) * (x))
int square_func(int x) { return x * x; }
int main(void)
{
int a = 5;
#if 0
// 错误示范被禁用:展开后同一完整表达式中出现两次无序的 a++。
int r1 = SQUARE(a++);
printf("SQUARE(a++): a=%d, result=%d\n", a, r1);
#endif
printf("SQUARE(a++) 会产生未定义行为,因此没有执行;a=%d\n", a);
int b = 5;
int r2 = square_func(b++);
printf("square_func(b++): b=%d, result=%d\n", b, r2);
return 0;
}
若取消 #if 0 保护,SQUARE(a++) 会产生未定义行为,因此不存在可以依赖的标准输出。当前安全版本只解释错误,不执行它;函数调用部分稳定输出 b=6, result=25。
SQUARE(a++) 会产生未定义行为,因此没有执行;a=5
square_func(b++): b=6, result=25
-E 展开 SQUARE(a++):
((a++) * (a++))
a++ 被贴了两次,两个对 a 的修改之间没有规定的求值顺序,行为未定义;不能简单概括成“稳定地递增两次”。函数 square_func(b++) 不同——实参表达式只出现一次,求值得到 5 后将值传入函数,因此 b 只递增一次。
核心区别:函数是先求值再传参,宏是先粘贴再求值。
防御规则:永远不要在宏参数中放带副作用的表达式——++、--、赋值、以及会修改状态的函数调用。需要安全性时,用函数。
3.4 多语句宏
再来看一个更隐蔽的问题。定义一个“交换两个变量“的宏:
#define SWAP(a, b) \
int temp = a; \
a = b; \
b = temp;
反斜杠 \ 是续行符。如果用在 if 后面:
if (x > y)
SWAP(x, y);
else
printf("x <= y\n");
-E 展开后:
if (x > y)
int temp = x; // 只有这一行归 if 管
x = y; // 脱离了 if,不管条件真假都会执行
y = temp; // else 也找不到配对的 if 了
else
printf("x <= y\n");
三行语句被原样粘贴到 if 后面,但 if 默认只管紧接着的第一条语句——后面两行脱离了控制,else 也断了关联。根因仍然是文本替换:预处理器只管粘贴,不管 C 的语法结构。
标准解决方案——用 do { ... } while(0) 把宏体包成一条整体语句:
#define SWAP(a, b) \
do { \
int _temp = a; \
a = b; \
b = _temp; \
} while(0)
展开后:
if (x > y)
do { int _temp = x; x = y; y = _temp; } while(0);
else
printf("x <= y\n");
do { ... } while(0) 是一条完整的语句,if 正好管住它。while(0) 循环体只执行一次,编译器会优化掉循环结构,零运行时开销。这是 C 语言中多语句宏的标准写法。
第十五章 文件IO
在前面的章节中,我们的程序始终与键盘和屏幕打交道——数据从键盘输入,结果输出到终端窗口,程序一旦退出,所有的计算结果便荡然无存。
这一章将打破这个局限,引入 C 语言中一项关键的机制:文件读写。掌握了文件操作,你的程序就能将数据持久地保存在磁盘上,下次启动时可以继续使用;也能处理数量远超手工输入上限的数据集。
本章内容:
- 四种读写方式:字符读写、行读写、格式化读写、二进制读写
- 高级主题:文件定位、错误处理、标准流
- 动手练习
一、文件操作基础
1. 为什么需要文件操作
到目前为止,我们程序的数据都来自键盘输入,结果也都输出到屏幕。一旦程序关闭,所有数据就消失了。但现实中的程序往往需要把数据永久保存下来:
- 日志系统:每次操作都需要记录到文件中,方便事后排查问题
- 文本编辑器:需要能打开文件、编辑内容、保存文件
这一切都依赖文件操作——让程序能够读写磁盘上的文件。C 语言通过标准库中的一组函数来操作文件,它们都声明在 <stdio.h> 中。
2. 文件指针:FILE *
在C语言中,操作文件的核心是一个叫 FILE 的结构体类型(由系统在 <stdio.h> 中定义)。我们不需要关心它的内部细节,只需要知道:每打开一个文件,系统会返回一个指向该文件信息结构的指针,我们通过这个指针来读写文件。
FILE *fp; // fp 是一个文件指针
你可以把 FILE * 想象成一张“通行证“:打开文件时,操作系统发放一张通行证,上面记录了文件的各种内部信息。后续的所有读写操作都必须出示这张通行证,系统通过它来辨认你操作的是哪个文件。
3. 打开和关闭文件
有了文件指针的概念,接下来看如何获取它。打开文件使用 fopen 函数,使用完毕则需要用 fclose 释放。
3.1 fopen —— 打开文件
FILE *fopen(const char *filename, const char *mode);
filename:要打开的文件名(字符串)。mode:打开模式,决定读/写/追加等操作,见下表。- 返回值:成功返回
FILE *,失败返回NULL。
常用模式:
| 模式字符串 | 含义 |
|---|---|
"r" | 只读。文件必须存在,否则打开失败。 |
"w" | 只写。如果文件存在,清空内容;如果不存在,创建新文件。 |
"a" | 追加写。在文件末尾添加内容;文件不存在则创建。 |
"r+" | 读写。文件必须存在。 |
"w+" | 读写。清空已有内容或创建新文件。 |
"a+" | 读取和追加写。文件不存在则创建。 |
3.2 fclose —— 关闭文件
int fclose(FILE *stream);
关闭文件会将缓冲区中的数据真正写入磁盘,并释放相关资源。打开的文件使用完后必须关闭,否则可能导致数据丢失或资源泄露。
将打开和关闭组合起来,就形成了文件操作的标准骨架:
FILE *fp = fopen("data.txt", "r");
if (fp == NULL)
{
printf("打开文件失败!\n");
return 1;
}
// ... 读写操作 ...
fclose(fp);
这个模式值得牢记:
- 先尝试打开(
fopen) - 立即检查返回值是否为
NULL - 确认成功后再进行后续读写操作
- 最后无论是否出错都要关闭文件(
fclose)
二、文件读写方法
当我们成功打开了一个文件,接下来的问题自然是如何读取其中的数据,或者向其中写入内容。C 语言标准库提供了四组不同粒度的读写函数:
- 按字符读写
- 按行读写
- 按格式读写
- 按二进制块读写
它们各有适用场景,下面逐一介绍。
1. 字符读写:fgetc 和 fputc
这是最底层的读写方式,每次只处理一个字符。虽然粒度最细,但在需要逐字节处理文件内容时(如加密、统计字符频率)是不可替代的工具。
1.1 fgetc —— 读取单个字符
int fgetc(FILE *stream);
从文件中读取一个字符,返回值是读取到的字符(转为 unsigned char 后的 int)。如果读到文件末尾或出错,返回 EOF(End Of File,通常定义为 -1)。
1.2 fputc —— 写入单个字符
int fputc(int c, FILE *stream);
向文件写入一个字符 c,成功返回写入的字符,出错返回 EOF。
1.3 示例:逐个字符复制文件
#include <stdio.h>
int main(void)
{
FILE *src = fopen("source.txt", "r");
if (src == NULL) { printf("无法打开源文件\n"); return 1; }
FILE *dest = fopen("copy.txt", "w");
if (dest == NULL) { printf("无法打开目标文件\n"); fclose(src); return 1; }
int ch;
while ((ch = fgetc(src)) != EOF)
{
fputc(ch, dest);
}
fclose(src);
fclose(dest);
printf("文件复制完成\n");
return 0;
}
2. 行读写:fgets 和 fputs
字符级别的读写虽然灵活,但对于以行为单位的文本(如配置文件、日志)来说过于繁琐。fgets 和 fputs 提供了一次处理一整行的便利。
2.1 fgets —— 读取一行
char *fgets(char *str, int n, FILE *stream);
从文件读取最多 n-1 个字符,放入 str 中,直到遇到换行符 '\n' 或文件末尾。它会保留读到的换行符(如果读到了),并在最后自动添加 '\0'。成功返回 str,失败或读到文件尾返回 NULL。
2.2 fputs —— 写入一行(不自动加换行)
int fputs(const char *str, FILE *stream);
向文件写入字符串 str(不包括末尾的 '\0')。成功返回非负值,失败返回 EOF。
注意:
fputs不会自动添加换行符,如果需要换行,字符串中必须包含'\n'。
2.3 示例:读取文件并显示行号
#include <stdio.h>
int main(void)
{
FILE *fp = fopen("data.txt", "r");
if (fp == NULL) { printf("无法打开文件\n"); return 1; }
char line[256];
int num = 1;
while (fgets(line, sizeof(line), fp) != NULL)
{
printf("%d: %s", num, line); // line 本身可能包含换行
num++;
}
fclose(fp);
return 0;
}
3. 格式化读写:fprintf 和 fscanf
行读写处理的是原始字符串,更多时候我们需要按照特定格式存储和读取数据——比如用空格分隔的姓名、年龄和成绩。这时就要用到格式化读写,它们与我们已经熟悉的 printf 和 scanf 用法几乎一样,只是多了一个 FILE * 参数来指定读写目标。
3.1 fprintf —— 格式化写入文件
int fprintf(FILE *stream, const char *format, ...);
3.2 fscanf —— 从文件按格式读取
int fscanf(FILE *stream, const char *format, ...);
3.3 示例:把学生信息写入文件再读出来
#include <stdio.h>
struct Student
{
char name[20];
int age;
float score;
};
int main(void)
{
struct Student s1 = {"小明", 18, 92.5};
// 写入文件
FILE *fp = fopen("student.txt", "w");
if (fp == NULL) return 1;
fprintf(fp, "%s %d %.1f\n", s1.name, s1.age, s1.score);
fclose(fp);
// 从文件读取
fp = fopen("student.txt", "r");
if (fp == NULL) return 1;
struct Student s2;
if (fscanf(fp, "%19s %d %f", s2.name, &s2.age, &s2.score) != 3)
{
fprintf(stderr, "学生数据格式错误\n");
fclose(fp);
return 1;
}
fclose(fp);
printf("姓名:%s,年龄:%d,成绩:%.1f\n", s2.name, s2.age, s2.score);
return 0;
}
注意:
fscanf以空白字符(空格、换行、制表符)分隔数据,对于字符串中不能有空格的名字,这没有问题。如果名字可能包含空格,需要用其他方式处理(如fgets+ 手动解析)。
4. 二进制读写:fread 和 fwrite
前面三种方式处理的都是文本数据,生成的文件人类可以直接阅读和编辑。但有时我们想把结构体等内存数据直接按字节写入文件,不经过格式转换——这就是二进制读写。
- 优点:速度更快,文件更紧凑
- 代价:生成的文件不再具有可读性,且跨平台兼容性较差(不同系统可能字节序不同、结构体对齐不同)
4.1 fwrite —— 按块写入
size_t fwrite(const void *ptr, size_t size, size_t count, FILE *stream);
ptr:要写入的数据的地址。size:每个数据块的大小(字节)。count:数据块的个数。- 返回实际写入的完整块数。
4.2 fread —— 按块读取
size_t fread(void *ptr, size_t size, size_t count, FILE *stream);
参数含义类似,返回实际读到的完整块数。
4.3 示例:二进制存储和读取结构体数组
#include <stdio.h>
struct Student
{
char name[20];
int age;
float score;
};
int main(void)
{
struct Student list[2] = {
{"小明", 18, 92.5},
{"小红", 19, 88.0}
};
// 写入二进制文件
FILE *fp = fopen("students.dat", "wb"); // "b" 表示二进制模式
if (fp == NULL) return 1;
if (fwrite(list, sizeof(struct Student), 2, fp) != 2)
{
fprintf(stderr, "写入文件失败\n");
fclose(fp);
return 1;
}
fclose(fp);
// 读取二进制文件
struct Student read_list[2];
fp = fopen("students.dat", "rb");
if (fp == NULL) return 1;
if (fread(read_list, sizeof(struct Student), 2, fp) != 2)
{
fprintf(stderr, "文件数据不完整或读取失败\n");
fclose(fp);
return 1;
}
fclose(fp);
for (int i = 0; i < 2; i++)
printf("%s %d %.1f\n", read_list[i].name, read_list[i].age, read_list[i].score);
return 0;
}
三、高级主题
掌握了文件的打开、关闭以及四组基本的读写方法之后,你已经可以完成大多数文件处理任务。但在实际开发中,还会遇到一些进阶需求:
- 如何在文件中跳转到任意位置进行随机访问?
- 如何处理读写过程中的各种错误?
- 程序启动时自动获得的三个标准流又是什么?
这些内容将帮助你写出更健壮、更灵活的文件处理代码。
1. 文件定位:fseek、ftell、rewind
文件读写有一个当前位置指示器,类似于光标——顺序读取或写入后,它会自动后移。但某些场景下需要跳跃访问,比如直接跳到文件末尾获取文件大小、或者回到开头重新读取。C语言提供了三个函数来操控这个位置指示器。
1.1 fseek —— 移动位置指示器
int fseek(FILE *stream, long offset, int whence);
offset:位置偏移。对二进制流通常可以按字节理解;文本流只保证0或先前由ftell返回的位置值等用法可移植。whence:起始点。SEEK_SET:文件开头SEEK_CUR:当前位置SEEK_END:文件末尾
1.2 ftell —— 获取当前位置
long ftell(FILE *stream);
返回当前位置的表示,失败时返回 -1L。对二进制流,它通常可作为从文件开头计算的字节位置;文本流的返回值主要用于稍后交还给 fseek,不应总当作真实字节数。
1.3 rewind —— 回到文件开头
void rewind(FILE *stream);
位置移动效果类似 fseek(stream, 0L, SEEK_SET),但 rewind 还会清除该流的文件结束标志和错误标志,而且自身没有返回值可供检查。因此二者并不完全等价。
1.4 示例:获取文件大小
#include <stdio.h>
int main(void)
{
FILE *fp = fopen("data.txt", "rb");
if (fp == NULL) return 1;
if (fseek(fp, 0, SEEK_END) != 0) { fclose(fp); return 1; }
long size = ftell(fp); // 二进制模式下通常可用作字节数
if (size < 0) { fclose(fp); return 1; }
printf("文件大小:%ld 字节\n", size);
rewind(fp); // 回到开头继续操作
fclose(fp);
return 0;
}
注意:用
ftell计算文件字节数时,建议用二进制模式"rb"打开文件。文本模式下,某些系统可能会进行换行转换,位置值不一定等同于真实字节数。
2. 错误处理
文件操作可能出现各种错误:文件不存在、权限不足、磁盘已满等等。除了检查 fopen 的返回值是否为 NULL 之外,C 标准库还提供了一组专门的函数来探测和清理错误状态。
2.1 feof —— 是否到达文件末尾
int feof(FILE *stream);
需要注意的是,只有在尝试读取失败之后,feof 才返回真。如果刚刚读完最后有效数据,位置指示器在文件尾,但没有越界读取,feof 依然返回假。
推荐做法:使用
while (fgets(...) != NULL)来循环读取,而不是while (!feof(fp))——后者会因为判断时机的滞后导致多处理一次空数据。
2.2 ferror —— 是否发生错误
int ferror(FILE *stream);
2.3 clearerr —— 清除错误和文件结束标志
void clearerr(FILE *stream);
3. 标准流:stdin、stdout、stderr
在宿主环境中,C 运行时会在程序启动时预先提供三个标准文本流。它们以 FILE * 形式暴露给程序,默认连接到哪里取决于启动方式和重定向设置,并不一定真的是键盘或屏幕。
| 流 | 含义 | 对应的设备 |
|---|---|---|
stdin | 标准输入 | 键盘 |
stdout | 标准输出 | 屏幕 |
stderr | 标准错误输出 | 屏幕(常用于输出错误信息) |
现在可以解释一个早该说破的事实:我们之前一直使用的 printf 实际上就是 fprintf(stdout, ...),而 scanf 就是 fscanf(stdin, ...)。理解这一点之后,你也可以把文件函数用在标准流上——例如将错误信息输出到 stderr,使其与正常的程序输出分开处理:
fprintf(stderr, "发生错误!\n"); // 输出到错误流
四、动手练习
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
// 练习1:统计文件中的字符数、单词数、行数
FILE *fp = fopen("text.txt", "r");
if (fp == NULL) { printf("文件不存在\n"); return 1; }
int chars = 0, words = 0, lines = 0;
int in_word = 0;
int last_char = '\n';
int ch;
while ((ch = fgetc(fp)) != EOF)
{
chars++;
if (ch == '\n') lines++;
last_char = ch;
if (ch == ' ' || ch == '\n' || ch == '\t')
in_word = 0;
else if (in_word == 0)
{
in_word = 1;
words++;
}
}
fclose(fp);
if (chars > 0 && last_char != '\n') lines++;
printf("字符数:%d\n单词数:%d\n行数:%d\n", chars, words, lines);
return 0;
}
第十六章 C语言头文件与代码文件组织
本章要点
从学习 C 语言第一天起,我们写的程序都塞在单个 .c 文件里。几十行的练习没问题,但真实项目动辄成百上千个函数、数万行代码——全堆在一个文件里,阅读、维护、协作都会崩溃。
本章介绍 C 语言拆分代码的核心理念——头文件(.h)与源文件(.c)的分工协作:
- 库的概念与 C 标准库——你一直在用的
stdio.h到底是什么 - 头文件基础:声明接口 vs 隐藏实现、
#include的工作机制 - 防止重复包含:
#pragma once与头文件保护符 - 多文件项目组织:
include/+src/目录结构 - 用 Clang 一次性编译多个源文件
掌握这些技术,你就具备了从示例代码迈向结构化项目的关键能力,也为下一章学习 CMake 构建系统做好准备。
一、头文件基础
1. 单文件编程的局限
将全部代码写在一个文件中,在小型练习阶段完全可行,但随着代码规模增长,若干问题会逐渐暴露出来:
- 文件过长,定位一个函数定义需要反复翻阅,严重影响阅读效率。
- 代码无法复用。如果实现了一个好用的
get_max函数并想在另一个程序中使用,就必须手动复制粘贴一份,既繁琐又容易引入不一致。 - 多人协作困难。所有代码集中于一个文件,两人同时修改时冲突频繁,版本管理代价高昂。
- 编译耗时增加。每次修改哪怕一行代码,整个文件都需要重新编译,无法利用增量编译的优势。
正如一篇长文需要划分章节,一个程序也应当拆分为多个文件。C 语言的做法是将代码分为头文件(.h)与源文件(.c),二者各司其职:头文件对外描述模块的接口,源文件对内完成具体实现。
2. 为什么需要头文件
在讨论文件拆分之前,先回顾一个我们已经熟悉的机制:当函数定义位于 main 函数之后时,必须在调用前给出函数声明,让编译器预先知晓该函数的存在。
#include <stdio.h>
// 函数声明
int add(int a, int b);
int main(void)
{
int r = add(3, 5);
printf("%d\n", r);
return 0;
}
// 函数定义
int add(int a, int b)
{
return a + b;
}
函数声明的职责是告知编译器:后面会有一个与此签名匹配的函数,参数类型和返回值类型已确定,请先通过编译。现在,如果把 add 的定义移至另一个 .c 文件中,main 所在的文件便无法直接看到它的实现。但只要在 main 的文件中保留一句声明,编译器就能继续工作——真正的“合体”由链接器在最后一步完成,将分散在各文件中的符号一一对接。
然而,如果每次使用某个模块都要在调用处手写一遍该模块中所有函数的声明,不仅重复劳动,而且极易出错。正确的做法是:将所有声明集中放入一个 .h 文件,任何需要使用该模块的源文件只需 #include 这个头文件即可。
这便是头文件的本质:
头文件相当于一份说明书的目录页。它列出了模块对外提供的函数(声明)、类型和常量;源文件则是说明书的具体正文(函数的实现)。使用者只需翻阅目录即可了解模块能力,无需深入实现细节。
3. 库的概念与 C 标准库
从第一章开始,你每写一个程序都会写 #include <stdio.h>,然后就能用 printf 和 scanf。你有没有想过:printf 的代码在哪?你并没有写过它,但它确实在为你工作。
printf 不是凭空出现的。它来自 C 标准库(C Standard Library)。在实际工具链中,编译器、标准库实现和操作系统 SDK 可能由不同组件提供;安装好的开发环境会把它们组合起来供程序使用。
库(library),通常是别人已经写好并以源码或编译产物形式提供的一组代码。C 标准库的接口和行为属于 C 标准的一部分;完整的输入输出等设施由宿主实现提供,而面向裸机等环境的自由宿主实现只需提供标准规定的子集。
标准库里有一批头文件,每个负责一类功能。你已经在用的:
| 头文件 | 主要提供 |
|---|---|
<stdio.h> | 输入输出:printf、scanf、fopen、fgets |
<stdlib.h> | 通用工具:malloc、free、atoi、rand |
<string.h> | 字符串操作:strlen、strcpy、strcat、strcmp |
<math.h> | 数学函数:sqrt、pow、sin、cos |
<ctype.h> | 字符判断:isalpha、isdigit、isspace |
<time.h> | 时间日期:time、clock |
<stdio.h>和"mathutils.h"的区别
#include有两种写法:尖括号通常用于实现或工具链提供的头文件;双引号通常先按实现规定搜索当前源文件附近或项目路径,找不到时再采用尖括号的搜索规则。具体目录顺序由编译器规定。实践中“标准库用尖括号、项目头文件用双引号”仍是清晰可靠的约定。
库的概念不止于标准库。你刚才写的 mathutils.h + mathutils.c,已经具备了一个小模块的接口与实现。第十七章先学习用 CMake 构建可执行程序;真正生成静态库、动态库以及导入第三方库属于后续的 CMake 进阶内容。
4. 头文件里放什么,源文件里放什么
头文件与源文件的职责划分非常清晰。头文件(.h)通常包含:
- 函数声明(函数头加分号)
- 结构体、共用体、枚举的类型定义
- 宏定义(
#define) - 全局变量的外部声明(
extern,初学阶段可以暂时略过)
源文件(.c)通常包含:
- 函数的完整定义(函数体)
- 仅在当前文件内部使用的辅助函数和静态变量
#include对应的头文件以及必要的系统头文件
下面通过一个简单的数学工具模块来展示这一分工。
mathutils.h —— 头文件(接口说明书)
// mathutils.h
#ifndef MATHUTILS_H
#define MATHUTILS_H
// 函数声明
int add(int a, int b);
int subtract(int a, int b);
#endif
mathutils.c —— 源文件(具体实现)
// mathutils.c
#include "mathutils.h"
int add(int a, int b)
{
return a + b;
}
int subtract(int a, int b)
{
return a - b;
}
main.c —— 使用模块的文件
// main.c
#include <stdio.h>
#include "mathutils.h"
int main(void)
{
int x = 10, y = 5;
printf("%d + %d = %d\n", x, y, add(x, y));
printf("%d - %d = %d\n", x, y, subtract(x, y));
return 0;
}
main.c 只需一行 #include "mathutils.h" 便获得了 add 和 subtract 的完整调用信息,完全不需要接触它们的实现细节。接口与实现的分离,正是模块化编程的基石。
5. 编写一个完整的头文件
在上一节 mathutils.h 的代码中,头文件的开头和结尾出现了三行此前没见过的指令:
#ifndef MATHUTILS_H
#define MATHUTILS_H
// ... 声明 ...
#endif
这三行是普通头文件通常都应该具备的包含保护(include guard)。少数特殊文件(例如有意多次展开的 X-macro 文件)会例外,但面向初学者编写常规头文件时,应默认加上保护。
假设项目里有两个头文件:a.h 定义了结构体 Point,b.h 需要使用 Point 所以包含了 a.h。而 main.c 同时需要 a.h 和 b.h:
// a.h
struct Point { int x; int y; };
// b.h
#include "a.h"
void print_point(struct Point p);
// main.c
#include "a.h"
#include "b.h"
预处理器展开 #include 后,main.c 实际看到的是:
// 从 #include "a.h" 展开
struct Point { int x; int y; };
// 从 #include "b.h" 展开
// → 展开 #include "a.h"
struct Point { int x; int y; }; // 重复定义!编译报错
void print_point(struct Point p);
struct Point 被定义了两次,编译器会报“重复定义“错误。这是多文件项目中必然遇到的问题——头文件被直接或间接包含多次,导致同一个声明重复出现。
解决方式就是在每个头文件里加上包含保护。回到 mathutils.h,逐行看这三条指令做了什么:
#ifndef MATHUTILS_H // ① 如果 MATHUTILS_H 还没有被定义过……
#define MATHUTILS_H // ② 那就立即定义它,然后……
// ... 头文件内容 ...
#endif // ③ 结束 #ifndef 的控制范围
#ifndef 是 “if not defined” 的缩写——如果后面的宏名尚未被 #define 定义过,则条件成立,预处理器保留它和 #endif 之间的内容;否则整段跳过。#ifndef、#define、#endif 都是预处理指令,在第十四章中已学过 #define 的基础,这里把它们组合成一个固定模式。
回到上面的 a.h,加上包含保护之后:
// a.h —— 加保护后
#ifndef A_H
#define A_H
struct Point { int x; int y; };
#endif
现在预处理器处理 main.c 时:
- 展开
#include "a.h":检查A_H——尚未定义,条件成立。定义A_H,展开struct Point。 - 展开
#include "b.h":- 展开
b.h中的#include "a.h":再次检查A_H——已经定义过了,条件不成立,跳过全部内容。 - 展开
b.h剩余的void print_point(...)。
- 展开
最终 main.c 看到的是:
struct Point { int x; int y; }; // 只出现一次
void print_point(struct Point p);
#ifndef / #define / #endif 是普通头文件的标准骨架。 宏名习惯取项目名与头文件名的大写加下划线形式,关键是避免与其他头文件的保护宏重名。
另一种等价写法
#pragma once更为简洁,大多数现代编译器都支持。本书推荐先掌握经典写法——它不依赖编译器扩展,且能帮助你巩固对预处理机制的理解。
有了头文件的基础概念,接下来需要面对一个实际问题:当项目文件逐渐增多时,如何将它们有条理地组织起来,而不是让 .h 和 .c 文件杂乱地堆放在同一目录中。下面将介绍一种经过广泛验证的目录结构,以及配套的编译方法。
二、多文件项目组织
1. 用目录组织代码:src 和 include
将所有 .c 和 .h 文件混杂在同一目录中,随着文件数量的增长同样会变得难以管理。业界普遍采用的方案是将接口与实现按目录分开存放:
include/:存放本项目的头文件,这是模块对外暴露的公共接口。src/:存放源文件,包含接口的具体实现。
更复杂的项目可能会在 src/ 下进一步按功能模块划分子目录,但对于初学者而言,从上述最简结构入手便足以应对大多数练习场景。
2. 用 Clang 编译多文件项目
沿用上节的目录结构,假设当前工作目录为 my_project/(即与 src/、include/ 同级),在命令行中执行以下命令即可完成编译。
一次性编译所有源文件:
clang src/main.c src/mathutils.c -Iinclude -o program.exe
各参数含义如下:
| 参数 | 含义 |
|---|---|
src/main.c | 第一个源文件 |
src/mathutils.c | 第二个源文件 |
-Iinclude | 让编译器去 include/ 目录寻找头文件 |
-o program.exe | 指定输出文件名 |
关于 -I 参数有两点说明:
-I(大写字母 i)后紧跟目录名,写成-Iinclude或-I include均可,前者更为规范- 源文件较多时可以依次列出。在 Linux 或 macOS 下也可利用通配符
src/*.c一次性指定,但 Windows 的 Clang 环境下通配符可能不生效,建议逐个列出
运行:
program.exe
输出:
10 + 5 = 15
10 - 5 = 5
至此,一个完整的多文件项目从编写到编译运行的全部流程已经走通。整个过程无需任何额外的构建工具,直接使用 Clang 即可完成。
概念和流程已经讲解完毕,但编程技能终究需要亲手实践才能真正内化。下面的练习将引导你从零搭建一个多文件项目,并鼓励你主动制造一些错误来深化理解。
三、动手练习
include/calc.h:
#pragma once
// 计算平方
int square(int x);
// 计算立方
int cube(int x);
src/calc.c:
#include "calc.h"
int square(int x)
{
return x * x;
}
int cube(int x)
{
return x * x * x;
}
src/main.c:
#include <stdio.h>
#include "calc.h"
int main(void)
{
int n = 5;
printf("%d 的平方 = %d\n", n, square(n));
printf("%d 的立方 = %d\n", n, cube(n));
return 0;
}
在 practice/ 目录下执行:
clang src/main.c src/calc.c -Iinclude -o practice.exe
practice.exe
预期输出:
5 的平方 = 25
5 的立方 = 125
第十七章 C语言统一项目构建系统——CMake初识
本章要点
本章配套项目位于仓库的 src/ch17-cmake/。它是教程第一次正式引入 CMakeLists.txt;此前章节除第十六章必须演示的头文件/源文件拆分外,都只需用 Clang 直接编译源码,不需要创建 Visual Studio 工程或 CMake 项目。
第十六章学会了把代码拆分到 src/ 和 include/ 目录,用一行 clang 命令编译多个源文件。两个文件时还好,但项目一旦有几十上百个源文件、需要链接外部库、需要跨平台编译——手动敲命令就不再可行。
这就需要构建系统。本章介绍 C/C++ 生态中最主流的构建系统——CMake。具体涵盖以下内容:
- 为什么需要构建系统——从手动编译的痛点出发
- CMake 项目的核心文件:
CMakeLists.txt - 第一个 CMake 项目——从单文件开始
- 配置、生成、编译三步工作流
- 多文件项目:加入头文件目录
- CMake 核心语法规则总结
学完本章,你将能用 CMake 管理自己的多文件 C 项目,也为阅读开源 C/C++ 项目的构建配置打下基础。
1.1 手动编译的完整过程
在第十六章里,我们学会了把代码拆分到 src/ 和 include/ 目录,然后用 Clang 一次性编译所有源文件:
clang src/main.c src/mathutils.c -Iinclude -o program.exe
两个源文件时,这行命令看起来还好。但在这行命令背后,编译器实际上做了四件事:
- 预处理:展开
#include和#define,将头文件内容插入源文件 - 编译:将每个
.c文件分别翻译为汇编代码 - 汇编:将汇编代码转换为机器指令,生成目标文件(
.o) - 链接:将多个目标文件和库文件合并为最终的可执行文件
当项目只有一个 main.c 时,一条命令全部搞定。但当源文件多起来之后,手动输入编译命令的缺陷就暴露了。
1.2 手动编译为什么不行
源文件多了,命令不可维护。 假如项目有 10 个 .c 文件,你每次编译都要把它们全部列出来。漏掉一个,编译时不会报错——链接器会报“未定义的引用“——你需要从报错信息倒推漏了哪个文件。
头文件路径越来越长。 真实项目可能有多个头文件目录:include/、include/utils、include/network……相应的 -I 参数写成一长串。
每次都全量编译,浪费时间。 如果你只修改了一个 .c 文件,按道理只需要重新编译这一个文件,再把所有目标文件链接一次。但手动命令无法自动判断哪些文件需要重新编译、哪些文件没改过可以直接复用——你只能全部重来。一个大型项目全量编译可能需要几分钟甚至几十分钟,而增量编译可能只需几秒。
跨平台编译需要不同的命令。 Windows 上的编译器叫 clang,生成的产物叫 xxx.exe;Linux/macOS 上产物没有 .exe 后缀,某些系统下数学库还要额外加 -lm。手写命令意味着你需要为每个平台维护一套编译指令。
多人协作不可行。 如果每个人都在自己的机器上手动输入编译命令,每个人的命令可能略有不同——有人用 -O2 开了优化,有人没开;有人用 Clang,有人用 GCC。程序在某些机器上能跑,在另一些机器上不能跑,排查起来无从下手。
1.3 构建系统做什么
构建系统要解决的,就是上面这些问题。它提供了一套机制,让你描述项目结构,然后由它去执行编译。具体而言,一个构建系统负责:
- 依赖管理:自动判断哪些源文件被修改过,只重新编译改动的部分
- 并行编译:同时编译多个互不依赖的源文件,充分利用多核 CPU
- 构建隔离:中间产物(
.o文件)和最终产物(.exe)放在独立目录,不和源代码混在一起 - 可复现:任何人拿到项目,用相同的构建命令,得到相同的构建结果
二、跨平台与通用构建器的三个角色
跨平台是什么
跨平台,简单说就是同一份代码能在不同操作系统上编译和运行。Windows、Linux、macOS 是三种不同的操作系统,它们各有各的编译器、各有各的系统库、各有各的可执行文件格式。一份 C 代码要在这三种系统上都能工作,就需要解决这些差异。
但为什么我们需要关心跨平台?一个初学者写的小程序,在自己电脑上能跑不就行了?这个问题先放在这里——读完本章,尤其是 3.3 节之后,你会有清晰的答案。
现在回到构建这件事本身。一个完整的 C/C++ 构建体系由三个角色组成:编译器负责翻译代码,基础构建工具负责调度编译过程,构建系统生成器负责将项目描述转换为构建规则。三个角色各司其职,下面逐一厘清。
2.1 角色一:编译器
编译器是将单个源文件翻译为机器指令的命令行程序。
- 输入与输出:给它一个
.c文件,它输出一个.o目标文件。你告诉它源文件在哪、头文件在哪、输出什么名字,它执行翻译,然后退出。 - 不关心全局:编译器不关心项目中有多少其他源文件,不关心文件之间的依赖关系——它只翻译你递给它的那一个文件。
- 这是唯一真正生成代码的角色:后续所有角色都是在调度或生成规则,只有编译器实际产出机器指令。
2.2 角色二:基础构建工具
基础构建工具是驱动整个编译过程的调度程序。它有自己的脚本语法,你可以直接编写脚本让它构建项目。
-
它有自己的构建脚本语法:每种基础构建工具定义了一套脚本规则。你按照它的语法编写构建脚本,描述清楚源文件、依赖关系和编译选项,然后运行它,它就会依次调用编译器完成翻译,最终调用链接器生成可执行文件。
以 Make 为例,它的脚本叫
Makefile。一个最简的单文件项目可以这样写:hello: main.c clang main.c -o hello将这份文件保存为
Makefile,运行make,Make 就会执行clang main.c -o hello。注意:Make 在 Windows 上默认不可用——它是 Unix/Linux 生态的标配工具,Windows 用户需要额外通过 MSYS2 或 Cygwin 安装才能使用。另一个常用的基础构建工具是 Ninja。它的设计目标就是快,脚本叫
build.ninja。同一个单文件项目,手写build.ninja大概长这样:rule cc command = clang $in -o $out build hello: cc main.c和 Makefile 一样,
build.ninja也是手写脚本——只是语法不同。 -
它负责调度整个编译过程:基础构建工具读取构建脚本,根据其中描述的依赖关系决定编译先后顺序;通过比较文件时间戳,跳过未改动的文件,只重新编译修改过的部分;将互不依赖的文件并行编译,充分利用多核 CPU;任一步骤出错立即停止,报告失败原因。
-
它的局限:有了基础构建工具,你从“每次手动输入编译命令“升级到了“写一次脚本,一键构建“。但它有两个局限:第一,脚本绑定平台和编译器——上面的
Makefile写死了clang,在只有 GCC 的 Linux 服务器或只有 MSVC 的 Windows 上无法直接使用;第二,语法面向机器而非面向项目——你写的是“每一步怎么编译“,而不是“项目由什么组成“。这两个局限,正是下一个角色要解决的。
2.3 角色三:构建系统生成器
构建系统生成器负责读取项目描述并生成基础构建工具需要的规则。 它本身不充当 C 编译器;真正执行各条编译命令的仍是 Ninja、Make、MSBuild 等底层构建工具。
- 它生成的是一份构建脚本:你用一种面向项目的描述语言,告诉构建系统生成器项目的组成——有哪些源文件、头文件在哪里、要生成什么产物。构建系统生成器读取这份描述,探测当前系统上的编译器和可用的基础构建工具,然后生成一份完整的基础构建工具脚本。接下来你运行基础构建工具,由它接手调度编译。
- “描述项目” vs “描述步骤”:手写的
Makefile或build.ninja写的是“每一步怎么编译“(clang -c src/main.c -o main.o……),而构建系统生成器的输入文件写的是“项目由什么组成“(源文件在src/,头文件在include/,编成可执行文件mycalc)。构建系统生成器负责把面向项目的描述翻译成面向机器的构建步骤——这正是它被称为“构建系统生成器“的原因。 - 跨平台的关键:构建系统生成器在生成构建脚本时,会根据当前系统自动选择合适的编译器、编译选项和链接方式,从而用同一份项目描述在不同平台上生成适配的构建规则。这是它区别于直接手写构建脚本的核心价值。
C/C++ 生态中最主流的构建系统生成器是 CMake,下一节将正式介绍它。
三、CMake 正式介绍
3.1 完整的构建流水线
把三个角色串起来,以 CMake + Ninja + Clang 为例,完整的构建流水线如下:
CMakeLists.txt ← 你写的项目描述
│
▼
[cmake] ← CMake:读取描述,生成规则
│
▼
build.ninja ← 基础构建工具需要的规则文件
│
▼
[ninja] ← 基础构建工具:读取规则,调度编译
│
▼
[clang] ← 编译器:实际翻译每个 .c 文件
│
▼
hello.exe ← 最终的可执行文件
核心要点:CMake 生成并维护构建规则,Ninja 按规则执行编译。 cmake --build 是统一入口,它会调用当前构建目录对应的底层工具;必要时,生成的规则还会先触发 CMake 自动重新配置。
打个比方:CMake 是翻译官,把你的项目描述(
CMakeLists.txt)翻译成施工图纸(build.ninja);Ninja 是工头,拿着图纸指挥工人(clang)按顺序施工。
3.2 CMake 是什么
CMake 是一个跨平台的构建系统生成器。 它让你用相对统一的语言描述目标、源文件、依赖和构建要求,再把这些信息转换为特定生成器能够执行的工程或规则文件。编译器负责翻译 C 源码,底层构建工具负责执行具体命令,而 CMake 负责组织目标关系、探测工具链并生成规则。
这一定位解释了 CMake 的几个关键行为:
- 为什么需要写
CMakeLists.txt? 因为 CMake 需要你告诉它“项目长什么样“——有哪些源文件、头文件在哪、要生成什么产物。它不能自动推断你的意图。 - 为什么
-G可以切换基础构建工具? 因为 CMake 的设计将“描述项目“和“执行构建“彻底分离了。同一份CMakeLists.txt,在 Windows 上可以生成 Ninja 规则,在 Linux 上可以生成 Make 规则,在 macOS 上可以生成 Xcode 工程——项目的描述不变,只改变输出目标。 - 为什么修改
CMakeLists.txt会触发重新生成? 因为项目描述变化后,构建规则也需要更新。常见生成器会把CMakeLists.txt登记为构建依赖,因此执行cmake --build build时通常会先自动重新运行 CMake,再继续编译;你也可以手动执行配置命令,让这一步更加明确。
理解了这三点,你就不是在“记 CMake 命令“,而是在用 CMake 的思维模型去组织项目。
3.3 为什么不直接手写构建脚本?
现在可以回答一个自然的问题了:既然基础构建工具只缺一份规则文件,而 CMake 只是一个“生成规则“的中间层,那我直接手写 Makefile 或 build.ninja,跳过 CMake,行不行?
技术上可以。CMake 不是编译 C 代码的必要环节。但直接手写构建脚本会面临一个根本难题和两个衍生问题。
根本难题:跨平台。 这里的“跨平台“需要从两个维度来理解。
第一个维度是编译器的差异。Windows 上通常用 MSVC 或 Clang,Linux 上用 GCC 或 Clang,macOS 上默认是 Apple Clang。三家的命令行参数不完全相同:MSVC 的编译器叫 cl.exe,参数风格是 /O2 而非 -O2;可执行文件后缀不同(Windows 有 .exe,Linux/macOS 没有);系统库的链接方式也不同。一份直接写死 clang 命令的构建脚本,放到只有 GCC 的 Linux 服务器上根本跑不起来。
第二个维度是基础构建工具本身的差异。不同平台上可用的基础构建工具通常不同:Linux 上 Make 几乎是标配,Windows 上默认只有 MSBuild,Ninja 虽然跨平台但需要额外安装。更关键的是,每个基础构建工具的脚本语法互不兼容——Makefile 的写法和 build.ninja 没有任何共同之处。手写构建脚本意味着你不仅在绑定平台,还在绑定一个特定的基础构建工具。
把两个维度乘起来:N 种编译器 × M 种基础构建工具 = N×M 套构建脚本需要维护。这还不算同一个编译器的不同版本。CMake 的价值就在于把这两个维度一起解决——同一份 CMakeLists.txt 可以搭配不同生成器和工具链。CMake 会使用你通过 -G、工具链文件、环境变量或平台默认值选定的环境进行探测;它不会替开发者判断哪一种工具链“最适合”项目。描述一次,多处构建。 你写的不是“怎么编译“的指令,而是“项目长什么样“的描述。
衍生问题:构建脚本的语法是面向机器的,不是面向项目的。 Makefile 和 build.ninja 的设计目标是让基础构建工具高效解析和执行,而不是让人容易阅读和编写。一个中等规模的 Makefile 很快会变成充斥着变量、通配符、条件判断的脚本——它描述的是“每一步怎么编译“,而不是“项目由什么组成“。维护这种指令序列的成本随项目规模快速增长,而 CMakeLists.txt 用 add_executable、include_directories 这种面向项目的语义,维护成本随规模线性增长,远低于手写构建脚本。
四、第一个 CMake 项目:单文件
从一个最简单的 Hello World 项目开始。先看完整的项目结构——包括你编写的文件,以及 CMake 和编译器生成的文件:
这张图里包含了 CMake 项目的全部组成部分,下面逐一认识。
4.1 项目的三个基本元素
一个 CMake 项目由三个基本元素组成:
- 源码文件 — 你要编译的 C 代码(
.c/.h) - CMakeLists.txt — CMake 项目的入口,描述项目的构建需求
- 构建目录 — CMake 和编译器产生的所有文件(通常命名为
build/)
元素一:源码文件
新建目录 hello_cmake/,创建 main.c:
#include <stdio.h>
int main(void)
{
printf("Hello, CMake!\n");
return 0;
}
和前几章写的 C 代码没有任何区别——CMake 并不要求特殊的代码写法。
元素二:CMakeLists.txt
在同一个目录下创建 CMakeLists.txt:
cmake_minimum_required(VERSION 3.16)
project(HelloCMake LANGUAGES C)
add_executable(hello main.c)
CMakeLists.txt 是 CMake 项目的入口文件。它描述三件事:项目叫什么、用什么语言、要编译哪些源文件。注意,这里写的是“项目里有什么“,而不是“怎么编译“——这和手写 Makefile 有本质区别。具体语法留到 4.4 节解读。
元素三:构建目录
构建目录是 CMake 的工作区——存放所有构建过程中产生的文件。当前目录结构还是最简单的样子:
hello_cmake/
├── CMakeLists.txt
└── main.c
build/ 目录还没有出现——它由 CMake 在构建时自动创建。接着往下看。
4.2 构建与编译
有了 main.c 和 CMakeLists.txt,怎么把它们变成可执行文件?和 clang 一样,CMake 提供了命令行工具。
第一步:生成构建规则
打开终端,进入 hello_cmake/ 目录,执行:
cmake -S . -B build -G Ninja
| 参数 | 含义 |
|---|---|
-S . | 指定源码目录为当前目录。CMake 在这里寻找 CMakeLists.txt |
-B build | 指定构建目录为 build。所有生成物都放在这里,不污染源码 |
-G Ninja | 指定基础构建工具为 Ninja。CMake 据此生成 build.ninja 规则文件。想换成 Make 则写 "Unix Makefiles" |
执行后,CMake 会依次完成三件事:
- 检测当前系统上的 C 编译器(Clang / GCC / MSVC),验证其是否可用
- 解析
CMakeLists.txt,理解项目的组成 - 在
build/目录下生成构建规则文件及辅助文件
第二步:编译
cmake --build build
cmake --build 是 CMake 提供的统一编译入口。它会自动调用构建目录中的基础构建工具——本例中是 Ninja——由 Ninja 读取 build.ninja 中的规则,逐个编译源文件并链接,最终生成可执行文件。
编译过程的输出大致如下:
[2/2] Linking C executable hello.exe
第三步:运行
build\hello.exe # Windows
# 或
./build/hello # Linux / macOS
输出:
Hello, CMake!
日常工作流:只改了 .c 文件 → 执行 cmake --build build,Ninja 自动识别改动,只重编译需要更新的文件。修改了 CMakeLists.txt → 可以直接构建并让生成规则自动触发重新配置,也可以先手动运行 cmake -S . -B build。新增 .c 文件时,还必须把它加入 add_executable 等目标的源文件列表。
4.3 构建目录里有什么
上面两步跑完之后,build/ 里到底多了什么?现在回头看。
执行 cmake -S . -B build -G Ninja(第一步)之后:
hello_cmake/
├── CMakeLists.txt
├── main.c
└── build/
├── build.ninja ← 构建规则文件("施工图纸")
├── CMakeCache.txt ← 配置缓存(编译器路径、探测结果等)
└── CMakeFiles/ ← CMake 内部辅助目录
执行 cmake --build build(第二步)之后,又多了编译产物:
build/
├── build.ninja
├── CMakeCache.txt
├── CMakeFiles/
│ └── hello.dir/
│ └── main.c.o ← 编译器生成的中间目标文件
└── hello.exe ← 最终可执行文件
逐个认识这些文件:
build.ninja:CMake 生成的核心产物。它记录了每个源文件的编译选项、.o文件的链接方式、头文件搜索路径——Ninja 严格按这份规则调度编译。由 CMake 自动维护,你不需要看懂或手动修改它。CMakeCache.txt:CMake 首次探测系统时(编译器路径、标准库位置等),将结果缓存于此。之后重新生成时直接读取缓存。切换编译器或构建工具时,建议删除整个build/目录重新配置,或使用cmake --fresh参数,仅删除CMakeCache.txt可能不够稳妥。CMakeFiles/:CMake 内部辅助文件,无需关心。main.c.o:编译器将main.c翻译成的中间目标文件。hello.exe:链接器合并main.c.o和系统库生成的最终可执行文件。
这里体现了一条核心原则:源码目录和构建目录彻底分离。你写的代码留在 hello_cmake/,CMake 和编译器产生的所有文件全部进入 build/:
- 清理简单:删除
build/目录就等于彻底清理,源码不受影响 - 源码整洁:不会被
.o、.exe等中间产物污染 - 版本控制友好:
.gitignore中只需写一行build/
4.4 CMakeLists.txt 语法解读
理清了项目结构和构建流程,现在回过头来逐行理解 CMakeLists.txt 中的三条命令。
cmake_minimum_required(VERSION 3.16)
声明此项目至少需要 CMake 3.16。版本过低时配置会直接报错;同时,这条命令会设置相关策略的兼容基线。它不能保证所有协作者使用完全相同的 CMake 版本或所有行为绝对一致。
project(HelloCMake LANGUAGES C)
定义项目名称为 HelloCMake,语言是 C。CMake 据此启用 C 编译器检测和相关默认设置。每个 CMake 项目必须有这一条——它相当于项目的“身份证“,后续命令都在它的上下文中工作。
add_executable(hello main.c)
用 main.c 生成一个可执行文件,名为 hello(Windows 上自动加 .exe)。这是最核心的一条——告诉 CMake“我要一个可执行文件,拿这些源文件去编译“。多个源文件时依次列出即可,例如 add_executable(hello main.c utils.c math.c)。
三条命令,四行代码,完整描述了一个 C 项目。对比 2.2 节手写的 Makefile 和 build.ninja,CMakeLists.txt 面向项目的优势一目了然——你不需要写“怎么编译“,只需要写“项目里有什么“。
五、CMake 命令行使用
第四节用到了 CMake 的两条核心命令,这一节系统讲解 CMake 命令行的完整用法。
5.1 两个阶段
CMake 的构建分为两个独立的阶段:
| 阶段 | 命令 | 做什么 | 由谁执行 |
|---|---|---|---|
| 配置与生成 | cmake -S ... -B ... | 读取 CMakeLists.txt,生成构建规则 | CMake |
| 编译 | cmake --build ... | 调用基础构建工具,执行编译和链接 | Ninja / Make / MSBuild |
概念上这两个阶段是分离的。修改 .c 文件后只需构建;修改 CMakeLists.txt 后需要重新生成规则,但常见生成器会在构建时自动完成这一步。新增源文件还需要修改目标的源文件列表,除非项目明确采用了带重新扫描机制的其他写法。
5.2 cmake 配置与生成
完整命令格式:
cmake -S <源码目录> -B <构建目录> -G <生成器>
-S <path> — 指定源码目录,即 CMakeLists.txt 所在的目录。CMake 在此寻找入口文件。通常用 . 表示当前目录。
-B <path> — 指定构建目录。所有生成物(构建规则、中间文件、最终产物)全部放入这个目录。目录不存在时 CMake 自动创建。名称可自定义,但业界约定俗成用 build/。
-G <generator> — 指定基础构建工具。CMake 据此生成对应格式的构建规则文件。常用生成器:
-G 参数值 | 基础构建工具 | 产物文件 | 适用平台 |
|---|---|---|---|
Ninja | Ninja | build.ninja | 跨平台(需安装 Ninja) |
"Unix Makefiles" | Make | Makefile | Linux / macOS(系统自带) |
"Visual Studio 17 2022" | MSBuild | .sln 工程 | Windows |
如果不指定 -G,CMake 会选择系统当前可用的默认生成器。
首次配置时,CMake 还会自动探测编译环境——检测可用的 C/C++ 编译器、验证标准库、确定目标平台——并将结果缓存到 build/CMakeCache.txt。之后的配置会复用缓存,加速执行。
5.3 cmake –build 编译
完整命令格式:
cmake --build <构建目录> [选项]
cmake --build 是一个统一的编译入口——你不必知道构建目录里用的是 Ninja 还是 Make,CMake 会自动识别并调用正确的工具。常用选项:
| 选项 | 含义 |
|---|---|
--config Release | 为 Visual Studio、Xcode、Ninja Multi-Config 等多配置生成器选择 Release |
--config Debug | 为多配置生成器选择 Debug |
-j <N> | 并行编译任务数,例如 -j 8 使用 8 个并行任务 |
--target <name> | 只编译指定目标(例如 --target hello) |
--clean-first | 编译前先清理旧的构建产物 |
5.4 常用操作速查
# 首次构建(或修改了 CMakeLists.txt / 新增了源文件)
cmake -S . -B build -G Ninja
cmake --build build
# 日常修改 .c 文件后,只需编译
cmake --build build
# 切换基础构建工具(Ninja → Make)时使用新的构建目录
cmake -S . -B build-make -G "Unix Makefiles"
cmake --build build-make
# Ninja 是单配置生成器:在配置阶段选择 Release
cmake -S . -B build-release -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build-release
# 彻底清理重建
# 删除 build/ 目录,重新配置和编译
cmake -S . -B build -G Ninja
cmake --build build
注意:同一个构建目录不能随意更换生成器;切换 Ninja、Make、Visual Studio 等生成器时,请使用新的构建目录,或显式清理/刷新原构建目录。对于已经配置好的工程,常见生成器会在发现
CMakeLists.txt变化时自动重新运行 CMake。
六、CMakeLists.txt 基本语法
学完第一个 CMake 项目,现在系统回顾 CMakeLists.txt 中最核心的三条命令。
6.1 cmake_minimum_required
cmake_minimum_required(VERSION x.y)
声明项目所需的最低 CMake 版本,通常放在顶层 CMakeLists.txt 开头。版本过低时配置阶段直接报错,并建立 CMake 策略的兼容基线;它减少版本差异,却不等于锁定了所有人的实际版本。
6.2 project
project(项目名 LANGUAGES C)
定义项目名称和语言。CMake 据此启用 C 编译器检测并设置项目相关变量。顶层工程通常调用一次 project();子项目也可以在自己的 CMakeLists.txt 中再次调用,因此并不存在“每个文件必须且只能有一条”的规则。
6.3 add_executable
add_executable(可执行文件名 源文件1 源文件2 ...)
将列出的源文件编译并链接为一个可执行文件。这是最核心的构建命令。源文件可以写在一行,多个文件时建议分行:
add_executable(mycalc
src/main.c
src/calc.c
)
6.4 命令速查表
| 命令 | 作用 |
|---|---|
cmake_minimum_required(VERSION x.y) | 指定 CMake 最低版本要求 |
project(项目名 LANGUAGES C) | 定义项目名称和语言 |
add_executable(目标名 源文件...) | 将源文件编译成可执行文件 |
掌握了这三条命令,你已经能构建任意纯 C 的单文件或多文件项目。下一节引入头文件搜索路径,需要一条新命令——include_directories。
七、加入头文件目录:多文件项目
回到第十六章“头文件与代码文件组织“的实践项目,用 CMake 来构建。
目录结构:
calc.h 和 calc.c 内容不变,main.c 也不变。
编写 CMakeLists.txt:
cmake_minimum_required(VERSION 3.16)
project(MyCalc LANGUAGES C)
add_executable(mycalc
src/main.c
src/calc.c
)
# 只给 mycalc 目标添加头文件搜索路径
target_include_directories(mycalc PRIVATE include)
这里出现了一条新命令——target_include_directories(mycalc PRIVATE include)。它把 include/ 添加到 mycalc 目标的头文件搜索路径中,相当于此前手写的 clang -Iinclude ...。CMake 会把该信息写进生成的构建规则,源文件中的 #include "calc.h" 就能被找到。
7.1 target_include_directories
target_include_directories(目标名 PRIVATE 路径1 路径2 ...)
为指定目标添加头文件搜索路径,相当于编译器的 -I 参数。PRIVATE 表示这些路径只用于构建该目标;以后创建库时还会接触 PUBLIC 和 INTERFACE。将配置绑定到具体目标,可以避免无意影响同一目录中的其他目标:
target_include_directories(mycalc PRIVATE
include
include/utils
include/network
)
构建过程:
cd practice
cmake -S . -B build -G Ninja
cmake --build build
build\mycalc.exe
输出:
5 的平方 = 25
5 的立方 = 125
和之前手写命令的结果完全一致,但构建配置清晰地记录在 CMakeLists.txt 中——任何人拿到项目都能用两条命令编译,不需要记住 -I 参数和文件列表。
三个角色的职责回顾:
| 角色 | 代表 | 做什么 |
|---|---|---|
| 构建系统生成器 | CMake | 读取 CMakeLists.txt,生成构建规则文件(build.ninja) |
| 基础构建工具 | Ninja | 读取构建规则,调度编译器、管理依赖、并行编译 |
| 编译器 | Clang | 将每个 .c 文件翻译为机器指令 |
数据结构:从存储到抽象
本篇要点
本篇是 C 语言数据结构学习的总导航。在正式进入具体的数据结构之前,先回答三个根本问题:
数据结构到底是什么?为什么需要多种数据结构?如何衡量一个数据结构的好坏?
具体涵盖:
- 程序 = 数据结构 + 算法:这句经典名言的真正含义
- 什么是数据结构:数据怎么“摆“、怎么“取“——核心框架:物理存储 × 逻辑规则
- 为什么需要不同的数据结构:数组的三大优势与三大短板,没有万能结构
- 时间复杂度:大 O 记号、三种循环模式、常见复杂度对比表
- 空间复杂度:衡量内存开销的工具
- 本篇学习路线:从数据存储结构(第十八章)到通用抽象数据结构(第十九章)
一、程序 = 数据结构 + 算法
计算机科学界有一句广为流传的名言:
程序 = 数据结构 + 算法
—— 尼克劳斯·维尔特(Niklaus Wirth),Pascal 语言之父,图灵奖得主
这句话揭示了写程序的本质——只做两件事:
| 任务 | 核心问题 | 举例 |
|---|---|---|
| 组织数据 | 数据怎么存?用变量、数组还是更复杂的结构? | 全班成绩:用数组 scores[50] 还是一维变量? |
| 处理数据 | 数据怎么算?查找、排序、插入、删除? | 成绩排序、查最高分、插入转学生 |
同一个问题,选择不同的数据组织方式,算法的难度和效率天差地别:
- 数组查第 5 号同学:
scores[4],一步搞定。 - 数组中间插入转学生:后面 45 人全部往后挪一格。
- 链表插入:改一个指针就好,但找第 5 号又得从头数起。
核心结论:数据结构就是研究“数据怎么组织“的学问。学好它,才能在面对具体问题时做出正确的选择。
二、什么是数据结构
2.1 一句话定义
数据结构 = 数据怎么“摆“、怎么“取“。
2.2 三个生活例子
| 场景 | 数据组织方式 | 核心需求 |
|---|---|---|
| 超市货架 | 按品类分区存储 | 按类别快速定位 |
| 图书馆书架 | 按索书号排列 | 按编号精确查找 |
| 食堂排队打饭 | 先来先服务 | 保证公平、先来先得 |
三者的共同问题:数据多了,怎么放才能又快又准地找到、添加、删除? 答案不同,因为场景需求不同。
2.3 核心框架:物理存储 + 逻辑规则
深入一层——数据最终都在内存里。针对本篇讨论的线性结构,可以先用两类常见存储方式建立分析框架:
| 物理存储方式 | 本质 | 代表结构 |
|---|---|---|
| 连续存储 | 一次性申请整段连续内存,数据挨着放 | 数组 |
| 非连续存储(链式存储) | 节点不要求彼此相邻,用指针串联 | 链表 |
链式存储的本质:每个节点除了存数据,还携带“其他节点在哪“的信息。单链表(只记后继)和双链表(记前驱+后继)只是这种思想最常用的两种实现形式——记录几个指针、指向谁,都是可以根据需求自由组合的。本教程后续链表代码统一采用带头节点写法:头节点不保存有效数据,真实数据从
head->next开始。
物理存储之上,才是逻辑规则。 抛开底层细节,给数据“加规则“:
| 逻辑规则 | 结果 | 举例 |
|---|---|---|
| 只能一端进出 | 栈(LIFO) | 函数调用、撤销操作 |
| 一端进、另一端出 | 队列(FIFO) | 任务调度、消息缓冲 |
| 取模绕回 + FIFO | 环形队列 | 固定容量循环缓冲 |
核心公式:数据结构 = 物理存储方式 + 逻辑组织规则
- 物理层决定效率上限:能不能 O(1) 随机访问?修改要不要搬移数据?记录几个关系指针?
- 逻辑层决定行为:LIFO 还是 FIFO?单向还是双向?
整个数据结构的学习,就是在反复体会这两层如何相互作用。
三、为什么需要不同的数据结构
3.1 数组的三大优势
| 优势 | 原因 | 时间复杂度 |
|---|---|---|
| 连续存放 | 首地址 + 下标 × 元素大小 = 精确地址 | — |
| 下标 O(1) 访问 | 通过固定次数的地址运算定位元素 | O(1) |
| 语法简单 | arr[i] 直读直写 | — |
在“数据量确定、主要操作为读取和遍历“的场景下,数组几乎是最优解。
3.2 数组的三大短板
考虑这个需求:维护一份待办事项列表,随时添加和删除。
| 短板 | 原因 | 代价 |
|---|---|---|
| 长度创建后固定 | C 内建数组不能原地改变长度;动态扩容可能搬迁 | 空间浪费或不够用 |
| 插入删除“大搬家“ | 连续存放,中间插入需要后面全部后移 | O(n),越大越痛 |
| 需要连续大块内存 | 大块连续分配可能因可用地址空间不足而失败 | 总空闲量够也不保证成功 |
核心认知:数组的短板不是设计缺陷,而是结构特性决定的——连续存放带来 O(1) 随机访问,也带来搬移代价。鱼和熊掌不可兼得。
3.3 数据结构的分类全景
放眼全局,数据结构种类繁多,但追根溯源:
数组和链表是实现数据结构时最常见的两类基础存储方式。栈、队列、树和图等抽象结构,可以根据需求用数组、链式节点或二者组合实现。
数组(连续存储)
├── 加规则"只能一端进出" → 栈
├── 加规则"一端进一端出 + 取模绕回" → 环形队列
└── 排序 + 二分查找 → O(log n) 查找
链表(链式存储)
├── 节点只记后继 → 单链表
├── 节点记前驱+后继 → 双链表
├── 加规则"一端进一端出" → 链式队列
├── 节点记录子节点关系 → 树的一种实现
└── 节点记录任意邻接关系 → 图的一种实现
链式存储的本质不在“几个指针“,而在“记录节点之间的关系信息“。单链表和双链表只是两种实现——向上扩展为树(一对多),再扩展为图(多对多)。这正是数据结构从线性到非线性的演进脉络。
四、时间复杂度
4.1 一句话定义
时间复杂度 = 随着数据量变大,操作耗时怎么增长。不关心具体秒数,只关心增长趋势。用大 O 记号表示。
4.2 大 O 记号四个要点
| 要点 | 说明 | 示例 |
|---|---|---|
| 注明分析情形 | 大 O 可用于最坏、平均等情形;本书通常分析最坏情况 | 线性查找最坏看 n 个 |
| 忽略常数 | O(2n) → O(n), O(100) → O(1) | 系数不影响增长趋势 |
| 忽略低阶项 | O(n² + n) → O(n²), O(n + log n) → O(n) | 高阶级碾压低阶 |
| 只看增长最快的一项 | n 变大时,最快的增长项主导一切 | n=1000000时 n² 比 n 大百万倍 |
4.3 从 C 语言循环看三种复杂度模式
| 模式 | 代码结构 | 复杂度 | 关键特征 |
|---|---|---|---|
| 单层循环 | for(i=0;i<n;i++) | O(n) | 数据量翻倍 → 耗时翻倍 |
| 嵌套循环 | for(...) for(...) | O(n²) | 数据量翻倍 → 耗时×4 |
| 并列循环 | for()... + for()... | O(n) | O(n)+O(n)=O(n),非 O(2n) |
⚠️ 关键区分:并列是 O(n) + O(n) = O(n),嵌套是 O(n) × O(n) = O(n²)。不要混淆!
代码示例:
// 单层循环 → O(n)
for (int i = 0; i < n; i++)
printf("%d\n", i);
// 嵌套循环 → O(n²)
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
printf("%d, %d\n", i, j); // n × n 次
// 并列循环 → 仍为 O(n),不是 O(2n)
for (int i = 0; i < n; i++) // O(n)
printf("%d\n", i);
for (int i = 0; i < n; i++) // O(n)
printf("%d\n", i * 2);
// 总复杂度:O(n)
4.4 常见复杂度速查表
| 大 O | 名称 | n=10 | n=100 | n=10000 | 典型操作 |
|---|---|---|---|---|---|
| O(1) | 常数 | 1 | 1 | 1 | 数组下标访问、赋值 |
| O(log n) | 对数 | ~3 | ~7 | ~13 | 二分查找(有序数组) |
| O(n) | 线性 | 10 | 100 | 10000 | 单层循环遍历、线性查找 |
| O(n²) | 平方 | 100 | 10000 | 1 亿 | 嵌套循环、冒泡排序 |
n 越大,差距越悬殊。n=10000 时,O(n²) 是 1 亿步,O(log n) 只需约 13 步。选对结构,效率可以相差几个数量级。
4.5 复杂度分析三步法
拿到一段代码,按顺序问自己:
| 步骤 | 问题 | 判断 |
|---|---|---|
| ① | 有没有循环? | 无循环 → 通常 O(1) |
| ② | 有没有嵌套循环? | 每嵌套一层 → 多乘一个 n |
| ③ | 有没有函数调用? | 被调函数的复杂度 × 调用次数 |
五、空间复杂度
空间复杂度 = 额外占用多少内存。同样用大 O 记号。
| 级别 | 含义 | 典型场景 |
|---|---|---|
| O(1) | 只用一个临时变量,额外内存恒定 | 就地遍历、交换 |
| O(n) | 需要和输入规模成正比的辅助空间 | 拷贝数组、创建等长新结构 |
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
// 空间复杂度 O(1):只用一个临时变量
int sum(const int arr[], size_t n) {
int temp = 0;
for (size_t i = 0; i < n; i++) temp += arr[i];
return temp;
}
// 空间复杂度 O(n):创建了和输入同等大小的新数组
int *copy_array(const int arr[], size_t n) {
if (n > SIZE_MAX / sizeof(int)) return NULL;
int *new_arr = malloc(n * sizeof(int));
if (new_arr == NULL && n != 0) return NULL;
for (size_t i = 0; i < n; i++) new_arr[i] = arr[i];
return new_arr;
}
实际意义:空间复杂度常用来回答“算法除输入和输出外,还需要多少辅助内存”;讨论数据结构本身时,也会单独比较其存储开销。例如链表每节点多存 1~2 个指针,数组栈和环形队列预分配空间,链式队列按需分配节点。
六、本篇学习路线
理解了“数据结构 = 物理存储 + 逻辑规则“这个核心框架之后,本篇分两个章节展开:
路线图
数据结构总览(本篇)
│ 建立全局认知:物理存储 × 逻辑规则、时间复杂度
│
├── 第十八章:数据存储结构
│ │ 聚焦物理存储层——连续存储 vs 链式存储
│ │
│ ├── 连续存储——数组的增删改查(查/改/增/删 × 复杂度分析)
│ ├── 链式存储——链表
│ │ ├── 单链表(只记后继)
│ │ ├── 双链表(记前驱+后继)
│ │ └── 发散思维:链式存储的本质(指针记录关系,自由组合)
│
└── 第十九章:通用抽象数据结构
│ 聚焦逻辑规则层——给存储加规则
│
├── 线性 vs 非线性数据结构(概念引入)
├── 栈(基于数组,LIFO)
├── 链式队列(基于链表,FIFO)
├── 环形队列(基于数组,取模绕回)
└── 总结与对比(全部结构效率总表 + 选型指南)
各章要点
第十八章 —— 数据存储结构:回答“数据在内存中怎么放“。
| 存储方式 | 代表 | 核心优势 | 核心代价 |
|---|---|---|---|
| 连续存储 | 数组 | O(1) 随机访问 | 插入删除 O(n),需连续大块内存 |
| 链式存储(只记后继) | 单链表 | 定位后增删无需搬移数据 | 无随机访问,定位需遍历 |
| 链式存储(记前驱+后继) | 双链表 | 已知节点地址时 O(1) 删除 | 每节点多存 2 个指针 |
链式存储的本质:记录节点之间的关系信息,几个指针、指向谁,自由组合。单/双只是两种常用实现。
第十九章 —— 通用抽象数据结构:回答“加上什么规则、产生什么行为“。
| 结构 | 底层存储 | 规则 | 行为 |
|---|---|---|---|
| 栈 | 数组 | 只能一端进出 | LIFO |
| 链式队列 | 链表 | 一端进、另一端出 | FIFO |
| 环形队列 | 数组 | FIFO + 取模绕回 | FIFO+复用 |
💡 建议的学习顺序:
- 先通读本篇,建立“物理存储 × 逻辑规则“的全局认知
- 按顺序学习第十八章(数据存储结构)和第十九章(通用抽象数据结构)
- 每学完一种结构,回到本章对照它属于哪一层——这样能帮你建立起系统化的数据结构知识框架
入门阶段结束后,进阶篇将继续深入非线性数据结构(树、图等),它们大多数也建立在数组和链表的基础之上。
第十八章 数据存储结构
本章要点
在数据结构总览中我们建立了一个核心认知:数据结构 = 物理存储方式 + 逻辑组织规则。 本章聚焦物理存储层——数据在内存中到底怎么放。
那么,数据结构到底是用来干什么的?不管它叫什么名字、长什么样,它最终只做四件事:
- 增:往数据集合里加入新元素
- 删:从数据集合里移除某个元素
- 改:修改某个位置或某个条件下的元素值
- 查:根据位置或值找到目标元素
增、删、改、查,就是数据结构的基本核心操作。 任何数据结构——无论是你已经熟悉的数组,还是本章将要学习的链表,以及后续章节会遇到的栈、队列、树、哈希表——归根结底,都是围绕这四个操作来设计和优化的。
不同的存储方式,这四个操作的效率天差地别。同一个“查“,数组上 O(1),链表上 O(n);同一个“增“,数组中间插入要搬移大量元素,链表只需改两个指针。正是这种效率差异,决定了每种数据结构适合什么场景、不适合什么场景。理解了它们各自的增删改查特性,你才能在面对实际问题时做出正确的选型。
从物理存储的角度,常见方式可以概括为两类:连续存储和非连续存储(链式存储)。
| 存储方式 | 本质 | 代表结构 |
|---|---|---|
| 连续存储 | 所有元素紧挨着存放在一块连续内存中 | 数组 |
| 非连续存储(链式存储) | 节点不要求彼此相邻,用指针串联 | 链表 |
本章深入剖析这两种存储方式的实现细节和操作效率。
具体涵盖:
- 连续存储——数组的增删改查:以数据结构的视角重新审视数组,逐一分析查、改、增、删四种操作的时间复杂度
- 非连续存储——链表:本节还将跳脱出单/双的具体形式,探讨链式存储的本质——记录前后节点信息,自由组合前驱后继
- 单链表:节点只记后继指针,覆盖增(头插/尾插)、删、查、改全部 CRUD 操作
- 双链表:节点同时记前驱和后继,可双向遍历;已知节点地址时 O(1) 删除,同样覆盖完整 CRUD
- 链式存储的本质:单链表和双链表只是链式存储的两种实现,核心是“记录节点之间的关系信息“
学完本章,你将彻底理解“连续 vs 链式“两种存储方式各自的优势和代价,并且不被单/双链表的具体形式所局限。
一、连续存储——数组的增删改查
1.1 以数据结构的视角看数组
第三章已经学习了数组的基础语法——声明、初始化、用下标访问。现在换一个角度:把数组当作连续存储的代表结构,分析它的四种核心操作——增、删、改、查。
之所以从这里开始,是因为数组是 C 语言中最基础、最常用的数据存储方式。理解了数组的 CRUD 操作及其复杂度,再看链式存储时就能自然地理解“为什么需要另一种存储方式“。
数组的本质是连续存储——所有元素紧挨着放在一块连续的内存中。首元素的地址称为基地址,任意元素 arr[i] 的地址可以通过公式精确计算:
&arr[i] = 基地址 + i × sizeof(元素类型)
这个公式是数组一切特性的根源。它带来了 O(1) 随机访问,也带来了插入和删除时的搬移代价。
1.2 查(Read / Search)—— 数组最擅长的操作
数组的“查“分为两种情况:按下标访问和按值查找。两者的效率天差地别。
按下标访问 —— O(1)
int arr[5] = {10, 20, 30, 40, 50};
int x = arr[2]; // 一步定位到第三个元素,值 = 30
arr[3] = 99; // 一步定位到第四个元素,修改为 99
借助“基地址 + 下标 × 元素大小”的寻址关系,编译器可以用固定数量的地址运算定位目标元素。具体会生成几条机器指令取决于平台和优化,复杂度分析只关心运算次数不随数组长度增长,因此按下标访问是 O(1)。
这是数组的核心优势,也是它区别于链表的最根本特征。链表要实现“找第 i 个元素“,必须从头走 i 步。
按值查找(无序数组)—— O(n)
int arr[5] = {10, 20, 30, 40, 50};
int target = 30;
int index = -1;
for (int i = 0; i < 5; i++) // 必须逐个比较
{
if (arr[i] == target)
{
index = i;
break; // 找到了,可以提前退出
}
}
数据没有排序,目标值可能在任何位置。最好情况(第一个就是):1 次比较,O(1)。最坏情况(在最后一个或不存在):n 次比较,O(n)。平均情况:n/2 次比较,O(n)。时间复杂度取最坏情况——O(n)。
按值查找(有序数组 + 二分查找)—— O(log n)
如果数组是有序的,就可以使用二分查找。在讲代码之前,先用一个你已经熟悉的场景来理解它的思想。
回忆一下第一章的猜数游戏:程序在 1~100 之间随机选了一个数,你来猜。最高效的猜法是什么?不是从 1 开始逐个往上试——那太慢了。而是每次猜中间值:
- 第一次猜 50。程序说“猜小了“——你立刻知道答案在 51~100,一半的数字被排除了。
- 第二次猜 75(51~100 的中间)。程序说“猜大了“——答案在 51~74,又排除了一半。
- 第三次猜 62(51~74 的中间)。如果还不对,继续缩窄范围……
你每猜一次,候选范围就缩小一半。100 个数字,最多猜 7 次一定能找到——因为 2⁷ = 128 > 100。这就是二分查找的核心思想:在一个有序的序列中,每次取中间位置的值和目标比较,根据比较结果将搜索范围缩小一半,直到找到目标或范围为空。
二分查找之所以高效,是因为它没有“遍历“——它从不逐个检查元素,而是像猜数一样,每次一刀切掉一半的候选区域。把这个思路翻译成 C 代码,就是下面这个样子(假设数组按从小到大排列;若按降序排列,需调换分支中的比较方向):
// 在有序数组 arr 中查找 target,返回下标,未找到返回 -1
int binary_search(int arr[], int n, int target)
{
int left = 0, right = n - 1;
while (left <= right)
{
int mid = left + (right - left) / 2; // 找中间位置
if (arr[mid] == target)
return mid; // 找到了
else if (arr[mid] < target)
left = mid + 1; // target 在右半部分
else
right = mid - 1; // target 在左半部分
}
return -1; // 没找到
}
函数一开始用 left 和 right 标记当前的搜索范围——最初是整个数组,left = 0,right = n - 1。
循环的每一步,先计算当前范围的中间位置 mid。这里用的是 left + (right - left) / 2 而不是更直观的 (left + right) / 2,是为了防止 left + right 溢出 int 的上限——这是二分查找中一个经典的细节陷阱。
拿到 mid 之后,比较 arr[mid] 和目标值,三种情况:
- 相等——命中目标,直接返回下标
mid。 arr[mid]偏小——说明目标在右半部分,把left右移到mid + 1,搜索范围缩小到右半边。arr[mid]偏大——说明目标在左半部分,把right左移到mid - 1,搜索范围缩小到左半边。
每轮循环,搜索范围缩小一半。10000 个元素的数组,最多只需要约 14 次比较。这就是 O(log n) 的威力。
💡 高效二分查找的前提是数据有序且支持随机访问。 普通链表不能 O(1) 定位到中间元素,即使勉强套用二分思路,也得不到数组上 O(log n) 的时间复杂度。
1.3 改(Update)—— 按下标修改 O(1)
“修改“是指把某个位置的值替换为新值。如果知道下标,和按下标访问完全一样:
arr[2] = 99; // 把下标为 2 的元素改为 99,O(1)
如果不知道下标、只知道“要把值为 30 的元素改成 99“,那就得先查找——查找的复杂度 O(n) 决定了整体复杂度。
| 场景 | 复杂度 |
|---|---|
| 已知下标,直接修改 | O(1) |
| 已知值,先查找再修改 | O(n) |
1.4 增(Insert)—— 数组最吃亏的操作
“插入“是指在数组的某个位置加入一个新元素。因为数组要求元素连续存放,插入意味着要把插入点之后的所有元素向后移一格,给新元素腾出空间。
在末尾插入 —— O(1)
int arr[100] = {10, 20, 30}; // 当前有 3 个元素
int size = 3;
arr[size] = 40; // 直接在末尾写入
size++; // 元素个数 +1
末尾还有空位时,直接在 arr[size] 处写入,一步完成——O(1)。这是数组插入的唯一高效场景。
在开头或中间插入 —— O(n)
// 在下标 index 处插入 value。
// 前置条件:index 在 [0, *size] 内,并且数组至少还有一个空位。
void insert_at(int arr[], int *size, int index, int value)
{
// (1) 从最后一个元素开始,每个元素向后移一格
for (int i = *size; i > index; i--)
{
arr[i] = arr[i - 1];
}
// (2) 在腾出的空位写入新值
arr[index] = value;
// (3) 元素个数 +1
(*size)++;
}
参数 size 声明为指针,因为函数内部需要修改调用方的元素计数变量。*size 同时也是搬移的起点——当前有多少个有效元素,搬移就从最后一个有效元素的后面开始。
搬移是插入的核心。循环从 *size(末尾的后一个位置)开始,从后往前逐个把元素向后挪一格:arr[i] = arr[i - 1]。为什么必须从后往前?假设从前往后——先把 arr[index] 赋值给 arr[index+1],那原来 arr[index+1] 的值就被覆盖了,再也找不回来。从最后一个元素开始往后挪,每个值在被覆盖之前都已经搬到了安全位置,不会丢失数据。
腾出空位后,arr[index] = value 把新值写入。最后 (*size)++ 让元素计数加一。注意这里的括号——*size++ 会被解析为 *(size++)(先拿到 size 指向的值,再把指针本身加一),而不是我们想要的“把 *size 的值加一“,所以必须写成 (*size)++。
时间复杂度分析:最坏情况是在下标 0 插入——所有 n 个元素都要向后移一格,O(n)。平均情况:插在中间,搬移 n/2 个元素,也是 O(n)。
完整示例
#include <stdio.h>
void insert_at(int arr[], int *size, int index, int value)
{
for (int i = *size; i > index; i--)
arr[i] = arr[i - 1];
arr[index] = value;
(*size)++;
}
int main(void)
{
int arr[100] = {10, 20, 30, 40, 50};
int size = 5;
printf("插入前:");
for (int i = 0; i < size; i++)
printf("%d ", arr[i]);
printf("\n"); // 输出:10 20 30 40 50
insert_at(arr, &size, 2, 99); // 在下标 2 插入 99
printf("插入后:");
for (int i = 0; i < size; i++)
printf("%d ", arr[i]);
printf("\n"); // 输出:10 20 99 30 40 50
return 0;
}
💡 数组空间不够时怎么办?
上面的示例假设数组容量
100足够大。如果容量不够,需要使用realloc扩展空间(动态数组),这在第十章已讲过。但即使用了realloc,插入时的搬移代价依然存在——realloc解决了“容量“问题,解决不了“搬移“问题。
1.5 删(Delete)—— 插入的逆操作
“删除“是指把某个位置的元素移除。和插入相反——插入是“往后移、腾空位”,删除是“往前移、填空位“。
// 删除下标 index 处的元素;前置条件:index 在 [0, *size) 内
void delete_at(int arr[], int *size, int index)
{
// (1) 从 index+1 开始,每个元素向前移一格
for (int i = index; i < *size - 1; i++)
{
arr[i] = arr[i + 1];
}
// (2) 元素个数 -1
(*size)--;
}
删除的搬移方向和插入相反。插入时从后往前搬,是为了给新元素腾空间;删除时是从前往后搬,用后面的元素覆盖前面的空位。循环从 index 开始,依次执行 arr[i] = arr[i + 1]——把 index + 1 及其后的所有元素逐个向前拉一格。从前往后是安全的,因为我们是”拉后面的覆盖前面的”,前面的值本来就是要丢弃的。
最后 (*size)-- 让有效元素计数减一。注意,原最后一个位置(arr[*size])的值并没有被清除,只是不再属于有效范围——下次插入新元素时会直接覆盖它,所以不影响正确性。
时间复杂度分析:最坏情况是删除下标 0——所有元素都要向前移一格,O(n)。删除最后一个元素(下标 size-1):不需要搬移,size-- 一步完成,O(1)。
1.6 数组 CRUD 总结
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 查(下标) | O(1) | 基地址 + 下标 × 元素大小 |
| 查(值,无序) | O(n) | 线性遍历 |
| 查(值,有序) | O(log n) | 二分查找 |
| 改(知下标) | O(1) | 同下标访问 |
| 改(知值) | O(n) | 先 O(n) 查找再 O(1) 修改 |
| 增(末尾) | O(1) | 直接写入,不搬移 |
| 增(头部/中间) | O(n) | 插入点之后所有元素向后移 |
| 删(末尾) | O(1) | 不搬移 |
| 删(头部/中间) | O(n) | 删除点之后所有元素向前移 |
数组的增删瓶颈在于搬移数据——这是“连续存放“必须付出的代价。链表正是为了解决这个问题而诞生的。
二、非连续存储——链表
2.1 什么是链式存储
数组要求所有元素连续存放,插入删除就得”大搬家”。链式存储换了一个完全不同的思路:让每个元素自己记住”前后的元素在哪里”,用指针把分散在各处的数据串联起来。
那么,”分散在各处”的数据是怎么来的?答案在第十章——malloc。数组的存储空间由编译器在栈上一次性分配(int arr[5]),大小写死、位置固定。而链式存储的每一个节点都是在需要时通过 malloc 在堆上逐个独立申请的——你不知道会有多少个节点、每个节点会分配在哪个地址,唯一能把它们组织起来的手段,就是在每个节点里存一个指向下一个节点的指针。
这就是链式存储和 malloc 的关系:malloc 提供了”按需、分散、独立”创建节点的能力,指针提供了把这些散落的节点串联成一条链的能力。 二者缺一不可。
链表由若干节点(Node)组成,每个节点包含两部分:
- 数据域:存储实际的数据值。
- 指针域:存储一个或多个指针,记录其他节点的地址。
链表的”头”负责保存整条链的入口,后续节点通过各自的指针域一级一级串下去;对于本章介绍的非循环链表,最后一个节点的后继指向 NULL,表示链表结束。
在 C 语言里,链式存储常见有两种组织方式:
- 不带头节点:
head直接指向第一个存有数据的节点;空链表时head == NULL。这种写法少一个节点,但插入、删除第一个节点时经常要单独处理,因为链表入口本身会变。 - 带头节点:先准备一个不存有效数据的节点作为
head,真实数据从head->next开始;空链表时head->next == NULL。这种写法多一个头节点,但链表入口稳定,头插、尾插、删除第一个数据节点和删除中间节点都更容易统一。
本教程的数据结构部分统一采用带头节点的实现。原因很朴素:初学阶段最容易出错的是“第一个节点要不要特殊处理”。头节点像一个固定的入口和哨兵,让 head 本身一直存在,很多操作都能从“修改某个节点的 next”这一条规则出发。
链式存储的核心取舍:牺牲了随机访问(想找第 N 个?必须从头数过去),换来了插入删除的灵活性(只改指针,不搬数据)和动态增长的弹性(不需要提前声明大小,来一个数据 malloc 一个节点)。需要频繁增删、或数据量事先不确定的场景下,这个取舍是值得的。
2.2 连续存储 vs 链式存储:初步对比
在深入链表的具体实现之前,先对两种存储方式做一个宏观对比:
- 连续存储(数组):所有元素紧挨着放在一块连续的内存中。知道首地址和元素大小,任意元素的位置可以精确算出来——
首地址 + 下标 × sizeof(元素)。这是数组 O(1) 随机访问的物理基础,也是”改”和”按下标查”极快的原因。但正因为要求连续,中间插入和删除需要搬移元素。 - 链式存储(链表):每个节点独立
malloc,散落在内存各处,通过指针串联。节点地址没有规律,找第 N 个必须从头走 N 步。但也正因为不要求连续,插入和删除只需改指针,不需要搬移任何数据。此外,链表的大小是动态的——不像数组那样必须在编译时定死长度。
连续存储带来了随机访问,也带来了搬移代价和固定大小。链式存储失去了随机访问,却换来了插入删除的灵活和动态增长的弹性。 这就是数组和链表所有差异的根源。
链表的变体取决于节点记录多少关系信息。接下来逐一讲解最主流的两种:单链表(节点只记后继)和双链表(节点同时记前驱和后继)。本章所有链表代码都使用带头节点的非循环链表。
2.3 单链表
单链表是链式存储最简单、最基础的形式。它的规则只有一条:每个节点只记录自己后面那个节点的地址。在带头节点的单链表里,head 是固定存在的入口节点,不保存有效数据;第一个真实数据节点是 head->next。后续节点一个记着一个,最后一个的指针指向 NULL,表示“后面没有了“。
用图来看就是这样一条单向的链:
节点结构定义
要把上面那张图变成 C 代码,第一步是定义“节点长什么样“。一个节点需要两样东西:存数据的空间,和一个指向下一个节点的指针。用结构体来描述:
typedef struct Node
{
int data; // 数据域:存放数据(这里以 int 为例)
struct Node *next; // 指针域:指向下一个同类型的节点
} Node;
这段代码在内存中造出来的东西长这样:
int data;:数据域。这里以int为例存整数,实际开发中可以换成任何你需要的类型。struct Node *next;:指针域。注意这里写的是struct Node *而不是Node *——因为typedef到花括号结束时才生效,在结构体内部编译器还不认识Node这个简称。next存的是“下一个节点的地址“,没有下一个节点时就指向NULL。
💡 为什么指针域的类型是
struct Node *?因为
next要指向的是另一个同类型的节点。一个节点内部包含一个指向同类型节点的指针——这在 C 语言中称为自引用结构。编译器只需要知道struct Node *是一个指针类型,不需要知道struct Node的全部细节就能处理它,所以这种自引用语法是合法的。
创建节点
有了节点类型,下一步就是“造节点”。创建节点的过程是:通过 malloc 向 C 运行库的内存分配器申请一块内存,再填写数据和指针。
Node *create_node(int data)
{
// (1) 申请内存
Node *new_node = malloc(sizeof *new_node);
// (2) 检查是否申请成功
if (new_node == NULL)
{
fprintf(stderr, "内存分配失败\n");
exit(EXIT_FAILURE);
}
// (3) 填写数据域
new_node->data = data;
// (4) 指针域初始化为 NULL
new_node->next = NULL;
// (5) 返回这个新节点的地址
return new_node;
}
函数的任务是:向堆申请一个 Node 大小的内存块,填好数据和指针,然后把这个节点的地址返回给调用者。
malloc(sizeof *new_node) 按变量本身取 sizeof——这样即使以后把 new_node 的类型从 Node * 改成别的,sizeof 也会自动跟上,不容易漏改。malloc 返回的是 void *,在 C 语言中可以自动转换为任何对象指针类型,不需要强制转换;关键是确保 #include <stdlib.h> 已经包含,否则编译器可能对 malloc 做出错误的假设。
malloc 在内存不足时会返回 NULL,必须检查。虽然实际环境中很少真的内存耗尽,但养成每次检查的习惯是专业程序员的基本素养——未定义行为往往就从一次未检查的 NULL 开始。
接下来填写节点内容。new_node->data = data 把传入的数据写入数据域,-> 运算符用于通过指针访问结构体成员。new_node->next = NULL 把指针域初始化为空——新节点刚创建还没有“下家“,暂时指向 NULL,等插入链表时由调用方来更新这个指针。
一切就绪后,return new_node 把新节点的地址返回出去。
初始化头节点
带头节点的链表在使用前,要先准备并初始化这个固定入口。头节点本身不保存有效数据,所以只需要把 next 设为 NULL:
void init_list(Node *head)
{
head->next = NULL;
}
初始化之后,head->next == NULL 表示空链表;一旦插入第一个数据节点,head->next 就指向它。
2.3.1 插入操作
单链表的插入有两种基本方式:头插法(插在链表最前面)和尾插法(插在链表最后面)。两种方式的实现思路和使用场景不同,逐一来看。
头插法——每次插在链表最前面
头插法的思路很简单:让新节点指向当前第一个数据节点,然后让头节点指向新节点。头节点本身不变,变的是 head->next。
过程图解:
void insert_at_head(Node *head, int data)
{
// (1) 创建一个新节点
Node *new_node = create_node(data);
// (2) 新节点的 next 指向当前第一个数据节点
new_node->next = head->next;
// (3) 头节点的 next 指向新节点
head->next = new_node;
}
步骤分解:
| 步骤 | 代码 | 分类 | 说明 |
|---|---|---|---|
| ① | Node *new_node = create_node(data); | 创建 | 调用 create_node,新节点 data=5,next=NULL |
| ② | new_node->next = head->next; | 指针操作 | 让新节点接住原来的第一个数据节点 |
| ③ | head->next = new_node; | 指针操作 | 头节点指向新节点,新节点成为第一个数据节点 |
- 参数
Node *head:传入固定存在的头节点。如果链表为空,head->next的值就是NULL,此时步骤②等价于new_node->next = NULL,结果正确。
上图完整展示了头插法的三个步骤:创建新节点 → 新节点接住原第一个数据节点 → 头节点指向新节点。注意步骤②中,head->next 存的是原第一个数据节点的地址,赋值后 new_node->next 就指向它;步骤③让 head->next 改为指向新节点,新节点成为第一个数据节点。顺序不能反——必须先让新节点接住后面的链,再修改头节点,否则原链表就丢失了引用。
调用方式(非常重要):
Node head; // 头节点,不保存有效数据
init_list(&head); // 空链表:head.next == NULL
insert_at_head(&head, 20); // 链表: 20
insert_at_head(&head, 10); // 链表: 10 → 20
insert_at_head(&head, 5); // 链表: 5 → 10 → 20
注意:带头节点后,调用者不需要接收返回值。head 这个入口节点始终存在,插入函数只修改 head.next 或后续节点的 next。
尾插法——每次插在链表最后面
尾插法的思路是:先找到链表的最后一个节点(next 指向 NULL 的那个),然后把新节点挂在它后面。
过程图解:
void insert_at_tail(Node *head, int data)
{
// (1) 创建新节点
Node *new_node = create_node(data);
// (2) 从头节点开始,遍历找到最后一个节点
Node *p = head;
while (p->next != NULL)
{
p = p->next;
}
// (3) 将最后一个节点的 next 指向新节点
p->next = new_node;
}
步骤分解:
| 步骤 | 代码 | 分类 | 说明 |
|---|---|---|---|
| ① | Node *new_node = create_node(data); | 创建 | 创建独立的新节点,next 初始为 NULL |
| ② | p = head; while (p->next != NULL) ... | 查找位置 | 从头节点出发,找到最后一个节点 |
| ③ | p->next = new_node; | 指针操作 | 最后节点的 next 从 NULL 改为指向新节点 |
上图完整展示了尾插法的三个阶段:创建新节点 → 找到最后一个节点 → 将最后节点的 next 指向新节点。代码里 Node *p = head 和 while (p->next != NULL) p = p->next 共同完成第二阶段:p 每轮检查 p->next,只要后面还有节点就前进一步;直到 p->next == NULL 时停下,此时 p 就是最后一个节点。空链表时循环一次也不进,p 停在头节点,第三阶段的 p->next = new_node 等价于 head->next = new_node,逻辑完全统一。
中间插入——插在某个节点之后
头插和尾插解决的是链表两端的插入;实际使用中还常常需要“把新节点插到某个已有节点之后”。例如链表为 10 → 20 → 30,希望把 25 插到 20 后面,结果应为 10 → 20 → 25 → 30。
过程图解:
void insert_after(Node *head, int target, int data)
{
// (1) 找到第一个 data == target 的节点
Node *p = head->next;
while (p != NULL && p->data != target)
{
p = p->next;
}
if (p == NULL) return;
// (2) 创建新节点
Node *new_node = create_node(data);
// (3) 新节点接住 p 原来的后继
new_node->next = p->next;
// (4) p 指向新节点
p->next = new_node;
}
中间插入的关键仍然是顺序:先让新节点接住后面的链,再让前一个节点指向新节点。如果先执行 p->next = new_node,原来 p->next 指向的后续节点就丢失了引用。带头节点并不改变中间插入的本质,它只是让头部插入也能使用同一类“改链接”的思路。
💡 头插法 vs 尾插法的选择
头插法的优点是快——不需要遍历,时间复杂度 O(1)。缺点是插入的顺序和数据出现的顺序是反的:你先插 10 再插 20,链表是 20→10。
尾插法的优点是保持顺序——先插 10 再插 20,链表是 10→20。但每次都要遍历到末尾,时间复杂度 O(n)(n 是当前链表长度)。如果频繁尾插,可以考虑维护一个尾指针来避免遍历——后面的双链表和链式队列就会采用这个思路。
2.3.2 删除操作
删除节点的思路是:找到要删除的节点,让它的前一个节点绕过它直接指向它的后一个节点,然后释放它的内存。
void delete_node(Node *head, int data)
{
// (1) 从头节点开始找目标节点的前一个节点
Node *p = head;
while (p->next != NULL && p->next->data != data)
{
p = p->next;
}
// (2) 找到了,执行删除
if (p->next != NULL)
{
Node *temp = p->next; // 暂存要删除的节点
p->next = temp->next; // 绕过它
free(temp); // 释放内存
}
}
带头节点后,删除逻辑不再分”删除第一个数据节点”和”删除中间节点”两种大分支。因为头节点可以充当第一个数据节点的前驱:如果要删的是第一个数据节点,循环一开始 p == head,直接让 head->next = temp->next 即可。
代码的核心逻辑是:找到目标节点的前一个节点 p,修改 p->next 让它绕过目标。这个 p 可能是头节点,也可能是普通数据节点。
| 步骤 | 代码 | 分类 | 说明 |
|---|---|---|---|
| ① | Node *p = head; | 起点 | 从头节点开始找前驱 |
| ② | while (p->next->data != data) | 指针操作 | p 停在目标节点的“前面“ |
| ③ | Node *temp = p->next; | 暂存 | 记下要删除的目标节点 |
| ④ | p->next = temp->next; | 指针操作 | p 绕过 temp,直接指向后继 |
| ⑤ | free(temp); | 释放 | 释放目标节点内存 |
上图完整展示了单链表删除的三个步骤:找前驱 → 绕过目标 →释放内存。步骤①中,while 循环检查 p->next->data 而不是 p->data——这是单链表删除的关键设计。因为单链表没有 prev 指针,如果检查 p->data,p 会停在目标节点本身,无法修改前驱的链接;检查 p->next->data 则让 p 停在目标前面,随后通过修改 p->next 就能绕过目标。步骤②中,temp = p->next 暂存目标节点地址,然后 p->next = temp->next 让前驱跳过目标直接指向后继——不搬移任何数据,只改一个指针。步骤③ free(temp) 释放目标节点内存,删除完成。
2.3.3 查找操作
链表中查找一个值,思路和数组的线性查找一样——从头到尾逐个比较。但因为链表没有下标,不能直接跳到第 N 个元素,只能沿着 next 指针一步一步往后走。
// 在链表中查找值为 data 的节点,返回节点地址;未找到返回 NULL
Node *find_node(Node *head, int data)
{
Node *p = head->next; // 从第一个数据节点开始
while (p != NULL && p->data != data) // 遍历比较
{
p = p->next; // 没找到就前进
}
return p; // 找到返回地址,未找到返回 NULL
}
用一个行走指针 p 从 head->next 出发,每走一步检查 p->data 是否等于目标值。相等就返回 p(目标节点的地址);走到 p == NULL 说明链表里没有这个值,返回 NULL。
调用者拿到返回值后,先判断是否为 NULL——非 NULL 就是找到了,可以读取 p->data;NULL 就是没找到。时间复杂度 O(n),最坏情况下目标在末尾或不存在,需要走遍整个链表。
2.3.4 修改操作
修改就是“找到 → 覆盖“。先调用 find_node 定位目标节点,然后直接改写它的数据域。
// 将值为 old_data 的节点的数据域改为 new_data
// 返回 true 表示修改成功,false 表示未找到
bool update_node(Node *head, int old_data, int new_data)
{
Node *p = find_node(head, old_data); // (1) 先查找
if (p == NULL) return false; // (2) 没找到,返回 false
p->data = new_data; // (3) 找到,覆写数据域
return true;
}
找到目标节点后,p->data = new_data 一次赋值就完成了修改——O(1)。但算上查找的时间,整体还是 O(n)。这和数组“已知值再修改“的场景一样:瓶颈在查找,不在修改本身。
如果已知节点地址(比如遍历过程中顺手改),修改只是 O(1)。这在实际开发中很常见——先 find_node 拿到地址,后续反复修改同一节点时直接通过地址操作,不需要再次查找。
💡 查 vs 改 vs 删的共性:三个操作的核心都是先找到目标节点。查找直接返回地址,修改在找到后覆写数据,删除在找到前驱后改指针。理解了这个共性,链表的操作就不再是零散的几个函数,而是一个统一的模式。
2.3.5 遍历操作
遍历就是沿着指针把每个节点都访问一遍。最常见的使用场景是打印链表内容和释放整个链表。
打印链表
void print_list(Node *head)
{
Node *p = head->next; // (1) 从第一个数据节点开始
while (p != NULL) // (2) 只要没有走到末尾
{
printf("%d → ", p->data); // (3) 打印当前节点的数据
p = p->next; // (4) 移动到下一个节点
}
printf("NULL\n"); // (5) 打印结尾标志
}
遍历的核心是一个“行走指针“ p,它从第一个数据节点(head->next)出发,每次沿着 next 走到下一个节点,直到 next 为 NULL 时停下。头节点不保存有效数据,所以出发时要跳过它。
循环体内,每走到一个节点,先打印它的数据域 p->data,然后执行 p = p->next 前进一步。就像沿着线索逐个访问,直到末尾。循环结束后打印 NULL 作为链表结束的标志。
释放整个链表
链表是 malloc 创建的,用完后必须逐个释放,否则造成内存泄漏。
void free_list(Node *head)
{
Node *p = head->next;
while (p != NULL)
{
Node *temp = p; // (1) 记住当前节点
p = p->next; // (2) 先前进到下一个节点
free(temp); // (3) 再释放之前记住的节点
}
head->next = NULL; // (4) 头节点保留,链表重新变空
}
释放链表的关键在于顺序:必须先拿到下一个节点的地址,再释放当前节点。如果反过来——先 free(p) 再访问 p->next——就是在读已经释放的内存,属于未定义行为,后果不可预料。
所以代码用一个 temp 暂存当前要释放的节点:temp = p,然后 p = p->next 先前进到下一个节点,最后 free(temp) 安全释放。就像一个接力——每次把当前节点交给 temp,自己先跨到下一步,再放手前一个。循环走完一遍,所有数据节点占用的内存都归还给系统。最后把 head->next 重新设为 NULL,头节点保留,链表回到空状态。头节点通常由调用方在栈上创建,不在这里释放。
2.3.6 时间复杂度分析
学完了单链表的全部操作,现在逐一分析每个操作的效率。不要只记结论——跟着代码看清楚“为什么是这个复杂度“。
(1)创建节点 —— O(1)
Node *create_node(int data)
{
Node *new_node = malloc(sizeof *new_node); // 一次内存分配
if (new_node == NULL) { fprintf(stderr, "内存分配失败\n"); exit(EXIT_FAILURE); }
new_node->data = data; // O(1):一次赋值
new_node->next = NULL; // O(1):一次赋值
return new_node; // O(1)
}
在常见的数据结构分析模型中,一次固定大小的 malloc 记作 O(1),所以此函数相对于链表长度是 O(1)。实际分配器的耗时由实现决定,语言标准不保证 malloc 必然是常数时间。
(2)头插法 —— O(1)
void insert_at_head(Node *head, int data)
{
Node *new_node = create_node(data); // O(1)
new_node->next = head->next; // O(1):接住原第一个数据节点
head->next = new_node; // O(1):头节点指向新节点
}
关键在 new_node->next = head->next 和 head->next = new_node 这两行——只改两个指针的指向,不涉及任何遍历或搬移。链表有 10 个节点还是 10 万个节点,头插的工作量完全一样——O(1)。
对比数组在头部插入:所有元素要向后移一格,O(n)。
(3)尾插法 —— O(n)
void insert_at_tail(Node *head, int data)
{
Node *new_node = create_node(data); // O(1)
Node *p = head;
while (p->next != NULL) // ← 这里决定了复杂度
p = p->next; // 每走一步 O(1),总共走 n 步
p->next = new_node; // O(1)
}
while 循环从头节点一直走到最后一个节点:链表有 n 个数据节点,最坏就要走 n 步。因此尾插是 O(n)。如果像后面的链式队列那样维护一个尾指针,尾插也可以做到 O(1)——多存一个指针,省掉每次遍历。
对比数组在尾部插入(有空间余裕时):直接写 arr[size] = x,O(1)。
(4)中间插入 —— 已知前驱 O(1),按值查找后 O(n)
void insert_after(Node *head, int target, int data)
{
Node *p = head->next;
while (p != NULL && p->data != target) // O(n):先找到插入位置
p = p->next;
if (p == NULL) return;
Node *new_node = create_node(data); // O(1)
new_node->next = p->next; // O(1):接住后继
p->next = new_node; // O(1):前驱指向新节点
}
如果已经拿到了要插入位置的前驱节点地址,真正的插入动作只需要改两个指针,是 O(1)。但本例按值 target 查找插入位置,需要先遍历链表,整体就是 O(n)。链表的优势在于“不搬移数据”,不是“自动知道位置在哪里”。
(5)删除节点 —— O(n)
void delete_node(Node *head, int data)
{
// 从头节点开始找目标节点的前驱:while 循环是 O(n)
Node *p = head;
while (p->next != NULL && p->next->data != data) // ← 遍历查找
p = p->next; // 最坏走 n 步
if (p->next != NULL) // 找到了
{
Node *temp = p->next;
p->next = temp->next; // O(1):绕过
free(temp); // O(1):释放
}
}
删除分两阶段:查找目标 → 执行删除。查找需要遍历,最坏走 n 步(O(n));找到后改一个指针、一次 free(O(1))。总复杂度 O(n),瓶颈在查找。
关键洞察:删除的指针操作本身是 O(1)(不搬数据),但前提是已经取得所需链接信息。单链表删除需要目标节点的前驱;头节点让“第一个数据节点”也拥有一个稳定前驱,因此删除代码不用再为它单独分支。数组删除即使知道下标,也要把后面所有元素前移——还是 O(n)。
(5)遍历和释放 —— O(n)
void print_list(Node *head)
{
Node *p = head->next;
while (p != NULL) { printf("%d ", p->data); p = p->next; } // 走 n 步,O(n)
}
void free_list(Node *head)
{
Node *p = head->next;
while (p != NULL) { Node *t = p; p = p->next; free(t); } // 走 n 步,O(n)
head->next = NULL;
}
两个函数都是从头走到尾,每节点访问一次——O(n)。
小结:
| 操作 | 复杂度 | 瓶颈在哪 |
|---|---|---|
| 创建节点 | O(1) | — |
| 头插 | O(1) | — |
| 尾插 | O(n) | 遍历找尾节点 |
| 中间插入 | O(n) | 遍历找插入位置 |
| 查找(按值) | O(n) | 遍历比较 |
| 修改(按值) | O(n) | 遍历查找(修改动作 O(1)) |
| 删除 | O(n) | 遍历找目标(删除动作 O(1)) |
| 遍历/释放 | O(n) | 必须访问每个节点 |
和数组的核心区别:数组的 O(n) 来自搬移数据,链表的 O(n) 来自遍历寻址。尾部操作的全面对比(包括和双链表的三方对比)见第十九章 总结与对比。
2.3.7 完整示例
将以上所有代码组合成一个可以编译运行的程序,验证单链表的各项操作:
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
// ========== 节点类型定义 ==========
typedef struct Node
{
int data;
struct Node *next;
} Node;
// ========== 初始化头节点 ==========
void init_list(Node *head)
{
head->next = NULL;
}
// ========== 创建数据节点 ==========
Node *create_node(int data)
{
Node *new_node = malloc(sizeof *new_node);
if (new_node == NULL)
{
fprintf(stderr, "内存分配失败\n");
exit(EXIT_FAILURE);
}
new_node->data = data;
new_node->next = NULL;
return new_node;
}
// ========== 头插法 ==========
void insert_at_head(Node *head, int data)
{
Node *new_node = create_node(data);
new_node->next = head->next;
head->next = new_node;
}
// ========== 尾插法 ==========
void insert_at_tail(Node *head, int data)
{
Node *new_node = create_node(data);
Node *p = head;
while (p->next != NULL)
{
p = p->next;
}
p->next = new_node;
}
// ========== 插在指定节点之后 ==========
void insert_after(Node *head, int target, int data)
{
Node *p = head->next;
while (p != NULL && p->data != target)
{
p = p->next;
}
if (p == NULL) return;
Node *new_node = create_node(data);
new_node->next = p->next;
p->next = new_node;
}
// ========== 删除节点 ==========
void delete_node(Node *head, int data)
{
Node *p = head;
while (p->next != NULL && p->next->data != data)
{
p = p->next;
}
if (p->next != NULL)
{
Node *temp = p->next;
p->next = temp->next;
free(temp);
}
}
// ========== 查找节点 ==========
Node *find_node(Node *head, int data)
{
Node *p = head->next;
while (p != NULL && p->data != data)
p = p->next;
return p;
}
// ========== 修改节点 ==========
bool update_node(Node *head, int old_data, int new_data)
{
Node *p = find_node(head, old_data);
if (p == NULL) return false;
p->data = new_data;
return true;
}
// ========== 打印链表 ==========
void print_list(Node *head)
{
Node *p = head->next;
while (p != NULL)
{
printf("%d → ", p->data);
p = p->next;
}
printf("NULL\n");
}
// ========== 释放链表 ==========
void free_list(Node *head)
{
Node *p = head->next;
while (p != NULL)
{
Node *temp = p;
p = p->next;
free(temp);
}
head->next = NULL;
}
// ========== 主函数 ==========
int main(void)
{
Node head;
init_list(&head);
// 测试尾插法
insert_at_tail(&head, 10);
insert_at_tail(&head, 20);
insert_at_tail(&head, 30);
printf("尾插后:");
print_list(&head); // 输出: 10 → 20 → 30 → NULL
// 测试中间插入
insert_after(&head, 20, 25);
printf("插入25:");
print_list(&head); // 输出: 10 → 20 → 25 → 30 → NULL
// 测试头插法
insert_at_head(&head, 5);
printf("头插后:");
print_list(&head); // 输出: 5 → 10 → 20 → 25 → 30 → NULL
// 测试删除
delete_node(&head, 20);
printf("删除20:");
print_list(&head); // 输出: 5 → 10 → 25 → 30 → NULL
// 删除第一个数据节点
delete_node(&head, 5);
printf("删除5:");
print_list(&head); // 输出: 10 → 25 → 30 → NULL
// 测试查找
Node *found = find_node(&head, 30);
if (found != NULL)
printf("找到节点,data = %d\n", found->data); // 输出: 找到节点,data = 30
// 测试修改
if (update_node(&head, 10, 99))
{
printf("修改10→99:");
print_list(&head); // 输出: 99 → 30 → NULL
}
// 释放链表
free_list(&head);
return 0;
}
运行输出:
尾插后:10 → 20 → 30 → NULL
插入25:10 → 20 → 25 → 30 → NULL
头插后:5 → 10 → 20 → 25 → 30 → NULL
删除20:5 → 10 → 25 → 30 → NULL
删除5:10 → 25 → 30 → NULL
找到节点,data = 30
修改10→99:99 → 25 → 30 → NULL
2.4 双链表
什么是双链表
单链表有一个明显的局限:只能单向行走。每个节点只有指向“后面“的指针,没有指向“前面“的指针。如果你已经走到了某个节点,突然想回退一步看看前一个节点——对不起,单链表做不到。删除某个节点时,也必须先找到它的前驱节点——因为你需要修改前驱节点的 next。
双链表在单链表的基础上给每个数据节点增加了一个前驱指针,指向它的前一个节点。带头节点的双链表中,第一个数据节点的 prev 指向头节点。这样一来:
- 从任意一个节点,既可以向前走(
next),也可以向后走(prev)。 - 给定一个节点的地址,可以直接删除它,不需要遍历找前驱。
- 维护头、尾指针时,可以在链表两端高效地插入和删除;本节只保存头节点,因此尾插仍需遍历。
双链表的特点:
| 特点 | 说明 |
|---|---|
| 双向遍历 | 既能从头走到尾,也能从尾走到头 |
| 删除更方便 | 已知节点地址可直接删除,不需要找前驱 |
| 额外空间开销更大 | 每个节点比单链表多存一个 prev 指针 |
| 插入/删除操作更复杂 | 每次要同时维护 prev 和 next 两个指针 |
节点结构定义
双链表的节点在单链表基础上增加了 prev 指针域:
typedef struct DNode
{
int data; // 数据域
struct DNode *prev; // 前驱指针:指向前一个节点
struct DNode *next; // 后继指针:指向后一个节点
} DNode;
双链表节点比单链表多了一个 prev 指针域。data 负责存数据,和单链表相同。next 是后继指针,指向后一个节点,和单链表的 next 功能一样——最后一个节点的 next 指向 NULL。prev 是新加入的前驱指针,指向前一个节点;如果是第一个数据节点,它的 prev 指向头节点,头节点自己的 prev 则为 NULL。有了这两个指针,任意节点都能同时访问它前后两个邻居,正着走用 next,反着走用 prev。
单链表用一个指针串起一条链,双链表用两个指针串起一条“双向链“——正着走用 next,反着走用 prev。
创建节点
void init_d_list(DNode *head)
{
head->prev = NULL;
head->next = NULL;
}
DNode *create_d_node(int data)
{
DNode *node = malloc(sizeof *node);
if (node == NULL)
{
fprintf(stderr, "内存分配失败\n");
exit(EXIT_FAILURE);
}
node->data = data;
node->prev = NULL; // 新节点暂时没有前驱
node->next = NULL; // 新节点暂时没有后继
return node;
}
init_d_list 初始化头节点,它的职责和单链表的头节点一样——不存有效数据,只作为链表入口。不同的是双链表头节点多了一个 prev 字段,这里也设为 NULL,表示头节点之前没有别的节点。
create_d_node 和单链表的 create_node 几乎一样,唯一的区别是新节点多了一个 prev 指针需要初始化。新创建的节点还没有“邻居“,所以 prev 和 next 都设为 NULL,等插入链表时由调用方来建立双向连接。
2.4.1 插入操作
双链表的插入同样分为头插法和尾插法。双链表因为可以双向遍历,维护一个尾指针会让尾插变得高效。但为了降低学习曲线,这里先介绍只维护头节点、不维护尾指针的基本版本。
头插法
头插法需要维护两个方向的指针:新节点 → 原第一个数据节点(next),以及原第一个数据节点 → 新节点(prev)。同时,新节点的 prev 要指向头节点。
void insert_at_head(DNode *head, int data)
{
DNode *new_node = create_d_node(data); // (1) 创建新节点
new_node->prev = head; // (2) 新节点前驱指向头节点
new_node->next = head->next; // (3) 新节点后继指向原第一个数据节点
if (head->next != NULL)
{
head->next->prev = new_node; // (4) 原第一个数据节点回指新节点
}
head->next = new_node; // (5) 头节点指向新节点
}
创建新节点之后,需要建立三个方向的连接:新节点到头节点(prev)、新节点到原第一个数据节点(next)、以及原第一个数据节点回指新节点(prev)。
首先,new_node->prev = head——作为新的第一个数据节点,它的前驱就是头节点。然后 new_node->next = head->next——让新节点的 next 接住原来的第一个数据节点(如果链表为空,head->next 是 NULL,结果也正确)。接下来是关键的一步:如果原来不是空链表,原第一个数据节点的 prev 必须回指新节点(head->next->prev = new_node),否则原第一个节点的前驱还指着别处,双向链就断了。最后 head->next = new_node 让头节点指向新节点,完成插入。
注意这几步的顺序:必须先让新节点的 prev 和 next 各就各位,再修改外部节点指向新节点。如果反过来先改 head->next,原来的第一个数据节点就丢失了引用,再也找不回来。
尾插法
void insert_at_tail(DNode *head, int data)
{
DNode *new_node = create_d_node(data); // (1) 创建新节点
// (2) 从头节点开始,遍历找到最后一个节点
DNode *p = head;
while (p->next != NULL)
{
p = p->next;
}
// (3) 建立双向连接
p->next = new_node; // 旧尾节点的后继指向新节点
new_node->prev = p; // 新节点的前驱指向旧尾节点
}
尾插的第一步和单链表一样——用 p 指针从头节点出发,沿着 next 一路走到最后一个节点(p->next == NULL 时停下)。空链表时循环不执行,p 就停在头节点。
找到尾节点后,建立双向连接:p->next = new_node 让旧尾节点(或头节点)的 next 从 NULL 改为指向新节点;new_node->prev = p 让新节点的 prev 指回去,完成双向绑定。空链表时 p 是头节点,所以 p->next = new_node 等价于 head->next = new_node,new_node->prev = p 就是指向头节点——逻辑完全统一。
中间插入
双链表中间插入同样可以描述为“插到某个已有节点之后”。和单链表相比,它多维护一个反向链接:新节点不仅要通过 next 指向后继,还要通过 prev 指向前驱;如果后继存在,后继的 prev 也必须改为新节点。
void insert_after(DNode *head, int target, int data)
{
// (1) 找到第一个 data == target 的节点
DNode *p = head->next;
while (p != NULL && p->data != target)
{
p = p->next;
}
if (p == NULL) return;
// (2) 创建新节点
DNode *new_node = create_d_node(data);
// (3) 新节点先接住前驱和后继
new_node->prev = p;
new_node->next = p->next;
// (4) 后继回指新节点
if (p->next != NULL)
{
p->next->prev = new_node;
}
// (5) 前驱指向新节点
p->next = new_node;
}
这里的顺序和头插法完全一致:先把新节点自己的 prev、next 设置好,再修改旁边节点的链接。若 p 原本是尾节点,p->next == NULL,则步骤④跳过,步骤⑤直接把新节点挂到尾部;因此这段代码也自然兼容“插在最后一个节点之后”的情况。
2.4.2 删除操作
双链表删除的一个巨大优势是:找到了目标节点后,可以直接删除它,不需要再遍历找前驱——因为前驱就记录在 prev 里。
void delete_node(DNode *head, int data)
{
DNode *p = head->next; // (1) 从第一个数据节点开始
while (p != NULL && p->data != data) // (2) 遍历查找目标节点
{
p = p->next;
}
if (p == NULL) return; // (3) 没找到,直接返回
// (4) 前驱跳过 p;前驱可能是头节点
p->prev->next = p->next;
if (p->next != NULL)
{
p->next->prev = p->prev; // 后继反向跳过 p
}
// (5) 释放目标节点
free(p);
}
和单链表不同,双链表的遍历直接找目标节点本身,而不是找它的前驱——因为目标节点自己就存了 prev,删除时不需要通过前驱来间接访问。
遍历从头节点的下一个(第一个数据节点)开始,逐个检查 p->data == data。如果走到末尾(p == NULL)都没找到,说明链表中没有这个值,直接返回。
找到目标节点后,执行“跳过“操作——让前驱和后继越过目标节点直接相连。p->prev->next = p->next 让前驱的 next 跳过 p,指向 p 的后继。这行对第一个数据节点也成立——此时 p->prev 就是头节点,所以不需要特殊处理。如果 p 不是尾节点,还需要让后继的 prev 回指 p->prev,完成双向的“绕过“。
最后 free(p) 释放目标节点。注意 free 必须在所有链接修改完成之后——因为修改链接时需要读取 p->prev 和 p->next,如果先释放了 p,这些指针就变成了野指针。
💡 和单链表删除对比
单链表删除时,需要找到目标节点的前驱(
while (p->next != NULL && p->next->data != data))。双链表因为每个数据节点都存了前驱的地址,所以直接找到目标节点本身即可,然后通过prev拿到前驱。这使得代码逻辑更清晰。
2.4.3 查找操作
双链表的正向查找和单链表完全一样——从 head->next 出发,沿 next 逐个比较。但因为多了 prev 指针,双链表还能反向查找:如果知道目标靠近尾部,可以先走到末尾再沿 prev 往回找。
// 正向查找(和单链表相同)
DNode *find_d_node(DNode *head, int data)
{
DNode *p = head->next;
while (p != NULL && p->data != data)
p = p->next;
return p;
}
// 反向查找:先从 tail 出发沿 prev 向前
// 适用于目标靠近链表尾部的场景
DNode *find_d_node_reverse(DNode *tail, int data)
{
DNode *p = tail;
while (p != NULL && p->data != data)
p = p->prev;
return p;
}
正向和反向查找的时间复杂度都是 O(n)——prev 指针让遍历方向自由了,但不会让查找更快(除非你知道目标靠近哪一端,选择从近端出发可以减少步数,但最坏情况不变)。
2.4.4 修改操作
双链表的修改和单链表完全一样——找到目标节点后,直接覆写 p->data。双链表多出来的 prev 指针只在删除和反向遍历时发挥作用,修改数据域不需要动指针。
bool update_d_node(DNode *head, int old_data, int new_data)
{
DNode *p = find_d_node(head, old_data);
if (p == NULL) return false;
p->data = new_data; // 只改数据,不动指针
return true;
}
找到之后,p->data = new_data 一次赋值就是 O(1)。加上查找阶段,整体 O(n)。
2.4.5 遍历操作
// 正向遍历(和单链表完全一样)
void print_forward(DNode *head)
{
DNode *p = head->next;
printf("NULL ←→ ");
while (p != NULL)
{
printf("%d ←→ ", p->data);
p = p->next;
}
printf("NULL\n");
}
// 反向遍历(先走到尾,再往回走)
void print_backward(DNode *head)
{
if (head->next == NULL)
{
printf("NULL\n");
return;
}
// 先走到最后一个节点
DNode *p = head->next;
while (p->next != NULL)
{
p = p->next;
}
// 从尾向头遍历
printf("NULL ←→ ");
while (p != head)
{
printf("%d ←→ ", p->data);
p = p->prev; // 沿着 prev 往回走
}
printf("NULL\n");
}
反向遍历分两段。第一段和单链表尾插法找最后一个节点一样——p 从 head->next 出发,沿着 next 走到末尾(p->next == NULL 时停下),此时 p 就是最后一个数据节点。
第二段从尾向头走。每步打印当前节点的数据,然后 p = p->prev 退回前一个节点。终止条件是 p == head——当 p 退回到头节点时,说明所有数据节点都访问完了,循环结束。头节点本身不存数据,不需要打印。
释放操作和单链表完全相同——沿着 next 逐个 free 即可,不必关心 prev。
2.4.6 时间复杂度分析
双链表的大部分操作和单链表相同:创建 O(1)、头插 O(1)、尾插 O(n)、按值中间插入 O(n)、遍历 O(n)。中间插入如果已经拿到前驱节点地址,真正接入新节点的动作是 O(1);按值插入仍要先查找位置,所以整体是 O(n)。这里重点分析它和单链表的两个关键差异。
差异一:删除不再依赖前驱
单链表删除时,必须找到目标节点的前驱:
// 带头节点的单链表删除 —— while 检查 p->next->data,为了停在"目标前面"
Node *p = head;
while (p->next != NULL && p->next->data != data) // O(n)
p = p->next; // p 停在目标前面
if (p->next != NULL)
{
Node *temp = p->next;
p->next = temp->next; // 绕过目标
free(temp);
}
双链表每个节点都存了 prev,可以直接定位到目标本身:
// 双链表删除 —— while 检查 p->data,找到目标本身即可
DNode *p = head->next;
while (p != NULL && p->data != data) // O(n):查找
p = p->next;
if (p != NULL) // 找到了
{
p->prev->next = p->next; // O(1):前驱绕过
if (p->next != NULL)
p->next->prev = p->prev; // O(1):后继回指
free(p); // O(1)
}
两者的查找都是 O(n),但双链表找到后直接通过 p->prev 获取前驱,不需要像单链表那样“提前停在前面“。在已知节点地址的前提下(比如遍历过程中顺手删除),双链表删除是真正的 O(1)——连遍历找前驱都省了。
差异二:支持反向遍历
// 反向遍历:先处理空链表,再走到末尾并沿 prev 往回走
if (head->next != NULL)
{
DNode *p = head->next;
while (p->next != NULL) p = p->next; // O(n):走到末尾
while (p != head)
{
printf("%d ", p->data);
p = p->prev; // 沿 prev 往回走
}
}
单链表只能单向走,双链表可以双向走。需要从后往前处理的场景(如撤销操作的历史记录),双链表是自然的选择。
双链表 vs 单链表 vs 数组的总览:
| 维度 | 数组 | 单链表 | 双链表 |
|---|---|---|---|
| 随机访问 | O(1) | O(n) | O(n) |
| 头插 | O(n) | O(1) | O(1) |
| 尾插 | O(1) | O(n) | O(n) |
| 中间插入(按值定位) | O(n) | O(n) | O(n) |
| 删除(已知节点地址) | O(n) | 还需前驱信息 | O(1) |
| 反向遍历 | 支持 | 不支持 | 支持 |
| 每节点指针开销 | 0 | 1 个指针 | 2 个指针 |
空间开销:双链表每个节点存 prev 和 next 两个指针,比单链表多一个指针。指针的具体字节数取决于平台,不能固定写成 8 字节。额外空间换来了“删除不依赖遍历找前驱”和“双向遍历”——值不值得,要看场景。
三方完整的操作效率对比(包括查找、空间、缓存等所有维度)见第十九章 总结与对比。
2.5 链式存储的本质
单双链表只是实现,不是本质
学完单链表和双链表,很容易产生一个印象:链表就是“有一个 next 指针“或者“有 next 和 prev 两个指针“的结构。但这是一个常见的误解——
单链表和双链表只是链式存储的两种具体实现,链式存储的本质不在“有几个指针“,而在“记录节点之间的关系信息“。
本质是什么
链式存储的核心思想只有一句话:每个节点除了存数据,还携带“其他节点在哪“的信息。
这个“信息“是什么形式,取决于你需要什么:
| 实现形式 | 记录的“关系信息“ | 能做什么 |
|---|---|---|
| 单链表 | 只记“下一个节点是谁“(1 个后继指针) | 单向遍历、头插头删 |
| 双链表 | 记“前一个和后一个分别是谁“(前驱+后继,2 个指针) | 双向遍历、已知节点时 O(1) 删除 |
| 十字链表 | 记“上下左右四个邻居是谁“(4 个指针) | 二维表格的快速行列遍历 |
| 跳表 | 记“下一个是谁“ + “跳 n 步后是谁”(多层索引指针) | O(log n) 查找、类似平衡树 |
它们形式各异,但底层逻辑完全一致——节点携带指针,指针表达关系。 单链表和双链表只是这个思想最常用的两种形态。
不被“单“和“双“框住
如果把思维框死在“链表只有单链表和双链表“里,就会错过链式存储的真正威力。链式存储的自由度非常高:
- 可以只记后继(单链表)——最简单,够用就好。
- 可以前驱后继都记(双链表)——每节点多花一个指针的空间,换来双向能力和已知节点时的 O(1) 删除。
- 可以记多个后继——这就是树(一个节点指向多个子节点)。
- 可以记任意节点的关系——这就是图(节点之间任意连线)。
沿着这条线索看,树和图本质上也是链式存储思想的延伸——只不过从“一对一的关系“(每个节点一个后继),变成了“一对多“(树)甚至“多对多“(图)。数据结构从线性到非线性的跨越,正是在指针记录的关系信息上做了扩展。
核心认知:不要把链表等价于“单链表“或“双链表“。链式存储的本质是用指针记录节点间的关系,几个指针、指向谁,都是可以根据需求自由组合的。单/双链表只是这种思想在“一对一线性关系“下的两种最常用实践。
三、本章小结
本章围绕物理存储层——数据在内存中到底怎么放——深入学习了两种最基本的存储方式:
| 存储方式 | 代表 | 核心优势 | 核心代价 |
|---|---|---|---|
| 连续存储 | 数组 | O(1) 随机访问 | 插入删除 O(n),需连续大块内存 |
| 链式存储 | 链表 | 定位后增删不搬移数据,可动态增长 | 无随机访问,需额外指针开销 |
数组——连续存储的代表。CRUD 分析表明:按下标访问 O(1) 是其最大优势;插入和删除因搬移数据而 O(n)。适合“数据量确定、主要操作为读取和遍历“的场景。
单链表——链式存储的基础形态,只记后继。头插 O(1),但随机访问和尾插需要遍历 O(n)。适合“频繁增删、数据量不确定“的场景。
双链表——在单链表基础上增加前驱指针。指针字段的开销增加,换来双向遍历和已知节点地址时 O(1) 删除;如果只知道值,仍要先用 O(n) 查找。
链式存储的本质——单链表和双链表只是实现形式。核心思想是“每个节点携带其他节点的位置信息“,用指针表达节点间的关系。这个思想延伸出去就是树和图——数据结构从线性到非线性的跨越。
从下一章开始,我们将在连续存储和链式存储的基础上“加规则“,构造出功能各异的抽象数据结构——栈、队列和环形队列。
第十九章 通用抽象数据结构
本章要点
在第十八章中,我们深入学习了两种最基础的物理存储方式——数组(连续存储)和链表(分散存储)。本章聚焦逻辑规则层:给基础存储加上规则,构造出功能各异的抽象数据结构。
回顾数据结构总览中的核心框架:数据结构 = 物理存储方式 + 逻辑组织规则。 数组和链式节点是本书线性结构中采用的两类基础存储方式;加上不同的访问规则之后,就能实现栈、队列等结构。规则不同,行为不同,但地基相通。
📌 入门阶段范围说明:通用抽象数据结构在数学上分为线性结构和非线性结构两大类。本章(入门阶段)只覆盖最基础的线性抽象数据结构——栈和队列。非线性结构(树、图等)将在进阶篇中继续学习。
具体涵盖:
- 线性与非线性数据结构:从数据元素之间关系的角度,理解两类抽象数据结构的本质区别
- 栈(基于数组):后进先出(LIFO),只能从一端进出
- 链式队列(基于链表):先进先出(FIFO),维护队首队尾双指针
- 环形队列(基于数组):用取模运算让数组首尾相接,解决“假溢出“
- 总结与对比:物理存储 × 逻辑规则的总览表,以及全部结构的操作效率综合对比
学完本章,你将理解“同一存储 + 不同规则 = 不同行为“——这正是数据结构设计的精髓。
一、线性数据结构与非线性数据结构
1.1 从数据元素之间的关系分类
在数据结构总览中,我们把数据结构分为两层——物理存储(数组/链表)和逻辑规则。本章聚焦逻辑规则层,也就是“通用抽象数据结构“。
抽象数据结构按照数据元素之间的关系,可以分为两大类:
| 分类 | 定义 | 特征 |
|---|---|---|
| 线性数据结构 | 元素之间存在一对一的前后关系 | 每个元素最多有一个前驱和一个后继 |
| 非线性数据结构 | 元素之间存在一对多或多对多的关系 | 一个元素可以关联多个其他元素 |
1.2 线性数据结构
线性结构的特点非常直观——数据排成一条线。每个元素(除首尾外)前面只有一个元素,后面也只有一个元素。
常见的线性抽象数据结构:
| 结构 | 规则 | 行为 |
|---|---|---|
| 栈 | 只能从一端进出 | 后进先出(LIFO) |
| 队列 | 一端进、另一端出 | 先进先出(FIFO) |
线性结构是最基础的抽象数据结构,也是入门阶段的重点。它们的核心操作(入栈/出栈、入队/出队)都围绕“一端或两端的顺序访问“展开。
1.3 非线性数据结构
非线性结构打破“排成一条线“的限制——一个元素可以连接多个元素,形成分支或网络状的关系。
常见的非线性抽象数据结构:
| 结构 | 元素关系 | 典型场景 |
|---|---|---|
| 树 | 一对多的层次关系 | 文件目录、组织架构、表达式解析 |
| 图 | 多对多的网络关系 | 地图导航、社交网络、网络路由 |
📌 入门阶段范围说明:非线性结构(树、图)的实现和分析更为复杂,将在进阶篇中详细讲解。入门阶段只聚焦线性抽象数据结构——栈和队列。理解好线性结构,是学习非线性结构的必要基础。
二、栈(基于数组)
2.1 什么是栈
回顾数据结构总览中的观点:数组和链式节点是两类常见的基础存储方式,许多抽象结构可以在它们之上增加访问规则来实现。 栈就是典型例子——约束数组只能从一端进出,就得到数组栈。同样,也可以用链表实现栈,只需约束只在头部插入和删除即可。本章先用数组实现,读者可以自行尝试链表版本。
栈规定:数据只能从一端进入,也只能从同一端取出。这一端称为栈顶。
这种规则看似是一种限制,实则是赋予了栈一个非常重要的特性:后进先出(LIFO,Last In First Out)——最后放进去的元素,最先被取出来。
栈的例子随处可见:
- 一叠盘子:洗完盘子往上摞,用的时候从上面取。最先摞上去的在最底下(最后才能用到),最后摞上去的在最上面(最先被用到)。
- 函数的调用与返回:常见 C 实现使用调用栈保存返回地址、部分局部状态等信息;具体调用约定由平台和编译器决定。
栈只允许三种操作:
| 操作 | 含义 |
|---|---|
| 入栈(push) | 将一个元素放入栈顶 |
| 出栈(pop) | 从栈顶取出一个元素 |
| 查看栈顶(peek) | 查看栈顶元素但不取出 |
2.2 栈的结构定义(基于数组)
用数组实现栈是最直观的方式:数组本身就提供了一片连续的存储空间。再配合一个变量 top 记录“栈顶在数组中的哪个位置“。
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#define MAX_SIZE 100
typedef struct
{
int data[MAX_SIZE]; // 存放数据的数组
int top; // 栈顶下标,-1 表示空栈
} Stack;
Stack 结构体包含两个核心成员:
int data[MAX_SIZE]:用数组存放栈中的元素。MAX_SIZE通过宏定义为 100,修改这个值就能改变栈的容量。int top:栈顶下标。top = -1表示栈是空的——没有任何元素时,没有合法的下标可以指向。放入第一个元素时top变成0,第二个元素放入时变成1,以此类推。
2.3 基本操作:初始化、判空、判满
// 初始化栈
void init_stack(Stack *s)
{
s->top = -1; // 将 top 设为 -1,表示空栈
}
// 判断栈是否为空
bool is_empty(Stack *s)
{
return s->top == -1;
}
// 判断栈是否已满
bool is_full(Stack *s)
{
return s->top == MAX_SIZE - 1; // top 到达数组最后一个下标时已满
}
init_stack 在使用栈之前必须调用——就像使用变量之前要初始化一样。它把 top 设为 -1,标记栈为空。
is_empty 判断 top == -1 是否成立。-1 不是有效的数组下标,这里只是一个约定,表示“还没有元素“。
is_full 判断 top 是否已经到达数组的最后一个下标 MAX_SIZE - 1。例如 MAX_SIZE = 100,有效下标是 0~99,top = 99 时栈已满。
2.4 入栈操作(push)
入栈的步骤:先检查栈是否已满,如果未满,将 top 加 1,然后把元素放入 data[top]。
bool push(Stack *s, int value)
{
if (is_full(s)) // (1) 检查是否已满
{
return false; // 入栈失败
}
s->top++; // (2) top 向上移动一格
s->data[s->top] = value; // (3) 在栈顶位置放入新元素
return true; // 入栈成功
}
入栈前先调用 is_full 检查是否已满。栈满时返回 false,防止数组越界——调用者可以根据返回值决定是否提示用户。
栈未满时,先把 top 加 1:空栈时 top = -1 加 1 变成 0;原来 top = 2(栈里有 3 个元素)加 1 变成 3。然后将新元素写入 data[top],入栈完成。
注意代码中使用 s-> 而不是 s.,因为参数 s 是 Stack * 类型的指针,指针访问结构体成员必须用 -> 运算符。
2.5 出栈操作(pop)
出栈的步骤:先检查栈是否为空,如果不空,取出栈顶元素,然后将 top 减 1。
bool pop(Stack *s, int *out)
{
if (is_empty(s) || out == NULL) // (1) 检查是否为空及输出指针是否有效
{
return false; // 出栈失败
}
*out = s->data[s->top]; // (2) 取出栈顶元素的值
s->top--; // (3) top 向下移动一格
return true; // 出栈成功
}
出栈需要把取出的值“传回“给调用者。C 语言中函数只能有一个返回值,这里用 bool 表示成功/失败,通过指针参数 out 把取出的值传出去。
函数首先检查两个条件:栈是否为空、out 是否为 NULL。空栈没有元素可取,空指针也不能被解引用,这两种情况都返回 false。
检查通过后,通过 *out = s->data[s->top] 将栈顶元素的值写入调用者提供的变量。*out 是解引用——因为 out 是指针,加上 * 才能访问指针指向的那个变量。然后 top 减 1,栈顶指针下移。
注意这里并没有真的“删除“数组中的数据——data[top] 的旧值还在,但下次入栈时会直接覆盖它。top 的移动已经意味着这个位置“可以重新使用了“。
2.6 查看栈顶(peek)
bool peek(Stack *s, int *out)
{
if (is_empty(s) || out == NULL)
{
return false;
}
*out = s->data[s->top]; // 只读取栈顶元素,不修改 top
return true;
}
peek 和 pop 的区别只在于:peek 不修改 top。它只是“看一眼“栈顶是什么,栈的内容没有任何变化。
2.7 组合使用示例(main)
下面的 main 函数与前面定义的 Stack 及各操作函数组合使用;可独立编译的多文件版本已同步到配套源码的 array-stack 项目。
int main(void)
{
Stack s;
init_stack(&s);
push(&s, 10);
push(&s, 20);
push(&s, 30);
int val;
while (pop(&s, &val)) // 只要出栈成功,就继续
{
printf("出栈:%d\n", val);
}
// 输出:出栈:30
// 出栈:20
// 出栈:10
// 注意:30 最先出来,10 最后出来——后进先出
return 0;
}
调用方式要点:
- 所有栈操作函数都接收
Stack *(指向栈的指针),所以调用时需要传地址:&s。 pop的第二个参数需要传一个int变量的地址,用来接收取出的值。- 使用
bool返回值判断操作是否成功,比直接假设操作总成功要安全。
时间复杂度分析
基于数组的栈,所有操作都是 O(1):
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
init_stack | O(1) | top = -1 |
is_empty / is_full | O(1) | 一次整数比较 |
push | O(1) | top++ + 数组赋值 |
pop | O(1) | 读取 +top-- |
peek | O(1) | 只读 data[top] |
空间开销:数组占用 O(MAX_SIZE) 个元素的空间;若把编译期常量 MAX_SIZE 视为固定值,则相对于当前元素个数可记为 O(1)。优点是开销可预测;缺点是容量定得过大可能浪费空间。若不想预分配,可用链表实现栈。
三、队列
3.1 什么是队列
和栈一样,队列也是给基础结构“加规则”得到的——给数组或链表加上“一端只能进、另一端只能出”的约束。正因如此,队列可以基于链表实现(同时维护队首、队尾指针时,头删和尾插都是 O(1)),也可以基于数组实现。本章两种都会讲,正好体会“同一抽象结构,不同底层实现”的取舍。
队列遵循**先进先出(FIFO,First In First Out)**原则——最早进入队列的元素,最早被取出来。
生活中队列的例子:
- 排队买票:先来的人排在前面,先买到票离开。后来的人只能排在队尾。
- 打印机任务队列:先提交的打印任务先执行。
- 消息队列:在操作系统中,进程间通信常用队列来传递消息。
队列有两个端点:
| 端点 | 含义 |
|---|---|
| 队首(front) | 出队的端点。从队首取出元素。 |
| 队尾(rear) | 入队的端点。在队尾添加元素。 |
队列有两种常见的实现方式:基于数组和基于链表。下面分别讲解。
3.2 基于链表的队列
用链表实现队列非常自然:第一个数据节点作为队首(出队端),最后一个数据节点作为队尾(入队端)。本教程统一使用带头节点的链式结构,因此链式队列也保留一个不存有效数据的头节点,真实队首是 head.next。这样入队操作相当于尾插法(但要避免每次遍历),出队操作相当于删除第一个数据节点。
为了让入队操作是 O(1)(不需要遍历),我们维护一个队列结构体:其中内嵌一个头节点 head,再记录一个队尾指针 rear。空队列时 head.next == NULL,并且 rear 指向头节点。
3.2.1 结构定义与初始化
// 队列节点(和单链表节点一样)
typedef struct QNode
{
int data;
struct QNode *next;
} QNode;
// 队列结构体:封装头节点和队尾指针
typedef struct
{
QNode head; // 头节点,不保存有效数据
QNode *rear; // 指向队尾节点;空队列时指向 head
} Queue;
// 初始化队列
void init_queue(Queue *q)
{
q->head.next = NULL;
q->rear = &q->head;
}
QNode 结构体——队列的节点类型,和单链表节点完全一样:数据域 + next 指针。链式队列本质上就是一个受限制的单链表(只允许头删和尾插),所以节点结构可以直接复用。
Queue 结构体封装了头节点和尾指针,包含两个成员:
QNode head:头节点,不保存有效数据。它的作用是让队首删除逻辑统一——无论队列是否为空,删除操作都能用同样的方式处理。QNode *rear:尾指针,始终指向队尾节点(空队列时指向头节点)。有了它,入队操作不用每次遍历到末尾,时间复杂度从 O(n) 降至 O(1)。
init_queue 将 head.next 设为 NULL(表示没有数据节点),并将 rear 指向头节点——此时队尾就是这个固定的入口节点。
3.2.2 入队操作
入队相当于链表的尾插法,但由于我们有 rear 指针,不需要遍历。
void enqueue(Queue *q, int value)
{
// (1) 创建新节点
QNode *new_node = malloc(sizeof *new_node);
if (new_node == NULL) { fprintf(stderr, "内存分配失败\n"); exit(EXIT_FAILURE); }
new_node->data = value;
new_node->next = NULL;
// (2) 将新节点挂在当前队尾的后面
q->rear->next = new_node;
// (3) 更新队尾指针
q->rear = new_node;
}
入队操作分为三步。首先,用 malloc 创建新节点并初始化数据和 next 指针,这和单链表的操作完全一样。
然后,将当前队尾节点的 next 指向新节点:q->rear->next = new_node。空队列时 rear 指向头节点,所以这行等价于 q->head.next = new_node——头节点后面直接接上新节点。
最后,更新队尾指针:q->rear = new_node,让它指向新的最后一个节点。队首的入口仍然由 q->head.next 记录,不受影响。
3.2.3 出队操作
出队相当于删除带头节点单链表的第一个数据节点。
bool dequeue(Queue *q, int *out)
{
// (1) 队列为空,无法出队
if (q->head.next == NULL || out == NULL)
{
return false;
}
// (2) 暂存队首节点
QNode *temp = q->head.next;
// (3) 取出数据
*out = temp->data;
// (4) 队首后移
q->head.next = temp->next;
// (5) 如果出队后队列为空,队尾重新指向头节点
if (q->head.next == NULL)
{
q->rear = &q->head;
}
// (6) 释放旧队首节点
free(temp);
return true;
}
出队前先做两项检查:队列是否为空(head.next == NULL 意味着没有数据节点)、out 是否为 NULL(空指针不能解引用)。任一条件成立,返回 false。
出队的核心是删除头节点后面的第一个数据节点。先用 temp 暂存队首节点的地址,然后通过 *out = temp->data 把数据传给调用者。接着让头节点绕过旧队首节点:q->head.next = temp->next。如果原来只有一个数据节点,temp->next 是 NULL,头节点的 next 也就变成了 NULL。
有一个容易遗漏的关键细节:当最后一个数据节点出队后,head.next 已经变成了 NULL,但 rear 仍然指向那个即将被释放的节点。如果不把 rear 改回头节点,它就会成为一个悬空指针(仍保存已释放对象的地址),后续入队操作通过它访问 next 会导致未定义行为。因此,出队后如果队列变空(head.next == NULL),必须执行 q->rear = &q->head。
最后,free(temp) 释放旧队首节点占用的内存。
3.2.4 遍历与释放
// 打印队列
void print_queue(Queue *q)
{
QNode *p = q->head.next;
printf("队首 → ");
while (p != NULL)
{
printf("%d → ", p->data);
p = p->next;
}
printf("NULL (队尾)\n");
}
// 释放队列
void free_queue(Queue *q)
{
QNode *p = q->head.next;
while (p != NULL)
{
QNode *temp = p;
p = p->next;
free(temp);
}
q->head.next = NULL;
q->rear = &q->head;
}
print_queue 的遍历方式和带头节点的单链表完全一样:从 head.next 开始,沿着 next 指针逐个访问,直到遇到 NULL。
free_queue 逐个释放所有数据节点后,要把 head.next 重新设为 NULL 并让 rear 回到头节点——保证队列回到空队列的标准状态,方便后续继续使用。
3.2.5 组合使用示例(main)
下面的 main 函数与本节前面定义的节点、队列及操作函数组合使用;可独立编译的多文件版本已同步到配套源码的 linked-queue 项目。
int main(void)
{
Queue q;
init_queue(&q);
enqueue(&q, 10);
enqueue(&q, 20);
enqueue(&q, 30);
printf("入队后:");
print_queue(&q); // 输出:队首 → 10 → 20 → 30 → NULL (队尾)
int val;
while (dequeue(&q, &val))
{
printf("出队:%d\n", val);
}
// 输出:出队:10
// 出队:20
// 出队:30
// 10 最先出来,30 最后出来——先进先出
free_queue(&q);
return 0;
}
时间复杂度分析
维护头节点入口和 rear 尾指针后,入队和出队都是 O(1):
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
init_queue | O(1) | head.next = NULL,rear = &head |
enqueue | O(1) | rear 直接定位末尾 |
dequeue | O(1) | head.next 直接定位队首 |
print_queue | O(n) | 遍历所有节点 |
free_queue | O(n) | 释放所有节点 |
对比普通单链表尾插 O(n):链式队列多存一个 rear 指针,把尾插变成了 O(1)。
空间复杂度:每个节点额外存一个 next 指针,n 个元素开销 O(n)。
3.3 环形队列(基于数组)
普通数组队列的问题
如果用普通数组来实现队列,你会很自然地这样做:定义一个数组,用 front 指向队首,用 rear 指向队尾。入队时 rear 后移,出队时 front 后移。
但这样会产生一个严重的问题——假溢出:
问题根源:front 和 rear 只增不减,出队腾出的前面的空间永远不会被重新利用。
环形队列的思路
解决方法是把数组“首尾相接“——当 rear 走到数组最后一个位置时,如果前面有空位,就让 rear 回到数组开头(下标 0)。同样,front 也这样绕圈走。数学上,这通过对数组大小取模来实现。
当 rear 走到数组最后一个位置 MAX_SIZE - 1 时,下一次入队计算 (rear + 1) % MAX_SIZE,结果就是 0——rear 绕回到了数组开头。
环形队列的结构定义
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#define MAX_SIZE 6 // 数组大小为6,实际最多存 5 个元素
typedef struct
{
int data[MAX_SIZE]; // 存放数据的数组
int front; // 队首下标(指向队首元素)
int rear; // 队尾下标(指向下一个要插入的位置)
} CircularQueue;
CircularQueue 结构体包含三个核心成员:
int data[MAX_SIZE]:存放数据的数组。MAX_SIZE设为 6,但环形队列会“牺牲一个位置“来区分空和满,所以实际最多只能存 5 个元素。为什么要牺牲一个位置?下文会详细解释。int front:队首下标,指向队列中第一个元素的位置。初始值为0。int rear:队尾下标,指向下一个要插入的位置(不是当前最后一个元素的位置)。初始值也为0。当front == rear时,队列为空。
初始化、判空、判满、获取大小
// 初始化
void init_queue(CircularQueue *q)
{
q->front = 0;
q->rear = 0;
}
// 判断队列是否为空
bool is_empty(CircularQueue *q)
{
return q->front == q->rear; // front 追上 rear → 空
}
// 判断队列是否已满(牺牲一个位置)
bool is_full(CircularQueue *q)
{
return (q->rear + 1) % MAX_SIZE == q->front;
// rear 的下一个位置是 front → 满
}
// 获取当前队列中的元素个数
int get_size(CircularQueue *q)
{
return (q->rear - q->front + MAX_SIZE) % MAX_SIZE;
}
init_queue 将 front 和 rear 都初始化为 0,表示空队列。
is_empty 判断 front == rear。初始状态下两者都是 0,满足这个条件。随着入队和出队的进行,当 front 追上了 rear,说明所有元素都已被取出,队列回到空状态。
is_full 是环形队列最关键的设计。判断条件是 (rear + 1) % MAX_SIZE == front——即 rear 再往前走一步就会碰到 front。为什么需要“牺牲一个位置“?因为如果所有格子都存数据(全满),front 和 rear 也会指向同一个位置,和全空的状态无法区分。牺牲一个存储位置后,“空“的标志是 front == rear,“满“的标志是 (rear + 1) % MAX_SIZE == front——两个条件不可能同时成立,空和满就能严格区分了。
get_size 使用公式 (rear - front + MAX_SIZE) % MAX_SIZE 计算当前元素个数。加上 MAX_SIZE 再取模,可以正确处理 rear 绕圈后数值小于 front 的情况,计算结果始终在 0 到 MAX_SIZE - 1 之间。
入队操作
bool enqueue(CircularQueue *q, int value)
{
if (is_full(q)) // (1) 检查是否已满
{
return false;
}
q->data[q->rear] = value; // (2) 在 rear 位置放入数据
q->rear = (q->rear + 1) % MAX_SIZE; // (3) rear 后移(环形)
return true;
}
入队前先检查队列是否已满。未满时,将新元素放在 rear 指向的位置。注意 rear 指向的是“下一个要插入的空位“而非最后一个元素,所以直接把值写入 data[rear] 即可。
写入后,rear 需要后移一位:(rear + 1) % MAX_SIZE。这里的取模运算(% MAX_SIZE)是整个环形队列的核心——当 rear 在数组最后一个位置 MAX_SIZE - 1 时,加 1 再取模的结果是 0,rear 就绕回到了数组开头,实现“首尾相接“的效果。
出队操作
bool dequeue(CircularQueue *q, int *out)
{
if (is_empty(q) || out == NULL) // (1) 检查是否为空及输出指针是否有效
{
return false;
}
*out = q->data[q->front]; // (2) 取出队首元素
q->front = (q->front + 1) % MAX_SIZE; // (3) front 后移(环形)
return true;
}
出队前先检查队列是否为空以及 out 是否有效。检查通过后,从 front 指向的位置取出元素:*out = q->data[q->front]。front 指向的是队首元素——即队列中第一个有数据的格子。
然后 front 后移一位:(front + 1) % MAX_SIZE,同样用取模实现环形移动。被“出队“的数据实际上还在数组中,但 front 已经绕过了它——下次入队时新数据会直接覆盖这个位置,相当于空间被回收利用了。
时间复杂度分析
环形队列的所有操作都是 O(1)——没有循环,没有遍历。跟着代码看:
// 入队:只有一次赋值 + 一次取模 + 一次后移
bool enqueue(CircularQueue *q, int value)
{
if (is_full(q)) return false; // O(1):一次取模比较
q->data[q->rear] = value; // O(1):数组赋值
q->rear = (q->rear + 1) % MAX_SIZE; // O(1):取模运算
return true;
}
// 出队:只有一次读取 + 一次取模 + 一次后移
bool dequeue(CircularQueue *q, int *out)
{
if (is_empty(q) || out == NULL) return false; // O(1):常数次比较
*out = q->data[q->front]; // O(1):数组读取
q->front = (q->front + 1) % MAX_SIZE; // O(1):取模运算
return true;
}
没有任何循环依赖当前元素个数——无论队列中有多少元素(只要未超过其固定容量),入队和出队的工作量都是恒定的 O(1)。
和链式队列对比: 都是入队出队 O(1),区别在空间——环形队列固定预分配(O(MAX_SIZE)),链式队列按需分配(O(n) 指针开销)。有容量上限选环形,无法预估上限选链式。
组合使用示例(main)
下面的 main 函数与本节前面定义的 CircularQueue 及各操作函数组合使用;可独立编译的多文件版本已同步到配套源码的 circular-queue 项目。
int main(void)
{
CircularQueue q;
init_queue(&q);
// 入队 5 个元素(MAX_SIZE=6,最多存5个)
enqueue(&q, 10);
enqueue(&q, 20);
enqueue(&q, 30);
enqueue(&q, 40);
enqueue(&q, 50);
printf("已满?%s\n", is_full(&q) ? "是" : "否"); // 输出:已满?是
// 出队 2 个,腾出空间
int val;
dequeue(&q, &val); printf("出队:%d\n", val); // 10
dequeue(&q, &val); printf("出队:%d\n", val); // 20
// 此时 [0][1] 是空的,rear 在 [5]。再入队 60,它会放在哪儿?
enqueue(&q, 60); // 60 放在 [5],随后 rear 从 5 绕回 0
enqueue(&q, 70); // 70 放在 [0],随后 rear 移到 1
printf("当前队列:");
while (dequeue(&q, &val))
{
printf("%d ", val);
}
// 输出:30 40 50 60 70(先进先出!)
printf("\n");
return 0;
}
运行输出:
已满?是
出队:10
出队:20
当前队列:30 40 50 60 70
70 被放到了下标 [0] 的位置——rear 成功绕回到了数组开头。这就是环形队列的核心价值:用取模运算让数组首尾相接,实现空间的循环利用。
四、总结与对比
4.1 回到原点:物理存储 × 逻辑规则
学完本章全部结构,回头看数据结构总览中提出的两层视角,一切变得清晰:
第一层:物理存储的两种常见组织方式。 本书当前的线性结构使用两种典型实现——整块连续存储(数组),以及节点不要求相邻、通过指针串联(链表)。这是一套便于入门分析的模型,并不意味着所有数据结构只能由二者单独实现。
第二层:逻辑规则,花样无穷。 抛开 malloc、指针、内存地址这些细节,在抽象的层面上给数据“加规则“——只能从一端进出就成了栈,一端进另一端出就成了队列,每个节点记两个指针就成了双链表,取模绕回就成了环形队列。规则不同,行为不同,但地基没变:
| 数据结构 | 物理存储 | 逻辑规则 | 适合场景 |
|---|---|---|---|
| 数组 | 连续(下标寻址) | 无额外限制,全随机访问 | 数据量确定、频繁读取 |
| 单链表 | 分散(链式节点) | 每个节点只记后继 | 频繁头插头删、顺序访问 |
| 双链表 | 分散(链式节点) | 节点同时记前驱和后继 | 双向遍历、已知节点时 O(1) 删除 |
| 栈 | 连续(数组) | 只能从一端进出(LIFO) | 函数调用、撤销、括号匹配 |
| 队列(链式) | 分散(链表) | 一端进另一端出(FIFO) | 任务调度、消息缓冲 |
| 环形队列 | 连续(数组) | FIFO + 取模绕回 | 固定容量缓冲(串口、音频) |
物理存储决定了底层效率的上限(能不能 O(1) 随机访问、修改要不要搬移数据),逻辑规则决定了结构的行为(LIFO 还是 FIFO、单向还是双向)。所谓“选择数据结构“,本质上就是在物理约束和逻辑需求之间找到最佳平衡。
4.2 操作效率综合对比
经过第十八章和本章的代码分析,数组、单链表、双链表、栈、队列在各种操作上的效率对比如下。这张表是选型时的参考手册:
| 操作 | 数组 | 单链表 | 双链表 | 谁赢? |
|---|---|---|---|---|
| 按下标随机访问 | O(1) | O(n) | O(n) | 数组,物理上可计算地址 |
| 按值查找(无序) | O(n) | O(n) | O(n) | 平手 |
| 按值查找(有序) | O(log n) | O(n) | O(n) | 数组可用二分 |
| 头部插入 | O(n) | O(1) | O(1) | 链表,只改指针 |
| 尾部插入 | O(1) | O(n) | O(n) | 数组有空间则直接写;链表需遍历 |
| 中间插入(已知插入点) | O(n) | O(1) | O(1) | 链表不搬数据;按下标找位置仍为 O(n) |
| 删除(已知节点地址) | O(n) | 还需前驱信息 | O(1) | 双链表可直接取得前驱 |
| 反向遍历 | 支持 | 不支持 | 支持 | 双链表有 prev |
| 每元素元数据开销 | 无指针字段 | 每节点+1指针 | 每节点+2指针 | 数组在此项最少;仍可能有预留容量 |
| 容量增长 | 需重新分配或预留空间 | 按需增加节点 | 按需增加节点 | 链表无需整体搬迁 |
| 结构 | 入栈/入队 | 出栈/出队 | 空间特点 |
|---|---|---|---|
| 栈(数组) | O(1) | O(1) | 预分配,容量固定 |
| 链式队列 | O(1) | O(1) | 按需分配,有指针开销 |
| 环形队列 | O(1) | O(1) | 预分配,空间复用 |
第二十章 C语言语法和概念常见问题逐一剖析
本章要点
C 语言里有几组概念,因为名字像、用法像、或者干脆就是中文翻译搞出来的文字游戏,初学者几乎一定会搞混。这一章把它们集中拆一遍——不是让你背,是让你以后遇到时能自己判断。
具体涵盖:
- 三组“词序颠倒“的声明辨析:函数指针/指针函数、数组指针/指针数组、常量指针/指针常量
- 六组容易混淆的概念对:
strlenvssizeof、constvsstatic、值传递 vs 指针传递、宏 vs 函数、声明 vs 定义、数组名 vs 指针
碰到不清楚的翻到对应小节就行,不用通读。
一、三组“词序颠倒“的声明辨析
面试和笔试题里经常出现这一类问题:“请说明函数指针和指针函数的区别”“数组指针和指针数组有什么区别”“常量指针和指针常量有什么不同”。回答这些问题其实没什么意义——它们在概念上根本不存在什么可比较的“区别“,纯粹是写法上长得太像了:同样的关键字、同样的符号,只差一对括号或者 const 换了个位置,扫一眼容易看错。
这里对这三组东西做讲解,目的不是搞文字游戏,也不是去分析它们概念上有什么不同——因为它们之间本来就不存在概念层面的对立。讲它们的唯一原因是:初学者在语法层面确实容易搞混。所以下面的内容不讲“区别“,只讲语法上怎么区分——看括号、看优先级、看 const 的位置。把这些看熟了,以后碰到这种题直接写就行。
1.1 函数指针 vs 指针函数
int (*p)(int, int); // 函数指针
int *p(int, int); // 返回指针的函数(所谓的"指针函数")
两行代码用到的字符完全一样——int、*、p、(int, int)——唯一的区别是一对括号加在了哪里。加在 *p 上,p 是指针;不加,p 是函数。
int (*p)(int, int):(*p) 的括号让 * 先和 p 结合,所以 p 是一个指针。指向一个接收两个 int、返回 int 的函数。叫函数指针——它是指针。
int *p(int, int):没有括号,(int, int) 先和 p 结合,所以 p 是一个函数,返回 int *。叫“指针函数“——它是函数,只是返回值碰巧是指针。指针函数从来不是一个独立的语法概念——它就是函数,返回值是指针类型。你不会把返回 double 的函数叫 “double 函数”,自然也没必要给返回指针的函数单独取一个名字。
怎么看:看最后一个词。指针→函数指针(是指针);函数→返回指针的函数(是函数)。
// 实际用法
int add(int a, int b) { return a + b; }
int (*op)(int, int) = add; // 函数指针,指向 add
int result = op(3, 5); // 通过指针调用
1.2 数组指针 vs 指针数组
int (*p)[5]; // 数组指针:指针,指向长度为5的int数组
int *p[5]; // 指针数组:长度为5的数组,元素是int*
int (*p)[5]:(*p) 先结合,p 是指针;[5] 说明它指向一个长度为 5 的数组。是指针。
int *p[5]:[] 优先级高于 *,所以 p 先和 [5] 结合成数组;int * 是元素类型。是数组。
指针数组从来不是一个独立的语法概念,它就是一个数组,只不过元素碰巧是指针类型。你不会把 double arr[5] 叫 “double 数组” 然后拿去和别的什么概念做辨析——它就是声明了一个数组,类型是 double。指针数组同理,就是声明了一个数组,元素类型是 int *。
优先级:[] > *。*p[5] 等价于 *(p[5])——先数组再指针。要打破顺序就加括号:(*p)[5]——先指针再数组。
int arr[3][5];
int (*row)[5] = arr; // row 指向 arr 的第一行(长度为5的数组)
row[1][2] = 100; // 修改 arr[1][2]
int a = 1, b = 2, c = 3;
int *ptrs[3] = {&a, &b, &c}; // 指针数组
1.3 常量指针 vs 指针常量
const int *p; // 常量指针:指向的数据不能通过 p 修改
int * const p; // 指针常量:p 本身不能改(指向不能变)
从右往左读:
const int *p:p是指针,指向const int——数据只读。int * const p:p是const,类型是int *——指针本身只读。
指针常量也不是什么独立概念——它就是一个 const 变量,类型碰巧是指针。和 const int x = 10 一样,只是这个 x 的类型是 int * 而已。常量指针同理,就是一个指针,指向的东西被 const 修饰了。
int a = 10, b = 20;
const int *p1 = &a;
p1 = &b; // ✅ 可以改指向
*p1 = 30; // ❌ 不能改数据
int * const p2 = &a;
p2 = &b; // ❌ 不能改指向
*p2 = 30; // ✅ 可以改数据
const int * const p3 = &a; // 两个都不能改
这组也是一样的文字游戏。常量指针——中心词是指针,它“持有“的是一个常量,所以指向的数据不能改;指针常量——中心词是常量,它是一个指针类型的常量,所以指针本身不能改。
二、六组容易混淆的概念对
2.1 strlen vs sizeof
两个都能告诉你“大小“,但说的不是同一件事。
sizeof | strlen | |
|---|---|---|
| 是什么 | 运算符(多数结果编译期确定) | 运行时函数 |
| 量的对象 | 类型/变量占的内存字节数 | 字符串\0 之前的字符数 |
计不计\0 | 计 | 不计 |
| 对指针 | 返回指针变量本身大小(通常 8) | 从指针指向处开始数到\0 |
| 需要头文件 | 不需要 | <string.h> |
char str[] = "Hello"; // 6 字节:H e l l o \0
sizeof(str); // 6
strlen(str); // 5
char *p = str;
sizeof(p); // 8(64位)—— 指针本身
strlen(p); // 5 —— 指向的字符串长度
最容易踩的坑:数组作为函数参数后会退化为指针,在函数里 sizeof 拿到的是指针大小,不是数组大小。
2.2 const vs static
两个关键字经常同时出现(static const),但改的是完全不同的东西。
const 改变的是变量的权限。 一个变量声明之后,默认是可读可写的——你可以读它的值,也可以给它赋新值。加了 const,编译器会在编译期检查:这个变量初始化之后,任何试图修改它的操作都会报错。仅此而已——const 不改变这个变量存在哪里、不改变它什么时候创建什么时候销毁、不改变谁能看见它。它只做一件事:把“可写“权限关掉。
const int x = 10;
x = 20; // ❌ 编译错误:权限不够
用于块作用域对象时,static 把对象的存储期改为静态存储期,但不扩大块作用域。普通自动局部对象每次执行进入相应块时形成新的生命周期;static 局部对象则贯穿整个程序执行,并在多次调用之间保留值。用于文件作用域声明时,static 影响的是链接属性,使名称只在当前翻译单元内链接。具体放入哪个内存段由实现决定。
void counter(void) {
static int count = 0; // 存在静态区,只初始化一次
count++;
printf("%d\n", count);
}
// 每次调用 counter(),count 都会在上次值的基础上 +1
把两件事对照看:
const→ 改权限:能读不能写static→ 改位置和寿命:从栈搬到静态区,活到程序结束;同时限制可见范围
它们改的不是同一个维度,所以可以组合:static const int MAX = 100;——放在静态区、整个程序生命周期都在、但不可修改。
另外,static 有三种上下文,效果不同:
| 用在 | 效果 |
|---|---|
| 局部变量 | 从栈搬到静态区,生命周期变整个程序运行期,但只在定义它的函数内可见 |
| 全局变量 | 可见范围从“所有文件可见“缩到“仅当前文件可见“ |
| 函数 | 同上,函数仅当前文件可见 |
void counter(void) {
static int count = 0; // 只初始化一次,值跨调用保留
count++;
printf("%d\n", count);
}
static const int MAX = 100; // 两者可以一起用:文件内可见、不可修改
它们不是二选一的关系,各管各的。
2.3 值传递 vs 指针传递
C 语言只有一种参数传递方式——值传递。所谓“指针传递“不是另一种方式,它还是值传递,只是传的那个值恰好是地址。
void by_value(int x) { x = 100; } // x 是实参的拷贝
void by_pointer(int *x) { *x = 100; } // x 是地址的拷贝
by_value(a):把 a 的值拷贝给 x,x 和外部的 a 没关系。
by_pointer(&a):把 a 的地址拷贝给 x。改 x 本身(比如 x = NULL)不影响外部。改 *x——顺着地址找到外部的 a 再修改——才影响。
void func(int *p) {
p = NULL; // 不影响外部,改的是 p 这个局部拷贝
*p = 100; // 影响外部,顺着 p 里的地址找到了外面的变量
}
C 没有 C++ 那种传引用(int &x)。所有参数传递都是拷贝——区别只在于你拷贝的是数据,还是数据的地址。
2.4 宏函数 vs 函数
#define SQUARE(x) ((x) * (x)) 和 int square(int x) { return x * x; } 看起来能做同一件事,但它们的执行方式完全不同。
| 宏 | 函数 | |
|---|---|---|
| 什么时候发生 | 预处理阶段(编译前) | 运行时 |
| 做了什么 | 文本替换 | 跳转→执行→返回 |
| 参数怎么处理 | 不先求值,直接粘贴文本 | 先求值,再把值传进去 |
| 有无类型检查 | 无 | 有 |
| 调试可见 | 不可见(宏名已被替换) | 可见 |
副作用——这是宏最大的坑:
#define SQUARE(x) ((x) * (x))
int a = 5;
SQUARE(a++); // 展开为 ((a++) * (a++))
// a 被递增两次,结果不可预测
int square(int x) { return x * x; }
int b = 5;
square(b++); // b++ 先求值得 5,传入函数,b 变成 6,返回 25
函数是先求值再传参,宏是先粘贴再求值。参数里有 ++、--、赋值这类副作用操作,宏会把它们复制多次。
什么时候用宏:定义编译时常量、减少样板代码。其他情况能写函数就写函数。
2.5 声明 vs 定义
int calc(int a, int b); // 声明:告诉编译器有这个东西
int calc(int a, int b) { // 定义:给完整的实现
return a * a + b * b;
}
- 声明:告诉编译器“这个名字对应什么东西、什么类型“。不分配存储、不生成代码。
- 定义:给名字配上实际的存储空间或函数体。一个东西只能有一份定义,但声明可以有多次。
为什么要分开?单文件里你把定义写调用者前面就行了,不需要声明。多文件项目里,声明放 .h,定义放 .c,其他文件通过 #include 拿到声明就能用。
变量的情况:
int x = 10; // 定义(分配空间并初始化)
extern int x; // 声明(x 在别处定义,这里只引用)
2.6 数组名 vs 指针
这是 C 语言里误解最深的问题。
数组名不是指针。 数组名代表整个数组对象——sizeof(arr) 返回整个数组的大小,正是因为编译器知道你声明的是一个数组。
但数组名在绝大多数表达式中会退化成指向首元素的指针。退化之后它的行为和一个指针没区别——指向 arr[0],arr + i 指向 arr[i],*arr 就是 arr[0]。
int arr[5] = {1, 2, 3, 4, 5};
// 退化:arr 变成 &arr[0]
int *p = arr;
func(arr);
arr + 1;
*arr; // arr[0]
// 不退化——这两个是例外
sizeof(arr); // 整个数组的大小(20 字节)
&arr; // 类型是 int (*)[5],不是 int**
&arr + 1 跳过的是整整一个数组,不是一个元素。
记住两条:sizeof(arr) 不退化,&arr 不退化。其他所有场景,arr 的行为等同于 &arr[0]。
三、总结
九组问题,分两类:
文字游戏类(前三组):函数指针/指针函数、数组指针/指针数组、常量指针/指针常量。它们之间的混淆主要是中文翻译搞出来的。看最后一个词、记住优先级和 const 的修饰规则,就够了。
正交概念类(后六组):每一对都是 C 语言里两个不同维度的机制,不是二选一的关系。搞清楚各自管什么维度,自然就不会搞混。
进阶
进阶部分内容正在编写中,敬请期待。
本部分将涵盖以下主题:
- CMake进阶使用——创建库和导入库:使用 CMake 创建静态库和动态库,以及如何导入第三方库
- CMake进阶使用——编译参数设置和编译优化:配置编译选项、优化级别、警告参数,理解 Debug/Release 构建
- 函数接口设计:利用
const type *约束只读语义、借助void *实现通用接口,设计出不易误用的 API - goto 的使用:C 语言最具争议的关键字——
goto的语法、陷阱与合理使用场景:错误集中处理、跳出嵌套循环、状态机 - 宏魔法:字符串化(
#)、记号粘贴(##)、可变参数宏、X Macro 等高级用法 - 内存对齐及其应用:理解内存对齐的底层原理,
alignof/alignas的使用及其性能影响 - 柔性数组:C99 柔性数组成员(Flexible Array Member)的语法、内存布局与实战场景
- 非线性数据结构——树:二叉树、二叉搜索树、平衡树(AVL、红黑树)的基本概念与 C 语言实现,二叉堆与优先队列
- 非线性数据结构——图:图的基本概念(有向/无向、加权)、邻接矩阵与邻接表存储方式、深度优先搜索(DFS)与广度优先搜索(BFS)
- C语言面向对象编程:用 C 语言实现面向对象的设计模式——封装、继承与多态
- initcall 风格机制:以 Linux 内核 initcall 为代表的启动期注册思路,通常依赖链接器或编译器扩展
- 实战项目:项目待定