C编程从入门到精通
一份写给零基础新手的C语言完全教程 · 章节清晰 · 实例丰富 · 循序渐进
第1章 环境搭建与第一个C程序
欢迎来到C语言的世界!C语言诞生于1972年,由丹尼斯·里奇(Dennis Ritchie)在贝尔实验室设计。虽然它已经50多岁了,但至今仍是编程界的"常青树"——操作系统(如Linux、Windows内核)、嵌入式设备、游戏引擎、数据库……到处都有C语言的身影。
学习编程的第一步,就是搭建好环境,然后亲手写出第一个程序!
1.1 安装C语言编译器
C语言是编译型语言,我们写的代码需要通过"编译器"转换成机器能懂的程序。最常用的C语言编译器是 GCC(GNU Compiler Collection)。
Windows 系统
推荐安装 MinGW-w64 或使用 Dev-C++ / Code::Blocks 这类集成开发环境(IDE),它们自带编译器,安装即用。
macOS 系统
打开终端,输入 xcode-select --install,系统会自动安装命令行工具(包含 GCC/Clang 编译器)。
Linux 系统
打开终端,运行:
bashsudo apt update
sudo apt install gcc
gcc --version
看到版本号就说明安装成功了!
1.2 第一个C程序:Hello World
打开一个文本编辑器(记事本、VS Code、Sublime等),输入以下代码:
c#include <stdio.h>
int main() {
printf("Hello, World!\n");
return 0;
}
逐行解释
| 代码 | 含义 |
|---|---|
#include <stdio.h> | 引入标准输入输出库,这样我们才能使用 printf 等函数 |
int main() | 程序的入口函数,程序从这里开始执行 |
{ ... } | 花括号内的代码是 main 函数要执行的内容 |
printf("Hello, World!\n"); | 在屏幕上打印"Hello, World!",\n 表示换行 |
return 0; | 程序正常结束,返回0给操作系统 |
1.3 编译和运行
把文件保存为 hello.c(注意后缀是 .c),然后在终端中执行:
bashgcc hello.c -o hello
./hello
第一行命令:用GCC编译 hello.c,生成可执行文件 hello(-o 指定输出文件名)。
第二行命令:运行这个程序。
1.4 代码的基本结构
每个C程序都有固定的基本骨架,就像盖房子需要地基一样:
c// 1. 头文件引入(使用哪个库就引入哪个)
#include <stdio.h>
// 2. 主函数(每个程序有且只有1个main函数)
int main() {
// 3. 在这里写你的代码
return 0; // 4. 返回0表示正常退出
}
1.5 基础实战:写几个小程序
实例1:输出多行文字
c#include <stdio.h>
int main() {
printf("*****************\n");
printf("* 欢迎学C语言 *\n");
printf("* 坚持就是胜利 *\n");
printf("*****************\n");
return 0;
}
printf 里的文字,换成你自己的问候语,重新编译运行看看效果!第2章 变量与数据类型
程序本质上是在处理数据。就像做菜需要不同的食材一样,编程也需要用不同的"容器"来存放不同类型的数据。这些"容器"就叫做变量。
2.1 什么是变量?
变量就是一块内存空间,我们给它取个名字,然后往里面存数据。有点像带标签的盒子:
cint age; // 声明一个叫age的变量,用来存整数
age = 18; // 把18这个值存进去
int score = 95; // 也可以声明的同时直接赋值(初始化)
2.2 基本数据类型
C语言提供了几种基本的"盒子"类型:
| 类型 | 名称 | 大小 | 取值范围 | 示例 |
|---|---|---|---|---|
int | 整型(整数) | 4字节 | -21亿 ~ 21亿 | 42, -100 |
float | 单精度浮点(小数) | 4字节 | 约 ±3.4×10³⁸ | 3.14, -0.5 |
double | 双精度浮点(小数) | 8字节 | 约 ±1.8×10³⁰⁸ | 3.1415926 |
char | 字符型 | 1字节 | -128 ~ 127 | 'A', '9' |
void | 无类型 | — | — | 用于函数返回值 |
2.3 变量命名规则
- 只能包含字母、数字和下划线(_)
- 不能以数字开头
- 不能和C语言的关键字重名(如
int,if,return等) - 区分大小写:
age和Age是两个不同的变量
int 1st; — 数字开头,错误!float my-score; — 不能用连字符,错误!正确写法:
int first; 或 float my_score;2.4 实例:使用各种数据类型
c#include <stdio.h>
int main() {
int age = 25;
float price = 19.99;
double pi = 3.141592653589793;
char grade = 'A';
printf("年龄:%d\n", age);
printf("价格:%.2f\n", price);
printf("圆周率:%.15f\n", pi);
printf("成绩等级:%c\n", grade);
return 0;
}
%d、%f、%c 叫格式占位符,就像占座一样,告诉 printf 这个位置要填入什么类型的数据。后面会详细讲。
2.5 整型的各种修饰
int 还可以配合 short、long、unsigned 等修饰符使用:
c#include <stdio.h>
int main() {
short s = 100; // 短整型(2字节,范围更小但省内存)
long l = 123456789L; // 长整型(8字节,范围更大)
unsigned int u = 300; // 无符号整型(只存非负数,范围翻倍)
printf("short: %d\n", s);
printf("long: %ld\n", l);
printf("unsigned: %u\n", u);
return 0;
}
int 和 double。只有需要节省内存或特殊需求时才用 short / long / unsigned。第3章 运算符与表达式
有了变量之后,就需要对它们进行各种"运算"——加减乘除、比大小、判断真假……这就是运算符的用武之地。
3.1 算术运算符
| 运算符 | 含义 | 示例 | 结果 |
|---|---|---|---|
+ | 加法 | 10 + 3 | 13 |
- | 减法 | 10 - 3 | 7 |
* | 乘法 | 10 * 3 | 30 |
/ | 除法 | 10 / 3 | 3(整数除法) |
% | 取模(求余数) | 10 % 3 | 1 |
5 / 2 的结果是 2,不是2.5!两个整数相除,结果还是整数(直接丢弃小数部分)。如果想得到小数,至少一个数写成浮点数:
5.0 / 2 或 5 / 2.0 结果是 2.5。3.2 实例:算术运算
c#include <stdio.h>
int main() {
int a = 15, b = 4;
printf("%d + %d = %d\n", a, b, a + b);
printf("%d - %d = %d\n", a, b, a - b);
printf("%d * %d = %d\n", a, b, a * b);
printf("%d / %d = %d\n", a, b, a / b); // 整数除法
printf("%d %% %d = %d\n", a, b, a % b); // 取余
// 浮点数除法对比
printf("15.0 / 4 = %.2f\n", 15.0 / 4);
return 0;
}
3.3 关系运算符(比较运算)
用于比较两个值的大小关系,结果只有:真(1) 或 假(0)。
| 运算符 | 含义 | 示例(a=5, b=3) | 结果 |
|---|---|---|---|
== | 等于 | a == b | 0(假) |
!= | 不等于 | a != b | 1(真) |
> | 大于 | a > b | 1(真) |
< | 小于 | a < b | 0(假) |
>= | 大于等于 | a >= b | 1(真) |
<= | 小于等于 | a <= b | 0(假) |
3.4 逻辑运算符
| 运算符 | 含义 | 说明 | 示例 |
|---|---|---|---|
&& | 与(AND) | 两边都为真才为真 | (5>3) && (4>2) → 真 |
|| | 或(OR) | 一边为真即为真 | (5>3) || (4<2) → 真 |
! | 非(NOT) | 取反 | !(5>3) → 假 |
3.5 复合赋值与自增自减
复合赋值运算符
cint x = 10;
x += 5; // 等价于 x = x + 5,现在x=15
x -= 3; // 等价于 x = x - 3,现在x=12
x *= 2; // 等价于 x = x * 2,现在x=24
自增(++)和自减(--)
cint i = 5;
i++; // 先使用i的当前值,再加1(后置++)
++i; // 先加1,再使用i的新值(前置++)
3.6 实例:自增前置后置的区别
c#include <stdio.h>
int main() {
int a = 5, b = 5;
printf("前置++a:%d (先加后用)\n", ++a); // a先变成6,再打印
printf("此时a的值:%d\n", a); // a已经是6
printf("后置b++:%d (先用后加)\n", b++); // 先打印b(5),再加1
printf("此时b的值:%d\n", b); // b变成6了
return 0;
}
++在前面就先变,++在后面就后变。第4章 输入与输出
程序要和用户交互,就需要输入(接收用户的数据)和输出(把结果显示出来)。C语言中最常用的就是 printf(输出)和 scanf(输入)。
4.1 printf — 格式化输出
printf 的完整格式:printf("格式字符串", 变量1, 变量2, ...);
常用格式占位符
| 占位符 | 对应类型 | 示例 |
|---|---|---|
%d | int(整数) | printf("%d", 42); |
%f | float / double(小数) | printf("%.2f", 3.14); |
%c | char(单个字符) | printf("%c", 'A'); |
%s | 字符串 | printf("%s", "hello"); |
%p | 指针(内存地址) | printf("%p", &x); |
%ld | long int | printf("%ld", 123L); |
%u | unsigned int | printf("%u", 100); |
4.2 scanf — 格式化输入
scanf("格式字符串", &变量1, &变量2, ...);
scanf 的变量前面要加 &(取地址符)!新手最常忘的就是这个。不加 & 程序会崩溃!4.3 实例:基本的输入输出
c#include <stdio.h>
int main() {
int age;
float height;
printf("请输入你的年龄:");
scanf("%d", &age); // 注意age前面的&
printf("请输入你的身高(米):");
scanf("%f", &height);
printf("\n=== 你的信息 ===\n");
printf("年龄:%d岁\n", age);
printf("身高:%.2f米\n", height);
return 0;
}
4.4 实例:计算圆的面积
一个完整的实用例子——让用户输入半径,程序计算面积:
c#include <stdio.h>
#define PI 3.14159
int main() {
double radius, area;
printf("请输入圆的半径:");
scanf("%lf", &radius); // double用%lf
area = PI * radius * radius;
printf("半径为 %.2f 的圆,面积 = %.4f\n", radius, area);
return 0;
}
n % 2 == 0,如果余数为0就是偶数。后面学了 if 语句就可以做这种判断了。第5章 条件判断
程序不是一条路走到黑的——很多时候要根据不同的条件执行不同的操作。比如:如果分数≥60分就显示"及格",否则显示"不及格"。这就是条件判断。
5.1 if 语句
最基本的条件判断结构:
cif (条件) {
// 条件为真时执行这里的代码
}
5.2 if-else 语句
cif (条件) {
// 条件为真时执行
} else {
// 条件为假时执行
}
5.3 实例:判断奇偶与成绩等级
c#include <stdio.h>
int main() {
int num;
printf("请输入一个整数:");
scanf("%d", &num);
if (num % 2 == 0) {
printf("%d 是偶数\n", num);
} else {
printf("%d 是奇数\n", num);
}
return 0;
}
5.4 else if — 多条件判断
c#include <stdio.h>
int main() {
int score;
printf("请输入考试成绩(0-100):");
scanf("%d", &score);
if (score < 0 || score > 100) {
printf("分数无效!\n");
} else if (score >= 90) {
printf("优秀!\n");
} else if (score >= 80) {
printf("良好\n");
} else if (score >= 70) {
printf("中等\n");
} else if (score >= 60) {
printf("及格\n");
} else {
printf("不及格,继续努力!\n");
}
return 0;
}
else if 是从上往下依次判断的,一旦某个条件成立,后面的就不执行了。所以条件范围要从"最严格"往"最宽松"写。5.5 switch 语句
当条件是基于某个变量的"等于"判断时,用 switch 比写一堆 if-else 更清晰:
c#include <stdio.h>
int main() {
int day;
printf("请输入星期几(1-7):");
scanf("%d", &day);
switch (day) {
case 1: printf("星期一\n"); break;
case 2: printf("星期二\n"); break;
case 3: printf("星期三\n"); break;
case 4: printf("星期四\n"); break;
case 5: printf("星期五\n"); break;
case 6:
case 7: printf("周末\n"); break;
default: printf("无效输入!\n");
}
return 0;
}
case 后面没有 break,程序会"穿透"继续执行下一个 case——这叫"fall through"(向下穿透)。有时候是故意用的(如上例中6和7都进入"周末"),但大多数时候是bug!5.6 三目运算符
一种简洁的条件表达式:条件 ? 值1 : 值2
c#include <stdio.h>
int main() {
int a = 10, b = 20;
int max = (a > b) ? a : b; // 如果a>b则取a,否则取b
printf("最大值是:%d\n", max);
// 也嵌套使用
int x = 0;
printf("%d 是%s\n", x, (x > 0) ? "正数" : (x == 0) ? "零" : "负数");
return 0;
}
if-else,可读性更好。第6章 循环结构
生活中经常需要重复做同一件事——比如每天背10个单词,连续30天。编程中也一样,如果需要让某段代码重复执行,就用循环。
C语言提供了3种循环:for、while、do-while。
6.1 while 循环
先判断条件,条件为真就执行循环体:
cwhile (条件) {
// 循环体——条件满足时重复执行
}
实例:从1数到5
c#include <stdio.h>
int main() {
int i = 1;
while (i <= 5) {
printf("%d ", i);
i++; // 别忘了让i增加,否则死循环!
}
printf("\n");
return 0;
}
i++,条件永远为真,循环会一直运行下去——按 Ctrl+C 才能强制终止!6.2 do-while 循环
和 while 很像,但先执行一次再判断条件——也就是说,循环体至少会执行1次:
c#include <stdio.h>
int main() {
int i = 1;
do {
printf("%d ", i);
i++;
} while (i <= 5);
printf("\n");
// 即使条件不满足,也会执行一次
i = 10;
do {
printf("至少执行一次!i=%d\n", i);
i++;
} while (i <= 5);
return 0;
}
6.3 for 循环——最常用的循环
for 循环把"初始化"、"条件"、"更新"集中在一起,结构清晰:
cfor (初始化; 条件; 更新) {
// 循环体
}
实例:for循环多种用法
c#include <stdio.h>
int main() {
// 从1到10
printf("1到10:");
for (int i = 1; i <= 10; i++) {
printf("%d ", i);
}
printf("\n");
// 递减:从10到1
printf("10到1:");
for (int i = 10; i >= 1; i--) {
printf("%d ", i);
}
printf("\n");
// 步长不为1:输出偶数
printf("2到20的偶数:");
for (int i = 2; i <= 20; i += 2) {
printf("%d ", i);
}
printf("\n");
return 0;
}
6.4 实例:九九乘法表
嵌套循环的经典例子——用两个 for 循环实现:
c#include <stdio.h>
int main() {
for (int i = 1; i <= 9; i++) {
for (int j = 1; j <= i; j++) { // j只到i,形成三角形
printf("%d×%d=%-2d ", j, i, i * j); // %-2d左对齐,占2位
}
printf("\n");
}
return 0;
}
6.5 break 和 continue
break:立即跳出循环(终止整个循环)continue:跳过本次循环剩余代码,进入下一次循环
c#include <stdio.h>
int main() {
// break:找到第一个能被7整除的数就停
printf("break示例:从1开始找7的倍数\n");
for (int i = 1; i <= 20; i++) {
if (i % 7 == 0) {
printf("找到7的倍数:%d,中断循环\n", i);
break; // 跳出整个for循环
}
printf("%d ", i);
}
printf("\n\n");
// continue:跳过偶数
printf("continue示例:只打印奇数\n");
for (int i = 1; i <= 10; i++) {
if (i % 2 == 0) {
continue; // 跳过偶数,不打印
}
printf("%d ", i);
}
printf("\n");
return 0;
}
6.6 实例:猜数字游戏
综合运用循环和条件判断:
c#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main() {
int secret, guess, attempts = 0;
srand(time(0)); // 初始化随机种子
secret = rand() % 100 + 1; // 生成1-100的随机数
printf("猜数字游戏(1-100之间)\n");
do {
printf("请输入你的猜测:");
scanf("%d", &guess);
attempts++;
if (guess > secret) {
printf("猜大了!\n");
} else if (guess < secret) {
printf("猜小了!\n");
} else {
printf("🎉 恭喜!猜对了!共用%d次\n", attempts);
}
} while (guess != secret);
return 0;
}
for:知道要循环几次的时候用(如遍历数组)while:不确定次数,只知道条件的时候用do-while:保证至少执行一次的情况(如猜数字,至少猜1次)第7章 数组
前面我们学的变量,一个变量只能存一个值。如果要存全班50个同学的成绩,难道要定义50个变量吗?那也太麻烦了!数组就是用来解决这个问题的——它可以存储一组相同类型的数据。
7.1 一维数组
7.1.1 声明数组
声明数组需要告诉计算机三件事:数据类型、数组名、数组大小。
c// 语法:数据类型 数组名[元素个数];
int scores[5]; // 声明一个包含5个整数的数组
float prices[10]; // 声明一个包含10个浮点数的数组
char letters[26]; // 声明一个包含26个字符的数组
声明了数组之后,系统会在内存中开辟一块连续的空间。比如 int scores[5],会在内存中分配 5×4=20 个字节的空间(每个 int 占 4 字节)。
int a[5] 的下标范围是 0~4,没有 a[5]!这是新手最容易犯的错误。7.1.2 初始化数组
数组可以在声明时直接赋初值:
c#include <stdio.h>
int main() {
// 完全初始化——给所有元素赋值
int a[5] = {10, 20, 30, 40, 50};
// 部分初始化——未指定的元素自动赋值为0
int b[5] = {1, 2, 3}; // b = {1, 2, 3, 0, 0}
// 省略大小——编译器自动计算元素个数
int c[] = {100, 200, 300}; // c的大小自动设为3
// 全部初始化为0
int d[5] = {0}; // d = {0, 0, 0, 0, 0}
// 逐一赋值(声明后)
int e[3];
e[0] = 7;
e[1] = 8;
e[2] = 9;
return 0;
}
7.1.3 访问和遍历数组
通过 数组名[下标] 来访问数组元素。配合循环,可以很方便地遍历整个数组:
c#include <stdio.h>
int main() {
int scores[5] = {85, 92, 78, 90, 88};
// 遍历数组:用下标从0到4访问每个元素
printf("所有成绩:");
for (int i = 0; i < 5; i++) {
printf("%d ", scores[i]);
}
printf("\n");
// 修改某个元素
scores[2] = 82; // 把78改成82
// 重新打印
printf("修改后的成绩:");
for (int i = 0; i < 5; i++) {
printf("%d ", scores[i]);
}
printf("\n");
return 0;
}
7.2 二维数组
一维数组就像一列格子,二维数组就像一张表格——有行有列。比如存储一个班级的考试成绩,一行是一个学生,一列是一科成绩。
7.2.1 声明和初始化
c// 语法:数据类型 数组名[行数][列数];
int matrix[3][4]; // 3行4列的二维数组
// 声明时初始化
int grid[2][3] = {
{1, 2, 3},
{4, 5, 6}
};
// 也可以写成一行(不推荐,不易读)
int grid2[2][3] = {1, 2, 3, 4, 5, 6};
// 省略第一个维度的大小(编译器会自动算出行数)
int grid3[][3] = {
{1, 2, 3},
{4, 5, 6},
{7, 8, 9}
}; // 3行3列
7.2.2 遍历二维数组
需要两层循环:外层循环控制行,内层循环控制列。
c#include <stdio.h>
int main() {
int scores[3][4] = {
{85, 90, 78, 92}, // 第1个学生的4科成绩
{76, 88, 95, 80}, // 第2个学生的4科成绩
{91, 82, 87, 79} // 第3个学生的4科成绩
};
printf(" 语文 数学 英语 编程\n");
printf(" ─────────────────\n");
for (int i = 0; i < 3; i++) { // 外层循环:遍历每个学生
printf("学生%d: ", i + 1);
for (int j = 0; j < 4; j++) { // 内层循环:遍历每科成绩
printf("%-5d", scores[i][j]);
}
printf("\n");
}
return 0;
}
7.2.3 二维数组的存储方式
在内存中,二维数组是按行优先存储的:先存第0行的所有元素,再存第1行的所有元素……所以 int a[2][3] 在内存中的排列是:a[0][0] → a[0][1] → a[0][2] → a[1][0] → a[1][1] → a[1][2]。
7.3 数组的经典应用:冒泡排序
冒泡排序是最经典的排序算法。它的思路很简单:每次比较相邻的两个数,把较大的往后"冒泡"。
来看一个详细的例子,对数组 {64, 34, 25, 12, 22} 进行升序排序:
c#include <stdio.h>
int main() {
int arr[] = {64, 34, 25, 12, 22};
int n = 5; // 数组长度
printf("原始数组:");
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n\n");
// 冒泡排序
for (int i = 0; i < n - 1; i++) { // 外层循环:需要n-1轮比较
printf("第%d轮开始:\n", i + 1);
for (int j = 0; j < n - 1 - i; j++) { // 内层循环:每轮比较的次数递减
printf(" 比较 arr[%d]=%d 和 arr[%d]=%d → ",
j, arr[j], j + 1, arr[j + 1]);
if (arr[j] > arr[j + 1]) {
// 交换相邻的两个数
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
printf("交换 → 数组变为:");
} else {
printf("不交换 → 数组:");
}
// 打印当前数组状态
for (int k = 0; k < n; k++) printf("%d ", arr[k]);
printf("\n");
}
printf(" 第%d轮结果:", i + 1);
for (int k = 0; k < n; k++) printf("%d ", arr[k]);
printf("\n\n");
}
printf("排序完成!最终结果:");
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
return 0;
}
int a[5] 只有 a[0]~a[4],访问 a[5] 会访问到未知内存,导致不可预料的错误。
2. 数组大小用变量:早期的C标准(C89)不允许 int n=5; int a[n];,标准C99之后才支持变长数组(VLA),最好用常量或宏定义。
3. 忘记初始化:局部数组如果没有初始化,里面存的是"垃圾值"(随机数),使用前一定要初始化。7.4 实例:求最大值和平均值
综合运用数组和循环,写一个程序:输入5个同学的成绩,打印出最高分和平均分。
c#include <stdio.h>
int main() {
int scores[5];
int sum = 0;
int max = 0; // 假设成绩都≥0
float avg;
// 1. 输入5个成绩
printf("请输入5个同学的成绩:\n");
for (int i = 0; i < 5; i++) {
printf(" 第%d个同学:", i + 1);
scanf("%d", &scores[i]);
}
// 2. 计算总和和最大值
for (int i = 0; i < 5; i++) {
sum += scores[i]; // 累加求和
if (scores[i] > max) { // 如果当前成绩比max大
max = scores[i]; // 更新max
}
}
// 3. 计算平均分
avg = (float)sum / 5; // 强制类型转换,防止整数除法
// 4. 输出结果
printf("\n========== 成绩统计 ==========\n");
printf("成绩列表:");
for (int i = 0; i < 5; i++) {
printf("%d ", scores[i]);
}
printf("\n");
printf("总 分:%d\n", sum);
printf("最高分:%d\n", max);
printf("平均分:%.1f\n", avg);
printf("==============================\n");
return 0;
}
sum / 5 是整数除法(会丢掉小数),要写成 (float)sum / 5 或 sum / 5.0。
3. 可以用 #define SIZE 5 定义数组大小,这样修改数据量时只需要改一处。第8章 字符串
C语言中没有专门的"字符串"类型。那字符串怎么表示呢?C语言用字符数组来存储字符串,并且用 '\0'(空字符) 作为字符串的结束标志。
8.1 字符数组与字符串
8.1.1 字符数组声明
c// 字符数组(不一定以\0结尾,不一定是字符串)
char c1[5] = {'H', 'e', 'l', 'l', 'o'}; // 只是字符数组,不是字符串
// 字符串(以\0结尾的字符数组才是字符串)
char s1[6] = {'H', 'e', 'l', 'l', 'o', '\0'}; // 字符串
char s2[6] = "Hello"; // 直接用字符串常量初始化,编译器自动加\0
char s3[] = "Hello"; // 编译器自动计算长度为6(含\0)
char c1[5] = {'H','e','l','l','o'}; —— 这只是字符数组,没有 '\0',不能当字符串用。
char s3[] = "Hello"; —— 这是字符串,末尾自动有 '\0',长度为6(不是5!)。字符串常量用双引号括起来,字符常量用单引号括起来,别搞混了:
c"A" // 字符串常量,包含'A'和'\0'两个字符
'A' // 字符常量,就是一个字符
8.1.2 字符串的输入与输出
c#include <stdio.h>
int main() {
char name[20]; // 预留20个字符的空间
printf("请输入你的名字:");
scanf("%s", name); // 注意:不需要 & 符号!数组名本身就是地址
printf("你好,%s!\n", name); // %s 是字符串格式符
// 逐个字符遍历字符串
printf("你的名字是:");
for (int i = 0; name[i] != '\0'; i++) {
printf("%c ", name[i]);
}
printf("\n");
return 0;
}
scanf("%s", ...) 遇到空格就会停止读取,所以输入 "Zhang San" 只会读到 "Zhang"。要读取带空格的字符串,可以用 fgets() 函数,后面会讲到。8.2 字符串处理函数
C语言提供了几个常用的字符串函数,都在 <string.h> 头文件中。在写代码之前需要先 #include <string.h>。
8.2.1 strlen() —— 获取字符串长度
返回字符串的长度,不包括 '\0'。
c#include <stdio.h>
#include <string.h>
int main() {
char str[] = "Hello";
printf("字符串:%s\n", str);
printf("长度:%zu\n", strlen(str)); // %zu 是 size_t 类型的格式符
// sizeof 和 strlen 的区别
printf("sizeof(str) = %zu\n", sizeof(str)); // 6(包含\0)
printf("strlen(str) = %zu\n", strlen(str)); // 5(不包含\0)
// 空字符串
char empty[] = "";
printf("空字符串的长度:%zu\n", strlen(empty)); // 0
return 0;
}
sizeof 问的是"数组这个盒子有多大",strlen 问的是"盒子里的字符串有多长"。一个是容器的容量,一个是内容的长度。8.2.2 strcpy() —— 字符串拷贝
把一个字符串复制到另一个字符数组中。不能直接用 = 给字符串赋值!
c#include <stdio.h>
#include <string.h>
int main() {
char src[] = "Hello";
char dest[20];
// 错误的做法(不能用 = 给数组赋值)
// dest = src; // ❌ 编译错误!
// 正确的做法:用 strcpy
strcpy(dest, src); // 把 src 复制到 dest
printf("源字符串:%s\n", src);
printf("目标字符串:%s\n", dest);
// 也可以直接复制字符串常量
char str[20];
strcpy(str, "C语言真好玩!");
printf("str = %s\n", str);
return 0;
}
strcpy() 不会检查目标数组是否够大!如果源字符串比目标数组长,会溢出到后面的内存中(这叫"缓冲区溢出",是安全漏洞的常见来源)。安全做法是用 strncpy()。8.2.3 strcat() —— 字符串拼接
把一个字符串追加到另一个字符串的末尾。
c#include <stdio.h>
#include <string.h>
int main() {
char str1[30] = "Hello"; // 长度一定要够大,否则会溢出!
char str2[] = " World";
printf("拼接前:%s\n", str1);
strcat(str1, str2); // 把 str2 拼接到 str1 的末尾
printf("拼接后:%s\n", str1);
// 可以多次拼接
strcat(str1, "!!!");
printf("再加感叹号:%s\n", str1);
// 自己拼接自己
char s[50] = "Ha";
strcat(s, s); // 要注意:s 必须有足够空间!
printf("Ha 重复:%s\n", s);
return 0;
}
8.2.4 strcmp() —— 字符串比较
比较两个字符串是否相等。不能直接用 == 比较字符串!
c#include <stdio.h>
#include <string.h>
int main() {
char pass[] = "123456";
char input[20];
printf("请输入密码:");
scanf("%s", input);
// 错误的做法
// if (input == pass) → ❌ 这样比较的是地址,不是内容!
// 正确的做法
int result = strcmp(input, pass);
if (result == 0) {
printf("✅ 密码正确!\n");
} else {
printf("❌ 密码错误!\n");
}
// strcmp 的返回值含义
printf("\nstrcmp 比较示例:\n");
printf("strcmp(\"abc\", \"abc\") = %d\n", strcmp("abc", "abc")); // 0
printf("strcmp(\"abc\", \"abd\") = %d\n", strcmp("abc", "abd")); // 负数('c' < 'd')
printf("strcmp(\"abd\", \"abc\") = %d\n", strcmp("abd", "abc")); // 正数('d' > 'c')
printf("strcmp(\"abc\", \"ab\") = %d\n", strcmp("abc", "ab")); // 正数('c' > '\0')
return 0;
}
- 0 = 相等(Zero = 等于 Zero difference)
- 负数 = 第一个字符串"小于"第二个(按字典序)
- 正数 = 第一个字符串"大于"第二个
8.2.5 字符串函数总结
c#include <stdio.h>
#include <string.h>
int main() {
char s1[50] = "Learn ";
char s2[] = "C ";
char s3[50];
// 1. 拷贝
strcpy(s3, "Programming"); // s3 = "Programming"
printf("拷贝后 s3 = %s\n", s3);
// 2. 拼接
strcat(s1, s2); // s1 = "Learn C "
strcat(s1, s3); // s1 = "Learn C Programming"
printf("拼接后 s1 = %s\n", s1);
// 3. 比较
if (strcmp(s2, "C ") == 0) {
printf("s2 等于 \"C \"\n");
}
// 4. 长度
printf("s1 的长度 = %zu\n", strlen(s1));
return 0;
}
8.3 实例:统计一句话中的单词个数
写一个程序,输入一句话,输出这句话中单词的个数。思路是:遍历每个字符,遇到空格就说明一个单词结束了。
c#include <stdio.h>
#include <string.h>
int main() {
char sentence[200];
int count = 0;
int in_word = 0; // 标记是否在单词中:1=在单词中,0=不在
printf("请输入一句话:");
fgets(sentence, sizeof(sentence), stdin); // fgets能读取带空格的字符串
// 去掉末尾的换行符(fgets会读取回车)
int len = strlen(sentence);
if (sentence[len - 1] == '\n') {
sentence[len - 1] = '\0';
}
// 遍历字符串
for (int i = 0; sentence[i] != '\0'; i++) {
char ch = sentence[i];
if (ch != ' ' && ch != '\t') {
// 如果当前不是空格,且之前不在单词中
// 说明遇到了一个新单词
if (in_word == 0) {
count++; // 单词个数+1
in_word = 1; // 标记进入单词状态
}
} else {
// 遇到空格,标记退出单词状态
in_word = 0;
}
}
printf("这句话有 %d 个单词。\n", count);
// 逐个字符显示分析过程(调试用)
printf("\n逐字分析:\n");
in_word = 0;
count = 0;
for (int i = 0; sentence[i] != '\0'; i++) {
char ch = sentence[i];
if (ch != ' ' && ch != '\t') {
if (in_word == 0) {
count++;
printf("[单词%d开始] ", count);
in_word = 1;
}
printf("'%c' ", ch);
} else {
if (in_word == 1) {
printf("[单词%d结束] ", count);
in_word = 0;
}
printf(" ");
}
}
if (in_word == 1) {
printf("[单词%d结束]", count);
}
printf("\n");
return 0;
}
scanf("%s") 测一下上面的程序会怎样?
2. 如果要处理中英文混排怎么办?(中文空格和英文空格不同)
3. 如果要统计"一句话,有——标点!"这种带标点的句子怎么办?第9章 函数
前面我们写程序,所有代码都堆在 main() 函数里。但程序一旦变长(几百行、几千行),全堆在一起就像把衣服全塞在一个箱子里——找什么都费劲。函数就是用来把代码分门别类、装进不同"抽屉"的工具。
- 避免重复:如果一段代码要反复使用,写成函数就不用每次都复制粘贴。
- 方便维护:要修改某个功能,只需要改对应的函数。
- 逻辑清晰:每个函数负责一个独立的任务,代码像搭积木一样。
9.1 函数的定义与调用
9.1.1 函数的语法
c/*
返回值类型 函数名(参数列表) {
函数体 // 要执行的代码
return 返回值;
}
*/
// 一个最简单的函数:打印分隔线
void printLine() {
printf("====================\n");
}
// 带参数的函数:打印指定个数的星号
void printStars(int n) {
for (int i = 0; i < n; i++) {
printf("*");
}
printf("\n");
}
// 带返回值的函数:计算两个整数的和
int add(int a, int b) {
int sum = a + b;
return sum; // 返回计算结果
}
9.1.2 函数的调用
"调用"函数就是"使用"函数,让函数去完成它的工作:
c#include <stdio.h>
// 函数声明(也叫函数原型)——告诉编译器有这个函数
void sayHello();
int max(int x, int y);
int main() {
// 调用函数
sayHello();
int result = max(10, 25);
printf("两数中较大的是:%d\n", result);
// 也可以直接使用返回值
printf("3和8中较大的是:%d\n", max(3, 8));
return 0;
}
// 函数定义
void sayHello() {
printf("你好!欢迎学习C语言!\n");
}
int max(int x, int y) {
if (x > y) {
return x;
} else {
return y;
}
}
- 把函数定义放在 main 之前(最简单,但文件一长就不方便)
- 在文件开头声明函数原型(推荐做法,如上例)
- 什么都不做——编译器会隐式声明,但这是旧式C的行为,现代编译器会警告
9.2 参数传递——值传递
C语言中,函数参数采用值传递(也叫"传值调用")。意思是:调用函数时,把实参的值复制一份给形参,函数内部修改形参,不会影响原来的实参。
c#include <stdio.h>
void swap(int x, int y) {
int temp = x;
x = y;
y = temp;
printf(" 函数内部交换后:x = %d, y = %d\n", x, y);
}
int main() {
int a = 5, b = 10;
printf("调用函数前:a = %d, b = %d\n", a, b);
swap(a, b); // 把 a 和 b 的值复制一份传给函数
printf("调用函数后:a = %d, b = %d\n", a, b);
// a 和 b 的值没有改变!
return 0;
}
9.3 返回值
函数通过 return 语句把结果传回给调用者。
9.3.1 return 的规则
c// 1. 有返回值的函数,必须有 return
int square(int n) {
return n * n; // 返回 n 的平方
// printf("这行代码永远不会执行!"); // ❌ 死代码!
}
// 2. void 类型不需要 return,也可以写 return; 提前结束
void greet(char name[]) {
if (name[0] == '\0') { // 如果名字是空字符串
printf("你好,匿名者!\n");
return; // 提前结束函数
}
printf("你好,%s!\n", name);
}
// 3. 可以有多个 return
int absValue(int n) {
if (n >= 0) {
return n; // 非负数直接返回
} else {
return -n; // 负数取相反数
}
}
// 4. 返回值类型要匹配
float pi() {
return 3.14159f; // 返回 float 类型
}
int main() {
printf("5的平方:%d\n", square(5));
greet("小明");
greet(""); // 空名字
printf("-3的绝对值:%d\n", absValue(-3));
printf("圆周率:%f\n", pi());
return 0;
}
- 忘记 return:非 void 函数没有 return 语句,会返回一个不确定的"垃圾值"。
- return 后面的代码不执行:return 一旦执行,函数立即结束,后面的代码永远不会运行,这叫"死代码"。
- 返回值类型不匹配:比如函数声明返回 int,却 return 3.14,会被截断为 3。
9.4 局部变量与全局变量
9.4.1 局部变量
定义在函数内部的变量叫局部变量。它的特点是:
- 只能在定义它的函数内部使用
- 函数执行完后自动销毁
- 不同函数可以有同名的局部变量,互不干扰
c#include <stdio.h>
void funcA() {
int x = 10; // funcA 的局部变量
printf("funcA 中的 x = %d\n", x);
}
void funcB() {
int x = 99; // 可以和 funcA 的 x 同名,互不影响
printf("funcB 中的 x = %d\n", x);
}
int main() {
funcA();
funcB();
// printf("%d", x); // ❌ 错误!main 里没有 x
return 0;
}
9.4.2 全局变量
定义在所有函数外部的变量叫全局变量。它的特点是:
- 所有函数都可以访问和修改它
- 在程序运行期间一直存在
- 默认初始化为 0
c#include <stdio.h>
// 全局变量——定义在所有函数外面
int globalCount = 0;
void addOne() {
globalCount++; // 可以访问和修改全局变量
printf(" addOne() 调用后:globalCount = %d\n", globalCount);
}
void addTen() {
globalCount += 10;
printf(" addTen() 调用后:globalCount = %d\n", globalCount);
}
int main() {
printf("初始值:globalCount = %d\n", globalCount);
addOne(); // +1
addOne(); // +1
addTen(); // +10
printf("最终值:globalCount = %d\n", globalCount);
// main 也可以直接修改
globalCount = 100;
printf("main 修改后:globalCount = %d\n", globalCount);
return 0;
}
9.4.3 局部变量 vs 全局变量 —— 对比
c#include <stdio.h>
int g = 100; // 全局变量
void test() {
int g = 200; // 局部变量,和全局变量同名
printf("在 test 函数中:\n");
printf(" g = %d(局部变量,遮蔽了全局变量)\n", g);
}
int main() {
printf("全局变量 g = %d\n", g);
test();
printf("回到 main 函数后:\n");
printf(" g = %d(全局变量的值没变)\n", g);
return 0;
}
- 任何函数都能修改它,出了 bug 很难排查是谁改的。
- 函数之间"偷偷"通过全局变量传数据,代码变得难以理解。
- 在多线程程序中,全局变量容易引发数据竞争问题。
9.5 综合实例:判断素数函数
写一个程序,判断用户输入的一个正整数是否为素数(质数)。
什么是素数?只能被 1 和它本身整除的正整数。比如:2、3、5、7、11、13……
c#include <stdio.h>
#include <math.h> // 要用 sqrt() 函数
// 函数声明
int isPrime(int n);
void printPrimeInfo(int n);
int main() {
int num;
printf("请输入一个正整数:");
scanf("%d", &num);
if (isPrime(num)) {
printf("%d 是素数!\n", num);
} else {
printf("%d 不是素数!\n", num);
}
// 打印更详细的信息
printf("\n");
printPrimeInfo(num);
// 打印 1~100 之间的所有素数
printf("\n1~100 之间的所有素数:\n");
int count = 0;
for (int i = 2; i <= 100; i++) {
if (isPrime(i)) {
printf("%d ", i);
count++;
// 每行显示10个
if (count % 10 == 0) {
printf("\n");
}
}
}
printf("\n共有 %d 个素数。\n", count);
return 0;
}
// 判断一个数是否为素数
// 返回值:1表示是素数,0表示不是素数
int isPrime(int n) {
// 小于2的数不是素数
if (n < 2) {
return 0;
}
// 2 是素数
if (n == 2) {
return 1;
}
// 偶数(除了2)不是素数
if (n % 2 == 0) {
return 0;
}
// 只需要检查到 sqrt(n) 即可,提高效率
// 只检查奇数因子
for (int i = 3; i <= sqrt(n); i += 2) {
if (n % i == 0) {
return 0; // 找到了因子,不是素数
}
}
return 1; // 没有找到因子,是素数
}
// 打印详细的素数判断过程
void printPrimeInfo(int n) {
if (n < 2) {
printf("%d 小于2,不是素数。\n", n);
return;
}
printf("判断 %d 是否为素数的过程:\n", n);
printf(" 检查从 2 到 sqrt(%d)=%.1f 的因子:\n", n, sqrt(n));
int found = 0;
for (int i = 2; i <= sqrt(n); i++) {
if (n % i == 0) {
printf(" %d ÷ %d = %d,能整除!→ 不是素数\n", n, i, n / i);
found = 1;
break;
} else {
printf(" %d ÷ %d = %d 余 %d,不能整除\n", n, i, n / i, n % i);
}
}
if (!found) {
printf(" 没有找到任何因子!→ %d 是素数!\n", n);
}
}
- 函数的定义和调用:
isPrime()和printPrimeInfo() - 参数传递:把用户输入的数字传给函数
- 返回值:
isPrime()返回 0 或 1 - 函数分工:一个函数做判断,一个函数打印信息,各司其职
- 头文件引用:
math.h提供sqrt()函数 - 编程优化:只检查到 sqrt(n),只检查奇数因子——效率大大提高
9.6 本章小结
| 概念 | 说明 |
|---|---|
| 函数定义 | 返回值类型 函数名(参数列表) { 函数体 } |
| 函数调用 | 函数名(实参列表),先声明后使用 |
| 值传递 | 实参的值复制给形参,函数内部修改不影响外部 |
| 返回值 | 用 return 返回结果,void 表示没有返回值 |
| 局部变量 | 定义在函数内,只能在函数内使用,函数结束即销毁 |
| 全局变量 | 定义在函数外,所有函数都能访问,谨慎使用 |
第10章 指针入门
指针是C语言最强大也最让新手害怕的特性。别担心——我们用快递柜的比喻来理解它。
10.1 内存地址 —— 快递柜的格子编号
计算机的内存就像一栋巨大的快递柜,里面有成千上万个格子(内存单元)。每个格子都有一个唯一的编号(内存地址)。
当你声明一个变量 int a = 10; 时,相当于在快递柜里申请了一个格子,存入数字10,而这个格子有一个固定的编号(比如 0x7ffeefbff5a8)。
我们平时用的是变量名(给格子贴的标签),但计算机底层用的是地址(格子的编号)。
c#include <stdio.h>
int main() {
int a = 10;
// & 是"取地址"运算符
printf("变量 a 的值:%d\n", a);
printf("变量 a 的地址:%p\n", &a);
return 0;
}
%p 是专门用来打印地址的格式符。每次运行程序,地址都可能不同——这是操作系统的内存保护机制(ASLR地址随机化),不影响我们学习指针。10.2 & 和 * 运算符 —— 问路和按地址敲门
C语言提供两个与指针密切相关的单目运算符:
| 运算符 | 名称 | 含义 | 类比 |
|---|---|---|---|
& | 取地址运算符 | 获取变量的内存地址 | 问路:"这个格子的编号是多少?" |
* | 解引用运算符 | 通过地址访问存储的值 | 按地址敲门:"到这个格子里取东西!" |
c#include <stdio.h>
int main() {
int a = 100;
// & 取地址 —— "这个变量住在几号房?"
printf("a = %d\n", a);
printf("a 的地址是:%p\n", &a);
// * 解引用 —— 拿到地址后,去那个地址拿值
// 注意:*(&a) 的意思是 "取 a 的地址,然后去那个地址取值"
printf("*(&a) = %d\n", *(&a)); // 一定等于 100
return 0;
}
& 看作"我在几号房?",把 * 看作"到这个房里看看有什么"。&变量 返回地址,*地址 返回值,它们互为逆操作:*&x == x。10.3 声明指针变量 —— 写一张"纸条"保存地址
指针变量就是用来存放内存地址的变量。好比你在纸条上写下快递柜的格口编号,以后拿着纸条就能找到那个格子。
声明语法:类型 *变量名;
int a 是一个盒子,里面装数字。int *p 也是一个盒子,但里面装的是别人家的门牌号(地址)。c#include <stdio.h>
int main() {
int a = 42; // 普通变量:a 里存的是数字 42
int *p; // 指针变量:p 里将来要存一个地址(int 类型变量的地址)
p = &a; // 把 a 的地址赋值给 p —— 纸条上写下了 a 的门牌号
// 现在 p 指向 a
printf("a 的值 = %d\n", a); // 直接访问
printf("p 保存的地址 = %p\n", p); // 打印纸条上的门牌号
printf("通过 *p 访问 a 的值 = %d\n", *p); // 拿着纸条找到那个格子取出东西
// 通过指针修改变量的值
*p = 99; // 拿着纸条找到格子,把里面的数字改成 99
printf("修改后 a 的值 = %d\n", a); // a 被改为 99
return 0;
}
10.4 指针与变量的关系图解
理解下面这张"内存图"是掌握指针的关键:
内存中的景象(每个格=4字节,int): ┌──────────────────────────────────────────────┐ │ 变量 a(值:42) │ │ ┌──────────────┐ │ │ │ 42 │ ← 格子里的内容 │ │ └──────────────┘ │ │ 地址:0x7ffeefbff5a8 │ │ │ │ 变量 p(指针,值:0x7ffeefbff5a8) │ │ ┌──────────────┐ │ │ │ 0x7ffeefbff..│ ← 纸条上写着 a 的地址 │ │ └──────────────┘ │ │ 地址:0x7ffeefbff5b0 │ │ │ │ 关系: │ │ p == &a (纸条上的地址 == a 的门牌号) │ │ *p == a == 42 (按纸条找格子 == 格子里的东西) │ └──────────────────────────────────────────────┘
p 存的是 a 的门牌号,*p 就是 a 本人。画家版:
p = 一张指向 a 的地图,*p = 按照地图走到 a 面前。10.5 新手常见错误
cint *p; // p 里面是垃圾值(随机地址)
*p = 100; // ❌ 向一个随机的地址写入数据 —— 程序崩溃!
解决:要么 p = &某个变量; 给它一个合法地址,要么 int *p = NULL; 先置空。
cint* p, q; // ❌ 你以为 p 和 q 都是指针?
// 实际上:p 是指针,q 是普通 int!
// 正确写法:int *p, *q; 或者分行写
10.6 NULL指针 —— 指向"空"的纸条
如果指针暂时没有指向任何变量,应该给它赋值为 NULL(空指针),表示"这张纸条现在还没写门牌号",而不是让它里面是垃圾值。
c#include <stdio.h>
int main() {
int *p = NULL; // 明确表示"没指向任何东西"
if (p != NULL) {
printf("*p = %d\n", *p); // 安全:只有非空才解引用
} else {
printf("p 是空指针,不能解引用!\n");
}
return 0;
}
10.7 实例:用指针输出变量的地址和值
c#include <stdio.h>
int main() {
int num = 2024;
char ch = 'A';
double pi = 3.14159;
int *pNum = #
char *pCh = &ch;
double *pPi = π
printf("===== 用指针查看不同类型变量 =====\n\n");
printf("int 变量:\n");
printf(" 变量名 num 的值 = %d\n", num);
printf(" 地址 &num = %p\n", &num);
printf(" 指针 pNum = %p\n", pNum);
printf(" 解引用 *pNum = %d\n\n", *pNum);
printf("char 变量:\n");
printf(" 变量名 ch 的值 = %c\n", ch);
printf(" 地址 &ch = %p\n", &ch);
printf(" 指针 pCh = %p\n", pCh);
printf(" 解引用 *pCh = %c\n\n", *pCh);
printf("double 变量:\n");
printf(" 变量名 pi 的值 = %.5f\n", pi);
printf(" 地址 &pi = %p\n", &pi);
printf(" 指针 pPi = %p\n", pPi);
printf(" 解引用 *pPi = %.5f\n\n", *pPi);
// 验证:pNum == &num 总是为真
if (pNum == &num) {
printf("✅ pNum == &num 成立:指针确实保存了变量的地址!\n");
}
// 通过指针修改变量
*pNum = 2025;
printf("通过 *pNum = 2025 修改后,num = %d\n", num);
return 0;
}
10.8 本章小结
| 概念 | 类比 | 代码 |
|---|---|---|
| 变量 | 快递柜的一个格子 | int a = 5; |
| 地址 | 格子的编号 | &a |
| 指针变量 | 写着编号的纸条 | int *p = &a; |
| 解引用 | 按纸条找到格子取东西 | *p |
| 空指针 | 空白纸条(没写编号) | int *p = NULL; |
第11章 指针进阶
掌握了指针的基本概念后,本章我们来学习指针的高级用法——指针与数组、指针与函数、多级指针和void指针。
11.1 指针与数组 —— 数组名就是地址
C语言中,数组名本身就是指向数组首元素的指针。这意味着你可以用指针的方式操作数组。
arr 就是第一栋(arr[0])的门牌号。要找第 i 栋(arr[i]),只需要在第一栋的门牌号上 + i 即可。c#include <stdio.h>
int main() {
int arr[] = {10, 20, 30, 40, 50};
int *p = arr; // 等价于 int *p = &arr[0];
printf("arr = %p (数组首地址)\n", arr);
printf("&arr[0] = %p (第一个元素的地址)\n", &arr[0]);
printf("p = %p (指针保存的值)\n\n", p);
// 用下标访问
printf("用下标访问:\n");
for (int i = 0; i < 5; i++) {
printf(" arr[%d] = %d\n", i, arr[i]);
}
printf("\n用指针访问(*(p+i)):\n");
for (int i = 0; i < 5; i++) {
printf(" *(p+%d) = %d\n", i, *(p + i));
}
printf("\n用指针移动访问(*p++):\n");
p = arr; // 重置指针到开头
for (int i = 0; i < 5; i++) {
printf(" *p = %d(p当前指向 arr[%d])\n", *p, i);
p++; // 指针移动到下一个元素
}
return 0;
}
arr[i] 和 *(arr + i) 完全等价!C编译器会把 arr[i] 自动翻译成 *(arr + i)。当 p 是一个指针时,p[i] 也等价于 *(p + i)。11.2 指针算术 —— 地址的加减法
指针加1 不是地址值+1,而是加上"指向类型"的字节大小:
c#include <stdio.h>
int main() {
int arr_i[] = {1, 2, 3};
char arr_c[] = {'A', 'B', 'C'};
double arr_d[] = {1.1, 2.2, 3.3};
printf("int 数组:\n");
printf(" arr_i = %p\n", arr_i);
printf(" arr_i+1 = %p (差了 %ld 字节 = sizeof(int))\n",
arr_i + 1, (long)((char*)(arr_i+1) - (char*)arr_i));
printf("char 数组:\n");
printf(" arr_c = %p\n", arr_c);
printf(" arr_c+1 = %p (差了 %ld 字节 = sizeof(char))\n",
arr_c + 1, (long)((char*)(arr_c+1) - (char*)arr_c));
printf("double 数组:\n");
printf(" arr_d = %p\n", arr_d);
printf(" arr_d+1 = %p (差了 %ld 字节 = sizeof(double))\n",
arr_d + 1, (long)((char*)(arr_d+1) - (char*)arr_d));
// 指针还可以相减:得到元素个数
int *p1 = &arr_i[0];
int *p2 = &arr_i[2];
printf("\np2 - p1 = %ld (两个指针之间相差 %ld 个元素)\n",
p2 - p1, p2 - p1);
return 0;
}
11.3 指针作为函数参数 —— 让函数"改变外面"的变量
C语言函数参数默认是值传递——函数拿到的是副本,改副本不影响原件。用指针传参,函数就能直接修改原始变量。
c#include <stdio.h>
// ❌ 值传递:无法修改外面的变量
void swap_wrong(int x, int y) {
int temp = x;
x = y;
y = temp;
// 这里交换的是副本,主函数中的 a、b 纹丝不动
}
// ✅ 指针传递:通过地址修改原始变量
void swap_right(int *px, int *py) {
int temp = *px; // temp = a 的值
*px = *py; // a = b 的值
*py = temp; // b = temp
}
int main() {
int a = 10, b = 20;
printf("交换前:a = %d, b = %d\n\n", a, b);
swap_wrong(a, b);
printf("swap_wrong 之后:a = %d, b = %d(没变化!)\n", a, b);
swap_right(&a, &b); // 传入 a 和 b 的地址
printf("swap_right 之后:a = %d, b = %d(成功交换!)\n", a, b);
return 0;
}
另一个常见用途:函数通过指针"返回"多个值。
c#include <stdio.h>
// 计算一个数的平方和立方,通过指针"带出"结果
void calc_square_cube(int n, int *square, int *cube) {
*square = n * n;
*cube = n * n * n;
// 函数没有 return 值,但通过指针修改了外面的变量
}
int main() {
int num = 5;
int sq, cb;
calc_square_cube(num, &sq, &cb);
printf("num = %d\n", num);
printf("平方 = %d\n", sq);
printf("立方 = %d\n", cb);
return 0;
}
cvoid safe_calc(int n, int *square, int *cube) {
if (square == NULL || cube == NULL) {
return; // 安全退出
}
*square = n * n;
*cube = n * n * n;
}
11.4 指针与数组作为函数参数
当数组作为函数参数时,它自动退化为指向首元素的指针。所以 int arr[] 和 int *arr 在函数参数中是等价的。
c#include <stdio.h>
// 这两种写法完全等价:
// void print_array(int arr[], int n)
// void print_array(int *arr, int n)
void print_array(int *arr, int n) {
for (int i = 0; i < n; i++) {
printf("%d ", arr[i]); // arr[i] 等价于 *(arr + i)
}
printf("\n");
}
// 通过指针修改数组元素
void double_elements(int *arr, int n) {
for (int i = 0; i < n; i++) {
arr[i] *= 2; // 直接修改原数组
}
}
int main() {
int numbers[] = {1, 2, 3, 4, 5};
int count = sizeof(numbers) / sizeof(numbers[0]);
printf("原始数组:");
print_array(numbers, count);
double_elements(numbers, count);
printf("翻倍后: ");
print_array(numbers, count);
return 0;
}
int arr[] 和 int *arr 等价,但在局部变量中不同:int arr[5] 会分配空间,int *arr 不分配空间。另外在函数内 sizeof(arr) 得到的是指针大小(8字节),不是数组大小!所以必须额外传长度参数 n。11.5 多级指针 —— 指向指针的指针
二级指针是指向指针的指针。如果一层指针是"写着门牌号的纸条",那二级指针就是"写着纸条位置的纸条"。
c#include <stdio.h>
int main() {
int a = 100; // 普通变量
int *p = &a; // 一级指针:p 存的是 a 的地址
int **pp = &p; // 二级指针:pp 存的是 p 的地址
printf("a = %d\n", a); // 直接访问
printf("*p = %d\n", *p); // 一级解引用
printf("**pp = %d\n", **pp); // 二级解引用:先到 p,再到 a
printf("\n地址关系:\n");
printf("&a = %p\n", &a);
printf("p = %p (p 的值 = a 的地址)\n", p);
printf("&p = %p\n", &p);
printf("pp = %p (pp 的值 = p 的地址)\n", pp);
return 0;
}
二级指针的常见用途:在函数中修改指针本身的值。
c#include <stdio.h>
#include <stdlib.h>
// 通过二级指针让函数"分配内存"并让外面的指针指向它
void create_array(int **pp, int n) {
*pp = (int*)malloc(n * sizeof(int)); // 修改外面指针的值
for (int i = 0; i < n; i++) {
(*pp)[i] = i * 10;
}
}
int main() {
int *arr = NULL;
create_array(&arr, 5); // 传入一级指针的地址
for (int i = 0; i < 5; i++) {
printf("arr[%d] = %d\n", i, arr[i]);
}
free(arr); // 用完释放
return 0;
}
11.6 void指针 —— 万能指针
void* 是一种特殊指针,它可以指向任何类型的数据。你可以把它理解为一个"通用纸条"——什么门牌号都能写。
c#include <stdio.h>
int main() {
int a = 42;
double b = 3.14;
char c = 'X';
void *vp; // 万能指针
// 可以指向任何类型
vp = &a;
printf("指向 int: *(int*)vp = %d\n", *(int*)vp);
vp = &b;
printf("指向 double:*(double*)vp = %.2f\n", *(double*)vp);
vp = &c;
printf("指向 char: *(char*)vp = %c\n", *(char*)vp);
return 0;
}
(int*)vp)转成具体类型后再操作。void* 常用于实现通用的函数。例如 C 标准库的 qsort 排序函数就用 void* 接受任何类型的数组。
c#include <stdio.h>
// 一个能打印"任意类型"地址和值的示例函数
void print_value(void *ptr, char type) {
switch (type) {
case 'i':
printf("int 值:%d\n", *(int*)ptr);
break;
case 'f':
printf("float 值:%.2f\n", *(float*)ptr);
break;
case 'c':
printf("char 值:%c\n", *(char*)ptr);
break;
default:
printf("未知类型\n");
}
}
int main() {
int iv = 100;
float fv = 3.14f;
char cv = 'Z';
print_value(&iv, 'i');
print_value(&fv, 'f');
print_value(&cv, 'c');
return 0;
}
11.7 实例:通过指针修改数组元素
c#include <stdio.h>
// 在数组中查找最大值,并把它放到第一个位置
// 同时通过指针返回最大值的位置(下标)
int find_and_promote_max(int *arr, int n, int *max_pos) {
if (arr == NULL || n <= 0) return -1;
// 假设第一个是最大值
int max_val = arr[0];
*max_pos = 0;
// 遍历查找真正的最大值
for (int i = 1; i < n; i++) {
if (arr[i] > max_val) {
max_val = arr[i];
*max_pos = i;
}
}
// 把最大值和第一个元素交换
if (*max_pos != 0) {
int temp = arr[0];
arr[0] = arr[*max_pos];
arr[*max_pos] = temp;
*max_pos = 0; // 最大值现在在位置0
}
return max_val;
}
int main() {
int scores[] = {67, 92, 88, 55, 96, 73, 81};
int n = sizeof(scores) / sizeof(scores[0]);
int position;
printf("原始成绩:");
for (int i = 0; i < n; i++) {
printf("%d ", scores[i]);
}
printf("\n");
int max = find_and_promote_max(scores, n, &position);
printf("最高分:%d(位置 %d)\n", max, position);
printf("调整后:");
for (int i = 0; i < n; i++) {
printf("%d ", scores[i]);
if (i == 0) printf("← 最高分已移到此处 ");
}
printf("\n");
// 使用指针遍历和修改
printf("\n所有不及格的分数(<60)加5分:\n");
int *p = scores;
for (int i = 0; i < n; i++) {
if (*p < 60) {
printf(" scores[%d] = %d → ", i, *p);
*p += 5; // 通过指针直接修改
printf("%d(已加分)\n", *p);
}
p++;
}
printf("\n最终成绩:");
for (int i = 0; i < n; i++) {
printf("%d ", scores[i]);
}
printf("\n");
return 0;
}
11.8 本章小结
| 进阶技能 | 说明 | 代码示例 |
|---|---|---|
| 指针遍历数组 | arr[i] 等价于 *(arr+i) | for(p=arr; p<arr+n; p++) |
| 指针算术 | p+1 跳过 sizeof(类型) 字节 | int *p; p+1 → 地址+4 |
| 指针作参数 | 函数通过指针修改原始变量 | void swap(int *a, int *b) |
| 数组作参数 | 退化为指针,需额外传长度 | void f(int *arr, int n) |
| 多级指针 | 指向指针的指针 | int **pp |
| void指针 | 万能指针,需转换后使用 | void *p; *(int*)p |
第12章 结构体与联合体
前面我们学过数组——它可以把多个相同类型的数据组合在一起。但如果想把不同类型的数据(比如姓名、年龄、分数)打包成一个整体呢?这时候就要用结构体。
12.1 结构体的定义和使用 —— 创建一个"自定义类型"
定义结构体的语法:
cstruct 结构体名 {
类型1 成员1;
类型2 成员2;
// ...
}; // ← 分号不能少!
c#include <stdio.h>
#include <string.h>
// 定义一个"学生"结构体类型
struct Student {
char name[20]; // 姓名(字符数组)
int age; // 年龄(整数)
float score; // 分数(浮点数)
}; // ← 这个分号非常重要!
int main() {
// 声明结构体变量并初始化
struct Student s1; // 创建了一个 Student 变量
// 用点号(.)访问成员
strcpy(s1.name, "张三");
s1.age = 20;
s1.score = 88.5;
// 也可以声明时直接初始化(按顺序)
struct Student s2 = {"李四", 22, 92.0};
struct Student s3 = {.name = "王五", .score = 76.5, .age = 19}; // 指定成员名(C99)
printf("===== 学生信息 =====\n\n");
printf("s1(逐个赋值):\n");
printf(" 姓名:%s\n", s1.name);
printf(" 年龄:%d\n", s1.age);
printf(" 分数:%.1f\n\n", s1.score);
printf("s2(顺序初始化):\n");
printf(" 姓名:%s\n", s2.name);
printf(" 年龄:%d\n", s2.age);
printf(" 分数:%.1f\n\n", s2.score);
printf("s3(指定成员初始化):\n");
printf(" 姓名:%s\n", s3.name);
printf(" 年龄:%d\n", s3.age);
printf(" 分数:%.1f\n", s3.score);
return 0;
}
s2 = s1; 会把 s1 的所有成员逐字节复制到 s2,相当于给了你一份完整的副本。12.2 结构体的大小 —— 字节对齐
结构体的大小并不等于所有成员大小的简单相加,因为编译器会做字节对齐以提高访问效率。
c#include <stdio.h>
struct Example {
char c; // 1 字节
int i; // 4 字节
char d; // 1 字节
}; // 直觉上 1+4+1=6,但实际上……
int main() {
// 注意:我们用了个"空"结构体演示对齐
struct Example e;
printf("char: %lu 字节\n", sizeof(char));
printf("int: %lu 字节\n", sizeof(int));
printf("结构体 struct Example 的大小:%lu 字节(不是 1+4+1=6!)\n",
sizeof(struct Example));
printf("\n各个成员的地址:\n");
printf("&e.c = %p\n", &e.c);
printf("&e.i = %p\n", &e.i);
printf("&e.d = %p\n", &e.d);
return 0;
}
优化技巧:把大的成员放在前面可以减少填充——
struct { int i; char c; char d; } 只有 8 字节。12.3 typedef —— 给类型起"外号"
每次写 struct Student 有点啰嗦。typedef 可以给类型起一个更简短的名字。
c#include <stdio.h>
#include <string.h>
// 定义结构体并用 typedef 取别名
typedef struct {
char name[20];
int age;
float score;
} Student; // 以后直接用 Student 代替 struct {…}
// 也可以分两步:
// struct Point { int x; int y; };
// typedef struct Point Point;
int main() {
Student s1 = {"小明", 18, 95.5};
// 注意:不再需要写 "struct" 关键字
printf("姓名:%s\n", s1.name);
printf("年龄:%d\n", s1.age);
printf("分数:%.1f\n", s1.score);
return 0;
}
typedef unsigned long long uint64; ——以后 uint64 x; 就是 unsigned long long x; 的简写。12.4 结构体数组 —— 管理多个"记录"
结构体最常用的场景就是作为数组元素——一个数组里存多个学生的信息。
c#include <stdio.h>
#include <string.h>
typedef struct {
char name[20];
int age;
float score;
} Student;
int main() {
// 结构体数组:3个学生
Student class[] = {
{"张三", 20, 88.5},
{"李四", 22, 92.0},
{"王五", 19, 76.5}
};
int n = sizeof(class) / sizeof(class[0]);
printf("===== 班级学生列表 =====\n");
printf("%-4s %-8s %-4s %s\n", "序号", "姓名", "年龄", "分数");
printf("----------------------------\n");
for (int i = 0; i < n; i++) {
printf("%-4d %-8s %-4d %.1f\n",
i + 1, class[i].name, class[i].age, class[i].score);
}
// 计算平均分
float total = 0;
for (int i = 0; i < n; i++) {
total += class[i].score;
}
printf("\n平均分:%.2f\n", total / n);
return 0;
}
12.5 结构体指针 —— 用箭头(->)访问成员
与普通变量一样,结构体也可以用指针操作。此时访问成员用 -> 箭头运算符。
p->成员 等价于 (*p).成员。箭头就像沿着指针"指向"结构体,然后"取"里面的成员。c#include <stdio.h>
#include <string.h>
typedef struct {
char name[20];
int age;
float score;
} Student;
// 用结构体指针作为函数参数 —— 避免复制整个结构体
void print_student(const Student *p) {
// 用 -> 访问成员
printf("姓名:%s | 年龄:%d | 分数:%.1f\n",
p->name, p->age, p->score);
}
// 通过指针修改结构体
void update_score(Student *p, float new_score) {
p->score = new_score; // 等价于 (*p).score = new_score
}
int main() {
Student s = {"赵六", 21, 80.0};
Student *ps = &s; // 结构体指针
printf("直接访问:姓名 = %s\n", s.name);
printf("用指针(->):姓名 = %s\n", ps->name); // 推荐写法
printf("用指针(*).:姓名 = %s\n\n", (*ps).name); // 等价写法
printf("调用函数前:");
print_student(&s);
update_score(&s, 95.0);
printf("修改分数后:");
print_student(&s);
// 结构体数组用指针遍历
Student class[] = {
{"张三", 20, 88.5},
{"李四", 22, 92.0}
};
printf("\n用指针遍历数组:\n");
Student *p = class; // 指向数组首元素
Student *end = class + 2; // 指向数组末尾(不包含)
while (p < end) {
printf(" %s %d岁 %.1f分\n", p->name, p->age, p->score);
p++; // 指针移动到下一个结构体
}
return 0;
}
const 修饰(如 const Student *p)表示只读不写,更安全。12.6 联合体 union —— 同一块内存的不同"视角"
联合体(union) 和结构体语法很像,但所有成员共享同一块内存。联合体的大小等于最大成员的大小。
c#include <stdio.h>
#include <string.h>
// 定义一个联合体:同一块 4 字节内存的不同解释
union Data {
int i; // 4 字节,解释为整数
float f; // 4 字节,解释为浮点数
char str[4]; // 4 字节,解释为字符数组
};
int main() {
union Data data;
printf("union Data 的大小 = %lu 字节\n\n", sizeof(union Data));
// 设置为整数
data.i = 42;
printf("设为整数:data.i = %d\n", data.i);
printf("此时 data.f = %f(垃圾值)\n", data.f);
printf("此时 data.str = ");
for (int i = 0; i < 4; i++) printf("%02X ", (unsigned char)data.str[i]);
printf("(内存的十六进制表示)\n\n");
// 设置为浮点数 —— 覆盖了之前的整数
data.f = 3.14f;
printf("设为浮点数:data.f = %f\n", data.f);
printf("此时 data.i = %d(被覆盖了!)\n", data.i);
printf("此时 data.str = ");
for (int i = 0; i < 4; i++) printf("%02X ", (unsigned char)data.str[i]);
printf("\n");
return 0;
}
联合体的典型应用:
c#include <stdio.h>
// 定义一个"通用数值"类型
typedef struct {
int type; // 0=int, 1=float, 2=char
union {
int i;
float f;
char c;
} value;
} GenericNumber;
void print_value(GenericNumber gn) {
switch (gn.type) {
case 0: printf("int:%d\n", gn.value.i); break;
case 1: printf("float:%.2f\n", gn.value.f); break;
case 2: printf("char:%c\n", gn.value.c); break;
default: printf("未知类型\n");
}
}
int main() {
GenericNumber n1 = {0, .value.i = 100};
GenericNumber n2 = {1, .value.f = 3.14f};
GenericNumber n3 = {2, .value.c = 'A'};
print_value(n1);
print_value(n2);
print_value(n3);
return 0;
}
12.7 struct 与 union 的区别
| 特性 | struct(结构体) | union(联合体) |
|---|---|---|
| 内存分配 | 每个成员独立占内存 | 所有成员共享同一块内存 |
| 总大小 | 所有成员大小之和(含对齐填充) | 最大成员的大小 |
| 成员访问 | 所有成员同时有效 | 只有最近写入的成员有效 |
| 典型用途 | 描述一个对象的多项属性(如学生信息) | 节省内存、同一数据的不同解释 |
| 初始化 | {成员1, 成员2, ...} | {.成员 = 值}(只初始化一个) |
12.8 综合实例:简单的学生信息管理系统
下面我们综合运用本章学到的结构体、结构体数组、结构体指针、typedef 等知识,实现一个完整的学生信息管理小程序。
c#include <stdio.h>
#include <string.h>
#define MAX_STUDENTS 100
#define NAME_LEN 20
// 定义学生结构体
typedef struct {
int id; // 学号
char name[NAME_LEN]; // 姓名
int age; // 年龄
float score; // 成绩
} Student;
// 全局变量:学生数组和学生数量
Student students[MAX_STUDENTS];
int student_count = 0;
// 添加学生
void add_student(int id, const char *name, int age, float score) {
if (student_count >= MAX_STUDENTS) {
printf("❌ 学生人数已达上限!\n");
return;
}
students[student_count].id = id;
strcpy(students[student_count].name, name);
students[student_count].age = age;
students[student_count].score = score;
student_count++;
printf("✅ 添加成功!\n");
}
// 显示所有学生
void list_students() {
if (student_count == 0) {
printf("📭 当前没有学生记录。\n");
return;
}
printf("\n========== 学生列表 ==========\n");
printf("%-6s %-4s %-10s %-4s %s\n", "序号", "学号", "姓名", "年龄", "成绩");
printf("------------------------------------\n");
// 用指针遍历结构体数组
Student *p = students;
for (int i = 0; i < student_count; i++, p++) {
printf("%-6d %-4d %-10s %-4d %.1f\n",
i + 1, p->id, p->name, p->age, p->score);
}
printf("==============================\n");
}
// 通过学号查找学生(返回指针,无符号 const)
Student* find_by_id(int id) {
for (int i = 0; i < student_count; i++) {
if (students[i].id == id) {
return &students[i];
}
}
return NULL;
}
// 修改学生成绩
void update_score(int id, float new_score) {
Student *p = find_by_id(id);
if (p == NULL) {
printf("❌ 未找到学号为 %d 的学生。\n", id);
return;
}
p->score = new_score; // 通过指针修改
printf("✅ 已修改 %s 的成绩为 %.1f\n", p->name, new_score);
}
// 删除学生(通过移动数组)
void delete_student(int id) {
int found = 0;
for (int i = 0; i < student_count; i++) {
if (students[i].id == id) {
found = 1;
}
if (found && i < student_count - 1) {
students[i] = students[i + 1]; // 结构体整体赋值
}
}
if (found) {
student_count--;
printf("✅ 已删除学号为 %d 的学生。\n", id);
} else {
printf("❌ 未找到学号为 %d 的学生。\n", id);
}
}
// 统计信息
void show_statistics() {
if (student_count == 0) {
printf("📭 没有学生数据可统计。\n");
return;
}
float sum = 0, max = 0, min = 100;
int pass_count = 0;
Student *p = students;
for (int i = 0; i < student_count; i++, p++) {
sum += p->score;
if (p->score > max) max = p->score;
if (p->score < min) min = p->score;
if (p->score >= 60) pass_count++;
}
printf("\n========== 成绩统计 ==========\n");
printf("总人数:%d\n", student_count);
printf("平均分:%.2f\n", sum / student_count);
printf("最高分:%.1f\n", max);
printf("最低分:%.1f\n", min);
printf("及格率:%.1f%%\n", (float)pass_count / student_count * 100);
printf("==============================\n");
}
int main() {
int choice, id, age;
char name[NAME_LEN];
float score;
// 预置一些测试数据
add_student(1001, "张三", 20, 88.5);
add_student(1002, "李四", 22, 92.0);
add_student(1003, "王五", 19, 45.0);
add_student(1004, "赵六", 21, 76.5);
do {
printf("\n╔══════════════════════════╗\n");
printf("║ 学生信息管理系统 ║\n");
printf("╠══════════════════════════╣\n");
printf("║ 1. 显示所有学生 ║\n");
printf("║ 2. 添加学生 ║\n");
printf("║ 3. 修改成绩 ║\n");
printf("║ 4. 删除学生 ║\n");
printf("║ 5. 按学号查找 ║\n");
printf("║ 6. 成绩统计 ║\n");
printf("║ 0. 退出 ║\n");
printf("╚══════════════════════════╝\n");
printf("请选择操作:");
scanf("%d", &choice);
switch (choice) {
case 1:
list_students();
break;
case 2:
printf("请输入学号:");
scanf("%d", &id);
printf("请输入姓名:");
scanf("%s", name);
printf("请输入年龄:");
scanf("%d", &age);
printf("请输入成绩:");
scanf("%f", &score);
add_student(id, name, age, score);
break;
case 3:
printf("请输入要修改的学号:");
scanf("%d", &id);
printf("请输入新成绩:");
scanf("%f", &score);
update_score(id, score);
break;
case 4:
printf("请输入要删除的学号:");
scanf("%d", &id);
delete_student(id);
break;
case 5: {
printf("请输入学号:");
scanf("%d", &id);
Student *p = find_by_id(id);
if (p) {
printf("学号:%d | 姓名:%s | 年龄:%d | 成绩:%.1f\n",
p->id, p->name, p->age, p->score);
} else {
printf("❌ 未找到该学生。\n");
}
break;
}
case 6:
show_statistics();
break;
case 0:
printf("👋 感谢使用学生信息管理系统!\n");
break;
default:
printf("❌ 无效选择,请重新输入。\n");
}
} while (choice != 0);
return 0;
}
- typedef 简化:用
typedef struct {...} Student;让代码更简洁 - 结构体数组:
Student students[MAX_STUDENTS]存储多条记录 - 结构体指针:
find_by_id返回Student*,update_score用p->score修改 - 指针遍历:
Student *p = students; ... p++高效遍历数组 - 结构体赋值:
students[i] = students[i+1]整体复制结构体
12.9 本章小结
| 知识点 | 语法 | 说明 |
|---|---|---|
| 定义结构体 | struct 名 { 成员列表 }; | 组合不同类型的变量 |
| 访问成员 | 变量.成员 | 点号运算符 |
| typedef | typedef ... 新名字; | 起别名,简化书写 |
| 结构体指针 | p->成员 = (*p).成员 | 箭头访问,常用在函数参数 |
| 结构体数组 | Student arr[10]; | 多条记录的集合 |
| 联合体 | union { ... }; | 共享内存,节省空间 |
第13章 动态内存管理
前面的课程中,我们用的变量和数组都是在程序启动时就分配好内存的,这种方式叫静态内存分配。但你有没有想过:如果我不知道用户要输入多长的名字怎么办?如果运行时才发现需要多少数据怎么办?
这时候就需要动态内存分配——程序在运行的时候,根据实际需要向系统申请内存。用完了还要及时还给系统。C语言提供了一组标准库函数来完成这件事,它们都在 <stdlib.h> 头文件里。
13.1 malloc —— 申请内存
malloc 的全称是 memory allocation(内存分配),它的作用是在堆(heap)上申请一块连续的内存空间。
c#include <stdlib.h>
void *malloc(size_t size);
- 参数:要申请的字节数
- 返回值:申请成功返回指向这块内存的指针,失败返回
NULL - 返回值类型是
void *(通用指针),使用时需要强制转换成目标类型
示例:申请一个整数空间
c#include <stdio.h>
#include <stdlib.h>
int main() {
int *p = (int *)malloc(sizeof(int)); // 申请一个 int 大小的空间
if (p == NULL) { // 一定要检查是否分配成功
printf("内存分配失败!\n");
return 1;
}
*p = 42;
printf("数值: %d\n", *p);
free(p); // 用完释放
return 0;
}
13.2 calloc —— 申请并清零
calloc 和 malloc 类似,但有两个区别:
- 它会把分配到的所有字节都初始化为
0 - 参数形式是"数量 × 每个大小",更直观
cvoid *calloc(size_t nmemb, size_t size);
// nmemb: 元素个数
// size: 每个元素的字节数
示例:申请一个包含5个整数的数组
c#include <stdio.h>
#include <stdlib.h>
int main() {
int *arr = (int *)calloc(5, sizeof(int)); // 5个int,全部初始化为0
if (arr == NULL) {
printf("内存分配失败!\n");
return 1;
}
for (int i = 0; i < 5; i++) {
printf("%d ", arr[i]); // 全是0
}
printf("\n");
free(arr);
return 0;
}
13.3 realloc —— 调整已有内存的大小
有时候申请的内存不够用了,但又不想丢掉原有数据,这时可以用 realloc 来"扩容"或"缩容"。
cvoid *realloc(void *ptr, size_t new_size);
// ptr: 原来 malloc/calloc 返回的指针
// new_size: 新的字节数
注意事项:
realloc可能把数据搬到新的地址,所以返回值才是新的指针- 旧指针在
realloc之后就不需要也不能再free了 - 如果
ptr传NULL,等价于malloc
c#include <stdio.h>
#include <stdlib.h>
int main() {
int *arr = (int *)malloc(3 * sizeof(int));
for (int i = 0; i < 3; i++) arr[i] = i + 1;
// 扩容到5个元素
int *temp = (int *)realloc(arr, 5 * sizeof(int));
if (temp == NULL) {
printf("扩容失败!\n");
free(arr);
return 1;
}
arr = temp; // 更新指针
arr[3] = 4;
arr[4] = 5;
for (int i = 0; i < 5; i++) {
printf("%d ", arr[i]);
}
printf("\n");
free(arr);
return 0;
}
13.4 free —— 释放内存
动态申请的内存用完之后,必须用 free 还给系统!如果不释放,程序占用的内存会越来越多,这叫内存泄漏。
cvoid free(void *ptr);
黄金法则:
- 每一个
malloc/calloc/realloc,都应该有一个对应的free - 释放后把指针设为
NULL,避免成为"野指针" - 不能重复释放同一块内存
- 不能释放栈上的地址(普通变量的地址)
cint *p = (int *)malloc(10 * sizeof(int));
// ... 使用 p ...
free(p);
p = NULL; // 好习惯!
13.5 什么是内存泄漏?
内存泄漏(Memory Leak) 是指程序中动态分配的内存,不再需要后没有被释放,导致这块内存一直"被占用",无法被系统回收再用。
看这个错误的例子:
c// ❌ 内存泄漏的例子
void leak() {
int *p = (int *)malloc(100 * sizeof(int));
// 忘掉了 free(p)!
// 函数返回后,再也找不到 p 的地址了,这块内存永远无法释放
}
如果这个函数在循环里被反复调用:
cwhile (1) {
leak(); // 每次循环泄漏 400 字节(100 * 4)
}
程序的内存会一直增长,直到系统资源耗尽——对于服务器程序来说,这是致命的 bug!
13.6 实战:动态输入姓名
用户输入自己的名字,但不知道名字有多长。我们用动态内存来搞定:先申请一个小缓冲区,不够了就扩容。
c#include <stdio.h>
#include <stdlib.h>
int main() {
int capacity = 10; // 初始容量
int size = 0; // 当前有效字符数
char *name = (char *)malloc(capacity * sizeof(char));
if (name == NULL) {
printf("内存分配失败!\n");
return 1;
}
printf("请输入你的姓名(输入 # 结束):\n");
char ch;
while (1) {
ch = getchar();
if (ch == '#' || ch == '\n') {
break;
}
if (size >= capacity - 1) { // 留一个位置给 '\0'
capacity *= 2;
char *temp = (char *)realloc(name, capacity * sizeof(char));
if (temp == NULL) {
printf("扩容失败!\n");
free(name);
return 1;
}
name = temp;
}
name[size++] = ch;
}
name[size] = '\0'; // 字符串结束符
printf("你好,%s!姓名长度为 %d 个字符。\n", name, size);
free(name);
return 0;
}
张三丰#
你好,张三丰!姓名长度为 3 个字符。
本章小结
malloc申请内存,calloc申请并清零,realloc调整大小,free释放内存- 一定要检查返回值是否为
NULL - 用完一定要
free,避免内存泄漏 - 释放后把指针置
NULL - 动态内存非常适合处理"运行时才知道大小"的数据
第14章 文件操作
到目前为止,我们的程序都是在内存里运行——数据一关程序就丢了。如果想把数据永久保存下来,就需要把数据写到文件里。C语言通过 <stdio.h> 提供了一套文件操作函数。
14.1 文件指针 FILE *
C语言操作文件的核心是一个叫 FILE 的结构体指针。你可以把它理解为"文件和程序之间的通道"。
cFILE *fp; // 定义文件指针
14.2 fopen —— 打开文件
操作文件前,必须先打开它。
cFILE *fopen(const char *filename, const char *mode);
// filename: 文件名(可以带路径)
// mode: "r"读取、"w"写入、"a"追加 等
// 返回值:成功返回 FILE *,失败返回 NULL
文件模式详解
| 模式 | 含义 | 文件不存在时 | 文件存在时 |
|---|---|---|---|
"r" | 只读 | 返回 NULL | 正常读取 |
"w" | 只写 | 创建新文件 | 清空原有内容 |
"a" | 追加 | 创建新文件 | 从末尾继续写 |
"r+" | 读写 | 返回 NULL | 正常读写 |
"w+" | 读写 | 创建新文件 | 清空原有内容 |
"a+" | 读写追加 | 创建新文件 | 读取从头,写入在尾 |
14.3 fclose —— 关闭文件
文件用完后一定要关闭!不关闭可能导致数据丢失(缓冲区没刷新)或资源泄漏。
cint fclose(FILE *fp);
// 成功返回 0,失败返回 EOF
14.4 写入文件
fprintf —— 格式化写入
和 printf 用法一模一样,只是多了一个文件指针参数:
cint fprintf(FILE *fp, const char *format, ...);
示例:把数据写入文件
c#include <stdio.h>
int main() {
FILE *fp = fopen("student.txt", "w"); // 打开文件,准备写入
if (fp == NULL) {
printf("文件打开失败!\n");
return 1;
}
fprintf(fp, "姓名: 小明\n");
fprintf(fp, "年龄: 18\n");
fprintf(fp, "成绩: 95.5\n");
fclose(fp);
printf("写入完成!\n");
return 0;
}
(查看 student.txt 文件内容)
fputs —— 写入字符串
cint fputs(const char *str, FILE *fp);
// 成功返回非负数,失败返回 EOF
cfputs("Hello, File!\n", fp);
14.5 读取文件
fscanf —— 格式化读取
和 scanf 一样,从文件中读取格式化的数据:
cint fscanf(FILE *fp, const char *format, ...);
示例:读取 student.txt
c#include <stdio.h>
int main() {
FILE *fp = fopen("student.txt", "r");
if (fp == NULL) {
printf("文件打开失败!\n");
return 1;
}
char name[50];
int age;
float score;
fscanf(fp, "姓名: %s\n", name);
fscanf(fp, "年龄: %d\n", &age);
fscanf(fp, "成绩: %f\n", &score);
printf("读取到:%s,%d岁,成绩%.1f\n", name, age, score);
fclose(fp);
return 0;
}
fgets —— 逐行读取字符串
cchar *fgets(char *str, int n, FILE *fp);
// 读取一行(含换行符),最多 n-1 个字符
// 成功返回 str,读到文件末尾或失败返回 NULL
cchar line[100];
while (fgets(line, sizeof(line), fp) != NULL) {
printf("%s", line); // 逐行打印文件内容
}
14.6 其他常用文件函数
| 函数 | 功能 |
|---|---|
fgetc(fp) | 读一个字符 |
fputc(ch, fp) | 写一个字符 |
feof(fp) | 判断是否到文件末尾(到末尾返回非0) |
remove("文件名") | 删除文件 |
rename("旧名","新名") | 重命名文件 |
14.7 实战:文件读写成绩
编写一个程序,让用户输入5名学生的姓名和成绩,保存到文件,然后再从文件中读出来显示。
c#include <stdio.h>
#include <string.h>
#define STUDENT_COUNT 5
#define NAME_LEN 20
int main() {
char names[STUDENT_COUNT][NAME_LEN];
float scores[STUDENT_COUNT];
FILE *fp;
// ====== 写入阶段 ======
printf("请输入 %d 名学生的姓名和成绩:\n", STUDENT_COUNT);
for (int i = 0; i < STUDENT_COUNT; i++) {
printf("第%d名学生姓名: ", i + 1);
scanf("%s", names[i]);
printf("第%d名学生成绩: ", i + 1);
scanf("%f", &scores[i]);
}
fp = fopen("scores.txt", "w");
if (fp == NULL) {
printf("文件打开失败!\n");
return 1;
}
for (int i = 0; i < STUDENT_COUNT; i++) {
fprintf(fp, "%s %.1f\n", names[i], scores[i]);
}
fclose(fp);
printf("✅ 成绩已保存到 scores.txt\n\n");
// ====== 读取阶段 ======
fp = fopen("scores.txt", "r");
if (fp == NULL) {
printf("文件打开失败!\n");
return 1;
}
printf("从文件读取的成绩如下:\n");
printf("========================\n");
char name[NAME_LEN];
float score;
float sum = 0;
int count = 0;
while (fscanf(fp, "%s %f", name, &score) == 2) {
printf("姓名: %-8s 成绩: %.1f\n", name, score);
sum += score;
count++;
}
fclose(fp);
printf("========================\n");
if (count > 0) {
printf("平均分: %.2f\n", sum / count);
}
return 0;
}
第1名学生姓名: 张三
第1名学生成绩: 88.5
第2名学生姓名: 李四
第2名学生成绩: 92
第3名学生姓名: 王五
第3名学生成绩: 76
第4名学生姓名: 赵六
第4名学生成绩: 95.5
第5名学生姓名: 孙七
第5名学生成绩: 83
✅ 成绩已保存到 scores.txt
从文件读取的成绩如下:
========================
姓名: 张三 成绩: 88.5
姓名: 李四 成绩: 92.0
姓名: 王五 成绩: 76.0
姓名: 赵六 成绩: 95.5
姓名: 孙七 成绩: 83.0
========================
平均分: 87.00
本章小结
fopen打开文件,fclose关闭文件——别忘了关!- 三种基本模式:
"r"读、"w"写(清空)、"a"追加 fprintf/fscanf:格式化的文件读写,和 printf/scanf 用法一样fputs/fgets:字符串级别的文件读写- 操作完文件记得关闭,否则可能丢失数据
第15章 预处理与宏
你是不是注意到,我们写的每个C程序最上面都有 #include <stdio.h>?这个以 # 开头的语句,就是预处理指令。
预处理发生在编译之前,它会对源代码进行文本层面的处理。预处理完成后才进入真正的编译过程。
15.1 #define —— 宏定义
#define 用来定义一个宏(macro),本质上是文本替换——编译器在预处理阶段会把代码中的宏名称替换成定义的内容。
不带参数的宏(符号常量)
c#define PI 3.14159
#define MAX_STUDENTS 100
#define SCHOOL_NAME "清华大学"
写上 #define PI 3.14159 后,代码中所有的 PI 都会被替换成 3.14159:
c#include <stdio.h>
#define PI 3.14159
int main() {
double r = 5.0;
double area = PI * r * r; // 预处理后被替换为: 3.14159 * r * r
printf("半径%.1f的圆面积 = %.4f\n", r, area);
return 0;
}
带参数的宏(宏函数)
宏甚至可以像函数一样带参数:
c#define SQUARE(x) ((x) * (x))
#define MAX(a, b) ((a) > (b) ? (a) : (b))
#define MIN(a, b) ((a) < (b) ? (a) : (b))
⚠️ 注意:参数要加括号!
为什么?看看不加括号的后果:
c#define BAD_SQUARE(x) x * x
// BAD_SQUARE(2 + 3) 被替换成: 2 + 3 * 2 + 3 = 2 + 6 + 3 = 11 ❌
// 正确的 SQUARE(2 + 3) 被替换成: (2+3) * (2+3) = 5 * 5 = 25 ✅
c#include <stdio.h>
#define SQUARE(x) ((x) * (x))
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int main() {
printf("SQUARE(5) = %d\n", SQUARE(5));
printf("SQUARE(2+3) = %d\n", SQUARE(2+3));
printf("MAX(10, 20) = %d\n", MAX(10, 20));
printf("MAX(SQUARE(3), SQUARE(4)) = %d\n", MAX(SQUARE(3), SQUARE(4)));
return 0;
}
SQUARE(2+3) = 25
MAX(10, 20) = 20
MAX(SQUARE(3), SQUARE(4)) = 16
宏 vs 函数:怎么选?
| 对比项 | 宏 | 函数 |
|---|---|---|
| 执行速度 | 快(无调用开销) | 有函数调用开销 |
| 代码体积 | 每次使用都会复制代码 | 只有一份代码 |
| 类型检查 | 无(只是文本替换) | 有严格的类型检查 |
| 副作用风险 | 较高(如 MAX(x++, y)) | 较低 |
15.2 #undef —— 取消宏定义
c#define TEMP 100
// ... 可以使用 TEMP ...
#undef TEMP
// 这之后 TEMP 不再被定义
15.3 #include —— 文件包含
#include 用于把另一个文件的内容"复制粘贴"到当前位置。
c#include <stdio.h> // 标准库头文件——用尖括号 <>
#include "myheader.h" // 自定义头文件——用双引号 ""
两种写法的区别:
<>:在系统指定的目录(如/usr/include)查找"":先在当前目录查找,找不到再去系统目录
自定义头文件示例(utils.h):
c// ====== utils.h ======
#ifndef UTILS_H
#define UTILS_H
#define PI 3.14159
int add(int a, int b) {
return a + b;
}
#endif
c// ====== main.c ======
#include <stdio.h>
#include "utils.h"
int main() {
printf("PI = %f\n", PI);
printf("add(3, 5) = %d\n", add(3, 5));
return 0;
}
add(3, 5) = 8
15.4 条件编译 #ifdef / #ifndef / #endif
条件编译让编译器根据条件决定哪些代码参与编译。这在跨平台、调试开关等场景中非常有用。
基本语法
c#ifdef 宏名
// 如果这个宏被定义了,才编译这里的代码
#endif
#ifndef 宏名
// 如果这个宏没有被定义,才编译这里的代码
#endif
#if / #elif / #else / #endif
c#if 条件表达式
// 条件为真时编译
#elif 另一条件
// 另一条件为真时编译
#else
// 以上都不满足时编译
#endif
实战:跨平台代码
c#include <stdio.h>
int main() {
#ifdef _WIN32
printf("你在 Windows 系统上运行\n");
#elif defined(__linux__)
printf("你在 Linux 系统上运行\n");
#elif defined(__APPLE__)
printf("你在 macOS 系统上运行\n");
#else
printf("未知操作系统\n");
#endif
return 0;
}
15.5 预定义宏
C语言内置了一些有用的宏:
c#include <stdio.h>
int main() {
printf("当前文件: %s\n", __FILE__);
printf("当前行号: %d\n", __LINE__);
printf("编译日期: %s\n", __DATE__);
printf("编译时间: %s\n", __TIME__);
printf("C标准版本: %ld\n", __STDC_VERSION__);
return 0;
}
当前行号: 7
编译日期: Jun 9 2026
编译时间: 14:30:00
C标准版本: 201112
15.6 实战:调试开关宏
在开发过程中,我们经常需要打印调试信息,但发布时又不能把这些信息留给用户。用条件编译可以完美解决:
c#include <stdio.h>
#include <time.h>
// ====== 调试开关 ======
// 取消下面这行的注释,启用调试模式
// #define DEBUG
#ifdef DEBUG
// 定义调试打印宏——会输出文件名、行号、信息
#define DEBUG_PRINT(fmt, ...) \
printf("[DEBUG] %s:%d: " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__)
#else
// 发布模式下,调试宏什么都不做
#define DEBUG_PRINT(fmt, ...) ((void)0)
#endif
// ====== 程序主体 ======
int main() {
int total = 0;
DEBUG_PRINT("程序启动");
for (int i = 1; i <= 10; i++) {
total += i;
DEBUG_PRINT("i = %d, 当前累加和 = %d", i, total);
}
printf("1 + 2 + ... + 10 = %d\n", total);
DEBUG_PRINT("程序结束,最终结果 = %d", total);
return 0;
}
关闭 DEBUG(发布模式):
开启 DEBUG(调试模式):
去掉 // #define DEBUG 前面的 //,再编译运行:
[DEBUG] main.c:31: i = 1, 当前累加和 = 1
[DEBUG] main.c:31: i = 2, 当前累加和 = 3
[DEBUG] main.c:31: i = 3, 当前累加和 = 6
[DEBUG] main.c:31: i = 4, 当前累加和 = 10
[DEBUG] main.c:31: i = 5, 当前累加和 = 15
[DEBUG] main.c:31: i = 6, 当前累加和 = 21
[DEBUG] main.c:31: i = 7, 当前累加和 = 28
[DEBUG] main.c:31: i = 8, 当前累加和 = 36
[DEBUG] main.c:31: i = 9, 当前累加和 = 45
[DEBUG] main.c:31: i = 10, 当前累加和 = 55
[DEBUG] main.c:35: 程序结束,最终结果 = 55
编译时的"开/关"技巧:
bash# 编译时开启调试(不需要改代码!)
gcc -DDEBUG main.c -o program
# 编译时关闭调试(默认)
gcc main.c -o program
15.7 #pragma —— 编译器指令
#pragma 是向编译器发送特殊指令,不同编译器支持的不一样。
c#pragma once // 只包含一次头文件(现代替代 #ifndef 的方案)
#pragma GCC warning "请更新这段代码" // 编译时产生警告
本章小结
#define定义宏——本质是文本替换- 带参宏的参数一定要加括号,避免运算符优先级问题
#include包含头文件——<>系统路径、""当前路径#ifdef/#ifndef/#endif条件编译——实现调试开关、跨平台- 条件编译非常适合"同一套代码、不同环境"的场景
第16章 综合项目:图书管理系统
恭喜你来到了最后一章!我们将用前面学到的所有知识(结构体、动态内存、文件操作、函数、指针)来做一个完整的项目——图书管理系统。
功能需求
- 添加图书:输入书名、作者、价格、库存数量
- 显示所有图书:以表格形式展示
- 查找图书:按书名关键词搜索
- 删除图书:按书名删除指定图书
- 保存到文件:退出时自动保存,启动时自动加载
- 退出系统
📚 图书管理系统 —— 完整代码
c/*
* 图书管理系统 - 完整实现
* 功能:添加 / 显示 / 查找 / 删除 / 文件持久化
* 编译:gcc book_manager.c -o book_manager
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// ====== 数据定义 ======
#define MAX_BOOKS 100 // 最大图书数量
#define NAME_LEN 50 // 书名最大长度
#define AUTHOR_LEN 30 // 作者名最大长度
#define DATA_FILE "books.dat" // 数据文件名
typedef struct {
int id; // 图书编号
char title[NAME_LEN]; // 书名
char author[AUTHOR_LEN]; // 作者
float price; // 价格
int stock; // 库存数量
} Book;
// ====== 全局变量 ======
Book books[MAX_BOOKS]; // 图书数组
int bookCount = 0; // 当前图书数量
// ====== 函数声明 ======
void showMenu();
void addBook();
void listBooks();
void searchBook();
void deleteBook();
void saveToFile();
void loadFromFile();
// ====== 主函数 ======
int main() {
loadFromFile(); // 启动时加载已有数据
int choice;
while (1) {
showMenu();
printf("请输入你的选择: ");
scanf("%d", &choice);
getchar(); // 吃掉回车
switch (choice) {
case 1: addBook(); break;
case 2: listBooks(); break;
case 3: searchBook(); break;
case 4: deleteBook(); break;
case 5:
saveToFile();
printf("感谢使用图书管理系统,再见!\n");
return 0;
default:
printf("无效选择,请重新输入(1-5)。\n");
}
printf("\n按回车键继续...");
getchar();
}
return 0;
}
// ====== 显示菜单 ======
void showMenu() {
system("clear"); // Windows 下改成 system("cls");
printf("╔══════════════════════════════╗\n");
printf("║ 📚 图书管理系统 ║\n");
printf("╠══════════════════════════════╣\n");
printf("║ 1. 添加图书 ║\n");
printf("║ 2. 显示所有图书 ║\n");
printf("║ 3. 查找图书 ║\n");
printf("║ 4. 删除图书 ║\n");
printf("║ 5. 保存并退出 ║\n");
printf("╚══════════════════════════════╝\n");
printf("当前藏书: %d 本\n\n", bookCount);
}
// ====== 添加图书 ======
void addBook() {
if (bookCount >= MAX_BOOKS) {
printf("❌ 库存已满,无法添加更多图书!\n");
return;
}
Book *b = &books[bookCount];
b->id = bookCount + 1;
printf("\n--- 添加新图书 ---\n");
printf("书名: ");
fgets(b->title, NAME_LEN, stdin);
b->title[strcspn(b->title, "\n")] = '\0'; // 去掉换行符
printf("作者: ");
fgets(b->author, AUTHOR_LEN, stdin);
b->author[strcspn(b->author, "\n")] = '\0';
printf("价格: ");
scanf("%f", &b->price);
printf("库存数量: ");
scanf("%d", &b->stock);
bookCount++;
printf("✅ 图书《%s》添加成功!编号: %d\n", b->title, b->id);
}
// ====== 显示所有图书 ======
void listBooks() {
if (bookCount == 0) {
printf("📭 当前没有任何图书。\n");
return;
}
printf("\n%s\n", "═══════════════════════════════════════════════════════════");
printf("%-4s %-20s %-12s %-8s %-6s\n", "编号", "书名", "作者", "价格", "库存");
printf("%s\n", "───────────────────────────────────────────────────────────");
for (int i = 0; i < bookCount; i++) {
printf("%-4d %-20s %-12s %-8.2f %-6d\n",
books[i].id,
books[i].title,
books[i].author,
books[i].price,
books[i].stock);
}
printf("%s\n", "═══════════════════════════════════════════════════════════");
printf("总计: %d 本图书\n", bookCount);
}
// ====== 查找图书 ======
void searchBook() {
if (bookCount == 0) {
printf("📭 当前没有任何图书。\n");
return;
}
char keyword[NAME_LEN];
printf("请输入书名关键词: ");
fgets(keyword, NAME_LEN, stdin);
keyword[strcspn(keyword, "\n")] = '\0';
int found = 0;
printf("\n🔍 搜索结果(关键词: %s):\n", keyword);
printf("%s\n", "───────────────────────────────────────────────────────");
printf("%-4s %-20s %-12s %-8s %-6s\n", "编号", "书名", "作者", "价格", "库存");
for (int i = 0; i < bookCount; i++) {
// 使用 strstr 判断书名是否包含关键词
if (strstr(books[i].title, keyword) != NULL) {
printf("%-4d %-20s %-12s %-8.2f %-6d\n",
books[i].id, books[i].title,
books[i].author, books[i].price, books[i].stock);
found = 1;
}
}
if (!found) {
printf("未找到包含"%s"的图书。\n", keyword);
}
}
// ====== 删除图书 ======
void deleteBook() {
if (bookCount == 0) {
printf("📭 当前没有任何图书。\n");
return;
}
char title[NAME_LEN];
printf("请输入要删除的书名: ");
fgets(title, NAME_LEN, stdin);
title[strcspn(title, "\n")] = '\0';
int foundIndex = -1;
for (int i = 0; i < bookCount; i++) {
if (strcmp(books[i].title, title) == 0) {
foundIndex = i;
break;
}
}
if (foundIndex == -1) {
printf("❌ 未找到《%s》这本书。\n", title);
return;
}
// 确认删除
printf("确定要删除《%s》吗?(y/n): ", books[foundIndex].title);
char confirm;
scanf(" %c", &confirm);
if (confirm != 'y' && confirm != 'Y') {
printf("已取消删除。\n");
return;
}
// 将后面的书往前移,覆盖掉要删除的
for (int i = foundIndex; i < bookCount - 1; i++) {
books[i] = books[i + 1];
}
bookCount--;
// 重新编号
for (int i = 0; i < bookCount; i++) {
books[i].id = i + 1;
}
printf("✅ 已删除《%s》。当前藏书 %d 本。\n", title, bookCount);
}
// ====== 保存到文件 ======
void saveToFile() {
FILE *fp = fopen(DATA_FILE, "w");
if (fp == NULL) {
printf("❌ 无法打开文件保存数据!\n");
return;
}
// 第一行写入总数
fprintf(fp, "%d\n", bookCount);
for (int i = 0; i < bookCount; i++) {
fprintf(fp, "%d|%s|%s|%.2f|%d\n",
books[i].id,
books[i].title,
books[i].author,
books[i].price,
books[i].stock);
}
fclose(fp);
printf("✅ 数据已保存到 %s(共 %d 本图书)\n", DATA_FILE, bookCount);
}
// ====== 从文件加载 ======
void loadFromFile() {
FILE *fp = fopen(DATA_FILE, "r");
if (fp == NULL) {
// 首次运行,没有数据文件,正常
return;
}
if (fscanf(fp, "%d\n", &bookCount) != 1) {
bookCount = 0;
fclose(fp);
return;
}
for (int i = 0; i < bookCount; i++) {
Book *b = &books[i];
// 用 | 分隔各字段来读取
fscanf(fp, "%d|%[^|]|%[^|]|%f|%d\n",
&b->id, b->title, b->author, &b->price, &b->stock);
}
fclose(fp);
printf("📖 已从 %s 加载 %d 本图书\n", DATA_FILE, bookCount);
}
💻 编译与运行
bash# 编译
gcc book_manager.c -o book_manager
# 运行
./book_manager
▶️ 运行效果展示
║ 📚 图书管理系统 ║
╠══════════════════════════════╣
║ 1. 添加图书 ║
║ 2. 显示所有图书 ║
║ 3. 查找图书 ║
║ 4. 删除图书 ║
║ 5. 保存并退出 ║
╚══════════════════════════════╝
当前藏书: 0 本
请输入你的选择: 1
--- 添加新图书 ---
书名: C语言入门到精通
作者: 张三
价格: 59.9
库存数量: 10
✅ 图书《C语言入门到精通》添加成功!编号: 1
--- 添加新图书 ---
书名: 数据结构与算法
作者: 李四
价格: 45.0
库存数量: 5
✅ 图书《数据结构与算法》添加成功!编号: 2
请输入你的选择: 2
═══════════════════════════════════════════════════════════
编号 书名 作者 价格 库存
───────────────────────────────────────────────────────────
1 C语言入门到精通 张三 59.90 10
2 数据结构与算法 李四 45.00 5
═══════════════════════════════════════════════════════════
总计: 2 本图书
请输入你的选择: 5
✅ 数据已保存到 books.dat(共 2 本图书)
感谢使用图书管理系统,再见!
代码设计解析
| 模块 | 技术点 | 对应章节 |
|---|---|---|
| 图书结构体 Book | struct + typedef | 第11章 |
| 书本数组 books[MAX_BOOKS] | 结构体数组 | 第11章 |
| 添加/删除时数组移位 | 数组元素移动 | 第6章 |
| fgets 输入字符串 | 安全的字符串输入 | 第9章 |
| strstr / strcmp 字符串操作 | 字符串函数 | 第9章 |
| saveToFile / loadFromFile | 文件读写(fprintf/fscanf) | 第14章 |
| 分隔符格式存储 | 文件格式设计(|分隔) | 第14章 |
| 函数模块化 | 函数封装 | 第5章 |
| 全局变量 bookCount | 全局变量 | 第5章 |
挑战一下
如果你觉得这个项目不过瘾,试试加入这些功能:
- 修改图书信息:修改指定图书的价格或库存
- 借书/还书:扣减/增加库存,添加借阅记录
- 排序功能:按价格从低到高排序,或按书名排序
- 统计功能:总藏书量、总价值(价格×库存)、平均价格
- 分页显示:如果图书很多,每页只显示10本
- 改用动态内存:用
malloc/realloc代替固定数组
🎉 恭喜你完成了C语言入门之旅!
通过这16章的学习,你已经掌握了C语言的核心知识:
- 变量、数据类型、运算符
- 流程控制(if、switch、循环)
- 数组、函数、指针
- 字符串、结构体
- 动态内存管理
- 文件操作
- 预处理与宏
下一步可以学习:链表、数据结构、Linux系统编程 或 算法竞赛入门。继续加油!🚀