想象一下,我们身处的数字世界,如同一座座宏伟的建筑。操作系统、编译器、数据库、嵌入式设备乃至绚丽的游戏引擎,它们都是这座大厦的重要组成部分。而C语言,正是构建这一切的坚固基石。自丹尼斯·里奇于贝尔实验室孕育出这颗编程界的明星以来,C语言凭借其高效性、灵活性以及对计算机底层那份极致的掌控力,历经数十载风雨洗礼,依旧是无数开发者心中的不二之选。
学习C语言,不仅仅是掌握一门编程技能,更是一次深入探索计算机灵魂的旅程。它能让你理解内存如何管理、程序如何与硬件交互,为你后续学习其他高级语言、深入计算机体系结构打下坚实的基础。本教程将引领你,从C语言的基础语法出发,逐步揭开其神秘面纱,助你在这条充满挑战与机遇的道路上稳步前行。
一、初识C语言:搭建你的第一个“Hello, World!”
万丈高楼平地起,我们从最经典的程序开始。
1.1 第一个C程序:向世界问好
#include <stdio.h> // 引入标准输入输出头文件
// main函数是程序的入口点,操作系统会从这里开始执行
int main() {
// printf是一个标准库函数,用于向控制台输出文本
// "\n" 是一个转义字符,表示换行
printf("Hello, World!\n");
// main函数返回0表示程序正常结束
return 0;
}
代码解析:
-
#include <stdio.h>
:这是一个预处理指令。它告诉编译器在实际编译之前,将stdio.h
(标准输入输出头文件)的内容包含进来。这个头文件里声明了我们后面用到的printf
等函数。 -
int main()
:这是C程序的主函数,每个C程序都必须有且只有一个main
函数。程序从main
函数开始执行,到main
函数结束。int
表示main
函数执行完毕后会返回一个整数值给操作系统。 -
printf("Hello, World!\n");
:调用printf
函数,将引号内的字符串输出到屏幕上。\n
是一个特殊的字符,代表换行。 -
return 0;
:表示main
函数执行成功并正常退出。通常,返回0代表成功,非0代表出现某种错误。 -
//
和/* ... */
:这些是注释。//
用于单行注释,/* ... */
用于多行注释。注释是给程序员看的,编译器会忽略它们。
1.2 开发环境的选择与搭建
要编译和运行C代码,你需要一个C编译器和开发环境。
-
编译器:
-
GCC (GNU Compiler Collection):Linux和macOS上最常用的开源编译器,也可通过MinGW/Cygwin在Windows上使用。
-
Clang:LLVM项目的一部分,以其快速编译和优秀的错误提示著称。
-
Visual C++ (MSVC):微软Windows平台下的编译器,集成在Visual Studio中。
-
-
集成开发环境 (IDE):
-
Visual Studio Code (VS Code):轻量级且强大的跨平台编辑器,通过插件支持C/C++开发。
-
Visual Studio:功能全面的Windows平台IDE。
-
CLion:JetBrains出品的专业C/C++跨平台IDE。
-
Dev-C++:简单易用的Windows平台IDE,适合初学者快速上手。
-
选择一个你用着顺手的即可。对于初学者,VS Code配合GCC/Clang或者Dev-C++都是不错的选择。
1.3 从源代码到可执行程序:编译与链接之旅
当你写完C代码(.c
文件)后,它并不能直接运行,需要经历以下步骤:
-
预处理 (Preprocessing):处理
#include
,#define
等预处理指令,展开宏,删除注释等。生成.i
文件。 -
编译 (Compilation):将预处理后的代码转换成汇编语言。生成
.s
文件。 -
汇编 (Assembly):将汇编代码转换成机器可以执行的二进制指令(目标代码)。生成
.o
或.obj
文件。 -
链接 (Linking):将你的目标代码和程序中用到的库函数(比如
printf
)的目标代码组合起来,生成最终的可执行文件(如Windows下的.exe
或Linux下的无后缀文件)。
二、C语言的基石:数据类型与变量
程序处理的是数据,而数据有不同的类型。
2.1 基本数据类型:构建数据的砖瓦
C语言提供了多种基本数据类型来存储不同种类的数据:
类型 | 关键字 | 大致字节数 (常见32/64位系统) | 典型范围 (有符号signed) | 用途描述 |
---|---|---|---|---|
字符型 |
| 1 | -128 ~ 127 | 存储单个字符 |
短整型 |
| 2 | -32,768 ~ 32,767 | 存储较小范围整数 |
整型 |
| 4 | -2,147,483,648 ~ 2,147,483,647 | 常用的整数类型 |
长整型 |
| 4 或 8 | 依赖系统和编译器 | 存储较大范围整数 |
更长整型 |
| 8 | 约 -9x10^18 ~ 9x10^18 | 存储非常大整数 |
单精度浮点型 |
| 4 | 约 ±3.4e±38 (6-7位有效数字) | 存储带小数的数 |
双精度浮点型 |
| 8 | 约 ±1.7e±308 (15-16位有效数字) | 存储更高精度小数 |
无类型 |
| N/A | N/A | 特殊用途,如指针 |
注意:long
的大小在不同系统和编译器下可能不同(32位系统通常4字节,64位系统通常8字节)。可以使用sizeof
运算符来查看特定类型在当前系统上占用的字节数。
2.2 类型修饰符:数据的更多面貌
-
signed
:表示有符号数(可以表示正、负、零),对char
和整型类型默认即为signed
。 -
unsigned
:表示无符号数(只能表示非负数)。同样的字节数,无符号类型可以表示更大的正数范围。例如unsigned int
。unsigned char u_char_val = 200; // 范围 0 ~ 255 signed char s_char_val = -100; // 范围 -128 ~ 127
-
const
:定义常量,其值在初始化后不能被修改。const double PI = 3.14159; // PI = 3.14; // 错误!PI是常量,不可修改
2.3 变量:存储数据的容器
变量是内存中用于存储数据的一块具名空间。
-
声明:告诉编译器变量的名字和类型。
数据类型 变量名;
-
初始化:在声明变量时给它一个初始值。
数据类型 变量名 = 初始值;
#include <stdio.h>
int main() {
int age; // 声明一个整型变量 age
age = 30; // 给 age 赋值
float salary = 5000.50f; // 声明并初始化一个浮点型变量 salary (f后缀表示float)
char grade = 'A'; // 声明并初始化一个字符型变量 grade (单引号括起字符)
// 变量在使用前通常需要初始化,否则其值是不确定的(垃圾值)
int uninitialized_var;
// printf("%d\n", uninitialized_var); // 行为未定义,可能输出任意值
printf("年龄: %d\n", age);
printf("薪水: %.2f\n", salary); // .2f 表示保留两位小数
printf("等级: %c\n", grade);
return 0;
}
2.4 常量:不变的值
-
#define
宏常量 (预处理指令):在预处理阶段进行文本替换。#define MAX_USERS 100 int users[MAX_USERS]; // 预处理后变为 int users[100];
-
const
限定符:定义类型化的常量,编译器会进行类型检查。更推荐使用const
。const int MAX_SCORE = 100; // MAX_SCORE = 99; // 编译错误
三、运算符与表达式:数据的加工厂
运算符用于对数据进行操作,表达式则是由数据和运算符组成的计算式。
3.1 算术运算符
+
(加), -
(减), *
(乘), /
(除), %
(取模/取余数)
int a = 10, b = 3;
printf("a + b = %d\n", a + b); // 13
printf("a / b = %d\n", a / b); // 3 (整数除法,结果截断小数)
printf("a %% b = %d\n", a % b); // 1 (10除以3的余数)
float c = 10.0f, d = 3.0f;
printf("c / d = %f\n", c / d); // 3.333333 (浮点数除法)
自增 ++
和自减 --
:
-
前缀:
++a
(先自增,后使用新值) -
后缀:
a++
(先使用原值,后自增)
int x = 5;
int y = ++x; // x先变成6,然后y被赋值为6。此时 x=6, y=6
int z = x++; // z先被赋值为6(x的当前值),然后x变成7。此时 x=7, z=6
printf("x=%d, y=%d, z=%d\n", x, y, z); // 输出 x=7, y=6, z=6
3.2 关系运算符
用于比较两个值,结果为真(1)或假(0)。 ==
(等于), !=
(不等于), >
(大于), <
(小于), >=
(大于等于), <=
(小于等于)
int num1 = 5, num2 = 10;
printf("num1 == num2 is %d\n", num1 == num2); // 0 (假)
printf("num1 < num2 is %d\n", num1 < num2); // 1 (真)
3.3 逻辑运算符
用于连接或修改关系表达式,结果也为真(1)或假(0)。
-
&&
(逻辑与):两边都为真,结果才为真。 -
||
(逻辑或):一边为真,结果就为真。 -
!
(逻辑非):真变假,假变真。
int age = 25;
float height = 1.75f;
// 年龄大于18 并且 身高大于1.7
if (age > 18 && height > 1.7) {
printf("符合条件\n");
}
短路求值:
-
对于
A && B
,如果A为假,则B不会被求值。 -
对于
A || B
,如果A为真,则B不会被求值。
3.4 位运算符 (了解即可,进阶内容)
直接对数据的二进制位进行操作。 &
(按位与), |
(按位或), ^
(按位异或), ~
(按位取反), <<
(左移), >>
(右移)
3.5 赋值运算符
=
(简单赋值), +=
, -=
, *=
, /=
, %=
(复合赋值) a += 5;
等价于 a = a + 5;
3.6 条件运算符 (三目运算符)
C语言中唯一的三目运算符:条件 ? 表达式1 : 表达式2
如果条件为真,则整个表达式的值为表达式1的值;否则为表达式2的值。
int score = 85;
char grade = (score >= 60) ? 'P' : 'F'; // P (Pass), F (Fail)
printf("成绩等级: %c\n", grade); // 输出 P
优点:简洁。缺点:嵌套过多时可读性差,不宜滥用。
3.7 sizeof
运算符
返回一个类型或一个变量所占用的内存字节数。它是一个编译时运算符(大多数情况)。
printf("sizeof(int) = %zu bytes\n", sizeof(int));
printf("sizeof(double) = %zu bytes\n", sizeof(double));
int arr[10];
printf("sizeof(arr) = %zu bytes\n", sizeof(arr)); // 输出 10 * sizeof(int)
```%zu` 是 `sizeof` 结果的推荐格式说明符。
### 3.8 运算符优先级与结合性
当一个表达式中包含多个运算符时,优先级决定了运算顺序(类似数学中的先乘除后加减)。结合性决定了相同优先级的运算符从左到右还是从右到左执行。
**经验法则**:不确定优先级时,多用括号 `()` 来明确运算顺序,提高代码可读性。
例如,`*p++` 和 `(*p)++` 是面试中常考的:
* `*p++`:后缀`++`优先级高于`*`,且结合性从左到右。相当于 `*(p++)`。它先取得`p`指向的值,然后`p`指针自增(指向下一个元素)。
* `(*p)++`:括号优先级最高。它先取得`p`指向的值,然后对这个值进行自增操作。`p`指针本身不移动。
---
## 四、程序的眼睛和嘴巴:标准输入与输出
程序需要与用户交互,接收输入并展示结果。
### 4.1 标准输出 `printf()`
我们已经多次使用过`printf`函数了。它的原型在`<stdio.h>`中。
`printf("格式控制字符串", 参数列表);`
**格式控制符**:
* `%d` 或 `%i`:输出有符号十进制整数。
* `%u`:输出无符号十进制整数。
* `%f`:输出浮点数 (默认6位小数)。
* `%.2f`:输出浮点数,保留2位小数。
* `%e` 或 `%E`:以科学计数法输出浮点数。
* `%c`:输出单个字符。
* `%s`:输出字符串 (字符数组,直到遇到`\0`)。
* `%p`:以十六进制形式输出指针地址。
* `%x` 或 `%X`:以十六进制形式输出无符号整数。
* `%%`:输出一个 `%` 字符。
```c
#include <stdio.h>
int main() {
int item_count = 10;
float price = 19.99f;
char item_name[] = "C语言教程"; // 字符串本质是字符数组
printf("商品名称: %s\n", item_name);
printf("数量: %d\n", item_count);
printf("单价: %.2f 元\n", price);
printf("总价: %.2f 元\n", item_count * price);
printf("商品地址(内存中): %p\n", (void*)item_name);
return 0;
}
4.2 标准输入 scanf()
用于从键盘读取用户输入。 scanf("格式控制字符串", &变量1的地址, &变量2的地址, ...);
关键点:
-
scanf
的参数必须是变量的地址,所以变量名前要加取地址符&
。 (数组名本身代表地址,通常不需要&
)。 -
输入数据时,各项数据之间默认用空格、制表符或回车分隔。
-
scanf
在遇到不匹配的输入时可能会停止读取。 -
安全隐患:
scanf("%s", str)
读取字符串时,若输入过长,会导致缓冲区溢出,非常危险。应使用限制长度的读取方式或fgets
。
#include <stdio.h>
int main() {
int age;
float height;
char name[50]; // 字符数组用于存储字符串
printf("请输入您的姓名: ");
scanf("%s", name); // 读取字符串时,name是数组名,代表地址,不用&
// 注意:这里有缓冲区溢出风险!
printf("请输入您的年龄和身高 (用空格隔开): ");
scanf("%d %f", &age, &height); // 读取整数和浮点数,需要&
printf("\n--- 您的信息 ---\n");
printf("姓名: %s\n", name);
printf("年龄: %d 岁\n", age);
printf("身高: %.2f 米\n", height);
// 更安全的字符串输入方式
char safe_name[50];
printf("\n再次输入您的姓名 (安全方式): ");
// fgets会读取换行符,如果不需要可以处理掉
if (fgets(safe_name, sizeof(safe_name), stdin) != NULL) {
// 移除可能存在的换行符
// size_t len = strlen(safe_name);
// if (len > 0 && safe_name[len-1] == '\n') {
// safe_name[len-1] = '\0';
// }
printf("安全获取的姓名: %s\n", safe_name);
}
return 0;
}
关于scanf
的返回值:scanf
返回成功读取并赋值的参数个数。如果发生错误或到达文件末尾,会返回EOF
。检查scanf
的返回值是一个好习惯。
4.3 字符输入输出
-
getchar()
:从标准输入读取一个字符,返回其ASCII码(int
类型),或在出错/文件结束时返回EOF
。 -
putchar(int c)
:将字符c
(实际是其ASCII码)输出到标准输出。
#include <stdio.h>
int main() {
char ch;
printf("请输入一个字符: ");
ch = getchar(); // 读取一个字符
printf("您输入的字符是: ");
putchar(ch); // 输出该字符
putchar('\n'); // 输出换行
return 0;
}
注意清空输入缓冲区:连续使用scanf
和getchar
时,scanf
可能会留下未读取的换行符在缓冲区中,影响后续getchar
的读取。例如 scanf("%d", &num); char c = getchar();
c可能会直接读到换行符。
五、程序的骨架:流程控制语句
流程控制语句决定了代码的执行顺序。
5.1 条件判断:if-else
语句
根据条件是否为真来执行不同的代码块。
if (条件1) {
// 条件1为真时执行
} else if (条件2) {
// 条件1为假,且条件2为真时执行
} else {
// 以上条件都为假时执行
}
示例:判断一个数的奇偶性
#include <stdio.h>
int main() {
int number;
printf("请输入一个整数: ");
scanf("%d", &number);
if (number % 2 == 0) {
printf("%d 是偶数。\n", number);
} else {
printf("%d 是奇数。\n", number);
}
return 0;
}
5.2 多路选择:switch-case
语句
基于一个表达式的值,选择执行多个代码块中的一个。
switch (表达式) {
case 常量值1:
// 代码块1
break; // 非常重要!防止"穿透"到下一个case
case 常量值2:
// 代码块2
break;
// ...更多case...
default: // 可选,当所有case都不匹配时执行
// 默认代码块
}
关键点:
-
switch
的表达式通常是整型或字符型。 -
每个
case
后的常量值必须是唯一的。 -
break
语句用于跳出switch
结构。如果没有break
,程序会继续执行后续case
中的代码,这称为“case穿透”,有时可以巧妙利用,但多数情况是错误源头。 -
default
子句是可选的,用于处理所有case
都不匹配的情况。
示例:根据数字输出星期几
#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: printf("星期六\n"); break;
case 7: printf("星期日\n"); break;
default: printf("输入错误,请输入1到7之间的数字。\n");
}
return 0;
}
5.3 循环执行:for
循环
当循环次数已知或有明确的计数器时,常使用for
循环。 for (初始化表达式; 条件表达式; 更新表达式) { // 循环体 }
执行顺序:
-
执行初始化表达式(仅一次)。
-
判断条件表达式:
-
若为真,执行循环体,然后执行更新表达式,再回到步骤2。
-
若为假,循环结束。
-
示例:打印1到5的数字
#include <stdio.h>
int main() {
for (int i = 1; i <= 5; i++) { // i从1开始,每次循环i增加1,直到i大于5
printf("%d ", i);
}
printf("\n"); // 输出: 1 2 3 4 5
return 0;
}
5.4 条件循环:while
循环
当循环条件满足时,重复执行循环体。 while (条件表达式) { // 循环体 }
执行顺序:
-
判断条件表达式:
-
若为真,执行循环体,再回到步骤1。
-
若为假,循环结束。 注意:循环体内部必须有改变条件表达式真假性的操作,否则可能造成死循环。
-
示例:计算1到100的和
#include <stdio.h>
int main() {
int sum = 0;
int i = 1;
while (i <= 100) {
sum += i; // sum = sum + i;
i++; // 更新循环变量
}
printf("1到100的和是: %d\n", sum); // 输出 5050
return 0;
}
5.5 先执行后判断:do-while
循环
与while
类似,但它至少会执行一次循环体,然后再判断条件。 do { // 循环体 } while (条件表达式);
注意末尾的分号!
示例:用户输入验证,至少输入一次
#include <stdio.h>
int main() {
int number;
do {
printf("请输入一个正整数: ");
scanf("%d", &number);
if (number <= 0) {
printf("输入无效,请重新输入。\n");
}
} while (number <= 0); // 如果输入的数非正,则继续循环
printf("您输入的正整数是: %d\n", number);
return 0;
}
5.6 循环控制语句
-
break
:立即跳出当前所在的整个循环(for
,while
,do-while
,switch
)。 -
continue
:立即结束本次循环迭代,跳过循环体中continue
之后的语句,直接开始下一次迭代的条件判断。 -
goto 标签;
:(强烈不推荐滥用)无条件跳转到程序中带有标签:
的语句处。滥用goto
会使程序流程混乱,难以阅读和维护,应尽量避免。
#include <stdio.h>
int main() {
printf("break示例:\n");
for (int i = 1; i <= 10; i++) {
if (i == 5) {
break; // 当i等于5时,跳出循环
}
printf("%d ", i); // 输出: 1 2 3 4
}
printf("\n\ncontinue示例:\n");
for (int i = 1; i <= 5; i++) {
if (i == 3) {
continue; // 当i等于3时,跳过本次迭代的printf
}
printf("%d ", i); // 输出: 1 2 4 5
}
printf("\n");
return 0;
}
基础的招式我们已经演练完毕,但这仅仅是C语言这座武学殿堂的门槛。接下来,我们将深入更为核心和强大的领域:数组与字符串的灵活运用,指针的精妙操控(C语言的灵魂所在!),函数的模块化设计,内存管理的深层智慧,以及如何通过结构体与联合体构建复杂数据,如何进行文件操作与外部世界交互,最后还有预处理指令的编译魔法。准备好迎接更精彩的挑战了吗?让我们继续C语言的深度探索之旅!
—————————————————————————————————更新于2025年5.29号
六、组织数据的力量:数组与字符串
当我们需要处理一组相同类型的数据时,数组就派上用场了。
6.1 一维数组:线性数据的集合
数组是一个固定大小的、存储相同类型元素的连续内存区域。
-
声明:
数据类型 数组名[数组大小];
int scores[5]; // 声明一个包含5个整数的数组,用于存储成绩 char message[100]; // 声明一个包含100个字符的数组,用于存储信息
-
初始化:
// 完整初始化 int numbers[5] = {10, 20, 30, 40, 50}; // 部分初始化,其余元素自动初始化为0 (对于数值类型) 或 '\0' (对于char类型) float prices[3] = {9.99f, 15.50f}; // prices[2] 会是 0.0f // 根据初始化列表自动确定大小 char vowels[] = {'a', 'e', 'i', 'o', 'u', '\0'}; // 大小为6,包含末尾的空字符
-
访问元素:通过下标(索引)访问,下标从0开始。
数组名[下标]
numbers[0] = 15; // 修改第一个元素的值 printf("第三个分数是: %d\n", scores[2]); // 访问第三个元素
-
遍历数组:通常使用
for
循环。#include <stdio.h> int main() { int data[4] = {5, 8, 12, 9}; printf("数组元素: "); for (int i = 0; i < 4; i++) { // 下标从0到3 printf("%d ", data[i]); } printf("\n"); return 0; }
重要:数组越界 C语言不进行数组边界检查。如果你试图访问arr[5]
在一个大小为5的数组中(有效下标0-4),这属于越界访问。结果是未定义的,可能导致程序崩溃或数据损坏。这是C语言中常见的错误来源。
6.2 二维数组:表格数据的表示
可以看作是“数组的数组”,常用于表示矩阵或表格。
-
声明:
数据类型 数组名[行数][列数];
int matrix[3][4]; // 声明一个3行4列的整型二维数组
-
初始化:
int table[2][3] = { {1, 2, 3}, // 第0行 {4, 5, 6} // 第1行 }; // 也可以不完全初始化 int partial_table[2][3] = {{10}, {20, 21}}; // partial_table[0][0]=10, partial_table[0][1]=0, partial_table[0][2]=0 // partial_table[1][0]=20, partial_table[1][1]=21, partial_table[1][2]=0
-
访问元素:
数组名[行下标][列下标]
table[1][0] = 40; // 修改第1行第0列的元素 printf("元素 (0,2) 是: %d\n", table[0][2]); // 输出 3
-
遍历二维数组:通常使用嵌套
for
循环。#include <stdio.h> int main() { int identity_matrix[3][3] = { {1, 0, 0}, {0, 1, 0}, {0, 0, 1} }; printf("单位矩阵:\n"); for (int i = 0; i < 3; i++) { // 遍历行 for (int j = 0; j < 3; j++) { // 遍历列 printf("%d ", identity_matrix[i][j]); } printf("\n"); // 每行结束后换行 } return 0; }
多维数组(三维及以上)也是类似的,但实际应用中不如一维和二维常见。
6.3 字符数组与字符串:文本的表示
在C语言中,字符串是以空字符 \0
(null terminator) 结尾的字符数组。
-
声明与初始化:
char greeting[6] = {'H', 'e', 'l', 'l', 'o', '\0'}; // 必须手动添加'\0' char farewell[] = "Goodbye"; // 使用字符串字面量初始化,编译器会自动添加'\0' // farewell的大小是8 (G,o,o,d,b,y,e,\0) char empty_str[10] = ""; // 初始化为空字符串,empty_str[0] = '\0'
-
字符串操作函数 (
<string.h>
):C标准库提供了一系列处理字符串的函数,需要包含头文件string.h
。-
strlen(const char *str)
:返回字符串的长度(不包括末尾的\0
)。 -
strcpy(char *dest, const char *src)
:将源字符串src
复制到目标字符串dest
(包括\0
)。不安全,可能导致缓冲区溢出。 -
strncpy(char *dest, const char *src, size_t n)
:最多复制n
个字符。如果src
长度小于n
,则用\0
填充dest
的剩余部分;如果src
长度大于等于n
,则复制的dest
可能不以\0
结尾,需要手动确保。 -
strcat(char *dest, const char *src)
:将源字符串src
拼接到目标字符串dest
的末尾。不安全。 -
strncat(char *dest, const char *src, size_t n)
:从src
最多拼接n
个字符到dest
,并总是在末尾添加\0
。 -
strcmp(const char *s1, const char *s2)
:比较两个字符串。-
若
s1 < s2
,返回负数。 -
若
s1 == s2
,返回0。 -
若
s1 > s2
,返回正数。
-
-
strchr(const char *str, int c)
:在字符串str
中查找字符c
首次出现的位置,返回指向该位置的指针,未找到则返回NULL
。 -
strstr(const char *haystack, const char *needle)
:在字符串haystack
中查找子串needle
首次出现的位置,返回指向该位置的指针,未找到则返回NULL
。
-
#include <stdio.h>
#include <string.h> // 引入字符串函数库
int main() {
char str1[20] = "Hello";
char str2[] = "World";
char result[50];
printf("str1: \"%s\", 长度: %zu\n", str1, strlen(str1)); // 输出 "Hello", 长度: 5
strcpy(result, str1); // result 现在是 "Hello"
strcat(result, " "); // result 现在是 "Hello "
strcat(result, str2); // result 现在是 "Hello World"
printf("拼接结果: \"%s\"\n", result);
if (strcmp(str1, "hello") == 0) { // 注意:strcmp是区分大小写的
printf("str1 和 \"hello\" 相等\n");
} else {
printf("str1 和 \"hello\" 不相等\n");
}
// 更安全的字符串复制
char safe_copy[10];
strncpy(safe_copy, "ThisIsALongString", sizeof(safe_copy) - 1);
safe_copy[sizeof(safe_copy) - 1] = '\0'; // 确保以空字符结尾
printf("安全复制 (strncpy): \"%s\"\n", safe_copy);
char sentence[] = "C programming is fun!";
char *substring = strstr(sentence, "is fun");
if (substring != NULL) {
printf("找到子串: \"%s\" 在位置: %ld\n", substring, substring - sentence);
}
return 0;
}
安全第一:在使用strcpy
, strcat
等函数时,务必确保目标缓冲区有足够的空间容纳源字符串以及末尾的\0
,否则极易引发缓冲区溢出,这是一个严重的安全漏洞。优先使用strncpy
, strncat
, snprintf
等更安全的版本。
七、C语言的灵魂:指针的奥秘
指针是C语言最强大也是最容易出错的特性。它允许我们直接操作内存地址,赋予了程序极大的灵活性。
7.1 什么是指针?
指针 (Pointer) 是一个变量,其值为另一个变量的内存地址。 就像门牌号指向一个具体的房子一样,指针变量存储的是某个数据在内存中的“门牌号”。
7.2 指针的声明与初始化
-
声明指针变量:
数据类型 *指针变量名;
-
数据类型
:表示该指针将指向何种类型的数据。 -
*
:表明这是一个指针变量。
int *p_int; // 声明一个指向整型变量的指针 p_int float *p_float; // 声明一个指向浮点型变量的指针 p_float char *p_char; // 声明一个指向字符型变量的指针 p_char
-
-
取地址运算符
&
:获取一个变量的内存地址。 -
初始化指针:将一个变量的地址赋给指针变量。
int age = 30; int *ptr_age; // 声明指针 ptr_age = &age; // 将age的地址赋给ptr_age,此时ptr_age指向age double salary = 6000.0; double *ptr_salary = &salary; // 声明并同时初始化
-
空指针
NULL
:一个特殊的指针值,表示该指针不指向任何有效的内存地址。初始化指针或在指针不再使用时将其设为NULL
是个好习惯,可以防止野指针。int *safe_ptr = NULL;
7.3 解引用运算符 *
(间接寻址运算符)
用于访问指针所指向的内存地址中存储的值。
#include <stdio.h>
int main() {
int var = 100;
int *ptr = &var; // ptr 存储 var 的地址
printf("变量 var 的值: %d\n", var);
printf("指针 ptr 存储的地址: %p\n", (void*)ptr); // %p 用于打印地址,通常强转为void*
printf("指针 ptr 指向的值 (*ptr): %d\n", *ptr); // 解引用,获取var的值
*ptr = 200; // 通过指针修改 var 的值
printf("通过指针修改后,var 的值: %d\n", var); // 输出 200
return 0;
}
*
的双重含义:
-
在声明指针时,
*
表示这是一个指针变量 (如int *p;
)。 -
在已声明的指针变量前,
*
表示解引用操作 (如*p = 10;
)。
7.4 指针与数组:天作之合
数组名在大多数表达式中会自动退化 (decay) 为指向其首元素的指针。
#include <stdio.h>
int main() {
int arr[5] = {10, 20, 30, 40, 50};
int *p_arr;
p_arr = arr; // 数组名arr退化为指向arr[0]的指针,等价于 p_arr = &arr[0];
printf("第一个元素 (通过数组名): %d\n", arr[0]);
printf("第一个元素 (通过指针解引用): %d\n", *p_arr);
// 指针算术:p_arr + i 指向 arr[i]
printf("第二个元素 (通过指针算术): %d\n", *(p_arr + 1)); // 等价于 arr[1]
printf("第三个元素 (通过指针算术): %d\n", *(p_arr + 2)); // 等价于 arr[2]
// 使用指针遍历数组
printf("使用指针遍历数组: ");
for (int i = 0; i < 5; i++) {
printf("%d ", *(p_arr + i));
// 也可以写成 p_arr[i],当p_arr指向数组首元素时,这是等价的
// printf("%d ", p_arr[i]);
}
printf("\n");
// 修改数组元素通过指针
*(p_arr + 3) = 45; // 修改 arr[3]
printf("修改后的arr[3]: %d\n", arr[3]); // 输出 45
return 0;
}
指针算术:
-
指针加(减)一个整数
n
,实际上是将指针向前(后)移动n * sizeof(指针指向的数据类型)
个字节。 -
两个指向同一数组的指针可以相减,结果是它们之间元素的个数。
数组名 vs 指针变量: 虽然数组名常退化为指针,但它们并不完全相同:
-
sizeof
运算符:sizeof(数组名)
返回整个数组所占的字节数。sizeof(指针变量)
返回指针变量自身的大小(通常是4或8字节)。 -
赋值:数组名是常量地址,不能被赋值 (如
arr = new_arr_addr;
是错误的)。指针变量可以被赋予新的地址。 -
取地址
&
:&数组名
得到的是指向整个数组的指针,其类型是数据类型 (*)[数组大小]
。&指针变量
得到的是指向该指针变量本身的指针(二级指针)。
7.5 指针与字符串
字符串字面量 (如 "Hello"
) 在内存中通常存储在只读区域,返回的是指向其第一个字符的char*
指针。
#include <stdio.h>
int main() {
char *message = "Welcome to C!"; // message 指向字符串 'W' 的地址
printf("字符串: %s\n", message);
printf("第一个字符: %c\n", *message); // 'W'
printf("第二个字符: %c\n", *(message + 1)); // 'e'
// 遍历字符串直到遇到空字符 '\0'
char *temp_ptr = message;
printf("逐个字符打印: ");
while (*temp_ptr != '\0') { // 或直接 while(*temp_ptr)
printf("%c", *temp_ptr);
temp_ptr++; // 指针移向下一个字符
}
printf("\n");
// 尝试修改字符串字面量通常会导致运行时错误(段错误)
// *(message + 0) = 'w'; // 危险操作!
char modifiable_str[] = "This can be changed.";
char *ptr_to_modifiable = modifiable_str;
*(ptr_to_modifiable + 0) = 't'; // 这是合法的,因为修改的是数组内容
printf("修改后的数组字符串: %s\n", modifiable_str);
return 0;
}
7.6 void*
指针:通用指针
void*
是一种特殊的指针类型,可以指向任何类型的数据,但不能直接解引用。在解引用前必须将其强制转换为具体的类型指针。常用于需要处理未知类型数据的函数接口,如 malloc
的返回值,memcpy
, memset
的参数。
#include <stdio.h>
#include <stdlib.h>
int main() {
void *generic_ptr;
int i_val = 10;
float f_val = 3.14f;
generic_ptr = &i_val;
// printf("%d\n", *generic_ptr); // 错误!不能直接解引用 void*
printf("指向int的值: %d\n", *((int*)generic_ptr)); // 强制转换为 int* 后解引用
generic_ptr = &f_val;
printf("指向float的值: %.2f\n", *((float*)generic_ptr));
// malloc 返回 void*
int *dynamic_arr = (int*)malloc(5 * sizeof(int));
if (dynamic_arr != NULL) {
printf("动态分配的内存地址 (void*): %p\n", (void*)dynamic_arr);
free(dynamic_arr);
}
return 0;
}
7.7 指针的陷阱:野指针与内存泄漏 (面试常客)
这是C语言中最具挑战性的部分,也是bug的主要来源。
-
野指针 (Dangling Pointer / Wild Pointer):指向一个不再有效(已被释放、超出作用域或未初始化)的内存区域的指针。
-
成因1:未初始化指针
int *wild_p1; // *wild_p1 = 10; // 灾难!wild_p1 指向未知内存
-
成因2:访问已释放 (
free
) 的内存int *p = (int*)malloc(sizeof(int)); free(p); // *p = 20; // 灾难!p现在是悬空指针
-
成因3:返回局部变量的地址
int* get_local_address() { int local_var = 100; return &local_var; // 错误!local_var在函数返回后销毁,地址失效 } // int *dangling_ptr = get_local_address(); // printf("%d\n", *dangling_ptr); // 访问悬空指针
-
后果:程序崩溃、数据损坏、行为不可预测。
-
避免:
-
声明指针时立即初始化,若无明确指向,则初始化为
NULL
。 -
内存被
free
后,立即将相关指针置为NULL
。 -
绝对不要从函数返回局部变量的地址。如果需要返回动态分配的内存,调用者负责释放。
-
-
-
空指针解引用 (Null Pointer Dereference):试图访问
NULL
指针指向的内存。int *null_p = NULL; // *null_p = 5; // 灾难!通常导致程序立即崩溃(段错误)
-
避免:在使用指针前,务必检查其是否为
NULL
。if (some_ptr != NULL) { // 安全使用 some_ptr }
-
-
内存泄漏 (Memory Leak):动态分配的内存(堆内存)在使用完毕后未被正确释放,导致这部分内存无法再被程序或其他程序使用。
-
成因:忘记调用
free()
,或丢失了指向已分配内存的唯一指针。 -
后果:程序长时间运行后可用内存逐渐减少,最终可能导致性能下降甚至系统崩溃。
-
避免:确保每一次
malloc
/calloc
/realloc
都有对应的free
。尤其在复杂的逻辑分支和错误处理中要特别小心。使用工具如 Valgrind 可以帮助检测内存泄漏。
-
7.8 函数指针:指向代码的指针
指针不仅可以指向数据,还可以指向函数。函数指针存储的是函数的入口地址。
-
声明:
返回类型 (*指针变量名)(参数类型列表);
int add(int a, int b) { return a + b; } void greet(char *name) { printf("Hello, %s!\n", name); } int (*op_func_ptr)(int, int); // 声明一个函数指针,指向返回int并接受两个int参数的函数 void (*greet_func_ptr)(char*);
-
赋值与调用:
op_func_ptr = add; // 或 op_func_ptr = &add; 函数名即地址 int result = op_func_ptr(10, 5); // 或 (*op_func_ptr)(10, 5); printf("Result: %d\n", result); // 输出 15 greet_func_ptr = greet; greet_func_ptr("Alice"); // 输出 Hello, Alice!
-
应用:回调函数 (Callback) 将一个函数指针作为参数传递给另一个函数,在被调函数执行的某个时刻,通过该函数指针调用外部函数。这是实现通用算法、事件驱动、插件机制等高级功能的关键。
#include <stdio.h> // 定义一个函数类型,方便声明函数指针 typedef int (*MathOperation)(int, int); int perform_op(int x, int y, MathOperation operation) { return operation(x, y); // 通过函数指针回调传入的函数 } int sum(int a, int b) { return a + b; } int product(int a, int b) { return a * b; } int main() { int res1 = perform_op(20, 7, sum); printf("Sum = %d\n", res1); // 输出 27 int res2 = perform_op(20, 7, product); printf("Product = %d\n", res2); // 输出 140 return 0; }
7.9 多级指针 (指针的指针)
一个指针可以指向另一个指针,形成多级指针。 数据类型 **ptr_to_ptr;
// 指向指针的指针 (二级指针)
#include <stdio.h>
int main() {
int x = 10;
int *p1 = &x; // p1 指向 x
int **p2 = &p1; // p2 指向 p1
printf("x = %d\n", x);
printf("*p1 = %d\n", *p1);
printf("**p2 = %d\n", **p2); // 两次解引用得到 x 的值
**p2 = 20; // 修改 x 的值
printf("修改后 x = %d\n", x); // 输出 20
return 0;
}
二级指针常用于需要在函数内部修改一个指针变量本身(而不是它指向的内容)的场景,例如动态分配一个指针数组。
八、模块化编程的基石:函数
函数是执行特定任务的一段独立代码块。通过函数,我们可以将大型程序分解为更小、更易于管理和复用的模块。
8.1 函数的定义与声明
-
函数定义 (Function Definition):提供函数的实际实现。
返回类型 函数名(参数类型1 参数名1, 参数类型2 参数名2, ...) { // 函数体:执行任务的代码 // ... return 返回值; // 如果返回类型不是void,则需要return语句 }
-
返回类型:函数执行完毕后返回给调用者的值的类型。如果函数不返回任何值,则为
void
。 -
函数名:函数的标识符。
-
参数列表:函数接受的输入值。每个参数都有其类型和名称。如果函数不接受参数,则参数列表为
void
或空。
-
-
函数声明 (Function Declaration / Prototype):告知编译器函数的名称、返回类型和参数类型,而不需要提供函数体。这使得我们可以在函数定义之前调用它,或者当函数定义在其他文件中时。
返回类型 函数名(参数类型1, 参数类型2, ...);
注意:声明中可以省略参数名,但写上参数名有助于提高可读性。
#include <stdio.h>
// 函数声明 (原型)
int multiply(int a, int b); // 告诉编译器有这样一个函数
void display_message(char message[]); // 数组作为参数,实际传递的是指针
int main() {
int product = multiply(5, 4); // 调用函数
printf("5 * 4 = %d\n", product);
display_message("Functions are cool!");
return 0;
}
// 函数定义
int multiply(int num1, int num2) {
return num1 * num2; // 返回计算结果
}
void display_message(char msg[]) { // msg 实际上是一个 char*
printf("Message: %s\n", msg);
}
如果函数定义出现在其首次调用之前,则可以省略函数声明。但良好的编程习惯是为所有非static
的函数提供声明(通常放在头文件中)。
8.2 函数的调用
通过函数名后跟括号以及必要的参数来调用函数。 函数名(实际参数1, 实际参数2, ...);
8.3 函数参数传递:值传递的本质
在C语言中,所有函数参数都是按值传递 (Pass by Value) 的。 这意味着当调用函数时,实际参数的值会被复制一份,然后传递给函数内部对应的形式参数。函数内部对形式参数的任何修改不会影响到调用者作用域中的实际参数。
#include <stdio.h>
void try_to_modify(int val) {
printf(" Inside try_to_modify, before modification: val = %d\n", val);
val = 100; // 修改的是 val 的副本
printf(" Inside try_to_modify, after modification: val = %d\n", val);
}
int main() {
int original_num = 10;
printf("In main, before calling try_to_modify: original_num = %d\n", original_num);
try_to_modify(original_num);
printf("In main, after calling try_to_modify: original_num = %d\n", original_num);
// 输出会显示 original_num 仍然是 10
return 0;
}
如何通过函数修改外部变量?——使用指针! 虽然C是值传递,但我们可以传递变量的地址(即指针)给函数。这样,函数内部虽然得到的是地址的副本,但这个地址仍然指向原始变量的内存位置。通过解引用指针,函数就可以修改原始变量的值。
#include <stdio.h>
void modify_via_pointer(int *ptr_to_val) { // 接收一个整型指针
printf(" Inside modify_via_pointer, before: *ptr_to_val = %d\n", *ptr_to_val);
*ptr_to_val = 200; // 通过解引用修改指针指向的原始变量
printf(" Inside modify_via_pointer, after: *ptr_to_val = %d\n", *ptr_to_val);
}
int main() {
int num = 50;
printf("In main, before calling modify_via_pointer: num = %d\n", num);
modify_via_pointer(&num); // 传递 num 的地址
printf("In main, after calling modify_via_pointer: num = %d\n", num);
// 输出会显示 num 变成了 200
return 0;
}
8.4 数组作为函数参数
当数组作为函数参数传递时,实际上传递的是指向数组首元素的指针,并且数组的长度信息不会一起传递。 因此,在函数定义中,数组参数 int arr[]
或 int arr[10]
实际上等价于 int *arr
。 通常需要额外传递一个参数来指明数组的大小。
#include <stdio.h>
// 三种等价的函数声明方式
// void print_array(int arr[], int size);
// void print_array(int *arr, int size);
void print_array(int arr[5], int size); // 这里的5会被编译器忽略,实际是 int*
void print_array(int an_array[], int array_size) {
printf(" Inside print_array, sizeof(an_array) = %zu (pointer size)\n", sizeof(an_array));
printf(" Array elements: ");
for (int i = 0; i < array_size; i++) {
printf("%d ", an_array[i]); // 可以使用数组下标访问
// 或者 an_array[i] 等价于 *(an_array + i)
}
printf("\n");
}
int main() {
int my_numbers[] = {1, 2, 3, 4, 5, 6, 7};
int N = sizeof(my_numbers) / sizeof(my_numbers[0]); // 计算数组元素个数
printf("In main, sizeof(my_numbers) = %zu (actual array size)\n", sizeof(my_numbers));
print_array(my_numbers, N);
// 在函数内部修改数组会影响原始数组
// 因为传递的是指向原始数组内存的指针
// (假设 print_array 内部修改了 an_array[0])
return 0;
}
8.5 return
语句
-
用于从函数返回一个值给调用者。返回值的类型必须与函数声明的返回类型兼容。
-
执行
return
语句会立即终止当前函数的执行。 -
对于返回类型为
void
的函数,return;
(不带值)可以用于提前退出函数,或者在函数末尾可以省略。
8.6 递归函数:自己调用自己
递归函数是指在函数定义中直接或间接调用自身的函数。 解决递归问题通常需要两个要素:
-
基本情况 (Base Case):递归终止的条件,不再进行递归调用,直接返回一个结果。
-
递归步骤 (Recursive Step):将问题分解为规模更小的相同子问题,并调用自身来解决这些子问题,然后组合结果。
经典示例:计算阶乘 n! = n * (n-1) * (n-2) * ... * 1
0! = 1
#include <stdio.h>
long long factorial(int n) {
if (n < 0) {
printf("阶乘未定义负数!\n");
return -1; // 或其他错误指示
}
// 基本情况
if (n == 0 || n == 1) {
return 1;
}
// 递归步骤
else {
return (long long)n * factorial(n - 1);
}
}
int main() {
int num = 5;
printf("%d! = %lld\n", num, factorial(num)); // 输出 5! = 120
num = 0;
printf("%d! = %lld\n", num, factorial(num)); // 输出 0! = 1
return 0;
}
递归的风险:
-
栈溢出 (Stack Overflow):如果递归没有正确的基准情况,或者递归深度过大(每次函数调用都会在栈上分配空间),会导致栈空间耗尽,程序崩溃。
-
效率问题:某些递归(如斐波那契数列的朴素递归)可能涉及大量重复计算,效率低下。可以考虑使用迭代或记忆化递归来优化。
8.7 内联函数 inline
(C99及之后)
inline
是一个给编译器的建议性关键字,请求编译器尝试将函数体直接嵌入到每个调用点,以减少函数调用的开销(如栈帧创建、参数传递等)。
-
适用场景:短小、简单且频繁调用的函数。
-
编译器决定:编译器不一定会采纳
inline
建议。复杂的函数、递归函数等通常不会被内联。 -
定义位置:内联函数的定义通常放在头文件中(如果需要在多个源文件使用),因为它需要在编译时可见。如果定义在
.c
文件中并标记为inline
,其作用域可能受限。
// 在头文件中或调用前定义
inline int max(int a, int b) {
return a > b ? a : b;
}
// 在 main.c 中
// #include "my_inline_header.h" // 假设max定义在头文件中
// int result = max(x, y); // 编译器可能会将max函数体直接替换到这里
inline
旨在替代某些场景下的宏函数,提供类型安全且更易调试的方案。
九、内存的精细管理:堆与栈的舞台
C语言赋予程序员直接管理内存的权力,这既是其强大的根源,也是挑战所在。理解内存如何划分和使用至关重要。
9.1 内存区域划分 (经典模型)
一个典型的C程序在运行时,其内存空间大致可以分为以下几个主要区域:
-
栈区 (Stack Segment):
-
管理者:编译器自动分配和释放。
-
存放内容:函数的局部变量、函数参数、函数调用的返回地址以及一些上下文信息(构成“栈帧”)。
-
特点:分配速度快,空间有限(通常几MB),遵循“后进先出”(LIFO)原则。函数调用结束时,其栈帧自动销毁。
-
风险:栈溢出 (Stack Overflow),通常由于过深的递归或在栈上分配了过大的局部数组导致。
-
-
堆区 (Heap Segment):
-
管理者:程序员手动通过
malloc()
,calloc()
,realloc()
等函数进行动态分配,并通过free()
手动释放。 -
存放内容:程序运行时动态申请的内存块。
-
特点:空间较大(取决于系统可用物理内存和虚拟内存),分配和释放相对较慢,容易产生内存碎片。
-
风险:内存泄漏 (忘记
free
)、悬空指针/野指针 (访问已free
的内存)、重复释放。
-
-
静态/全局数据区 (Static/Global Data Segment):
-
管理者:程序启动时由系统分配,程序结束时由系统回收。
-
存放内容:
-
已初始化数据段 (Data Segment):存放已初始化的全局变量和静态变量(包括静态局部变量)。
-
BSS段 (Block Started by Symbol):存放未初始化或初始化为0的全局变量和静态变量。这部分内存在程序加载时不占可执行文件空间,在运行时由系统清零。
-
-
特点:在程序整个运行期间都存在。
-
-
代码区 (Code Segment / Text Segment):
-
管理者:程序加载时载入。
-
存放内容:程序的机器指令(编译后的二进制代码)。
-
特点:通常是只读的,以防止程序意外修改自身指令。
-
-
常量区 (Constant Data Segment / Read-Only Data):
-
存放内容:字符串字面量 (如
"Hello"
) 和用const
修饰的全局/静态变量(其值在编译时确定)。 -
特点:通常是只读的。
-
9.2 动态内存分配:堆的使用
当程序在运行时需要一块大小不确定的内存,或者需要一块生命周期不局限于某个函数的内存时,就需要动态内存分配。这些函数定义在 <stdlib.h>
中。
-
void* malloc(size_t size)
:-
分配
size
字节的未初始化内存。 -
返回值:成功则返回指向分配内存块起始位置的
void*
指针;失败(如内存不足)则返回NULL
。 -
使用:通常需要将返回的
void*
强制转换为所需类型的指针。必须检查返回值是否为NULL
。
-
-
void* calloc(size_t num_elements, size_t element_size)
:-
分配
num_elements * element_size
字节的内存,并将所有字节初始化为0。 -
返回值:同
malloc
。 -
使用:比
malloc
多了一个自动清零的步骤,对于需要初始化为0的数据结构很方便。
-
-
void* realloc(void *ptr, size_t new_size)
:-
重新调整
ptr
指向的已分配内存块的大小为new_size
。 -
ptr
:必须是之前由malloc
,calloc
,realloc
返回的指针,或者是NULL
。 -
行为:
-
如果
new_size >
原大小:可能会在原址扩展(如果后续空间足够),或者重新分配一块足够大的新内存,并将原内存块的内容拷贝到新内存块,然后释放原内存块。 -
如果
new_size <
原大小:可能会截断数据,原址缩小。 -
如果
ptr
为NULL
:行为类似malloc(new_size)
。 -
如果
new_size
为0
且ptr
非NULL
:行为依赖于实现,可能释放内存并返回NULL
,或返回一个最小尺寸的指针(之后仍需free
)。避免这种用法,如果想释放,直接用free(ptr)
。
-
-
返回值:成功则返回指向调整后内存块的
void*
指针(可能与原ptr
不同);失败则返回NULL
(此时原ptr
指向的内存仍然有效且未被释放)。 -
重要:使用
realloc
后,应始终用其返回值更新原来的指针变量,因为内存块可能已移动。
-
-
void free(void *ptr)
:-
释放
ptr
指向的之前动态分配的内存块,使其可以被后续的内存分配请求重用。 -
重要:
-
只能
free
由malloc/calloc/realloc
返回的指针。 -
对同一块内存
free
多次(重复释放 (Double Free))会导致严重错误,通常是程序崩溃。 -
free(NULL)
是安全的操作,什么也不会发生。 -
free
后,原指针ptr
仍然持有原来的地址(现在是无效地址),成为悬空指针。良好的习惯是free
后立即将指针置为NULL
:free(my_ptr); my_ptr = NULL; ```c
-
-
#include <stdio.h> #include <stdlib.h> // for malloc, calloc, realloc, free #include <string.h> // for memset (可选)
int main() { int num_students; printf("请输入学生人数: "); scanf("%d", &num_students);
// 1. 使用 malloc 分配存储学生分数的数组
float *scores_malloc = (float*)malloc(num_students * sizeof(float));
if (scores_malloc == NULL) {
perror("malloc 分配失败"); // perror 会打印错误信息
return 1; // 异常退出
}
printf("Malloc 分配的内存地址: %p\n", (void*)scores_malloc);
// scores_malloc 指向的内存是未初始化的,内容不确定
for (int i = 0; i < num_students; i++) {
scores_malloc[i] = 0.0f; // 手动初始化或使用
}
// 2. 使用 calloc 分配并初始化
int *ages_calloc = (int*)calloc(num_students, sizeof(int));
if (ages_calloc == NULL) {
perror("calloc 分配失败");
free(scores_malloc); // 释放已分配的
scores_malloc = NULL;
return 1;
}
printf("Calloc 分配的内存地址 (已清零): %p\n", (void*)ages_calloc);
// ages_calloc 指向的内存已自动初始化为0
for (int i = 0; i < num_students; i++) {
printf("ages_calloc[%d] = %d\n", i, ages_calloc[i]); // 应为0
}
// 3. 使用 realloc 调整 scores_malloc 的大小
int new_num_students = num_students + 5;
float *scores_realloc = (float*)realloc(scores_malloc, new_num_students * sizeof(float));
if (scores_realloc == NULL) {
perror("realloc 失败");
// scores_malloc 仍然有效,如果realloc失败
// free(scores_malloc); // 根据情况处理
// free(ages_calloc);
return 1;
}
// realloc 成功后,scores_malloc 可能已失效,必须使用 scores_realloc
scores_malloc = NULL; // 防止误用旧指针
printf("Realloc 调整后的内存地址: %p\n", (void*)scores_realloc);
// 新扩展的部分 (从 num_students 到 new_num_students-1) 是未初始化的
// 4. 释放内存
printf("释放内存...\n");
free(scores_realloc);
scores_realloc = NULL; // 好习惯
free(ages_calloc);
ages_calloc = NULL; // 好习惯
printf("动态内存操作完成。\n");
return 0;
}
**动态内存管理的黄金法则**:
1. **检查分配**:`malloc/calloc/realloc` 的返回值必须检查是否为 `NULL`。
2. **及时释放**:不再需要的动态内存必须用 `free` 释放。
3. **避免重复释放**:同一块内存不能 `free` 多次。
4. **避免悬空指针**:`free` 后将指针置为 `NULL`。
5. **谁分配,谁释放**:通常,分配内存的模块/函数负责释放它,或者明确规定由谁来释放。
---
## 十、自定义数据蓝图:结构体与联合体
C语言允许我们通过结构体和联合体创建自定义的复合数据类型。
### 10.1 结构体 (`struct`):捆绑不同类型的数据
结构体允许将多个不同类型的数据项组合成一个单一的逻辑单元。
* **定义结构体类型**:
```c
struct 结构体标签 { // "结构体标签"是可选的,但通常会写
数据类型1 成员名1;
数据类型2 成员名2;
// ...
}; // 注意末尾的分号
```
示例:定义一个表示学生的结构体
```c
struct Student {
char name[50];
int student_id;
float gpa;
};
```
* **声明结构体变量**:
```c
struct Student s1; // 声明一个Student类型的变量s1
struct Student s2, s3;
```
* **初始化结构体变量**:
```c
// 方式1:按成员顺序初始化 (C99+)
struct Student alice = {"Alice Smith", 1001, 3.75f};
// 方式2:指定成员初始化 (C99+) - 更推荐,不易出错
struct Student bob = {.name = "Bob Johnson", .student_id = 1002, .gpa = 3.5f};
// 方式3:逐个成员赋值
struct Student carol;
strcpy(carol.name, "Carol Williams"); // 字符串复制需要strcpy
carol.student_id = 1003;
carol.gpa = 3.9f;
```
* **访问结构体成员**:使用点运算符 `.`
`结构体变量名.成员名`
```c
printf("学生姓名: %s\n", alice.name);
alice.gpa = 3.8f; // 修改成员值
```
* **结构体指针**:
当通过指向结构体的指针访问成员时,使用箭头运算符 `->`。
`结构体指针名->成员名` (等价于 `(*结构体指针名).成员名`)
```c
#include <stdio.h>
#include <string.h>
struct Point {
int x;
int y;
};
int main() {
struct Point p1 = {10, 20};
struct Point *ptr_p1 = &p1;
printf("p1.x = %d, p1.y = %d\n", p1.x, p1.y);
// 通过指针访问
printf("ptr_p1->x = %d, ptr_p1->y = %d\n", ptr_p1->x, ptr_p1->y);
// 等价写法
printf("(*ptr_p1).x = %d, (*ptr_p1).y = %d\n", (*ptr_p1).x, (*ptr_p1).y);
ptr_p1->x = 30; // 修改 p1.x 的值
printf("修改后 p1.x = %d\n", p1.x); // 输出 30
return 0;
}
```
* **结构体作为函数参数和返回值**:
结构体可以像其他类型一样按值传递给函数或从函数返回。但传递大型结构体时,复制开销较大,通常传递结构体指针效率更高。
```c
struct Student get_student_details(); // 返回结构体
void print_student_info(struct Student s); // 按值传递
void update_student_gpa(struct Student *s_ptr, float new_gpa); // 按指针传递
```
* **结构体数组**:
`struct Student class_roster[30];`
`class_roster[0].student_id = 1004;`
### 10.2 联合体 (`union`):共享内存的魔术师
联合体也用于组合不同类型的数据项,但其所有成员**共享同一块内存空间**。联合体的大小由其**最大的成员**决定。
* **定义与声明**:与结构体类似,只是关键字为 `union`。
```c
union Data {
int i;
float f;
char c;
};
```
* **特性**:
* 任何时候,只有一个成员是**有效**的。对一个成员赋值会覆盖其他成员的值(因为它们用的是同一块内存)。
* 访问非当前有效成员的值,结果是未定义的(或取决于具体实现)。
* **应用场景**:
* **节省内存**:当多个数据项不会同时使用时。
* **类型双关 (Type Punning)**:以不同类型解释同一块内存数据。这种做法需要非常小心,其行为可能依赖于平台和编译器,不具备良好的可移植性。
```c
#include <stdio.h>
union Value {
int int_val;
double double_val;
char char_val[8]; // 假设double是8字节
};
int main() {
union Value data;
printf("sizeof(union Value) = %zu bytes\n", sizeof(union Value)); // 通常是8
data.int_val = 12345;
printf("As int: %d\n", data.int_val);
// 此时 data.double_val 和 data.char_val 的值是未定义的/垃圾值
data.double_val = 987.654;
printf("As double: %f\n", data.double_val);
// 此时 data.int_val 的值已被覆盖
strcpy(data.char_val, "Test"); // 小心!确保不超过联合体大小
printf("As char array: %s\n", data.char_val);
// _此时 data.double_val 的值也已被覆盖_
return 0;
}
10.3 typedef
:给类型起别名
typedef
关键字用于为已有的数据类型创建一个新的名字(别名)。
-
优点:
-
简化复杂类型声明(如结构体、联合体、函数指针)。
-
提高代码可读性。
-
增强代码可移植性(例如,定义一个
my_int_t
,在不同平台可以typedef
为不同的基础整型)。
-
#include <stdio.h>
#include <string.h>
// 为结构体起别名
typedef struct {
char make[20];
char model[20];
int year;
} Car; // Car 现在是 struct { ... } 的别名
// 为指向Car结构体的指针起别名
typedef Car* CarPtr;
// 为函数指针起别名
typedef void (*NotificationHandler)(const char *message);
void send_email_notification(const char *msg) {
printf("Emailing: %s\n", msg);
}
int main() {
Car my_car = {"Toyota", "Camry", 2023};
// struct Vehicle my_car; // 如果没有typedef,需要这样写
printf("My car: %d %s %s\n", my_car.year, my_car.make, my_car.model);
CarPtr car_ptr = &my_car;
printf("Car model via pointer: %s\n", car_ptr->model);
NotificationHandler notifier = send_email_notification;
notifier("System maintenance soon!");
return 0;
}
十一、与外部世界对话:文件操作
程序经常需要从文件中读取数据或将数据写入文件,以实现数据持久化。C语言通过<stdio.h>
中的一组函数来进行文件操作。
11.1 文件指针 FILE*
在C中,文件是通过一个名为 FILE
的结构体类型来表示的。我们通常不直接操作FILE
结构体的内容,而是通过一个指向FILE
类型的指针(通常称为文件指针)来间接操作文件。
11.2 打开和关闭文件
-
FILE *fopen(const char *filename, const char *mode)
:打开一个文件。-
filename
:要打开的文件的路径和名称。 -
mode
:文件打开模式,决定了可以对文件进行哪些操作。常用模式:-
"r"
(read):只读方式打开。文件必须已存在。 -
"w"
(write):只写方式打开。如果文件存在,则清空其内容;如果文件不存在,则创建新文件。 -
"a"
(append):追加方式打开。如果文件存在,则写入的数据会添加到文件末尾;如果文件不存在,则创建新文件。 -
"r+"
:读写方式打开。文件必须已存在。 -
"w+"
:读写方式打开。如果文件存在,则清空;不存在则创建。 -
"a+"
:读写方式追加。如果文件存在,写入位置在末尾,读取位置在开头;不存在则创建。 -
还可以加上
"b"
表示以二进制模式打开,如"rb"
,"wb"
,"ab"
等。默认是文本模式。
-
-
返回值:成功则返回指向该文件的
FILE*
指针;失败(如文件不存在且模式为"r",或无权限)则返回NULL
。必须检查返回值。
-
-
int fclose(FILE *fp)
:关闭一个已打开的文件。-
fp
:要关闭的文件的文件指针。 -
作用:确保所有缓冲在内存中的数据被写入磁盘(对于写模式),释放文件相关的系统资源。
-
返回值:成功则返回0;失败则返回
EOF
。 -
重要:打开的文件在使用完毕后必须关闭,否则可能导致数据丢失或资源泄漏。
-
#include <stdio.h>
int main() {
FILE *output_file;
FILE *input_file;
// 打开文件用于写入
output_file = fopen("example.txt", "w");
if (output_file == NULL) {
perror("无法创建或打开 example.txt 进行写入");
return 1;
}
printf("example.txt 已成功打开用于写入。\n");
// ... 进行写入操作 ...
fclose(output_file); // 关闭文件
printf("example.txt 已关闭。\n");
// 打开文件用于读取
input_file = fopen("example.txt", "r"); // 假设 example.txt 已被创建并有内容
if (input_file == NULL) {
perror("无法打开 example.txt 进行读取");
// 可能需要处理 output_file 未成功创建的情况,或example.txt不存在
return 1;
}
printf("example.txt 已成功打开用于读取。\n");
// ... 进行读取操作 ...
fclose(input_file);
printf("example.txt 已关闭。\n");
return 0;
}
11.3 文本文件的读写
-
格式化输入/输出:
-
int fprintf(FILE *fp, const char *format, ...)
:类似printf
,但输出到指定的文件fp
。 -
int fscanf(FILE *fp, const char *format, ...)
:类似scanf
,但从指定的文件fp
读取。
-
-
字符输入/输出:
-
int fgetc(FILE *fp)
:从文件fp
读取一个字符。返回字符的ASCII码或EOF
。 -
int fputc(int char, FILE *fp)
:将字符char
写入文件fp
。
-
-
字符串输入/输出:
-
char *fgets(char *str, int n, FILE *fp)
:从文件fp
读取最多n-1
个字符到字符串str
中,或者读到换行符\n
为止。总会在末尾添加\0
。比gets
安全。如果读取到换行符,它也会被存入str
。 -
int fputs(const char *str, FILE *fp)
:将字符串str
(不包括末尾的\0
)写入文件fp
。
-
#include <stdio.h>
#include <stdlib.h> // for exit
int main() {
FILE *fp;
char name[50];
int age;
char buffer[256];
// --- 写入数据到文件 ---
fp = fopen("data.txt", "w");
if (fp == NULL) {
perror("无法打开 data.txt 进行写入");
exit(1);
}
fprintf(fp, "姓名: Alice\n");
fprintf(fp, "年龄: 30\n");
fputs("爱好: 编程\n", fp);
fputc('X', fp); // 写入单个字符
fputc('\n', fp);
fclose(fp);
printf("数据已写入 data.txt\n\n");
// --- 从文件读取数据 ---
fp = fopen("data.txt", "r");
if (fp == NULL) {
perror("无法打开 data.txt 进行读取");
exit(1);
}
printf("从 data.txt 读取内容:\n");
// 逐行读取 (推荐)
while (fgets(buffer, sizeof(buffer), fp) != NULL) {
printf("%s", buffer); // fgets 读取的行已包含换行符
}
// 如果要使用fscanf (注意匹配格式)
// rewind(fp); // 将文件指针移回文件开头
// if (fscanf(fp, "姓名: %s\n年龄: %d\n", name, &age) == 2) {
// printf("\n通过fscanf读取: 姓名=%s, 年龄=%d\n", name, age);
// }
fclose(fp);
return 0;
}
11.4 二进制文件的读写 (了解)
对于非文本数据(如图片、音频、结构体数据块),应使用二进制模式打开文件,并使用以下函数:
-
size_t fread(void *ptr, size_t size_of_element, size_t num_elements, FILE *fp)
:从文件fp
读取num_elements
个数据项,每个数据项大小为size_of_element
字节,存入ptr
指向的内存。返回成功读取的数据项个数。 -
size_t fwrite(const void *ptr, size_t size_of_element, size_t num_elements, FILE *fp)
:将ptr
指向内存中的num_elements
个数据项写入文件fp
。返回成功写入的数据项个数。
11.5 文件定位
-
long ftell(FILE *fp)
:返回文件指针当前位置相对于文件开头的偏移字节数。 -
int fseek(FILE *fp, long offset, int whence)
:设置文件指针的位置。-
offset
:偏移量。 -
whence
:起始位置。-
SEEK_SET
:从文件开头。 -
SEEK_CUR
:从当前位置。 -
SEEK_END
:从文件末尾。
-
-
-
void rewind(FILE *fp)
:将文件指针重置到文件开头 (等价于fseek(fp, 0L, SEEK_SET)
)。
11.6 错误检查与文件结束判断
-
int feof(FILE *fp)
:检查是否已到达文件末尾。如果在读取操作后调用,且已达末尾,则返回非零值。 -
int ferror(FILE *fp)
:检查在文件操作中是否发生了错误。如果发生错误,返回非零值。可以使用clearerr(fp)
来清除错误标记。
重要:在进行文件读写循环时,通常不应仅依赖feof()
作为循环条件,因为feof()
只有在尝试读取文件末尾之后才会为真。更好的做法是检查读取函数(如fgets
, fread
, fscanf
)的返回值。
十二、编译前的魔法:预处理指令
预处理是在正式编译代码之前,由预处理器执行的一系列文本操作。预处理指令都以 #
开头。
12.1 #include
:包含头文件
我们已经用过很多次了。它将指定文件的内容插入到当前位置。
-
#include <filename.h>
:用于包含标准库头文件。预处理器通常在系统指定的标准包含目录中查找。 -
#include "filename.h"
:用于包含用户自定义的头文件。预处理器通常先在当前源文件所在的目录查找,如果找不到,再去标准包含目录查找(具体查找顺序可能因编译器而异)。
12.2 #define
:定义宏
宏提供了一种文本替换机制。
-
对象宏 (Object-like Macro):定义一个符号常量。
#define PI 3.14159 #define BUFFER_SIZE 1024 // 在代码中,PI会被替换为3.14159,BUFFER_SIZE会被替换为1024 double circumference = 2 * PI * radius; char my_buffer[BUFFER_SIZE];
-
函数宏 (Function-like Macro):定义带参数的宏,看起来像函数调用。
#define SQUARE(x) ((x) * (x)) // 参数和整个宏体都用括号括起来,避免优先级问题 #define MAX(a, b) ((a) > (b) ? (a) : (b)) int result = SQUARE(5); // 替换为 ((5) * (5)) int larger = MAX(num1, num2 + 1); // 替换为 ((num1) > (num2 + 1) ? (num1) : (num2 + 1))
函数宏的陷阱:
-
优先级问题:务必用括号把宏参数和整个宏体包围起来。
-
副作用:如果宏参数带有副作用(如
i++
),且在宏体中多次使用该参数,副作用会执行多次,可能导致意想不到的结果。int a = 3; int sq_a_plus_plus = SQUARE(a++); // 展开为 ((a++) * (a++)) // 结果和 a 的最终值可能是未定义的或不符合预期的 printf("a=%d, sq_a_plus_plus=%d\n", a, sq_a_plus_plus);
对于有副作用的参数,或者逻辑复杂的场景,使用真正的函数(可能是
inline
函数)通常更安全。 -
类型不安全:宏是纯文本替换,不进行类型检查。
-
-
多行宏:使用
\
作为行连接符。#define PRINT_ERROR(message) \ do { \ fprintf(stderr, "错误: %s (文件: %s, 行: %d)\n", \ message, __FILE__, __LINE__); \ } while(0) // do-while(0) 技巧使得宏在任何地方表现得像一条语句,即使在if后面不加大括号 // __FILE__ 和 __LINE__ 是预定义的宏,分别表示当前文件名和行号
-
字符串化运算符
#
:在函数宏中,将宏参数转换为字符串字面量。 -
标记连接运算符
##
:将两个标记(token)连接成一个新的标记。#define STRINGIFY(x) #x #define CONCAT(a, b) a##b printf("%s\n", STRINGIFY(Hello World)); // 输出 "Hello World" int CONCAT(counter, 1) = 0; // 变成 int counter1 = 0;
12.3 #undef
:取消宏定义
#undef MACRO_NAME
用于取消之前定义的宏。
12.4 条件编译指令
允许根据特定条件选择性地编译某部分代码。
-
#if 常量表达式
-
#ifdef 宏名
(if defined) -
#ifndef 宏名
(if not defined) -
#elif 常量表达式
(else if) -
#else
-
#endif
常见用途:
-
防止头文件重复包含 (Header Guards):
// my_header.h #ifndef MY_HEADER_H // 如果 MY_HEADER_H 未定义 #define MY_HEADER_H // 则定义 MY_HEADER_H // ... 头文件的实际内容 ... // 结构体定义, 函数声明等 #endif // MY_HEADER_H
或者使用非标准的但被广泛支持的
#pragma once
(通常放在头文件顶部)。 -
平台特定代码:
#if defined(_WIN32) || defined(_WIN64) // Windows 平台代码 #include <windows.h> #elif defined(__linux__) // Linux 平台代码 #include <unistd.h> #elif defined(__APPLE__) // macOS 平台代码 #else #warning "未知平台!" #endif
-
调试代码开关:
#define DEBUG_MODE 1 // 或 0 #if DEBUG_MODE == 1 #define LOG_DEBUG(msg) printf("[DEBUG] %s\n", msg) #else #define LOG_DEBUG(msg) // Release模式下,宏展开为空,无操作 #endif LOG_DEBUG("程序启动...");
12.5 其他预处理指令
-
#error message
:使预处理器输出一条错误信息并停止编译。 -
#warning message
:使预处理器输出一条警告信息,编译继续。 -
#pragma directive
:提供编译器特定的指令。如#pragma pack(n)
用于控制结构体内存对齐,#pragma once
。其行为依赖于具体编译器。
十三、C语言实战演练 (以牛客BM1为例)
理论学习后,通过实际的算法题目来巩固和应用C语言知识是非常重要的。这里以牛客网101必刷榜单中的BM1“反转链表”为例,展示如何用C语言实现。
13.1 BM1: 反转链表
题目描述:给定一个单链表的头结点 head
,请反转链表,并返回反转后的链表的头结点。
链表节点定义 (通常题目会给出或需要自己定义):
struct ListNode {
int val; // 节点存储的值
struct ListNode *next; // 指向下一个节点的指针
};
解题思路 (迭代法): 我们需要三个指针来辅助反转:
-
prev
:指向当前节点反转后的前一个节点,初始为NULL
。 -
curr
:指向当前正在处理的节点,初始为head
。 -
next_temp
:临时保存当前节点的下一个节点,防止链表断裂。
遍历链表,对于每个curr
节点:
-
保存
curr->next
到next_temp
。 -
将
curr->next
指向prev
(实现反转)。 -
将
prev
更新为curr
。 -
将
curr
更新为next_temp
(移动到下一个节点)。 当curr
变为NULL
时,prev
就是反转后链表的头结点。
C语言实现:
#include <stdio.h>
#include <stdlib.h> // for malloc, free
// 链表节点定义 (假设已给出或自行定义)
struct ListNode {
int val;
struct ListNode *next;
};
/**
* 反转单链表
* @param head ListNode类 链表的头结点
* @return ListNode类 反转后的链表的头结点
*/
struct ListNode* reverseList(struct ListNode* head) {
struct ListNode *prev = NULL; // 前一个节点,初始为NULL
struct ListNode *curr = head; // 当前节点,初始为头结点
struct ListNode *next_temp = NULL; // 临时保存下一个节点
while (curr != NULL) {
next_temp = curr->next; // 1. 保存下一个节点
curr->next = prev; // 2. 当前节点指向前一个节点 (反转)
prev = curr; // 3. prev向后移动
curr = next_temp; // 4. curr向后移动
}
return prev; // 当curr为NULL时,prev即为新的头结点
}
// --- 以下为辅助测试代码 ---
// 创建链表 (例如: 1->2->3->NULL)
struct ListNode* createList(int arr[], int n) {
if (n == 0) return NULL;
struct ListNode *head = (struct ListNode*)malloc(sizeof(struct ListNode));
if (!head) return NULL;
head->val = arr[0];
head->next = NULL;
struct ListNode *current = head;
for (int i = 1; i < n; i++) {
current->next = (struct ListNode*)malloc(sizeof(struct ListNode));
if (!current->next) { /* 处理内存分配失败 */ return head; }
current = current->next;
current->val = arr[i];
current->next = NULL;
}
return head;
}
// 打印链表
void printList(struct ListNode* head) {
struct ListNode *temp = head;
while (temp != NULL) {
printf("%d -> ", temp->val);
temp = temp->next;
}
printf("NULL\n");
}
// 释放链表内存
void freeList(struct ListNode* head) {
struct ListNode *current = head;
struct ListNode *next_node;
while (current != NULL) {
next_node = current->next;
free(current);
current = next_node;
}
}
int main() {
int arr[] = {1, 2, 3, 4, 5};
int n = sizeof(arr) / sizeof(arr[0]);
struct ListNode *original_head = createList(arr, n);
printf("原始链表: ");
printList(original_head);
struct ListNode *reversed_head = reverseList(original_head);
printf("反转后链表: ");
printList(reversed_head);
freeList(reversed_head); // 释放反转后链表的内存
// 注意:original_head此时指向反转后链表的尾部或已失效
// 如果原始链表也需要独立释放,需额外处理或复制链表操作
return 0;
}
关键点分析:
-
指针操作:整个过程的核心是对
next
指针的修改。 -
动态内存:如果链表是动态创建的,反转后记得正确释放内存。
-
边界条件:考虑空链表 (
head == NULL
) 和只有一个节点的链表。此算法能正确处理。
这个例子很好地结合了结构体、指针、循环和动态内存分配(在测试代码中)等C语言核心概念。通过解决这类问题,可以大大加深对C语言的理解和运用能力。
十四、总结与展望:C语言的无尽征途
至此,我们已经一起走过了C语言核心知识的大部分旅程。从最初的“Hello, World!”,到数据类型、运算符、流程控制,再到数组、字符串、指针的精妙、函数的模块化、内存管理的智慧,以及结构体、文件操作和预处理的实用技巧。
C语言,以其简洁的语法和强大的底层操控能力,构建了现代计算世界的基石。它不仅仅是一门编程语言,更是一种理解计算机工作原理的思维方式。
回顾核心要点:
-
指针:C语言的灵魂,理解指针的指针、函数指针、野指针等概念至关重要。
-
内存管理:栈与堆的区别,
malloc
/free
的正确使用,避免内存泄漏和悬空指针。 -
数据结构:数组、结构体、联合体是构建复杂数据的基础。
-
模块化:通过函数将代码组织成可复用的单元。
-
安全意识:注意数组越界、缓冲区溢出、
scanf
的风险等。
后续学习建议:
-
深入理解指针与内存模型:这是C语言的精髓,多画图、多调试、多思考。
-
标准库的熟练运用:除了
<stdio.h>
、<stdlib.h>
、<string.h>
,还有<math.h>
、<time.h>
、<ctype.h>
等。 -
数据结构与算法:用C语言实现常见的数据结构(链表、栈、队列、树、图)和算法,这是提升编程内功的关键。多刷题,如牛客、LeetCode等。
-
多文件编程与项目组织:学习如何使用Makefile或CMake组织中大型项目,理解头文件和源文件的分离。
-
系统编程 (Linux/Unix方向):如果对底层感兴趣,可以学习文件I/O、进程、线程、网络编程等。
-
嵌入式开发:C语言在嵌入式领域有广泛应用。
-
阅读优秀C代码:学习他人是如何组织代码、处理错误的。
C语言的学习是一条漫长但充满回报的道路。不要害怕犯错,从错误中学习是进步最快的方式。多动手实践,多编写代码,多参与项目。
结语:
好了,这是本人从2025年5月初期到6月5号一直在随笔写的c语言复习总结资料,如果觉得还不错的,觉得本教程对您有帮助,请不吝点赞、收藏、关注!后续将继续更新牛客利扣面试大厂必刷100热题榜单的全部解题套路+模板!!!!
*** 更新标记
——————————————————————————————更新于2025年6月5号 :