C语言系统性笔记
也是花了一周把之前C语言笔记汇总又过了一遍,笔记记下的是当时的思考和理解,会看以前的笔记真是有感而发。重新整理了很多C语言以前没注意到点,以及相关底层,总之个人还是比较满意,也希望帮助到学C的同学。
1、C语言概述
C语言简述
C语言是什么
C语言是一门通用计算机编程语言,作为面向过程的编程语言,它既具有高级语言的特点,又具有汇编语言的特点。
C语言可以直接操作内存方面,所以在性能上非常卓越。由于C语言接近底层硬件,执行速度是非常快的,几乎所有的计算机和嵌入式设备都支持C语言开发。
C语言基础认知
C语言的定位
C 语言是高级语言,是高级语言中最底层的。所以开发操作系统一般用C语言+汇编。从底层往上依次是:物理层面,汇编层面,高级语言层面。
-
物理层:CPU 只认机器码(二进制),这是硬件决定的,无法改变。
-
汇编层:把机器码“翻译”成人类能读的助记符,一条指令对应一条机器码,没有任何抽象。
-
C语言:在汇编之上做了一层“薄薄的抽象”。
一切皆内存哲学
这是我学完Python重看Cpp记录的,想谈谈C语言是否有Python那种“一切皆为对象”的角度。
上面说了汇编是机器码人看的版本,C语言是在汇编之上做了一层“薄抽象”。所以我们要以C语言中一切皆内存,C 语言的本质就是内存的操作去理解。
-
变量 → 内存地址
-
指针 → 直接操作地址
-
函数 → 栈帧的自动管理
-
控制结构 → 条件跳转的封装
你写的每一个变量、每一个函数、每一个指针,最终都在操作内存。没有对象、没有类、没有自动管理,没有其他包装。
C 语言 = 内存 + 地址 + 手动控制。C 语言里,所有东西都有地址,而指针就是用来操作这些地址的。
来个伪代码示意:
int a = 5; // 在内存某处放了 5
int *p = &a; // p 记住了那个位置
*p = 10; // 通过位置改了值
2、C语言开发环境
GCC的概念
GCC是C语言极其重要,基础性的一个编译器。这里讲清楚GCC,需要引入一下Java对比助于理解。
Java 的方式(二进制级跨平台)
-
一次编译,到处运行:JDK 里的 javac 在任何一个系统上编译
.java文件,生成的都是统一的.class字节码文件。该文件可以在任何安装了相同版本 JRE 的系统上运行。 -
工具链统一:JDK 本身是一个相对统一的工具包,在不同平台上行为高度一致。
所以在下载JDK的时候会发现所有平台的版本名字都叫JDK。
C/C++ 的方式(源码级跨平台)
-
到处编译:必须在目标平台上,使用针对该平台的编译器,将源代码重新编译一次。
-
生成原生二进制文件:编译后生成的是直接针对特定 CPU 指令集和操作系统的可执行文件(如 Windows 的
.exe,Linux 的.elf)。 -
工具链分化:所以不同平台需要不同的、专门为该平台打造的编译器工具链。
C 没有统一的“官方实现”,所以各个平台都有自己C的编译器。C的跨平台是相对于源码的,就是代码跨平台不用做修改,但是要用对应平台的编译器重新编译。
C语言开发环境
下面就体现了,C的开发环境在各个平台上都不叫同一个名字。
Windows开发环境
GCC 是 C/C++ 的原始编译器,最初是为 Unix/Linux 开发的。Windows 本身不原生支持 GCC,不过 Windows 有自己的原生编译器 MSVC。不过我习惯 GCC 的命令行和生态,推荐用 MinGW。
MinGW
-
MinGW 是 GCC 的一个特定版本/移植版本,专门为 Windows 系统打造。
Linux开发环境
Linux系统天然支持C语言开发,C语言本来就是为 Unix 包括 Linux 系统开发的。
GCC
-
最流行的开源C语言编译器,几乎所有 Linux 发行版都自带,是 Linux 系统默认的C编译器。
C语言开发工具
Vscode
Vscode本身只是一个纯粹的文本编辑器,通过安装特定语言的插件可以变身为该语言的开发工具,在此基础上配置好对应的开发环境,就构成了完整的集成开发环境IDE。
这里我是除汇编语言外的所有语言都是用VScode,所以我这里只写了Vscode的配置。当然Cpp的开发工具还有Clion等等,其他的网络上都有相关下载配置。
3、格式化输入输出
输入输出模型
scanf和printf是C语言中使用最频繁的两个函数,它们用来进行格式化输入和输出。上大学那会我并没有深入了解过他们。
速度差异
在硬件层面,CPU,内存,I/O 设备都在不断迭代,但有一个核心矛盾一直存在,即这三者之间的速度差异。CPU >> 内存 >> I/O设备。
为了平衡内存和 IO 设备之间的速度差异,内存中设置一些缓冲区,其中就有标准输入缓冲区 (stdin) 和标准输出缓冲区 (stdout). 一般情况下,stdin 关联到键盘,而 stdout 关联到屏幕。
如下图:scanf的作用是,从 stdin 读取数据到程序;printf的作用是,将输出结果写入到 stdout。

输入函数
scanf函数的作用:根据格式串读取 stdin 中的字符,并将字符转换成指定类型的数据后,写到后面表达式所指定的位置。
scanf(格式串, 表达式1, 表达式2, ...);
scanf函数的格式串中应只包含转换说明,后面变量的前面加&符号(取地址运算符)。
// 只包含转换说明的正确示例
scanf("%d%lf",&i,&j);
// 错误示例
scanf("%d,%lf",&i,&j); // 无效,只包含转换说明,逗号不是
scanf("%d %lf",&i,&j); // 有效,空格是有效的,但是不推荐
scanf函数本质是:模式匹配函数。
-
从左到右,根据格式串,匹配转换说明。如果匹配成功,继续匹配下一个,匹配失败立刻返回。
-
其中
%d说明scanf读入的是一个整数值,i是一个int类型的变量。我们可以使用%f读入一个float类型的值,x就是一个float类型的变量。
scanf("%d",&i);
scanf("%f",&i); //%f:匹配是一个float类型的变量。
scanf("%c",&i) //%c:匹配第一个非空的字符
输出函数
printf函数的作用:显示格式串中的内容,并用后面表达式的值替换格式串中的转换说明。C 语言没有限制printf可以显示变量的数量,我们也可以同时显示多个变量的值。
printf("后面没有变量,单纯输出这句话");
printf("x=%d,y=%d,z=%d\n",x,y,z);
格式串中包含普通字符和转换说明。普通字符会原样显示,转换说明则会替换为后面表达式的值。
printf函数实际是将其他类型的数据转换成字符数据,并输出到 stdout 缓冲区中。
转换说明详解
转换说明是什么
还记得第一次学C语言的时候并没有发觉,为什么转换说明哪里都需要加双引号。双引号不是输出字符串的格式么。
没错,输出都是按字符串输出。其中的%d,%f就是转换说明,它们的作用是告诉printf如何将相应的数据类型转换为字符串并打印出来,同时也起占位符的作用,用来指明变量在显示中的位置。
-
普通字符就按原样直接输出。
-
其他数据,转换说明,占位符。
格式如下
%m.p x
// m.p 控制输出格式,比如控制小数保留
// x 如何发生类型转换,整数输出,小数输出?
代码示例
-
如果要强制 %f 显示小数点后 p 位数字,可以把 .p 放置在 % 和 f 之间。例如保留小数后p位。
// 例如保留小数后p位。 printf("%.pf",i) -
如果要强制 %f 显示小数的,显示最少字符的数量,可以把m放置在 % 和 f 之间。
// 例如显示4位字符 printf("%4d",i) -
组合使用,都是用于输出格式调整。
printf("%2.5f",i)
编写第一个C程序
编写一个C语言程序,输出HelloWorld。这里为了感受一下编译器的感觉,用记事本编写,通过命令的方式编译执行。之后的代码都使用的IDE。
GCC编译命令
GCC是什么前面已经说过了,GCC在cmd编译命令如下:
gcc program.c -o program.exe
代码示例
新建一个文本文件,输入如下代码:
#include <stdio.h>
int main() {
printf("Hello, World!\n");
return 0;
}
修改文件名后缀为.c格式,这是C语言源文件格式人。然后使用GCC编译,我是配置了环境变量。没有配置MinGW环境,去到MinGW的bin目录下也可以。

可以看到左边就会生成一个.exe文件就可以运行了。
可执行程序如何生成的
前面的代码是如何变成.exe程序的呢。在之前我们下载的MinGW其实是一个工具链,一个C程序经过工具链的变为可执行文件的过程如下。

解释一下:
-
预处理:首先程序会由预处理器进行处理。预处理器执行以 # 开头的指令。比如把头文件中内容 copy 到源代码中,或者是对宏进行文本替换等。
-
编译:经过预处理器处理的文件会交给编译器进行编译。编译器也就是前面重点介绍的GCC会把程序翻译成对应平台的汇编代码。
-
汇编:汇编器会把生成的汇编代码翻译成对应平台的机器代码 (目标代码)。然而程序现在是不能运行的,还需要经过最后一个步骤链接。
-
链接:在链接阶段,链接器会把由汇编器生成的目标代码和程序需要的其他附加代码整合在一起,生成最终可执行的程序。
注意:在C/C++中,编译单元为源文件。也就是说我们会对每一个源文件进行编译,生成对应的目标文件。然后将多个目标文件链接在一起,生成可执行程序。
这些工作都不需要我们操心。现在的编译器其实很多也集成了一整套工具链比如MinGW,我们只需要点几个按钮,或者输入几行命令就行了。
4、C语言基本结构
C程序结构
基本结构
C语言程序的结构是固定的:什么代码放什么位置、起什么作用,都有明确规矩。以下面的Hello World的代码为例:
#include <stdio.h> // 头文件
#define MAX 100 // 宏定义
int a = 10; // 全局变量
int main() { // 主函数
printf("Hello, World!\n");
return 0;
}
我个人的理解归纳看成三个部分
-
顶部:头文件部分、宏定义部分。
-
中部:全局变量声明部分。
-
底部:主函数和其他函数。
顶部
头文件
C语言的标准库提供常用功能,通过头文件引入。头文件就是函数声明和宏定义,告诉编译器你要用哪些外部的东西。
常用头文件:
-
<stdio.h>:输入输出(printf、scanf) -
<stdlib.h>:内存管理、程序控制(malloc、free) -
<string.h>:字符串操作(strcpy、strlen) -
<math.h>:数学运算(sin、cos、sqrt)
代码示例
#include <stdio.h> // 标准输入输出库
#include <math.h> // 数学运算库
int main(){
int x = -2
x = fabs(x); // 调用绝对值函数,绝对值函数来自数学运算库
printf("%d",x); // 输出 2
return 0;
}
宏定义
宏定义并不是必须的,只是在某些情况下定义宏代码会非常可观。宏定义可以用于定义宏常量或宏函数,通常在程序的开头进行定义。
宏不用定义类型。实际上是把对应部分的值给替换成定义的后面部分表达式。常用编写宏函数替换频繁调用且简短的函数。
代码示例
#include <stdio.h> // 标准输入输出库
#define MAX 10 // 宏常量
#define SIZE(a) (sizeof(a)/sizeof(a[0])) // 宏函数
int main(){
int a[MAX]={1,2,3,4,5,6,7,8,9,10}
SIZE(a); // 实际用时编译器会替换成(sizeof(a)/sizeof(a[0]))
return 0;
}
中部
全局变量
全局变量,详细总结在变量哪里,这里大概把握一下定义的位置。定义在头文件下面,函数体的外部。
代码示例
#include <stdio.h>
#define MAX 100
int x = 10; // 全局变量
int y = 20; // 全局变量
int main(){
return 0;
}
底部
函数
main函数和其他函数。这里的其他函数是指除主函数main以外的其他自定义函数。自定义函数后面会在函数那章细总结。这里大概看一下代码位置,有一个整体的把握。
代码示例
#include <stdio.h>
int x = 10; // 全局变量
// 其他函数一般习惯定义在主函数上方;定义在下方,需要在主函数特别说明
int func1(){
print("我是一个函数");
return 0;
}
// main函数是C程序的入口函数
int main() {
printf("Hello, World!\n");
return 0;
}
主函数的写法
main函数也称程序的主函数,是C程序的起点,所有C程序的执行总是从main函数开始。main函数的返回值通常表示程序的退出状态,返回值0通常表示程序正常结束,非零值表示出现了错误。
代码示例:无参版本
#include <stdio.h>
int x = 10; // 全局变量
// main函数是C程序的入口函数
int main() {
printf("Hello, World!\n");
return 0;
}
代码示例:带参版本(命令行参数)
#include <stdio.h>
int x = 10; // 全局变量
int main(int argc, char *argv[]) {
// argc:命令行参数个数
// argv:命令行参数数组
printf("程序的名称是:%s\n", argv[0]); // argv[0] 是程序的名称
printf("命令行参数的数量:%d\n", argc);
// 打印所有传递的参数
for (int i = 1; i < argc; i++) {
printf("参数 %d: %s\n", i, argv[i]);
}
return 0;
}
-
argc表示命令行参数的个数,argv是一个字符指针数组,存储每个参数的值(包括程序名)。 -
例如:如果执行
./program file1.txt file2.txt,则argc为3,argv[0]为./program包括程序名,argv[1]为file1.txt,argv[2]为file2.txt。
命令行参数详解
对上面那个命令行参数在说一下。听名字也能感受到,命令行参数(操作系统传递给main函数的参数),接收来自终端的命令行。命令行参数有利于编写通用的程序。
代码示例
创建一个C源文件。
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[]) {
if (argc < 2) {
printf("Usage: ./program <name>\n");
return 1;
}
// 输出第一个命令行参数
printf("Hello, %s!\n", argv[1]);
// 检查是否传入 -v 参数,如果传入 -v 或 --verbose 参数,程序将输出详细模式的提示。
if (argc > 2 && (strcmp(argv[2], "-v") == 0 || strcmp(argv[2], "--verbose") == 0)) {
printf("Verbose mode enabled.\n");
}
return 0;
}
编译命令行输入
./program.C changli -v
输出
Hello, changli! Verbose mode enabled.
注释
注释的作用
注释是程序中对代码的解释,目的是提高代码的可读性,便于自己或他人理解代码的逻辑和目的。
注释的两种类型
-
单行注释:以
//开始,注释内容只会出现在//后面的部分,直到该行结束。 -
多行注释:多行注释由
/*和*/包围,可以跨越多行。/* 这是多行注释, 可以跨越多行。 */ printf("Hello, World!\n"); // 单行注释
5、标识符与关键字
标识符
标识符是什么
在编写程序时,需要对变量、函数、宏等内容进行命名。这些名字我们称为标识符。
标识符命名规则:
-
由字母、数字和下划线组成。必须以字母或者下划线开头,数字不能作为开头。
-
标识符是区分大小写的,也就是Age和age是两个不同的标识符。
-
C语言的关键字不能作为标识符。
标识符命名规范
良好的编码风格使代码更加清晰易懂,便于团队协作和后期维护。C语言没有强制性的编码规范,但以下规范是大多团队协作比较遵从的:
-
见名知意:使用有意义的名称,能够准确反映其作用。
-
驼峰命名法:标识符中的单词首字母大写,
Firstname、Lastname。 -
下划线命名法:为了使名字清晰,单词与单词之间必要的时候可以插入下划线进行区分,
First_name、Last_name。
代码示例
合法标识符:
int _id; // 合法
int cay123; // 合法
int MyName; // 合法
不合法标识符:
int my name; // 不合法:只能由字母、数字和下划线组成
int @qq; // 不合法:只能由字母、数字和下划线组成
int void; // 不合法:C语言的关键字不能作为标识符
关键字
关键字是什么
对 C 编译器有特殊意义的名称,我们称为关键字 (keyword)。关键字是不能作为标识符来使用的。
为了知识的整体性,我还是给它框进来了,其实不用去记关键字,反正我压根没去记过,关键字本身就随处可见。常见汇总如下:
auto enum unsigned void
break extern return volatile
case float short
char for signed
const goto sizeof
continue if static
default struct while
do int switch
double long typedef
else register union
6、数据类型
字面量
字面量是什么
数据类型之前我们先来看字面量是什么。字面量就是现实中的数据在程序中以何种形式书写,就是我们常说的变量值等等的那个值,只是专业术语上叫字面量。
整数字面量
C 语言支持使用二进制、八进制、十进制或者十六进制来书写。常用的也就十进制。
-
二进制字面值包含数字 0~1,而且必须以 0b 或 0B 开头。如:0b1010, 0b11111111, 0B101010。
-
十进制字面值没什么好说就是我们平常看到的阿拉伯数字。如:15, 255, 32767。
-
八进制字面值包含数字 0~7,而且必须以 0 开头。如:017, 0377, 077777。
-
十六进制字面值包含数字 0~9 和字母 a~f,而且总以 0x 开头。其中字母即可以是大写也可以是小写,如:0xff, 0xfF, 0x2FF, 0X3ff, 0X4fF, 0XFF。
浮点数字面量
浮点数常量有多种书写方式。浮点数必须包含小数点或者是指数;字母 E (或 e) 后面的数字表示以 10 为底的指数。
例如,下面都是 57.0 的有效表示方式:
-
57.0,57.0e0,57E0,5.7e1,5.7e+1,57e2,570.e-1
浮点数科学计数法(字母e 后面的数字表示以 10 为底的指数)
-
57e2 其实就是5700
-
570.e-1 其实就是57
字符字面量
char 类型的变量可以存储单个字符,需要注意字符字面值应该用单引号括起来。
例如:
char flag = 'F';
char x = '0';
数据类型概述
数据类型是什么
数据类型就是告诉计算机每种数据是怎样的。数据类型能更加丰富的表达生活中的各种值以及节约空间。C语言基本数据类型:整型、浮点型、字符型。其中字符型也可以看作是整数类型中的一种。
操作符sizeof
sizeof是一个在编译时由编译器处理的操作符。它用来获取一个变量或数据类型所占用的字节数。sizeof是编译时计算的,不会在运行时产生开销。
它后续在数组里面还有妙用。用来计算某一个类型的值所占内存长度(字节)。
语法格式:
sizeof(item) // item是变量或者数据类型
代码示例
#include <stdio.h>
int main(){
int x = 10;
printf("%d\n", sizeof(x)); // 输出:4(int类型变量4个字节)
printf("%d\n", sizeof(int)); // 输出:4(int类型占4个字节)
return 0;
}
ASCll码
ASCll码是计算机中的编码。而前面说的C 语言是把字符当作小的整数进行处理的,毕竟字符和整数之间的关联是非常强的。
我们只需要记住三个关键ASCLL码即可:0–>48 A–>65 a–>97
-
字符A~Z的Ascll码值从65~90表示(26个字母)
-
字符a~z的Ascll码值从97~122表示(26个字母)
-
数字符0~9的Ascll码值是48~57
对应的大小写字符(A和a)的Ascll码值的差值是32.
| 字符 | 对应的ASCLL码 |
|---|---|
| A~Z | 65~90 |
| a~z | 97~122 |
| 0~9 | 48~57 |
ASCLL码可以将数字转换为对应的字符。'0' 作为基准字符,给定一个数字 n,就可以得到字符 '0' 到 '9' 之间的字符。转换其他数字也是这样。
代码示例
比如将数字9变字符'9'。0的ascll编码是48,9的ascll码是57。
#include <stdio.h>
int main() {
int num = 9; // 数字 9
char ch = '0' + num; // 将数字转换为字符 '9'
printf("字符: %c\n", ch); // 输出 '9'
return 0;
}
当计算中出现字符时,C 语言会使用字符对应的整数值,而这些值的转换就是通过ASCll码表。
#include <stdio.h>
int main() {
char ch;
int i;
i = 'a'; // i:97
ch = 65; // ch:'A'
ch = ch + 1; // ch:'B'
ch++; // ch:'C'
// 我们也可以像比较整数那样对字符进行比较。比如,下面的代码可以把小写字母变成大写字母。
if (ch >= 'a' && ch <= 'z'){
ch = ch - 'a' + 'A';
}
// 我们还可以像下面这样使用 for 循环遍历所有的大写字母
for (ch = 'A'; ch <= 'Z'; ch++) {
printf("%c ", ch); // 输出当前字母
}
return 0;
}
基本数据类型
C语言是面向过程编程语言,没有其他面向对象语言那种引用数据类型。
整型
整型用于表示整数。C语言提供了多种整型,分别用于存储不同范围的整数。整数又可以分为有符号整数和无符号整数。默认情况下,C 语言的整数类型都是有符号的;若要声明为无符号整数,则需要在数据类型前面多加一个unsigned关键字。
基本整型:
-
int:整数类型,通常占用4个字节(32位)。 -
short:短整数类型,通常占用2个字节(16位)。 -
long:长整数类型,通常占用4个或8个字节(32位或64位,这个取决于何种操作系统)。 -
long long:扩展精度整数类型,通常占用8个字节(64位)。
有符号与无符号:
关于无符号数、有符号数是计算机组成原理的知识。这个底层不知道并不会过多影响我们基本编程。所以知道它们是什么就好了。
默认情况下,C语言的整型是有符号的,能表示正、负、零。加上unsigned后变为无符号类型,舍弃负数表示,换取更大的正数范围。
代码示例
关于读写:%d只适用于读写int类型的数据。读写无符号整数、短整数和长整数,那么我们需要一些新的转换说明符。具体的转换声明具体在如下代码示例中体现。
#include <stdio.h>
int main() {
int a = -1; // 有符号整型,值为 -1
unsigned int b = 2; // 无符号整型,值为 2
short c = 1; // 有符号短整型,值为 1
long d = 99999L; // 有符号长整型,值为 99999
long long e = 999999999LL; // 有符号长长整型,值为 999999999
printf("%d\n", a); // 输出 -1
printf("%u\n", b); // 输出 2
printf("%d\n", c); // 输出 1
printf("%ld\n", d); // 输出 99999
printf("%lld\n", e); // 输出 999999999
return 0;
}
浮点型
浮点型用于表示带有小数部分的实数。C语言提供了几种浮点类型,以支持不同的精度需求。
基本浮点型:
默认情况下,浮点常量都是double类型。如果需要表明以单精度方式存储,可以在末尾加字母 F 或 f,如果以long double方式存储,则在后面加 L 或 l。所以实际中我们更多使用的还是double类型。
-
float:单精度浮点数,通常占用4个字节。 -
double:双精度浮点数,通常占用8个字节。 -
long double:扩展精度浮点类型,通常占用12或16个字节,具体长度取决于编译器和系统平台。
这三种类型都可以表示带有小数点的数值,它们的区别在于存储的精度和范围。
代码示例
关于读写:可以使用转换说明符%f来读写 float 类型的数据。读写double和long double类型所需的说明符与float略有不同。那么我们需要一些新的转换说明符。具体的转换声明具体在如下代码示例中体现。
#include <stdio.h>
int main() {
float pi = 3.14f; // 单精度浮点数,f后缀表示float类型
double pi1 = 3.1415926; // 双精度浮点数,默认就是double类型
long double pi2 = 3.1415926535L; // 长双精度浮点数,L后缀表示long double
printf("%.2f\n", pi); // 输出保留2位小数:3.14
printf("%.9lf\n", pi1); // 输出保留9位小数:3.141592600
printf("%.11Lf\n", pi2); // 输出保留11位小数:3.14159265350
return 0;
}
字符型
不同的机器可能使用不同的字符集,因此 char 类型的值也可能根据计算机的不同而不同。不过,当今使用最广泛的字符集是ASCII字符集,它已成为事实上的标准。
需要注意的是C语言是没有字符串string类型的。
基本字符型:
字符型用于存储单个字符。C语言提供了char类型来表示字符,同时也可以用于存储小整数。
-
char:占用1个字节(8位),可以表示ASCII字符。 -
unsigned char:无符号字符类型,范围从0到255。 -
signed char:有符号字符类型,范围从-128到127。
代码示例
关于读写:scanf和printf可以使用转换说明符%c对单个字符进行读写操作。注意空白字符用%c也能被匹配到。如果需要跳过前面的空白字符,则要在转换说明符 %c 前面加一个空格:
scanf(" %c", &ch);
scanf 格式串中的空格意味着"跳过零个或着多个空白字符"。
#include <stdio.h>
int main() {
char ch = 'A'; // 字符'A',ASCII码为65
unsigned char ch1 = 999; // 无符号字符,范围0-255,999溢出
signed char ch2 = -100; // 有符号字符,范围-128到127,-999溢出
printf("%c\n", ch); // 输出字符'A'
printf("%u\n", ch1); // 输出无符号整数值 231(溢出了所以结果不是预期)
printf("%d\n", ch2); // 输出有符号整数值 -100
return 0;
}
其他读写方法
C 语言还提供了另外一些读写单个字符的方法,使用 getchar 和 putchar 读/写字符。
-
putchar函数:接收输入的字符变量 -
getchar函数:只接收一个字符
代码示例
#include <stdio.h>
int main() {
// 定义字符变量
char character_1,character_2;
printf("输入两个字符");
character_1=getchar(); // 输入字符
character_2=getchar();
printf("输出结果:");
putchar(character_1); // 输出字符
putchar(character_2);
return 0;
}
转义字符
有些字符我们是无法从键盘上直接输入的。为了让程序能够处理字符集中的每一个字符,C 语言提供了字符的一种特殊表示方式——转义序列。
转义字符在编程语言中用于表示一些无法直接表示的字符或者特殊功能的字符。它们以反斜杠 \ 开头,后跟一个或多个字符来指定特定的含义。
代码示例
列举几个常见常用的转义字符及其作用。
#include <stdio.h>
int main() {
// 换行符 \n
printf("Hello, World!\n"); // 输出后换行
// 回车符 \r
printf("Hello, World!\r12345\n"); // 输出:"12345, World!",输出会覆盖前面的内容
// 水平制表符 \t
printf("Name\tAge\tLocation\n"); // 输出表格形式,Name、Age、Location 之间有制表符
// 垂直制表符 \v
printf("Line 1\vLine 2\vLine 3\n"); // 在文本中插入垂直制表符,效果通常取决于终端
// 输出特殊字符:反斜杠,单引号,双引号。等等都是(\+你期望的特殊字符)
printf("这是一个反斜杠: \\");
printf("单引号字符: \'");
printf("双引号字符: \"");
return 0;
}
布尔类型
布尔类型用于表示判断,C语言没有真正的原生bool类型,<stdbool.h>只是通过宏定义将_Bool类型别名为bool,底层仍然是整数类型(0表示false,1表示true)。
代码示例
#include <stdio.h>
#include <stdbool.h>
int main() {
bool flag = true; // 实际存储为1
bool isReady = false; // 实际存储为0
printf("%d\n", flag); // 输出: 1
printf("%d\n", isReady); // 输出: 0
printf("%d\n", 3 > 5); // 输出: 0
return 0;
}
枚举类型
枚举类型用于定义一组具名的整数常量,使代码更加易读和易维护。通过enum可以为相关的常量赋予有意义的名字。(没啥特殊的,就是定义一组常量放在一起)
关于枚举类型中的赋值:
-
"自动对应" 是指在枚举类型中,如果没有为某个枚举成员显式指定值,它会自动继承前一个枚举成员的值,并且会根据顺序递增。
-
如果没有显式赋值,编译器会默认从 0 开始递增。第一个枚举常量的值为 0,之后的枚举常量的值会依次递增。
代码示例
#include <stdio.h>
// 定义一个枚举类型表示星期
enum Weekday {
Sunday = 0, // Sunday 对应 0
Monday, // Monday 自动对应 1
Tuesday, // Tuesday 自动对应 2
Wednesday, // Wednesday 自动对应 3
Thursday, // Thursday 自动对应 4
Friday, // Friday 自动对应 5
Saturday // Saturday 自动对应 6
};
int main() {
// 定义一个变量并使用枚举类型
enum Weekday today;
// 为变量赋值
today = Wednesday;
// 根据枚举值输出结果
if (today == Wednesday) {
printf("Today is Wednesday!\n");
}
// 输出枚举的整数值
printf("Wednesday is day %d of the week.\n", Wednesday); // 输出 3
return 0;
}
自定义型
这个在数据结构中用得非常广泛,这里自定义类型并不是真的自己定义的数据类型,实际上就是给类型重命名。自定义类型可以就理解为类型重命名。
格式如下:
typedef 类型 别名;
使用typedef定义别名,编译器会把别名加入它所能识别的类型名列表中。这样我们可以像使用其他内置类型一样把别名当成类型声明。
使用别名主要有以下两个优点:
-
增加代码的可读性 (前提是选择合适的类型名)。
-
增加代码的可移植性。
代码示例
#include <stdio.h>
// 定义 unsigned int 类型的别名 unit
typedef unsigned int unit;
// 定义别名实际上编译器会把 Bool 看作 int 的同义词
typedef int Bool;
int main() {
// 使用 unit 定义变量 a,类型为 unsigned int
unit a = 10;
// 使用 Bool 定义变量 flag,类型为 int
Bool flag = 1; // 设置 flag 为 1,表示布尔值 true
// 打印 a 和 flag 的值
printf("a = %u\n", a); // %u 用于打印 unsigned int 类型
printf("flag = %d\n", flag); // %d 用于打印 int 类型
return 0;
}
关于取值范围
与其懵懵懂懂的,不如一次搞个清!上我的至高理解。先来理解位。上代码块:以int为例,分别讲述有符号和无符号。照搬一个概念有符号和无符号的概念。
有符号与无符号
关于无符号数、有符号数是计算机组成原理的知识。这个底层不知道并不会过多影响我们基本编程。所以知道它们是什么就好了。
默认情况下,C语言的整型是有符号的,能表示正、负、零。加上unsigned后变为无符号类型,舍弃负数表示,换取更大的正数范围。
取值范围
前面说过int占4个字节,一共32位,这个位数二进制位,底层都是二进制位表示的。一个字节就是8位表示如下:每一位都有对应的权值(2的幂次),从右向左依次为:
有符号的各位权值对应如下:
-
第0位(最低位):权值 2⁰ = 1
-
第1位:权值 2¹ = 2
-
第2位:权值 2² = 4
-
第3位:权值 2³ = 8
-
...
-
第30位:权值 2³⁰ = 1073741824
-
第31位(最高位):权值 -2³¹ = -2147483648(符号位,有符号整型)
int:00000000 00000000 00000000 00000000
// 按位权展开每个位置上的权值
int:-2³¹ 2³⁰ 2²⁹ ... 2² 2¹ 2⁰
而无符号的区别在于最高位的符号没有了,舍弃负数表示,换取更大的正数范围。
int:00000000 00000000 00000000 00000000
// 按位权展开每个位置上的权值
int:2³¹ 2³⁰ 2²⁹ ... 2² 2¹ 2⁰
这就是底层表示,同理知道各数据类型所占字节就知道表示范围,注意是0~31是包含0所以是32位。
溢出问题
我前面的例子专门放了一个溢出的例子,这个不用过多去深究他,操过数据类型的表示范围就是溢出了,溢出带来的问题结果不是预期值。避免溢出就好了。
实际中也基本不会用到超过表示的范围,除了C和Cpp有专门的高精度算法用于处理大数据的数值,其他编程语言经过发展数值表示都不用考虑"超范围",不会溢出。
7、变量与常量
变量的定义与初始化
变量是什么
变量是计算机程序中的一个基本概念,它是用于存储数据的一个命名内存位置。可以通过变量来存储不同类型的数据,例如整数、浮点数、字符等,并可以在程序中多次使用、修改这些数据。
变量的命名尊从标识符的规则。
-
只能由字母(包括大写和小写)、数字和下划线( _ )组成。且不能以数字开头。
-
变量名中区分大小写的。
-
变量名不能使用关键字。比如:char float
变量定义
变量定义时需要指明数据类型和变量名。
语法格式:
数据类型 变量名;
代码示例
#include <stdio.h>
int main() {
int high; // 定义一个整型变量high
double weight; // 定义一个浮点型变量weight
char sex; // 定义一个字符型变量sex
// 赋值
high = 181;
weight = 171.526;
sex = 'M';
// 输出变量值
printf("身高: %d\n", high); // 输出身高: 181
printf("体重: %.2lf\n", weight); // 输出体重: 171.53
printf("性别: %c\n", sex); // 输出性别: M
return 0;
}
变量定义并初始化
变量在定义时可以同时赋予初始值,这被称为初始化。这也是我们最常见的形式。
语法格式:
数据类型 变量名 = 初始值;
代码示例
#include <stdio.h>
int main() {
// 定义变量并初始化
int high = 181; // 定义并初始化整型变量high
double weight = 171.526; // 定义并初始化双精度浮点型变量weight
char sex = 'M'; // 定义并初始化字符型变量sex
// 输出变量值
printf("身高: %d\n", high); // 输出身高: 181
printf("体重: %.2lf\n", weight); // 输出体重: 171.53
printf("性别: %c\n", sex); // 输出性别: M
return 0;
}
注意:试图访问未初始化的变量,其行为是未定义的。在有些编译器中,可能会得到一个无意义的值;在另一些编译器中,则可能发生更坏的情况 (如程序崩溃)。所以最好定义就初始化。
变量作用域和生命周期
变量的作用域
变量作用域指变量在程序中可以被访问的代码区域,有两种分别是全局变量和局部变量。
-
全局变量:作用域是整个工程。(大括号外边的变量在哪都能用)
-
局部变量:作用域是变量所在的局部范围。(大括号内部的变量只能在大括号里用)
局部变量与全局变量同名,在函数体里局部变量会覆盖掉同名的全局变量。而局部变量只在局部有效,出了代码块后就没了。所以起作用的就是全局变量了。
代码示例
#include <stdio.h>
int num = 10; // 全局变量
void function() {
int num = 20; // 局部变量
printf("num: %d\n", num); // 输出:20
}
int main() {
printf("num: %d\n", num); // 输出:10
function(); // 调用函数
printf("num: %d\n", num); // 输出:10
return 0;
}
生命周期
变量的生命周期指的是变量的 创建 到变量的 销毁 之间的一个时间段。
-
全局变量的生命周期是:整个程序的生命周期。 (在哪都行!)
-
局部变量的生命周期是:进入作用域生命周期开始,出作用域生命周期结束。(大括号大括号!)
静态Static
static的作用
在很多语言里面都有static,它们的作用都是相同的,static是用来修饰变量和函数的。
静态修饰变量和函数
实际项目中,一个工程会有很多源文件。当一个全局变量或者函数被static修饰,这个全局变量或函数就只能在本源文件内使用,不能在其他源文件内使用。
static在修饰全局变量和函数上是一致的,都是保证static修饰的对象只能在本源文件内使用,不能在其他源文件内使用。而static修饰局部变量却不一样。
静态局部变量
static修饰局部变量使得局部变量变成了静态变量。
函数内的静态局部变量可以延长生命周期来保留其值,即使函数调用结束后也不会回收,下次调用时,静态局部变量仍然保持上次修改后的状态,且作用域不变。
在其他语言这个算是闭包的基础,不过C语言并没有其他高级语言那样有原生的闭包。
代码示例
未用static修饰的局部变量
#include <stdio.h>
void test(){
int i = 0;
i++;
printf("%d ", i);
}
int main(){
int i = 0;
for (i = 0; i < 10; i++){
test();
}
return 0;
}
// 输出:1 1 1 1 1 1 1 1 1 1
// i局部变量,进入函数会创建i赋值1,出了函数被销毁
使用static修饰的局部变量
#include <stdio.h>
void test(){
// static修饰局部变量
static int i = 0;
i++;
printf("%d ", i);
}
int main(){
int i = 0;
for (i = 0; i < 10; i++){
test();
}
return 0;
}
// 输出:1 2 3 4 5 6 7 8 9 10
// 静态局部变量,生命周期延长到整个程序运行期间,下次调用时,仍然保持上次修改后的状态。
常量的定义
常量是什么
常量是指程序中值不可以改变的数据。C语言有两种方式定义常量:宏定义 和const关键字。
宏定义常量
#define是C语言的预处理指令,用于创建符号常量或带参数的宏。在编译前,预处理器会机械地将代码中的宏名替换为指定的内容。
语法格式:
#define 常量名 值
代码示例
#include <stdio.h>
#define MAX_SIZE 100
int main() {
int arr[MAX_SIZE]; // 使用宏常量MAX_SIZE
printf("MAX_SIZE:%d\n", MAX_SIZE);
return 0;
}
宏定义函数
#define也可以用于定义宏函数,实现简单的代码复用。我认为宏函数其实也是替换,只是替换的样子看上去是函数。
语法格式
// 表示式要关于x的
#define 函数名(x) 表达式
注意:在定义宏函数时,使用括号可以避免运算优先级问题。
代码示例
#include <stdio.h>
#define SIZE(a) (sizeof(a)/sizeof(a[0])) // 宏函数
int main(){
int a[MAX]={1,2,3,4,5,6,7,8,9,10}
SIZE(a); // 实际用时编译器会替换成(sizeof(a)/sizeof(a[0]))
return 0;
}
关键字Const
const是不变的的意思,在语法层面对变量进行修饰告诉编译器这个变量的值在初始化后不能被修改。与#define不同,const定义的常量具有类型,可以进行类型检查。
语法格式:
const 数据类型 常量名 = 值;
代码示例
#include <stdio.h>
int main() {
const int MAX_USERS = 50;
// MAX_USERS = 100; //会报错,const常量不能被修改
printf("MAX_USERS: %d\n", MAX_USERS);
return 0;
}
8、运算符与表达式
算术运算符
常见算术运算符
算术运算符用于执行基本的数学计算,包括加、减、乘、除和取模(余数)等操作。
| 运算符 | 作用 | 示例 |
|---|---|---|
+ |
加 | a + b |
- |
减 | a - b |
* |
乘 | a * b |
/ |
除 | a / b |
% |
取余 | a % b |
注意:
-
/和%的区别:两个数据做除法,/取结果的商,%取结果的余数。 -
运算中存在隐式转换,数据类型表示范围小的会自动往数据类型表示范围大的转换。整数只能得到整数,要想得到小数,必须有浮点数参与运算。
代码示例
#include <stdio.h>
int main() {
int a = 10;
int b = 3;
printf("%d\n", a / b); // 输出3
printf("%d\n", a % b); // 输出1
return 0;
}
注意事项
-
整数除法:两个整数相除,结果只保留整数部分,直接舍弃小数(不进行四舍五入)
-
取模运算:只能用于整数,返回两数相除后的余数
取模运算常见技巧,用于取数,比如可以用于数值的拆分,一个5位数,将其拆分为个位、十位、百位、千位、万位,打印在控制台。
站在每次个位数取余的角度去挨个拆解这个5位数分别取整或取余。
#include <stdio.h>
int main() {
int num = 12345; // 设定一个5位数
// 打印每一位
printf("%d\n", num / 10000); // 万位
printf("%d\n", (num / 1000) % 10); // 千位
printf("%d\n", (num / 100) % 10); // 百位
printf("%d\n", (num / 10) % 10); // 十位
printf("%d\n", num % 10); // 个位
return 0;
}
关系运算符
关系运算符
关系运算符用于比较两个值,并返回布尔类型的结果(true或false)。关系运算符主要用于控制流语句(如if和while)中的条件判断。
| 运算符 | 描述 | 示例 |
|---|---|---|
== |
等于 | a==b,判断a和b的值是否相等,成立为true,不成立为false |
!= |
不等于 | a!=b,判断a和b的值是否不相等,成立为true,不成立为false |
> |
大于 | a>b,判断a是否大于b,成立为true,不成立为false |
< |
小于 | a<b,判断a是否小于b,成立为true,不成立为false |
>= |
大于等于 | a>=b,判断a是否大于等于b,成立为true,不成立为false |
<= |
小于等于 | a<=b,判断a是否小于等于b,成立为true,不成立为false |
表达式 i < j < k 在 C 语言中是合法的,但可能不是你所期望的含义。该表达式首先检测 i 是否小于 j,然后用比较后产生的结果 (0 或者1) 和 k 进行比较。
若要测试 j 是否位于 i 和 k 之间。需要加入逻辑运算符,我们应该使用:i < j && j < k。
注意事项:
-
关系运算符的结果都是
boolean类型,要么是true,要么是false。 -
千万不要把“
==”误写成“=”,"=="是判断是否相等的关系,"="是赋值。 -
关系运算符可以用于基本数据类型和相同类型的对象。需要确保操作数类型相容。
代码示例
#include <stdio.h>
int main() {
int a = 10;
int b = 20;
printf("%d\n", a == b); // 0, false
printf("%d\n", a != b); // 1, true
printf("%d\n", a > b); // 0, false
printf("%d\n", a >= b); // 0, false
printf("%d\n", a < b); // 1, true
printf("%d\n", a <= b); // 1, true
// 关系运算的结果是0 (false) 或 1 (true),可以将其赋值给int类型变量
int flag = a > b;
printf("%d\n", flag); // 输出0 (false)
return 0;
}
逻辑运算符
逻辑运算符把各个运算的关系表达式连接起来组成一个复杂的逻辑表达式,以判断程序中的表达式是否成立,判断的结果是 true 或 false。
逻辑运算符用于处理布尔值,通常用于控制流语句(如if和while)中的条件判断。
常见逻辑运算符
| 符号 | 作用 | 说明 |
|---|---|---|
&& |
逻辑与 | a && b,a和b都是true,结果为true,否则为false |
|| |
逻辑或 | a || b,a和b都是false,结果为false,否则为true |
! |
逻辑非 | !a,a为1,返回0;a为0,返回1 |
其中需要特别注意的是, && 和 || 会对操作数进行 "短路" 计算。也就是说,这些操作符会首先计算左操作数的值,然后计算右操作数;如果整个表达式的值可以由左操作数的值推导出来,那么将不会计算右操作数的值。
比如说有0则0,左边就以及为假了,整个表达式所以为假了,就不会去计算右边的了。
代码示例
#include <stdio.h>
int main() {
int age = 25;
int score = 85;
int isVip = 1; // 1表示是会员,0表示不是
// 逻辑与:年龄在18-30之间 且 成绩>=80
if (age >= 18 && age <= 30 && score >= 80) {
printf("符合青年优秀学员标准\n");
}
// 逻辑或:年龄大于60 或 是会员
if (age > 60 || isVip) {
printf("享受老年或会员优惠\n");
}
// 逻辑非:不是会员
if (!isVip) {
printf("非会员请先注册\n");
}
return 0;
}
自增自减运算符
自增自减运算符
自增自减字面意思就是自动加1,自动减1。
| 符号 | 作用 | 说明 |
|---|---|---|
++ |
自增 | 变量的值加1 |
-- |
自减 | 变量的值减1 |
注意事项:
-
++和--既可以放在变量的后边,也可以放在变量的前边。 -
单独使用的时候,
++和--无论是放在变量的前边还是后边,结果是一样的。 -
在计算时用,如果放在变量的后边,先计算,再做
++或者--。 -
在计算时用,如果放在变量的前边,先做
++或者--,再计算。
代码示例
自增自减用法都一样,最常见的用法:单独使用和赋值运算。
#include <stdio.h>
int main() {
int i = 10;
i++; // 单独使用
printf("i: %d\n", i); // i: 11
int j = 10;
++j; // 单独使用
printf("j: %d\n", j); // j: 11
int x = 10;
int y = x++; // 赋值运算,++在后边,所以是使用x原来的值赋值给y,x本身自增1
printf("x: %d, y: %d\n", x, y); // x: 11, y: 10
int m = 10;
int n = ++m; // 赋值运算,++在前边,所以是使用m自增后的值赋值给n,m本身自增1
printf("m: %d, n: %d\n", m, n); // m: 11, n: 11
return 0
}
++i意味着 "立即自增 i";而i++意味着 "先用 i 的原始值,稍后再自增 i"。--运算符具有相同的特性
位运算符
位运算符指的是二进制位的运算,先将十进制数转成二进制后再进行运算。在二进制位运算中,1表示true,0表示false。
位运算符
| 符号 | 计算方式 |
|---|---|
& |
遇到0(false)则0(false),两边同时为1(true),结果才是1(true) |
| |
遇到1(true)则1(true),两边同时为0(false),结果才是0(false) |
^ |
相同为false,不同为true |
~ |
取反,二进制位全部取反,0变1,1变0,包括符号位 |
<< |
有符号左移运算,左边符号位丢弃,右边补齐0 |
>> |
有符号右移运算,根据符号位,补齐左边 |
位运算符主要用于底层或硬件方面。一般在应用层面的开发不常用。它确实比较难懂的,所以很少有人用。这里我们仅了解一下比较常见十进制正整数的位运算。
位运算晦涩难懂的原因,我想是一些必要的概念不明确。我会先从必要的概念入手,放心并不会很多。
必要概念
-
大多数编程语言中,定义
int类型默认是有符号整数。有符号数是计算机中能表示正负数值的数字类型。 -
计算机中数据的存储都是以二进制的补码形式。二进制运算也都是用补码实现的,补码的最高位为符号位。最高位为0时,表示正数;最高位为1时,表示负数。
-
比如整数int占4个字节,一个字节是8个二进制位。而位运算就是直接对整数本身的二进制上的位(bit)进行操作。
-
两个数之间的位运算,是将两个数都转换为二进制后,对应的二进制位上做操作。二进制位运算中,1表示true,0表示false。
十进制数转二进制数就不用过多讲述了,这个转换的二进制数一般是原码表示,而计算机中参与运算的是补码形式。
这里快速回顾一下原反补:
-
正数:对正数而言,原码、反码和补码都是一样的。
-
负数:负数的原码是对应正数原码的符号位取反。反码是原码符号位以外,按位取反。补码是反码加1。
关于补码符号位为什么能参与位运算?
-
总结就是一句话:补码的最高位能起到显示正负的功能,但不是真正的符号位,而是如假包换的数值位。
逻辑位运算
我们先看前面4个逻辑位运算,通过这三个例子:正数与正数位运算,负数与负数位运算,正数与负数位运算就理解了。
#include <stdio.h>
/*
& 位与 : 遇0则0,全1才1
| 位或 : 遇1则1,全0才0
^ 位异或 : 相同为0, 不同为1
~ 取反 : 全部取反, 0变1, 1变0 (也包括符号位)
// 6 & 2
00000000 00000000 00000000 00000110 // 6的二进制补码
& 00000000 00000000 00000000 00000010 // 2的二进制补码
-----------------------------------------
00000000 00000000 00000000 00000010 // 由于是正数,补码就是原码,结果就是2
// -6 & -2
11111111 11111111 11111111 11111010 // -6的二进制补码
& 11111111 11111111 11111111 11111110 // -2的二进制补码
-----------------------------------------
11111111 11111111 11111111 11111010 // 这是一个负数的补码,还需要返推回原码才是结果。(返推后最终结果为 -6)
// 6 & -2
00000000 00000000 00000000 00000110 // 6的二进制补码
& 11111111 11111111 11111111 11111110 // -2的二进制补码
-----------------------------------------
00000000 00000000 00000000 00000110 // 最高位也就是符号位是0是正数,补码就是原码,结果就是6
// ~6
00000000 00000000 00000000 00000110 // 6的二进制补码
~ 11111111 11111111 11111111 11111001 // 取反
-----------------------------------------
11111111 11111111 11111111 11111000 // 取反后的补码看最高位是1,是负数,还需要反推回原码才是结果。(返推后最终结果为 -7)
// ~ -6
11111111 11111111 11111111 11111010 // -6的二进制补码
~ 00000000 00000000 00000000 00000101 // 取反
-----------------------------------------
00000000 00000000 00000000 00000101 // ou~,是正数,直接就是结果为5
*/
int main() {
printf("%d\n", 6 & 2); // 输出 2
printf("%d\n", -6 & -2); // 输出 -6
printf("%d\n", 6 & -2); // 输出 6
printf("%d\n", ~6); // 输出 -7
printf("%d\n", ~(-6)); // 输出 5
return 0;
}
记住参与位运算的都是补码。看运算结果的补码,补码的符号位(最高位)正负。为0代表正数,而正数原码、反码、补码是相同了,所以直接就是结果数。为1代表负数,而负数的补码需要返推回原码后才是运算结果。负数补码推回原码很简单,负数的补码怎么来的就怎么回去。
移位运算符
剩下2个移位运算。我们对内存中的数移位时实际上也都是对相应数的二进制补码上的位进行移动,最后补码转换为原码后在按位权展开返回十进制。
而补码的移动中,位的取舍规则:
-
在有符号右移运算中,补齐规则:根据符号位的值来决定左边的填充值,确保结果的符号(正负)是正确的。即正数补0,负数补1。
-
在有符号左移运算中,补齐规则:左移丢弃符号位,然后在右边补充零。
左移不需要考虑符号位:主要用于数值的扩大,符号位可能变化,但不影响操作目的。这个记住就好,要讲清为什么很长,而且记住这个已经足够操作位运算了
右移需要考虑符号位:特别是对于负数,符号位需要扩展,以确保数值的符号不发生错误的变化。也是记住就好,要讲清为什么很长,而且记住这个已经足够操作位运算了。
我们通过例子来理解移位运算。
-
左移
<<等价于 乘以 2,即每左移一位,相当于将数值扩大一倍。 -
右移
>>等价于 除以 2,但对于负数,右移时符号位需要考虑
因为每个二进制位上都有对应的位权(二进制数第i位的权为2^(i-1)),而移位相当于是位权的变化。

#include <stdio.h>
/*
<< 有符号左移运算,二进制位向左移动, 左边符号位丢弃, 右边补齐0
运算规律: 向左移动几位, 就是乘以2的几次幂
>> 有符号右移运算,二进制位向右移动, 使用符号位进行补位
运算规律: 向右移动几位, 就是除以2的几次幂
// 12 << 2
|00000000 00000000 00000000 00001100 // 12的二进制补码
00|000000 00000000 00000000 0000110000 // 左移2位,左边符号位丢弃, 右边补齐0
--------------------------------------
00000000 00000000 00000000 00110000 // 移位后的补码,反推求结果的原码。不过我们知道向左移动几位, 就是乘以2的几次幂,所以结果为48
// -12 << 2
|11111111 11111111 11111111 11110100 // -12 的二进制补码
11|111111 11111111 11111111 1110010000 // 左移2位,符号位丢失,右边补齐0
--------------------------------------
11111111 11111111 11111111 10010000 // 移位后的补码,反推求结果的原码。不过我们知道向左移动几位, 就是乘以2的几次幂,所以结果为-48
// 12 >> 2
00000000 00000000 00000000 00001100| // 12的二进制补码
00000000 00000000 00000000 00000011|00 // 右移2位, 使用符号位进行补位
--------------------------------------
00000000 00000000 00000000 00000011 // 移位后的补码,反推求结果的原码。不过我们知道向右移动几位, 就是除以2的几次幂,所以结果为3
// -12 >> 2
11111111 11111111 11111111 11110100| // -12 的二进制补码
1111111111 11111111 11111111 111001|00 // 右移2位, 使用符号位进行补位。
--------------------------------------
11111111 11111111 11111111 11111001 // 移位后的补码,反推求结果的原码。不过我们知道向右移动几位, 就是除以2的几次幂,所以结果为-3
*/
int main() {
printf("%d\n", 12 << 2); // 输出 48
printf("%d\n", -12 << 2); // 输出 -48
printf("%d\n", 12 >> 2); // 输出 3
printf("%d\n", -12 >> 2); // 输出 -3
return 0;
}
左移右移,移动的位数超过总长度怎么办,这又涉及到底层溢出处理。
位移次数会对操作数的位数进行模运算。也就是如果我们移位操作中,移位超过了它的位数(在
int类型中是 32 位),它会对位移数进行模运算,确保位移数不会超出实际有效位数。
位运算的应用
具体的编程实战中,什么时候想到用呢。需要改变自身的二进制位达到某种目的。
示例:用位运算判断奇偶数。
-
想象一下偶数和奇数的二进制补码。由于二进制对应位置上的权重都是2的次方幂,换言之除了2的0次方,其余的都是2的倍数。
-
显然偶数都是能被2整除的,除了第一位上2的0次方为1,其余位上都是2的倍数,所有的偶数的二进制2的0次方位上都是为0,而所有奇数的二进制无论怎样2的0次方位上都是为1。则对1进行&位运算做判断就好了。
#include <stdio.h>
int main() {
int number;
printf("请输入一个整数:");
scanf("%d", &number); // 读取整数
if ((number & 1) == 0) {
printf("%d 是偶数\n", number);
} else {
printf("%d 是奇数\n", number);
}
return 0;
}
赋值运算符
赋值运算符
赋值运算符的作用是将一个表达式的值赋给左边,左边必须是可修改的,不能是常量。
| 运算符 | 作用 | 说明 |
|---|---|---|
= |
赋值 | a=10,将10赋值给变量a |
+= |
加后赋值 | a+=b,将a+b的值给a |
-= |
减后赋值 | a-=b,将a-b的值给a |
*= |
乘后赋值 | a*=b,将a×b的值给a |
/= |
除后赋值 | a/=b,将a÷b的商给a |
%= |
取余后赋值 | a%=b,将a÷b的余数给a |
其实后面五个都是把算术运算符结合的简单赋值运算符而已。
代码示例
#include <stdio.h>
int main() {
int a = 20;
a += 8; // 等同于 a = a + 8
printf("a += 8的结果为: %d\n", a); // 28
a -= 7; // 等同于 a = a - 7
printf("a -= 7的结果为: %d\n", a); // 21
a *= 6; // 等同于 a = a * 6
printf("a *= 6的结果为: %d\n", a); // 126
a /= 5; // 等同于 a = a / 5
printf("a /= 5的结果为: %d\n", a); // 25
a %= 4; // 等同于 a = a % 4
printf("a %%= 4的结果为: %d\n", a); // 1
return 0;
}
赋值和初始化区别
C语言对初始化有特殊照顾,允许这样做。要从内存的角度上去理解
-
初始化:变量还没诞生,编译器在“创造”它的时候,可以直接在内存里摆好数据。
-
赋值:变量已经存在了,数组名代表的是地址(一个常量),你不能改变一个常量的值。
三目运算符
三目运算符
三元运算符用于根据条件选择值。其逻辑为:如果条件表达式成立或者满足则执行表达式1,否则执行第二个。
语法格式:
关系表达式 ? 表达式1 : 表达式2;
-
如果条件为
true,则计算并返回表达式1的值。 -
如果条件为
false,则计算并返回表达式2的值。
条件运算符的两个表达式(表达式1和表达式2)应尽量类型一致,以避免不必要的类型转换。
三元运算符虽然可以嵌套使用,处理更复杂的条件判断,但是可读性非常差,逻辑条理非常差,不建议嵌套使用。
代码示例
#include <stdio.h>
int main() {
int a = 10;
int b = 20;
// 判断 a > b 是否为真,如果为真取 a 的值,如果为假,取 b 的值
int max = (a > b) ? a : b;
printf("较大的数是: %d\n", max); // 20
return 0;
}
运算符优先级
运算符连接表达式,创建更复杂的表达式。所以这里有一个运算符优先级的问题。这个运算符的优先级,就是我们小学学的哪个加减乘除,乘除要先于加减运算。
万能法
这么多运算符,编程的时候还要想一下优先级?谁记谁愚蠢。在小学中也学过括号的内的先计算,同理编程中也有括号先运算的规则。
括号优先级最高,()里面又可以(),对运算符优先级拿捏不准就用(),希望先计算的用() 括起来。
代码示例
#include <stdio.h>
int main() {
int a = 5, b = 10, c = 3, d = 2;
// 没有括号的表达式
int res1 = a + b * c - d / 2;
// 结果是 5 + (10 * 3) - (2 / 2) = 5 + 30 - 1 = 34
printf("res1 = %d\n", res1); // 输出: 34
// 使用括号改变运算顺序
int res2 = (a + b) * (c - d) / 2;
// 结果是 (5 + 10) * (3 - 2) / 2 = 15 * 1 / 2 = 7
printf("res2 = %d\n", res2); // 输出: 7
// 更复杂的括号使用
int res3 = (a + (b * c) - (d / 2)) * 2;
// 结果是 (5 + (10 * 3) - (2 / 2)) * 2 = (5 + 30 - 1) * 2 = 34 * 2 = 68
printf("res3 = %d\n", res3); // 输出: 68
return 0;
}
9、类型转换
“C语言默认情况下,整数和浮点数字面量和变量类型都是按有符号处理的,除非显式加上 unsigned。”所以我们的运算都基于有符号数的讨论。在我回看我的笔记的时候发现前面没有更多的讨论小数和无符号数。梳理了一下我认为归纳在这里笔记最有逻辑感。
类型转换基础
类型转换是什么
在C语言中,存在不同类型的数据需要一起参与运算,所以这些数据类型之间是需要相互转换的。比如整数和小数,有符号数和无符号数运算,各同数据类型的运算都是正常运算,只需要注意不同类型之间的隐式类型转换,表示范围小的往大的转。
主要有两种类型转换:
-
隐式类型转换:在程序中通常发生在表示范围较小的数据类型转换为表达范围较大的数据类型。这种转换方式是自动的,直接书写即可。
-
强制类型转换:类型范围大的数据或者变量,不能直接赋值给类型范围小的变量,会报错,必须进行强制类型转换。
简单来说隐式自动小转大,强制手动大转小。
类型转换的发生
存在不同类型的变量之间相互赋值或者运算。类型转换底层原理是计算机组成原理的内容,这里只需要知道类型转换是为了避免不同类型变量之间的计算结果或者赋值结果的精度缺失。
无论那种转换都需要注意:
-
整数提升:如果操作数中有任何低于
int和unsigned int的类型,会首先将该操作数转换为 int 类型或者unsigned int类型。 -
同一转换等级的有符号整数和无符号整数一起参与运算时:有符号整数会转换成对应的无符号整 数。
signed -> unsigned
把有符号操作数转换为无符号类型时,可能会导致某些隐蔽的编程问题,所以最好尽量避免使用无符号整数。(无符号整数一般用于底层开发中,在应用层面很少会使用)。
代码示例
char类型的数据转换为int类型是按照ASCll码表中对应的int值进行计算的。
#include <stdio.h>
int main() {
int a = 'a';
printf("%d\n", a); // 将输出97
return 0;
}
整数默认是int类型,byte、short和char类型数据参与运算均会自动转换为int类型。
#include <stdint.h>
int main() {
int8_t b1 = 10;
int8_t b2 = 20;
// 先来看易错情况:byte类型计算会自动转换,最后返回int类型的结果
// int8_t b3 = b1 + b2;
// 这两种就不会报错
int b3 = b1 + b2; // 返回int类型的结果
int8_t b4 = (int8_t)(b1 + b2); // 强转成int8_t类型的结果返回
return 0;
}
隐式类型转换
隐式转换是什么
隐式类型转换是编译器在不同数据类型之间自动进行的类型转换,以确保运算和赋值的正确性,无需程序员手动干预。
-
“在表达式运算、赋值、函数调用和返回时,当操作数类型不匹配,系统会自动进行隐式类型转换,基本遵循小范围类型向大范围类型提升的规则。”
-
隐式类型转换是自动,一般是同一转换等级,还有表示范围小的数据类型隐式转换为表示范围大的数据类型,有符号转无符号(因为无符号表示范围大嘛)。
代码示例
#include <stdio.h>
int main() {
// 小范围类型(short)转大范围类型(int)
short a = 10;
int b = a; // a 自动转换为 int
printf("b = %d\n", b); // 输出 b = 10
// 有符号类型转无符号类型
int x = -10;
unsigned int y = x; // x 自动转换为 unsigned int
printf("y = %u\n", y); // 输出 y = 4294967286 (无符号表示)
return 0;
}
显式类型转换
显式转换是什么
显式类型转换又称强制类型转换。虽然 C 语言的隐式转换使用起来非常方便,但是有时候我们需要更大程度上控制类型转换。因此,C 语言提供了强制类型转换。
强制类型转换是范围大的转范围小的类型,表示范围越大精度越高,而高精度转为小精度可能会存在精度缺失的问题。这是计算机底层的共性问题,几乎所有编程语言都有,了解就好。
语法格式:
目标数据类型 变量名 = (目标数据类型)值;
C 语言其实把 括号()看作是一元运算符,一元运算符的优先级是高于二元运算符的。
二元运算符需要两个操作数来执行操作,一元运算符只需要一个操作数。
强制类型转换显示表明肯定会发生的转换。有时候,我们还可以使用强制类型转换来避免溢出。关于溢出问题更多的是操作系统的知识,并不影响C语言的基本编程。
代码示例
假设我们需要将一个很大的 int 类型的数值强制转换为 short 类型。如果不进行强制类型转换,可能会发生溢出。我们通过强制转换来将 int 类型转换为 short 类型,并避免溢出。
#include <stdio.h>
int main() {
int large_number = 30000; // 超过 short 范围的值
// 正常情况下,如果直接赋值可能发生溢出,因为 short 的范围是 -32768 到 32767
// short small_number = large_number; // 这会导致溢出
// 使用强制类型转换来避免溢出
short small_number = (short)large_number; // 强制转换避免溢出
printf("large_number = %d\n", large_number);
printf("small_number = %d\n", small_number); // 输出结果 30000,避免了溢出
return 0;
}
10、流程控制
在一个程序执行的过程中,各条语句的执行顺序对程序的结果是有直接影响的。所以,我们必须清楚每条语句的执行流程。可以分为三种最基本的结构:顺序结构、分支结构、循环结构。
顺序结构
程序的默认执行模式
顺序结构也是所有程序控制结构的基础,它按照代码出现的先后顺序,自上而下依次逐行执行。顺序结构是程序的默认执行模式。任何程序的底层都是由顺序执行单元组合而成的。
代码示例
从上到下,逐行打印
#include <stdio.h>
int main() {
printf("打印第一行\n"); // 输出 打印第一行
printf("打印第二行\n"); // 输出 打印第二行
printf("打印第三行\n"); // 输出 打印第三行
printf("打印第四行\n"); // 输出 打印第四行
return 0;
}
在上面的代码中,程序会按照从上到下的顺序依次执行每一行代码,依次打印四行。
分支结构
分支结构用于根据条件的真假来决定执行不同的代码块。C语言的分支结构主要包括if语句、if-else语句和switch语句。分支结构使得程序能够在运行时做出不同的决策。
if语句
if语句用于在条件为真时执行特定的代码块。它是最基本的条件控制语句。
语法格式:
if (关系表达式) {
// 条件为真时执行的语句体
}
执行流程:
-
程序正常顺序执行。
-
遇到if语句块,先计算关系表达式的值
-
如果括号内的关系表达式的值为true就执行if语句块。
-
如果括号内的关系表达式的值为false就不执行if语句块。
-
-
继续执行后面的语句。
代码示例
#include <stdio.h>
int main() {
int num = 10;
// 使用if语句判断num是否为正数
if (num > 0) {
printf("num 是一个正数。\n"); // 输出 num 是一个正数。
}
return 0;
}
if-else语句
if - else语句允许在条件为真时执行一个代码块,而在条件为假时执行另一个代码块。
语法格式:
if (关系表达式) {
语句块1;
} else {
语句块2;
}
执行流程:
-
程序正常顺序执行。
-
遇到if语句块,先计算关系表达式的值
-
如果括号内的关系表达式的值为true就执行if语句块1。
-
如果括号内的关系表达式的值为false就执行else语句块2。
-
-
继续执行后面的语句内容。
代码示例
#include <stdio.h>
int main() {
// 程序判断一个数,是奇数还是偶数
int num = 9;
if (num % 2 == 0) {
printf("偶数\n");
} else {
printf("奇数\n");
}
return 0;
}
悬空else问题
if (y != 0)
if (x != 0)
result = x / y;
else
printf("Error: y is equal to 0\n");
上面 else 子句究竟属于哪一个 if 语句呢?缩进暗示它属于最外层的 if 语句。然而 C 语言遵循的规则是 else 子句属于离它最近的,且还没有和其他 else 匹配的 if 语句。
为了使 else 子句属于外层的 if 语句,我们可以用花括号将内层的 if 语句括起来:
if (y != 0) {
if (x != 0)
result = x / y;
} else
printf("Error: y is equal to 0\n");
if-else if语句
else if语句用于在第一个if条件不满足时,提供多个条件判断的可能性。它允许在多个条件之间进行选择。
语法格式:
if (关系表达式1) {
语句体1; // 关系表达式1满足时执行的代码
} else if (关系表达式2) {
语句体2; // 关系表达式2满足时执行的代码
}
......
else {
语句体n+1; // 所有条件都不满足时执行的代码
}
执行流程:
-
程序正常顺序执行。
-
遇到if-else if语句块,先计算关系表达式1的值
-
如果值为true就执行语句体1;如果值为false就计算关系表达式2的值
-
如果值为true就执行语句体2;如果值为false就计算关系表达式3的值
-
......
-
如果没有任何关系表达式为true,就执行语句体n+1。
-
-
继续执行后面的语句内容。
代码示例
#include <stdio.h>
int main() {
printf("开始\n");
int score;
printf("请输入成绩:");
scanf("%d", &score);
if (score >= 90 && score <= 100) {
printf("优秀\n");
} else if (score >= 80 && score <= 89) {
printf("良好\n");
} else if (score >= 70 && score <= 79) {
printf("中等\n");
} else if (score >= 60 && score <= 69) {
printf("及格\n");
} else if (score >= 0 && score <= 59) {
printf("请努力加油\n");
} else {
printf("成绩有误!\n");
}
printf("结束\n");
return 0;
}
switch语句
switch语句是一种控制结构,用于在多个可能的执行路径中选择一个。它根据一个表达式的值匹配不同的case,并执行相应的代码块。在处理多个条件时,switch比多个if-else结构更高效。
-
表达式:表达式的结果必须是整型(
int、char、枚举类型),不支持String或浮点型。 -
case:每个case后面跟一个常量值。如果表达式的值与某个case的常量值匹配,执行该case下的代码。case后面的值必须是整型常量表达式,且必须与switch表达式的类型兼容。
-
常量值:常量可以是整型、字符型常量,不能是浮点型。
-
break:用于终止switch语句,防止执行后续的case。避免case穿透。
-
default:可选部分,如果没有匹配到任何case,则执行default中的代码。
语法格式:
switch (关系表达式) {
case 常量1:
// 当表达式等于常量值1,执行语句1;
break;
case 常量2:
// 当表达式等于常量值2,执行语句2;
break;
// ...任意多的case语句
default:
// 当表达式不匹配任何case时执行的代码
break;
}
执行流程:
-
程序正常顺序执行。
-
遇到switch语句块,先计算关系表达式的值
-
先和case的常量值依次比较,一旦有对应的值,就会执行相应的语句,在执行的过程中,遇到break就会结束。
-
最后如果所有的case都和表达式的值都不匹配,就会执行default语句体部分,然后结束switch语句。
-
-
继续执行后面的语句内容。
代码示例1
#include <stdio.h>
int main() {
int week;
printf("请输入星期数:");
scanf("%d", &week);
switch(week) {
case 1:
case 2:
case 3:
case 4:
case 5:
printf("工作日\n");
break;
case 6:
case 7:
printf("休息日\n");
break;
default:
printf("您的输入有误\n");
break;
}
return 0;
}
注意:如果switch中的case,没有对应break的话,则会出现case穿透的现象。
case穿透的现象:当开始case穿透,后续的case就不会具有匹配效果,内部的语句都会执行,直到看见break,或者将整体switch语句执行完毕,才会结束。
代码示例2
在实际情况中应该避免类似情况的发生。以下是一个case穿透示例:
#include <stdio.h>
int main() {
int week;
printf("请输入星期数:");
scanf("%d", &week);
switch (week) {
case 1:
case 2:
case 3:
case 4:
case 5:
printf("工作日\n"); // 无break语句,就不会跳出了
case 6:
case 7:
printf("休息日\n");
break;
default:
printf("您的输入有误\n");
break;
}
return 0;
}
在上述代码中,case 1 到 case 5 没有使用 break,因此在输入星期 1、2、3、4 或 5 时,会继续执行 case 6 或 case 7 代码块,打印 休息日,即使是工作日。
循环控制
循环控制语句用于重复执行某段代码,直到满足特定条件。C语言中的循环结构主要有三种:for循环、while循环和do-while循环,分别适用于不同的场景。
for循环
for循环常用于已知循环次数的情况,把循环变量的初始化、循环条件、变量变化三个要素写在一起,便于理解和维护。
语法格式:
for (初始化;条件判断;迭代语句) {
// 循环体;
}
-
初始化:定义循环控制变量并初始化。简单说就是循环开始的时候什么样的。
-
条件判断:在每次循环开始前判断,如果条件为真,则执行循环体;如果为假,则退出循环。简单说就是判断循环能否一直执行下去。
-
迭代语句:每次循环结束后执行的操作,通常用于更新循环控制变量。简单说就是控制循环能否执行下去。
-
循环体:循环反复执行的内容,简单说就是你希望反复执行的事情。
执行流程:
-
执行初始化语句。
-
执行条件判断语句,看其结果为true还是false,为false,循环结束,为true,继续执行。
-
执行循环体语句。
-
执行条件控制语句。
-
5回到2继续。
代码示例
在控制台输出1-5和5-1的数据
#include <stdio.h>
int main() {
// 需求:输出数据1-5
for(int i = 1; i <= 5; i++) {
printf("%d\n", i);
}
printf("--------\n");
// 需求:输出数据5-1
for(int i = 5; i >= 1; i--) {
printf("%d\n", i);
}
return 0;
}
在控制台输出1+...+100的和。
#include <stdio.h>
int main() {
// 求和的最终结果必须保存起来,需要定义一个变量,用于保存求和的结果,初始值为0
int sum = 0;
// 从1开始到5结束的数据,使用循环结构完成
for(int i = 1; i <= 5; i++) {
// 将反复进行的事情写入循环结构内部
// 此处反复进行的事情是将数据 i 加到用于保存最终求和的变量 sum 中
sum += i;
/*
sum += i; sum = sum + i;
第一次:sum = sum + i = 0 + 1 = 1;
第二次:sum = sum + i = 1 + 2 = 3;
第三次:sum = sum + i = 3 + 3 = 6;
第四次:sum = sum + i = 6 + 4 = 10;
第五次:sum = sum + i = 10 + 5 = 15;
*/
}
// 当循环执行完毕时,将最终数据打印出来
printf("1-5之间的数据和是:%d\n", sum);
return 0;
}
再次提升一点难度:求1-100之间的偶数和 。可以搭配前面的分支语句实现。
#include <stdio.h>
int main() {
// 求和的最终结果必须保存起来,需要定义一个变量,用于保存求和的结果,初始值为0
int sum = 0;
// 对1-100的数据求和与1-5的数据求和几乎完全一样,仅仅是结束条件不同
for(int i = 1; i <= 100; i++) {
// 对1-100的偶数求和,需要对求和操作添加限制条件,判断是否是偶数
if(i % 2 == 0) {
sum += i;
}
}
// 当循环执行完毕时,将最终数据打印出来
printf("1-100之间的偶数和是:%d\n", sum);
return 0;
}
while循环
while循环适合那种“我不知道要循环多少次,只知道什么时候该停下来”的情况,条件满足就一直循环,不满足就退出。
语法格式:
while (条件判断) {
// 循环体;
// 迭代语句;
}
-
条件判断:在每次循环开始前判断,如果条件为真,则执行循环体;如果为假,则退出循环。简单说就是判断循环能否一直执行下去。
-
循环体:循环反复执行的内容,简单说就是你希望反复执行的事情。
-
迭代语句:每次循环结束后执行的操作,通常用于更新循环控制变量。简单说就是控制循环能否执行下去。
执行流程
-
执行初始化语句。
-
执行条件判断语句,看其结果为true还是false,为false,循环结束,为true,继续执行。
-
执行循环体语句。
-
执行条件控制语句。
-
5回到2继续。
代码示例
#include <stdio.h>
int main() {
// 需求:在控制台输出5次"HelloWorld"
// for循环实现
for(int i = 1; i <= 5; i++) {
printf("HelloWorld\n");
}
printf("--------\n");
// while循环实现
int j = 1;
while(j <= 5) {
printf("HelloWorld\n");
j++;
}
return 0;
}
do-while循环
do...while 语句和 while 语句关系紧密。事实上,do...while 语句本质上就是 while 语句,只不过其控制表达式是在每次执行完循环体之后进行判定的。
do...while 语句和 while 语句的唯一区别是:do...while 语句的循环体至少会执行一次,而 while语句在控制表达式的值初始为 0 时,一次都不会执行。
语法格式:
do {
// 循环体;
// 迭代语句;
}while(条件判断);
-
循环体:首先执行一次,然后检查条件。如果条件为真,则继续执行,否则退出。
-
迭代语句:每次循环结束后执行的操作,通常用于更新循环控制变量。简单说就是控制循环能否执行下去。
-
条件判断:在每次循环开始前判断,如果条件为真,则执行循环体;如果为假,则退出循环。简单说就是判断循环能否一直执行下去。
执行流程:
-
执行初始化语句。
-
执行循环体语句。
-
执行条件控制语句。
-
执行条件判断语句,看其结果为true还是false,为false,循环结束,为true,继续执行。
-
⑤回到②继续。
代码示例
#include <stdio.h>
int main() {
// 需求:在控制台输出5次"HelloWorld"
// for循环实现
for(int i = 1; i <= 5; i++) {
printf("HelloWorld\n");
}
printf("--------\n");
// do...while循环实现
int j = 1;
do {
printf("HelloWorld\n");
j++;
} while(j <= 5);
return 0;
}
循环嵌套
嵌套是一种很基础的编程思维,其实所有语句你都可以尝试嵌套的使用。编程就是这样,一个不断尝试组织代码的过程。循环嵌套就是在循环里面在写循环。
一定要深刻理解,嵌套循环,在一个循环内部可以包含另一个循环,这被称为嵌套循环。它常用于处理多维数组或复杂的重复逻辑。
代码示例
-
理解:请反复理解这句话(整个内循环,就是外循环的一个循环体,内部循环体没有执行完毕,外循环是不会继续向下执行的)
-
结论:外循环执行一次,内循环执行一圈。
#include <stdio.h>
int main() {
// 外循环控制小时的范围,内循环控制分钟的范围
for (int hour = 0; hour < 24; hour++) {
for (int minute = 0; minute < 60; minute++) {
printf("%d时%d分\n", hour, minute);
}
printf("--------\n");
}
return 0;
}
跳转语句
跳转语句用于改变程序的执行流程,主要包括 break 和 continue,以及goto。goto 在实际开发中很少使用,容易破坏代码结构,一般不推荐。所以跳转语句我们主要用break和continue。
-
break 语句会把控制转移到整个循环的后面,而 continue 会将控制转移到循环体的末尾。break 语句会跳出循环,而 continue 语句仍然留在循环体内。
-
break 语句和 continue 语句还有另外一个区别:break 语句可以用于 switch 语句和循环,而 continue 只能用于循环。
break语句
break语句用于强制结束当前所在的循环(for、while、do-while)或switch语句,程序会跳出该结构,继续执行后面的代码。
代码示例
#include <stdio.h>
int main() {
for (int i = 1; i <= 10; i++) {
if (i == 5) {
break; // 当i等于5时退出循环,循环会立即停止,不会打印5以后的值
}
printf("当前值: %d\n", i);
}
return 0;
}
continue语句
continue语句用于跳过本次循环中剩余的代码,直接进入下一次循环迭代。循环不会结束,只是提前结束当前这一轮。
代码示例
循环执行第一次,第二次,第三次,第四次,跳过第五次这个循环,直接执行第六次,第七次......
#include <stdio.h>
int main() {
for (int i = 1; i <= 10; i++) {
if (i == 5) {
continue; // 跳过5,跳过当前循环的输出,直接进入下一个迭代。
}
printf("当前值: %d\n", i);
}
return 0;
}
死循环
死循环也就是一直循环,永不停止,一般来说应该避免出现死循环。
一般来说循环中的条件判断语句要是不写,或者判断结果一直为真且一直不结束,那循环就会陷入死循环。
-
for(;;){} -
while(true){} -
do {} while(true)
但是在某些情况下合理的使用死循环加跳转语句却会有奇效果。比如实现某个用户任意输入。
代码示例
#include <stdio.h>
int main() {
int input;
while (1) {
printf("请输入一个数字(0退出):");
scanf("%d", &input);
if (input == 0) {
printf("程序结束\n");
break; // 跳出死循环
}
printf("你输入的数字是:%d\n", input);
}
return 0;
}
11、数组
生活中需要存储大量的数据,比如一个学校的成绩,不能创建几千个变量,过于复杂。要存储大量的数据,怎么存储?用数组来解决这个问题。
数组基础
数组是什么
数组就是存储数据长度固定的容器,用于存储多个相同数据类型的元素。它在内存中是连续存储的,允许通过索引快速访问元素。
数组的基本特点
特点如下:
-
长度固定:数组的大小在创建时确定,不能动态改变。
-
元素类型相同:数组中的所有元素类型相同。
-
随机访问:可以通过下标直接访问数组元素。
数组的下标
下标也称为索引。是描述元素在数组中的位置顺序的。不过要注意数组的下标是从0开始的,这点需要成为肌肉记忆。区间为[0,10),是有直观意义的,它表示我们操作了数组中下标为0到9的每个元素。
当然数组里面的元素也可以是一个数组,本质上我们可以无限套娃,所以数组理论上可以有无限维度,不过通常也只用到二维。最多到三维。
三维数组是专业工具而非通用工具。在日常Web开发、业务系统中很少见,但在数据处理、科学计算、机器学习、图形学等领域是必备技能。
一维数组
一维数组的定义
一维数组是我们最基础最基本的数组,数组在内存中是连续存储的,便于高效地访问和操作数据。不过需要注意数组的下标都是从0开始的。比如下图a[10]。

基本语法:
数据类型 数组名[数组大小];
-
数据类型:数组元素可以是任何类型的。
-
数组名:数组的名称,用于访问和操作数组。
-
数组大小:数组中元素的个数,必须是编译时确定的常量。定义后,数组的大小不能动态改变。
代码示例
#include <stdio.h>
int main() {
int a[10]; // 定义一个包含10个整数的数组
return 0;
}
一维数组的初始化
数组在定义时可以同时赋初值。如果提供的初始值个数少于数组长度,剩余元素会自动补零。
语法结构:
数据类型 数组名[数组大小] = {元素1, 元素2, ..., 元素n};
// 部分初始化
int num[5] = {1,2}; // num = {1, 2, 0, 0, 0}
// 不指定大小,由初始化列表决定
int num[] = {1,2,3,4,5}; // 自动推断数组大小为5
代码示例
#include <stdio.h>
int main() {
int a[5] = {1,2,3,4,5}; // 完全初始化
int b[5] = {1,2}; // 部分初始化,剩余元素自动为0
// 输出数组元素
for (int i = 0; i < 5; i++) {
printf("%d",b[i]); // 输出 1 2 0 0 0
}
return 0;
}
计算数组元素个数
前面说过一个sizeof函数,在数组中有妙用。我们可以利用sizeof函数计算数组的所占空间和元素个数。
计算数组所占空间总大小
sizeof(arr);
计算数组中的元素个数
// 我们可以使用 sizeof 运算符确定数组的大小 (字节)。如果数组 a 包含 10 个整数,那么sizeof(a) 的值通常为 40。sizeof 运算符也可以确定数组元素的大小,两者相除即得到数组的长度
sizeof(arr)/sizeof(arr[0]);
代码示例
#include<stdio.h>
// 我比较喜欢写成宏,这样的好处:即使数组的长度发生改变,这个 for 循环是不需要发生变化的。
#define SIZE(a) (sizeof(a)/sizeof(a[0])) // a必须是一个数组
int main(){
int arr[] = {0};
printf("%d\n",sizeof(arr));
printf("%d\n",sizeof(arr[0]));
printf("%d\n",sizeof(arr)/sizeof(arr[0]));
for (i = 0; i < SIZE(a); i++)
a[i] = 0;
return 0;
}
下标访问数组元素
C语言中的数组支持随机访问,也就是可以通过下标(索引)直接访问任意位置的元素
代码示例
#include <stdio.h>
int main() {
int a[5] = {1,2,3,4,5};
// 访问数组元素
printf("第一个元素: %d\n", a[0]); // 输出 1
printf("第五个元素: %d\n", a[4]); // 输出 5
// 修改数组元素
a[2] = 30; // 将第三个元素修改为30
printf("第三个元素: %d\n", a[2]); // 输出 30
return 0;
}
一维数组的遍历
遍历操作非常之重要,定义好数组之后,我们经常需要把里面的元素挨个过一遍,这就是遍历。用循环配合数组下标,就能轻松实现。
代码示例
#include <stdio.h>
int main() {
int a[5] = {10, 20, 30, 40, 50};
int sum = 0;
// 遍历数组
for(int i = 0; i < 5; i++) {
printf("%d ", a[i]); // 输出 10 20 30 40 50
sum += a[i];
}
printf("%d", sum); // 输出 150
return 0;
}
数组越界问题
这个问题我上学的时候老是注意不到。数组下标有效范围是0 ~ 长度-1,越界访问属于未定义行为,程序可能崩溃或产生错误结果。
代码示例
#include <stdio.h>
int main() {
int a[5] = {10, 20, 30, 40, 50};
// 正确访问:下标0~4
for(int i = 0; i < 5; i++) {
printf("%d ", a[i]); // 10 20 30 40 50
}
printf("\n");
// 越界访问:下标5(超出范围)
printf("%d\n", a[5]); // 未定义行为,可能输出随机值或崩溃
// 越界写入:下标-1(超出范围)
a[-1] = 99; // 未定义行为,可能破坏其他数据
return 0;
}
二维数组
数组可以有任意维数。其中最常用的是一维数组,其次是二维数组,一般很少见到更高维的数组。从逻辑上来看二维数组是一个excel那种的二维表格。
内存上实际还是一维数组,只是数组中的每一个元素也是一个一维数组,所以二维数组本质还是一个一维数组,几乎所有高纬数组本质都是一维数组,只是在层层套娃。
二维数组还是像一维数组那样按行存储的,只不过的它每一个数据项也是一个数组了。所以只是逻辑上我们看成一个二维表格,实际的物理存储上还是一维数组。

二维数组的定义
二维数组行和列也都是从0开始的。
语法结构:
数据类型 数组名[行数][列数];
-
行数:二维数组的行数。
-
列数:二维数组每行的元素个数。
代码示例
#include <stdio.h>
int main() {
int arr[5][6]; // 定义一个5行6列的二维数组
return 0;
}
二维数组的初始化
二维数组在定义时进行初始化,每行需要用一对花括号{}包围,行里面的元素用逗号分隔。
语法结构:
数据类型 数组名[行数][列数] = {
{元素11, 元素12, ..., 元素1n},
{元素21, 元素22, ..., 元素2n},
...
{元素m1, 元素m2, ..., 元素mn}
};
不过请注意:
-
如果初始化式的长度不够,那么剩余元素被初始化为 0。如,下面的初始化式只填充了数组的前 3行,后面 2 行将被初始化为 0。
-
如果内层初始化式不足以填满数组的一行,那么这一行剩余的元素会被初始化为 0。
代码示例
#include <stdio.h>
int main() {
// 二维数组初始化
int arr[3][4] = {
{1, 2, 3, 4},
{5, 6}, // 剩余元素自动补0
{7, 8, 9} // 剩余元素自动补0
};
// 遍历输出
for(int i = 0; i < 3; i++) {
for(int j = 0; j < 4; j++) {
printf("%d ", arr[i][j]);
/*
输出 1 2 3 4
5 6 0 0
7 8 9 0
*/
}
printf("\n");
}
return 0;
}
计算数组元素个数
前面说过一个sizeof函数,在数组中有妙用。我们可以利用sizeof函数计算数组的所占空间和元素个数。
计算二维数组所占空间总大小
sizeof(arr);
计算二维数组中的行数
sizeof(arr) / sizeof(arr[0]);
计算二维数组中的列数
sizeof(arr[0]) / sizeof(arr[0][0]);
计算二维数组中的总元素个数
sizeof(arr) / sizeof(arr[0][0]);
代码示例
#include<stdio.h>
// 宏定义:行数
#define ROWS(a) (sizeof(a) / sizeof(a[0]))
// 宏定义:列数
#define COLS(a) (sizeof(a[0]) / sizeof(a[0][0]))
// 宏定义:总元素个数
#define TOTAL_SIZE(a) (sizeof(a) / sizeof(a[0][0]))
int main(){
int arr[3][4] = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
};
printf("数组总大小: %zu字节\n", sizeof(arr));
printf("一行大小: %zu字节\n", sizeof(arr[0]));
printf("一个元素大小: %zu字节\n", sizeof(arr[0][0]));
printf("行数: %zu\n", ROWS(arr));
printf("列数: %zu\n", COLS(arr));
printf("总元素个数: %zu\n", TOTAL_SIZE(arr));
// 遍历二维数组
for (int i = 0; i < ROWS(arr); i++) {
for (int j = 0; j < COLS(arr); j++) {
printf("%d ", arr[i][j]);
}
printf("\n");
}
return 0;
}
下标访问数组元素
二维数组在逻辑上可以看成excel那种二维表格,通过行下标和列下标访问特定位置的元素。
代码示例
#include <stdio.h>
int main() {
int arr[5][6] = {
{1, 2, 3, 4, 5, 6},
{7, 8, 9, 10, 11, 12},
{13, 14, 15, 16, 17, 18},
{19, 20, 21, 22, 23, 24},
{25, 26, 27, 28, 29, 30}
};
// 访问元素
printf("arr[0][1] = %d\n", arr[0][1]); // 输出 2
printf("arr[3][5] = %d\n", arr[3][5]); // 输出 24
// 修改元素
arr[0][1] = 22; // 将第1行第2列的元素修改为25
printf("修改后的 arr[0][1] = %d\n", arr[0][1]); // 输出 22
return 0;
}
二维数组的遍历
遍历二维数组需要嵌套循环,外层循环遍历行,内层循环遍历列。
代码示例
#include <stdio.h>
int main() {
int arr[3][4] = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
};
int sum = 0;
// 遍历二维数组
for(int i = 0; i < 3; i++) {
for(int j = 0; j < 4; j++) {
printf("%d ", arr[i][j]);
sum += arr[i][j];
}
printf("\n");
}
printf("数组所有元素之和为:%d\n", sum);
return 0;
}
数组越界问题
二维数组的越界问题仍然值得留意,行列下标都是从0开始的哦。
代码示例
#include <stdio.h>
int main() {
int a[3][4] = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
};
// 正确访问:行下标0~2,列下标0~3
for(int i = 0; i < 3; i++) {
for(int j = 0; j < 4; j++) {
printf("%d ", a[i][j]); // 输出所有元素
}
printf("\n");
}
printf("\n");
// 越界访问:行下标3(超出范围)
printf("%d\n", a[3][0]); // 未定义行为,可能输出随机值或崩溃
// 越界访问:列下标4(超出范围)
printf("%d\n", a[0][4]); // 未定义行为,可能输出随机值或崩溃
// 越界写入:行下标-1(超出范围)
a[-1][0] = 99; // 未定义行为,可能破坏其他数据
return 0;
}
常量数组
常量数组
这个在编程中很有用的,比如对应的图算法,以及一些二维表的遍历中等等场景个人认为比较有用的,故也总结在这里。
无论是一维数组还是二维数组,我们都可以在声明时加上 const 修饰符而变成 "常量":
const char hex_chars[] = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'A', 'B', 'C', 'D', 'E', 'F'}
定义常量数组通常是为了做对比,比如有一个二维数组做运算后,需要对比各个位置上的值来判断等操作,就可以用预先定义好的常量数组去做对比。在数学上可以充当比如单位矩阵的效果。
程序在运行期间不会对数组进行修改。 const 不仅仅可以修饰数组,它可以修饰任意变量。但是 const在数组声明中特别有用,因为数组经常包含一些不会发生改变的信息。
代码示例
#include <stdio.h>
int main() {
// 常量数组:单位矩阵(3x3)
const int identity[3][3] = {
{1, 0, 0},
{0, 1, 0},
{0, 0, 1}
};
// 待验证的矩阵
int matrix[3][3] = {
{1, 0, 0},
{0, 1, 0},
{0, 0, 1}
};
int isIdentity = 1; // 假设是单位矩阵
// 对比验证
for(int i = 0; i < 3; i++) {
for(int j = 0; j < 3; j++) {
if(matrix[i][j] != identity[i][j]) {
isIdentity = 0; // 不是单位矩阵
break;
}
}
}
if(isIdentity) {
printf("该矩阵是单位矩阵\n");
} else {
printf("该矩阵不是单位矩阵\n");
}
return 0;
}
12、字符串
字符串与字符数组
C语言中有单个字符的数据类型char,但是C语言没有别的编程语言的String字符串类型,C语言处理字符串的方法本质上是利用数组实现的。
字符串存储
C 语言会为长度为 n 的字符串字面值分配长度为 n + 1 的内存空间。这块内存空间将用来存储字符串字面值中的字符,以及一个用来标志字符串末尾的标记字符 (空字符)。空字符用 '\0' 表示。
例如,字符串字面量"Cay"的存储,当然字符串字面量也可以为空。空字符用 '\0' 表示。

字符串的结束标志是一个 \0 的转义字符,这是字符串的标志!!!
注意:关于空字符特别说明,空格
' '≠'\0'
字符数组
字符数组不一定是字符串,字符串一定是字符数组。如果字符数组中末尾结束标识不是以空字符"\0"结尾,那他就不是字符串,只是一个字符类型的数组。
注意区别:
char arr1[3] = {'C', 'a', 'y'}; // 字符数组,不是字符串
char arr2[4] = {'C', 'a', 'y', '\0'}; // 字符数组,同时也是字符串
字符串
只有当字符数组末尾包含'\0'时才构成C语言中的字符串。
定义并初始化
推荐定义初始化的时候不指定数组长度,这样编译器会自动计算长度,存储你的字符串和添加一个空字符,这非常棒,最推荐这种,爱死他了。
// 推荐方式,自动添加'\0'
char str1[] = "chang";
// 其他方式,手动添加'\0'
char str1[6] = {'c', 'h', 'a', 'n', 'g', '\0'};
如果两个字面量之间仅有空白字符,那么这两个字符串字面"相邻",C语言在编译时会将相邻的字面值拼接在一起。
char str1[] = "chang" "li"; // 等价于 char str1[] = "changli";
字符串在数组长度方面与普通数组的初始化规则完全一致,如果提供的字符串长度短于数组长度,剩余元素会自动补空字符。
-
如果初始化时,你的字符串(不包括那个\0)和字符数组的长度相同。由于没有给空字符留空间,编译器不会试图存储空字符。
-
一定要确保字符数组的长度大于你要存的字符串的长度,不然的话编译器不会把他当成字符串,而是只会当成一个字符数组。字符数组无法当成字符串做。
代码示例
// 情况1:长度刚好,没有空间存'\0'
char str1[8] = "chang li";
/*
结果:[c][h][a][n][g][ ][l][i]
这不是字符串,只是字符数组
*/
// 情况2:长度足够,自动添加'\0'
char str2[9] = "chang li";
/*
结果:[c][h][a][n][g][ ][l][i][\0]
这是字符串
*/
// 情况3:长度更大,多余位置填'\0'
char str3[12] = "chang li";
/*
结果:[c][h][a][n][g][ ][l][i][\0][\0][\0][\0]
这是字符串,后面多余的自动填'\0'
*/
字符串输出
-
当使用字符串函数(如
printf的%s)时,必须确保字符数组以'\0'结尾,否则可能导致内存泄漏或程序崩溃。 -
同时转换说明%s打印字符串时功能原理是会遇到'\0'自动停下
代码示例
#include <stdio.h>
int main(){
char arr1[] = "bit";
char arr2[] = {'b', 'i', 't'};
char arr3[] = {'b', 'i', 't', '\0'};
printf("%s\n", arr1); // 输出bit(%s打印字符串时功能原理是会遇到'\0'自动停下)
printf("%s\n", arr2); // 输出bit烫烫烫烫 bc(因为%s没遇到'\0')
printf("%s\n", arr3); // 输出bit(创建时手动加了'\0')
return 0;
}
字符串函数库
C语言在<string.h>头文件中提供了一系列字符串处理函数,方便我们对字符串进行各种操作。这里归纳几个常用的,使用这些函数时候注意关注'\0'的位置。
只有当字符数组末尾包含'\0'时才构成C语言中的字符串,此时才能安全地作为字符串函数的参数使用,否则会导致越界访问。
字符串的长度strlen
strlen函数返回字符串str的长度:str中第一个空字符之前的字符个数(不包括最后那个空字符)。
函数原型
size_t strlen(const char *str);
参数说明:
-
str:指向字符串的指针(以'\0'结尾的字符数组)
代码示例
#include <stdio.h>
#include <string.h>
int main() {
char str[] = "chang li"; // 注意:空格 ' ' ≠ '\0'
int len;
// 使用strlen函数计算字符串长度
len = strlen(str);
printf("字符串%s的长度是: %d\n", str, len); // 字符串chang li的长度是: 8
return 0;
}
字符串的复制strncpy
这里有两个方法,strcpy和strncpy都是用于将一个字符串复制给另一个字符串。选择strncpy是因为可以避免溢出更安全,可以手动选择复制长度更有操作性。
函数原型
char *strcpy(char *dest, const char *src);
// 使用 strncpy 后要手动确保字符串以'\0'结尾
char *strncpy(char *dest, const char *src, size_t n);
参数说明:
-
dest:目标字符串指针 -
src:源字符串指针 -
n:最多复制的字符数
代码示例
#include <stdio.h>
#include <string.h>
int main() {
char dest[10];
char src[] = "chang li";
// "chang li" 实际有8个字符(c h a n g 空格 l i)
// dest 大小是10,可以完整存放
// 最多复制9个字符,留出1个位置给'\0'
strncpy(dest, src, sizeof(dest) - 1);
dest[sizeof(dest) - 1] = '\0'; // 手动确保字符串以'\0'结尾
printf("dest: %s\n", dest); // 输出: chang li
return 0;
}
字符串的拼接strncat
这里有两个方法,strcat和strncat都是用于将一个字符串拼接到另一个字符串的末尾。选择strncat是因为可以避免溢出更安全,可以手动控制追加长度更有操作性。
函数原型
char *strcat(char *dest, const char *src);
// strncat 无论追加多少,都会在结果末尾自动添加 '\0'
char *strncat(char *dest, const char *src, size_t n);
参数说明:
-
dest:目标字符串指针(必须足够大,能容纳原字符串 + 追加字符 +'\0') -
src:源字符串指针 -
n:最多从src中追加的字符数
代码示例
#include <stdio.h>
#include <string.h>
int main() {
char dest[20] = "chang li";
char src[] = " cay";
// "chang li" 占8个字符(c h a n g 空格 l i)
// dest 大小是20,剩余空间为 20 - 8 - 1 = 11(留1个位置给'\0')
// 最多追加11个字符,src 有4个字符(空格+c a y+'\0')
// 最多追加剩余空间-1个字符,确保不溢出
strncat(dest, src, sizeof(dest) - strlen(dest) - 1);
printf("dest: %s\n", dest); // 输出: chang li cay
return 0;
}
字符串的比较strncmp
这里有两个方法,strcmp和strncmp都是用于比较两个字符串是否相等。选择strncmp是因为可以限制比较的字符数,避免越界访问,更安全可控。
函数原型
int strcmp(const char *str1, const char *str2);
// strncmp 最多比较前 n 个字符
int strncmp(const char *str1, const char *str2, size_t n);
参数说明:
-
str1:第一个字符串指针 -
str2:第二个字符串指针 -
n:最多比较的字符数
返回值:
| 返回值 | 含义 |
|---|---|
0 |
两个字符串相等(或前 n 个字符相等) |
< 0 |
str1 小于 str2 |
> 0 |
str1 大于 str2 |
比较规则:按字符的ASCII码值逐个比较,遇到第一个不同的字符即决定大小。
代码示例
#include <stdio.h>
#include <string.h>
int main() {
char str1[] = "chang li";
char str2[] = "chang li cay";
char str3[] = "chang li";
int result;
// 比较前8个字符("chang li" 的长度)
// str1 和 str2 的前8个字符都是 "chang li",相等
result = strncmp(str1, str2, 8);
printf("比较前8个字符: %d\n", result); // 输出: 0(相等)
// 比较前9个字符
// str1 只有8个字符,第9个字符是 '\0'
// str2 的第9个字符是 空格(ASCII 32)
// '\0'(ASCII 0)< 空格(ASCII 32),所以 str1 < str2
result = strncmp(str1, str2, 9);
printf("比较前9个字符: %d\n", result); // 输出: 负数
// 比较 str1 和 str3(完全相等)
result = strncmp(str1, str3, sizeof(str1));
printf("比较完整字符串: %d\n", result); // 输出: 0(相等)
return 0;
}
字符串读取
老方案做法
用标准输入输出scanf和printf搞定
-
输出字符串都是用
printf函数 + 转换说明是%s,会逐个输出字符串中的字符,直到遇到空字符。 -
读取字符串都是用
scanf函数 + 转换说明%s,会跳过前导空白,读取字符直到遇到空白字符为止,并在末尾自动添加'\0',因此无法读入包含空格的整行文本。
前导空白就是
scanf只关心你输入的第一个单词,前面的空格都会被无视。
代码示例
#include <stdio.h>
int main() {
char str[20];
// 读取字符串
printf("请输入一个字符串: ");
scanf("%s", str); // 不需要加 &,数组名自动转为指针
// 输出字符串
printf("你输入的字符串是: %s\n", str);
return 0;
}
新方案做法
用输入输出函数 puts 和 gets 搞定
-
使用
puts函数,会逐个输出字符串中的字符直到遇到空字符,输出结束后自动添加换行符\n。 -
使用
gets函数,不会跳过前导空白,从第一个字符开始读取,直到遇到换行符\n才停止,并将换行符替换为空字符'\0'存储,因此可以读入包含空格的整行文本。
注意
scanf和gets函数都不会检查数组是否越界,因此使用这两类函数是不安全的。
代码示例
#include <stdio.h>
int main() {
char str[20];
// 读取字符串(可以读入空格)
printf("请输入一行文本: ");
gets(str); // 从第一个字符开始读取,直到换行符
// 输出字符串(自动添加换行符)
puts(str);
return 0;
}
13、函数
函数的概念
函数是什么
函数是一段封装了特定功能的代码块,可以被重复调用执行。前面其实已经使用了很多函数了,比如scanf和printf,这是标准库提供的函数。当然我们也可以根据需求书写函数解决问题。
函数的定义
语法结构
返回类型 函数名(参数列表) {
// 函数体
// 可选的返回语句
}
解释一下:
-
返回类型:函数执行结束后要返回的数据类型。函数不能返回数组,但可以返回数组元素,除此之外,函数可以返回任意类型的值。如果函数类型声明为 void,则函数没有返回值。
-
函数名:标识函数的名称,调用时使用。
-
参数列表:函数名的后面是形式参数列表。我们需要在每个形式参数前面指定它的类型,并且参数之间用逗号分隔。如果函数没有形式参数,可以标明为 void。
-
函数体:包含声明和语句的代码块。注意在函数体内声明的变量只属于此函数,即局部变量,其他函数不能访问和修改这些变量。
函数的调用
写完函数后,我们需要在程序的某个位置执行写好的函数,可以通过函数名来调用函数。
代码示例
#include <stdio.h>
// 函数定义:计算两个整数的和
int add(int a, int b) {
return a + b;
}
int main() {
int num1 = 5;
int num2 = 10;
int sum;
// 调用add函数,传递num1和num2作为参数
sum = add(num1, num2);
printf("num1 + num2 = %d\n", sum); // 输出: num1 + num2 = 15
return 0;
}
函数参数与返回值
函数参数
函数通过参数接收外部传入的数据,用于在函数内部执行特定操作。参数可以是一个值、一个数组、甚至是一个指针等。传参有两种方式分别是值传递和地址传递。
传参的方式
-
值传递:将实参的副本传递给函数,函数内修改参数不影响原始变量。
-
地址传递:将实参的地址(指针)传递给函数,函数内通过指针修改变量会影响原始变量。
代码示例
#include <stdio.h>
// 值传递:交换两个数的值
void swap01(int a, int b) {
int temp = a;
a = b;
b = temp;
printf("值传递函数内部: a=%d, b=%d\n", a, b); // 值传递函数内部: a=20, b=10
}
// 地址传递:交换两个数的值
void swap02(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
int main() {
int x = 10, y = 20;
printf("交换前: x=%d, y=%d\n\n", x, y); // 交换前: x=10, y=20
// 值传递调用
swap_value(x, y);
printf("值传递调用后: x=%d, y=%d\n\n", x, y); // 值传递调用后: x=10, y=20
// 地址传递调用
swap_pointer(&x, &y);
printf("地址传递调用后: x=%d, y=%d\n", x, y); // 地址传递调用后: x=20, y=10
return 0;
}
函数返回值
函数通过 return 语句将结果返回给调用者,返回值的类型由函数定义中的返回类型决定。
代码示例1
类型转换:如果 return 语句中表达式的类型与函数返回类型不匹配,系统会隐式转换为返回类型。
#include <stdio.h>
int func(void) {
double x = 3.14;
return x; // double 隐式转换为 int
}
int main() {
int result = func();
printf("%d\n", result); // 输出: 3
return 0;
}
代码示例2
void 函数:也可以使用 return 提前返回,但 return 后面不能接表达式。
#include <stdio.h>
void func(int n) {
if (n <= 0)
return; // 提前返回
printf("%d\n", n);
}
int main() {
func(5); // 输出: 5
func(-3); // 无输出,提前返回
return 0;
}
代码示例3
函数不能直接返回多个值,也不能直接返回数组,但是可以通过后面的指针实现返回多个值。也就是多个返回值可以通过指针指向数组首元素这个桥梁,全部返回到一个数组。
#include <stdio.h>
// 通过指针参数返回多个值
void func(int arr[], int n, int* min, int* max) {
*min = arr[0];
*max = arr[0];
for (int i = 1; i < n; i++) {
if (arr[i] < *min) *min = arr[i];
if (arr[i] > *max) *max = arr[i];
}
}
int main() {
int nums[] = {3, 7, 1, 9, 4};
int min, max;
func(nums, 5, &min, &max);
printf("最小值: %d, 最大值: %d\n", min, max);
return 0;
}
递归函数
递归函数是什么
递归函数是指在函数内部调用自身的函数。如果一个函数调用它本身,那么这个函数就是递归的。
递归思想
把⼀个⼤型复杂问题层层转化为⼀个与原问题相似,但规模较⼩的⼦问题来求解;直到⼦问题不能再被拆分,递归就结束了。所以递归的思考⽅式就是把大事化小的过程。
递归中的递就是递推的意思,归就是回归的意思
至高理解
我大学四年其实对递归都懵懵懂懂的,我尝试过从很多个角度去理解递归。循环堆栈去理解,模拟递归运算去理解,都能想通但是始终就写不出。总缺少那么个阀点。
当我大四考研的时候重新好好学习高数之后,我发现计算机几乎所有层面都可以站在数学的角度去理解,去思索。所以这次我要以数学去解决他。
最最最重要的核心:递归是数学归纳法在编程中的直接体现。
| 数学归纳法 | 递归函数 |
|---|---|
| 证明 P(1) 成立 | 写终止条件(最小规模直接返回) |
| 假设 P(k) 成立 | 相信递归调用能解决子问题 |
| 证明 P(k+1) 成立 | 用子问题的结果构造当前解 |
常见误区
这应该是80%人都有的误区,这也是为什么要从数学上去理解的原因。
-
模拟视角:我有点强迫症,必须要脑子里面模拟递归过程,才安心,必须验证每一个阶段才行,即使递归函数写出来了,我能相信这个函数么。
-
数学视角:数学归纳法已经证明了,只关心奠基和递推关系,其他的归纳法都证明了,不用关心细节。好好好你可能还是不直观。
这个是心理上的问题,就是你做数学题的时候代入函数计算,等比等差公式记得把,你知道首项和那个公差或者公比,就可以直接用公式了,压根就不会去模拟了对不对!
数学三步法写递归
无论什么递归问题,只问自己三个问题,这也是递归的限制条件和思想的体现。
按数学角度看就是:(以证明 1+2+...+n = n(n+1)/2 为例)
-
奠基:最小规模的问题,答案是什么?;比如 n = 1 时,等式成立
-
假设:假设 n = k 时成立
-
递推:证明 n = k+1 时也成立
第 1 步:奠基
最小规模的问题,答案是什么?
-
不需要递归,一眼就能看出答案;比如n = 0 或 n = 1 时,直接返回什么?
if (n == 0) return 0;
if (n == 1) return 1;
第 2 步:归纳假设
假设函数能正确解决规模为 n-1(或更小)的子问题
-
这是“数学信仰”:不关心怎么算出来的
-
就像你相信等差等比公式一样,相信你的递归函数
第 3 步:递推关系
如何用子问题的结果,构造当前问题的解?
-
这是递归函数的“核心公式”
-
找到
f(n)和f(n-1)(或f(n/2)等)之间的数学关系
return 子问题的结果 + 当前层要做的事;
通用模板
按上面三步法走,可以总结出递归模板如下:
类型 函数名(参数) {
// 结束条件
if (最简单的情况) {
return 直接结果;
}
// 相信递归能搞定小一号的问题
类型 子结果 = 函数名(缩小后的参数);
// 把小结果拼上当前这一步
return 子结果 + 当前层的处理;
}
行你已经完成新手教程了,直接挑战递归之王吧,解决他就干掉递归了。
递归之王
汉诺塔问题:有三根柱子(A、B、C),A柱上有 n 个大小不一的盘子,大的在下,小的在上。
规则:
-
每次只能移动一个盘子
-
大盘不能压在小盘上(必须上小下大)
-
目标:把 A 柱上的所有盘子移动到 C 柱,可以借助 B 柱
求:打印出每一步的移动步骤
思考:A 柱上有 n 个的盘子,大的在下,小的在上。把A 柱上的所有盘子移动到 C 柱,大盘不能压在小盘上。
这说明不管怎么移动最后 A 柱下面那个最大的盘都要去到 C 柱下面。所以有下面推导
-
移动最大盘的时候C柱此时一定是空的才能把最大的放到C柱最下面。
-
同时A柱上只能剩下这个最大盘。其他的所有盘一定在B柱上。
-
而且说了大盘不能压在小盘上,B盘上的也一定是大的在最下面。
-
也就是说第一步要把上面那堆盘子从A柱挪到B柱上去。然后才能把最大盘从A柱移动到C柱。然后在想办法把B柱的盘子全部移动到C柱上,也是小盘子压大盘子。
-
经过上面这一轮后,会发现原本A柱上的盘子除了最底下最大的盘子给了C柱,其他盘子按着A柱原本的摆放顺序还是在B柱上。而始终是那三个柱子,盘子还是那个从下到上的排列。
-
唯一的区别就是A柱的盘子原封不动全部到B柱了,除了最低下那个最大的移动到了C柱。
-
说明后面的过程都可以按每次移动最大的一块给C柱,然后其他盘子还是原封不动给另一个柱。一样的方式,只是盘子每次少了一个。
接下来就是代码实现了,就是代码模拟思维去实现他。注意上面的思考全是基于递归思想去推导的哦。
| 步骤 | 思考过程 |
|---|---|
| 奠基 | n=1 时,只有一个盘子,直接从 A 移到 C |
| 假设 | 假设 hanoi(n-1, ...) 能正确把 n-1 个盘子从一根柱子移到另一根柱子 |
| 递推 |
1、把上面 n-1 个盘子从 A 移到 B(借助 C) 2、把最大盘从 A 移到 C 3、把 B 上的 n-1 个盘子移到 C(借助 A) |
代码示例
#include <stdio.h>
void move(char from, char to) {
printf("%c -> %c\n", from, to);
}
void hanoi(int n, char from, char aux, char to) {
if (n == 1) {
move(from, to);
return;
}
hanoi(n - 1, from, to, aux); // 把上面 n-1 个从 A 移到 B(借助 C)
move(from, to); // 把最大盘从 A 移到 C
hanoi(n - 1, aux, from, to); // 把 B 上的 n-1 个移到 C(借助 A)
}
int main() {
int n = 3;
hanoi(n, 'A', 'B', 'C');
return 0;
}
14、指针
内存与指针
内存是什么
内存是电脑上特别重要的存储器,计算机中程序的运行都是在内存中进行的 。为了有效的使用内存,就把内存划分成一个个小的内存单元,每个内存单元的大小是1个字节。
为了能够有效的访问到内存的每个单元,就给内存单元进行了编号,这些内存单元的编号也被称为该内存单元的地址。通过地址就可以找到空间存储的东西。
内存与指针
内存单元的编号称为地址,也就是指针。变量在内存中创建时会分配空间,每个内存单元都有地址,因此变量也有自己的地址。
地址可以被取出并存储起来,以便后续通过这个地址找到变量并对其进行操作。在C语言中,用于存放地址的类型称为指针类型,该类型定义的变量称为指针变量。
指针是什么
指针是内存单元的地址。指针变量是一个变量,用于存储指针(地址)。通过指针变量,程序可以间接访问和修改存储在特定内存位置的数据。
指针可以实现动态内存管理、高效操作数组和字符串、构建链表树等复杂数据结构,以及通过传址调用提升函数参数传递的灵活性和效率。
直接上一个伪代码帮助理解
int i = 10;
int* p = &i;
printf("i 的地址: %p\n", &i); // 输出: i的地址
printf("p 的值: %p\n", p); // 输出: i的地址
解释一下:
-
指针就是地址,指针变量就是存放地址的变量。
-
int* p = &i;p是变量名,不是*p,所以指针变量p实际上存的是&i,也就是i的地址。 -
变量
p类型int*。 -
变量
p存储到值是&i。
内存示意图如下,所以当指针变量p存储变量 i 的地址时,我们就说p指向了i。

解引用(*)
前面说了p是变量名,指向的是i的地址,不过我要用i的值啊,还是上那个伪代码。
int i = 10;
int* p = &i;
printf("i 的值: %d\n", i); // 输出: 10
printf("*p 的值: %d\n", *p); // 输出: 10
p是变量名指向的是一个地址,加上 * 运算符,就顺着地址拿到/修改那个地址里存的值”。我个人的邪修理解是对地址在取地址就是数了。
指针变量
指针变量是什么
指针变量是存储内存地址的变量。指针变量的声明与普通变量的声明基本一样,唯一不同的就是必须在指针变量名的前面加上星号。
语法结构:
// C语言标准允许三种写法,功能完全等价
数据类型 *指针变量名;
数据类型* 指针变量名; // 个人最推荐这种,理解力拉满
数据类型 * 指针变量名;
解释一下我最推荐的方式:
-
数据类型*:指针数据类型,例如
int*,double*,char*。 -
指针变量名:指针的名称,和前面普通的变量名没什么区别,主要认前面的数据类型;
代码示例
#include <stdio.h>
int main() {
int i = 10;
int* p = &i;
printf("i 的地址: %p\n", &i); // 输出: i的地址
printf("p 的值: %p\n", p); // 输出: i的地址
printf("i 的值: %d\n", i); // 输出: 10
printf("*p 的值: %d\n", *p); // 输出: 10
return 0;
}
取地址运算符(&)与解引用(*)
这个很重要,c++里面还会常用。在前面scanf函数中是常客了,& 运算符没有什么多的意思,就是可以获取变量的地址。
而一旦指针变量指向了对象,就可以使用 * 运算符访问被指向的对象。例如,如果 p 指向了 i,那么下面的语句将显示 i 的值:
int i = 10;
int* p = &i
printf("%d\n", *p); // 输出10
只要 p 指向了 i,那么 *p 就是 i 的别名。*p 不仅拥有和i相同的值,而且对 *p 的改变也会改变 i 的值。用数学思维,可以把 & 和 * 看作是一对逆运算。
指针的初始化与野指针
野指针是指未被初始化的指针,如果指针变量 p 未被初始化或者指向了一个未知的区域,这样的指针我们称为野指针。试图对野指针使用解引用运算符会导致未定义的行为:
#include <stdio.h>
int main(void){
// int* p; // 未被初始化的指针(野指针)
int* p = 0xcba; // 不要用整数直接给指针变量初始化(野指针)
return 0;
}
指针变量赋值
赋值没什么说的,就是赋地址给指针变量,看下面两个例子,深入理解指针的"指向"这个抽象东西。
代码示例1:p = q — 指针变量赋值(交换地址)
#include <stdio.h>
int main() {
int i = 3, j = 4;
int* p = &i;
int* q = &j;
printf("初始状态:\n");
printf("i=%d, j=%d\n", i, j); // i=3, j=4
printf("*p=%d, *q=%d\n", *p, *q); // *p=3, *q=4
p = q; // 将 q 中存储的地址赋值给 p,p 现在指向 j
printf("\n执行 p = q 后:\n");
printf("i=%d, j=%d\n", i, j); // i=3, j=4(变量本身未变)
printf("*p=%d, *q=%d\n", *p, *q); // *p=4, *q=4(p 和 q 都指向 j)
return 0;
}
代码示例2:*p = *q — 解引用赋值(赋值值)
#include <stdio.h>
int main() {
int i = 3, j = 4;
int* p = &i;
int* q = &j;
printf("初始状态:\n");
printf("i=%d, j=%d\n", i, j); // i=3, j=4
printf("*p=%d, *q=%d\n", *p, *q); // *p=3, *q=4
*p = *q; // 将 q 指向的值(j的值4)赋给 p 指向的变量(i)
printf("\n执行 *p = *q 后:\n");
printf("i=%d, j=%d\n", i, j); // i=4, j=4(i 的值被修改)
printf("*p=%d, *q=%d\n", *p, *q); // *p=4, *q=4
return 0;
}
区别:
p = q指针变量赋值,*p和*q指向对象的赋值。
指针常量和常量指针
两个易混淆的概念
指针常量和常量指针
-
指针常量:指针本身是常量,指向的地址不可改变,但指向地址中的值可以改变。
-
常量指针:指针指向的内容是常量,指向地址中的值不可改变,但指针的指向可以改变。
语法结构:
// 指针常量
数据类型* const 指针变量名;
// 常量指针
const 数据类型* 指针变量名;
解释一下:
他们两者的区别就是看const修饰的位置,这里就可以体现我之前推荐数据类型*的第二个原因。
-
指针常量:
const在数据类型*右边:修饰的是指针变量本身,指针的指向不能变,但指向的值可以变。 -
常量指针:
const在数据类型*左边:修饰的是指向的内容,指针指向的那个值不能变,但指针可以指向别处。
代码示例
#include <stdio.h>
int main() {
int a = 10, b = 20;
// 指针常量:const 在 * 右边,指向不能变
int* const p1 = &a;
*p1 = 30; // 可以:修改指向的值
// p1 = &b; // 错误:不能改变指向
// 常量指针:const 在 * 左边,值不能变
const int* p2 = &a;
// *p2 = 30; // 错误:不能修改指向的值
p2 = &b; // 可以:改变指向
printf("指针常量: a = %d\n", a); // 输出: 30
printf("常量指针: *p2 = %d\n", *p2); // 输出: 20
return 0;
}
指针与数组的关系
数组名与指针
C 语言中指针和数组的关系非常紧密,我们可以用指针去处理数组。而数组那一章说过数组名代表数组的首地址,则数组名可以被视为指向数组第一个元素的指针。

也就是说我们可以定义一个指针指向数组名(数组首地址)就可以间接通过指针操作整个数组了。
代码示例
#include <stdio.h>
int main() {
int arr[] = {10, 20, 30, 40, 50};
int* p = arr; // 指向数组首地址(等价于 &arr[0])
// 通过指针遍历数组
for (int i = 0; i < 5; i++) {
printf("%d ", *(p + i)); // 输出: 10 20 30 40 50
}
// 修改数组元素
*p = 100; // 修改第一个元素
*(p + 3) = 400; // 修改第四个元素
printf("\n修改后: ");
for (int i = 0; i < 5; i++) {
printf("%d ", arr[i]); // 输出: 100 20 30 400 50
}
return 0;
}
指针的算数运算
将指针 p 指向数组元素,这没什么大不了的。但是,通过对指针 p 执行指针算术运算,我们可以访问数组 a 的其他所有元素!C 语言支持 3 种格式 (而且只有 3 种) 的指针算术运算:
-
指针加上一个整数,结果是指针。
-
指针减去一个整数,结果是指针。
-
两个指针相减,结果是那个整数。
代码示例
#include <stdio.h>
int main() {
int a[] = {10, 20, 30, 40, 50};
int* p = a; // 指向第一个元素
int* q = &a[4]; // 指向最后一个元素
// 指针 + 整数
printf("*(p+2) = %d\n", *(p+2)); // 30
// 指针 - 整数
printf("*(q-2) = %d\n", *(q-2)); // 30
// 指针 - 指针
printf("q - p = %d\n", q - p); // 4(元素个数差)
return 0;
}
指针作为函数参数传递数组
数组作为函数参数实际是传入数组的首地址作为参数。数组名在传递给函数时,总是被视为指针(也就是说会退化成指针)。
C 语言没有提供任何简便的方法供函数确定数组的长度。如果需要知道数组的长度,我们必须在外面用sizeof计算出数组长度,提供进入函数里面。
int func(int a[], int n){}
在调用 func 函数时,会把指向数组的第一个元素的指针赋值给 a,数组本身并没有复制。所以既然传递数组时,只是传递一个指针,那么就可以把数组类型的形式参数声明为指针类型。
修改如下:
int func(int* a, int n){}
而数组传入的是指针这种地址的参数,所以是地址传递,是会影响外部数组的数据的。
仅仅是对于形参而言,声明数组和声明指针是一样的;但对于变量而言,声明数组和声明指针是不同的。
代码示例
#include <stdio.h>
// 数组形式参数
void func1(int arr[], int n) {
for (int i = 0; i < n; i++) {
arr[i] *= 2;
}
}
// 指针形式参数(等价)
void func2(int* arr, int n) {
for (int i = 0; i < n; i++) {
*(arr + i) *= 2;
}
}
int main() {
int nums[] = {1, 2, 3, 4, 5};
int size = sizeof(nums) / sizeof(nums[0]);
for (int i = 0; i < size; i++) {
printf("%d ", nums[i]); // 修改前:1 2 3 4 5
}
func1(nums, size); // 用数组形式
for (int i = 0; i < size; i++) {
printf("%d ", nums[i]); // 修改后: 2 4 6 8 10
}
// 重置数组
int nums2[] = {1, 2, 3, 4, 5};
func2(nums2, size); // 用指针形式
for (int i = 0; i < size; i++) {
printf("%d ", nums2[i]); // 修改后: 2 4 6 8 10
}
return 0;
}
指针操作数据
用指针指代数组
既然数组名可以作为指针,那么我们反过来也可以把指针当作数组名来使用。
代码示例
#include <stdio.h>
#define N 5
int main() {
int a[N] = {10, 20, 30, 40, 50};
int sum = 0, *p = a; // p 指向数组首地址
// 把指针当作数组名使用
for (int i = 0; i < N; i++) {
sum += p[i]; // p[i] 等价于 *(p + i)
}
printf("数组元素: ");
for (int i = 0; i < N; i++) {
printf("%d ", p[i]); // 输出: 10 20 30 40 50
}
printf("\n总和: %d\n", sum); // 输出: 150
// 验证 p[i] 等价于 *(p + i)
printf("\n验证等价关系:\n");
printf("p[2] = %d\n", p[2]); // 30
printf("*(p + 2) = %d\n", *(p + 2)); // 30
return 0;
}
编译器会把 p[i] 看作是 *(p + i),对指针变量取下标是非常常见的一种用法。
用指针遍历数组
我们可以通过对指针变量进行重复的自增来遍历数组中的元素。
代码示例
#include <stdio.h>
int main() {
int arr[] = {10, 20, 30, 40, 50};
int* p = arr; // p 指向第一个元素
// 通过指针自增遍历数组
printf("遍历数组: ");
for (int i = 0; i < 5; i++) {
printf("%d ", *p); // 输出当前指向的元素
p++; // 指针自增,指向下一个元素
}
return 0;
}
运算符(*)与自增运算符
在处理数组元素时,经常组合使用 * 运算符和 ++ 运算符。比如先对某个元素进行赋值,然后前进到下一个元素,利用数组下标可以这样写:
// 将j赋值给数组
a[i++] = j;
如果有一指针 p 指向 a[i],那么我们可以这样写
*p++ = j;
当然* 和 ++还有其他组合如下:
| 表达式 | 含义 |
|---|---|
*p++ 或 *(p++) |
表达式的值为 *p ,然后 p 自增 |
(*p)++ |
表达式的值为 *p,然后 *p 自增 |
*++p 或 *(++p) |
表达式的值为 *(p+1),然后 p 自增 |
++*p 或 ++(*p) |
表达式的值为 *p+1,然后 *p 自增 |
这四种组合都可能出现在程序中,不过最常见的还是*p++。比如对数组元素求和时。
for (p = &a[0]; p < &a[N]; p++)
sum += *p;
改写成
p = &a[0];
while (p < &a[N])
sum += *p++;
-
运算符
*和--运算符的组合方式类似与*和++的组合,这里我就跳了。
指针作为参数和返回值
C 语言函数调用时,是进行值传递的。所以在函数调用中,我们无法改变实参的值。
指针作为参数
指针提供了此问题的解决方法:不再传递 a 和 b 作为函数的实际参数,而是传递 &a 和 &b。相应地我们也会把形式参数声明为对应的指针类型。调用函数时,*p 和 *q 分别为变量 a 和 b 的别名,因此可以通过 *p 和 *q 改变变量 a 和 b 的值。
void swap(int* p, int* q) {
int temp = *p;
*p = *q;
*q = temp;
}
// swap(&a, &b);
指针作为参数实际上并不新鲜,我们在 scanf 函数中早就用过了。
scanf("%d", &i);
虽然 scanf 函数格式串之后的参数必须是指针,但并不是说我们必须使用 & 运算符。
int i;
int* p = &i;
scanf("%d", p);
指针作为返回值
我们不仅可以为函数传递指针,还可以编写返回指针的函数。
int* func(int a[], int n) {
return &a[n/2]; // 函数返回的是一个数组元素的地址
}
但是注意永远不要返回指向当前栈帧区域的指针。函数执行完毕后,函数内部栈帧会被回收释放,这块内存不再属于该函数。虽然地址还在,但里面存储的数据可能随时被其他函数覆盖。
int* func(void) {
int i; // 局部变量
...
return &i; // 返回局部变量的指针得到的是一块失效的内存地址
}
返回这样的指针,得到的是一块已经失效的内存地址,称为悬垂指针。后续通过这个指针访问数据,结果是未定义行为(可能崩溃、可能读到错误的值)。
函数指针与指针函数
在一开始的基础认知中就提了心法,C语言中一切皆为地址,指向函数的指针并没有人们想象中的奇怪,毕竟函数也占用内存单元,函数也有地址,就像所有的变量都有地址一样。
两个易混淆的概念
函数指针和指针函数
-
指针函数:返回值为指针的函数,本质是函数。
-
函数指针:指向函数的指针变量,本质是指针变量。
语法结构:
// 指针函数:返回指针的函数
数据类型* 函数名(参数列表);
// 函数指针:指向函数的指针变量
数据类型 (*指针变量名)(参数列表);
解释一下:
这个和前面常量指针和指针常量几乎一样的,两者的区别就是看 * 的位置
-
指针函数:
*在函数名左边(数据类型* 函数名),表示函数返回的是指针类型。 -
函数指针:
*在函数名括号内(数据类型 (*指针名)(参数)),表示这是一个指向函数的指针变量。
代码示例
#include <stdio.h>
// 指针函数:返回值为指针
int* getMiddle(int arr[], int n) {
return &arr[n / 2]; // 返回数组中间元素的地址
}
// 普通函数,供函数指针使用
int add(int a, int b) {
return a + b;
}
int subtract(int a, int b) {
return a - b;
}
int main() {
// ========== 指针函数示例 ==========
int nums[] = {10, 20, 30, 40, 50};
int* mid = getMiddle(nums, 5); // 指针函数返回指针
printf("指针函数: 中间元素 = %d\n", *mid); // 输出: 30
// ========== 函数指针示例 ==========
int (*p)(int, int); // 定义函数指针
p = add; // 指向 add 函数
printf("函数指针: add(3,5) = %d\n", p(3, 5)); // 输出: 8
p = subtract; // 指向 subtract 函数
printf("函数指针: subtract(10,3) = %d\n", p(10, 3)); // 输出: 7
return 0;
}
回调函数
回调函数是什么
函数指针作为另一个函数的参数就是回调函数。在 C 语言中把函数指针作为参数传递是十分普遍的,这样的函数我们称之为回调(callback)函数。
回调函数是C语言实现异步、事件驱动、解耦设计的基石,实际开发中无处不在。C语言的回调函数在思想上就是其他面向对象语言的高阶函数。
-
C语言不支持高阶函数(函数不能作为返回值)
-
只能通过函数指针实现有限的“回调”功能
-
这是C语言作为过程式语言的特性限制
这里实在理解抽象把他想象成用C语言去描述微积分,符合函数。
场景说明
假设我们要编写一个通用的 integrate 函数,用来求某个函数 f 在 a 点到 b 点之间的积分,那么就可以把 f 声明为函数指针。
函数声明
假设我们希望对参数是 double 类型、返回值也是 double 类型的函数求积分,那么 integrate 函数可以声明如下:
double integrate(double (*f)(double), double a, double b);
注意:
(*f)中的圆括号不能省略,这说明f是一个指向函数的指针;如果省略圆括号写成double *f(double),则f是一个返回值为double*的函数(指针函数)。
上式也可以声明成更简洁的形式(编译器会自动将函数类型转换为函数指针):
double integrate(double f(double), double a, double b);
函数调用
在调用 integrate 函数时,我们可以把某个函数的名称当作第一个参数:
result = integrate(sin, 0.0, PI / 2);
传入函数的名称,就相当于传递函数的地址,这和数组非常类似(数组名也代表数组首地址)。
函数内部使用
在 integrate 函数内部,我们可以这样调用函数指针 f 指向的函数:
y = (*f)(x); // 标准写法
上式还可以写成更简洁的形式就是那个我们数学上的函数表达式屌爆了!
y = f(x); // 等价写法,编译器会自动处理
*f 表示 f 指向的函数。因此,在 integrate(sin, 0.0, PI / 2) 执行的时候,每次调用 *f,其实调用的就是 sin 函数。
代码示例
#include <stdio.h>
#include <math.h>
// 函数指针作为参数:求函数 f 在 [a, b] 上的积分(矩形法近似)
double integrate(double (*f)(double), double a, double b) {
int n = 1000; // 划分的份数
double h = (b - a) / n; // 步长
double sum = 0.0;
for (int i = 0; i < n; i++) {
double x = a + (i + 0.5) * h; // 中点
sum += f(x); // 调用回调函数(等价于 (*f)(x))
}
return sum * h;
}
// 普通函数:计算 x 的平方
double square(double x) {
return x * x;
}
int main() {
// 使用库函数 sin
double result1 = integrate(sin, 0.0, 3.14159 / 2);
printf("∫ sin(x) dx [0, π/2] = %.4f\n", result1);
// 使用自定义函数 square
double result2 = integrate(square, 0.0, 2.0);
printf("∫ x² dx [0, 2] = %.4f\n", result2);
return 0;
}
动态内存分配
动态内存分配在 C 语言编程中有着举足轻重的作用,因为它是链式结构的基础。我们可以把动态分配的内存链接在一起,是形成链表、树、图等灵活的数据结构的基础。
内存分配函数
我们可以使用下面三个函数进行动态内存分配,这些函数都声明在 <stdlib.h> 头文件中。
-
void* malloc(size_t size):分配size个字节的内存块,不对内存块进行清零;如果无法分配指定大小的内存块,返回空指针。 -
void* calloc(size_t nmemb, size_t size):为 指定个数 的元素分配内存块,其中每个元素占size个字节,并且对内存块进行清零;如果无法分配指定大小的内存块,返回空指针。 -
void* realloc(void *ptr, size_t size):调整先前分配内存块的大小。如果重新分配内存大小成功,返回指向新内存块的指针,否则返回空指针。
这三个函数当中, malloc 是常用的,也是效率最高的。
返回类型
当我们调用这些动态内存分配函数申请堆内存空间的时候,函数是无法知道我们打算往内存中存储什么类型数据的,所以函数返回 void* 类型的指针。
void* 类型的值是"通用"指针,指针本质上其实就是内存地址。void* 类型的指针可以转换成其它类型的指针,其它类型的指针也可以转换成 void* 类型的指针。
代码示例
#include <stdio.h>
#include <stdlib.h>
int main() {
// malloc 返回 void*,需要强制转换为目标类型
int* p = (int*)malloc(5 * sizeof(int));
if (p == NULL) {
printf("内存分配失败\n");
return 1;
}
p[0] = 10;
printf("%d\n", p[0]); // 输出: 10
free(p);
return 0;
}
malloc
malloc从堆区申请一块连续的内存空间,大小按字节指定。函数返回这块内存的首地址,但内存里的数据是随机的,不会帮你清空。
函数原型:
void* malloc(size_t size);
参数说明:
-
size:要分配的字节数
代码示例
一定要用 sizeof 运算符计算所需的内存空间。因为在 C 语言中,同一类型在不同平台上所占内存大小可能是不相同的。
#include <stdio.h>
#include <stdlib.h>
int main() {
int n = 5;
// 分配 n 个整型的内存空间
int* p = (int*)malloc(n * sizeof(int));
// 检查分配是否成功
if (p == NULL) {
printf("内存分配失败\n");
return 1;
}
// 初始化并访问分配的内存
for (int i = 0; i < n; i++) {
p[i] = (i + 1) * 10;
printf("p[%d] = %d\n", i, p[i]);
}
// 释放内存,避免内存泄漏
free(p);
return 0;
}
calloc
calloc 从堆区申请一块连续的内存空间,每个元素具有相同的大小,并将分配的内存初始化为零。
函数原型:
void* calloc(size_t num, size_t size);
参数说明:
-
num:元素的个数 -
size:每个元素占用的字节数
代码示例
为数组申请内存空间时,我们往往会使用 calloc 函数。因为 calloc 会对所申请的内存空间清零,而 malloc 不会。
#include <stdio.h>
#include <stdlib.h>
int main() {
int n = 5;
// 分配 5 个整型元素的内存,并自动清零
int* p = (int*)calloc(n, sizeof(int));
if (p == NULL) {
printf("内存分配失败\n");
return 1;
}
// 所有元素已自动初始化为 0
for (int i = 0; i < n; i++) {
printf("p[%d] = %d\n", i, p[i]); // 全部输出 0
}
// 释放内存
free(p);
return 0;
}
realloc
realloc 用来改变已分配内存块的大小,可以变大也可以变小。如果要扩大,可能会把这块内存搬到别的地方去。
为数组分配内存空间之后,稍后我们可能会发现数组过大或者过小。这时可以使用 realloc 函数将数组调整到合适的大小。
函数原型:
void* realloc(void *ptr, size_t new_size);
参数说明:
-
ptr:指向先前通过malloc、calloc或realloc分配的内存块的指针(可为NULL,此时等价于malloc(new_size)) -
new_size:新内存块的大小(字节数)
C 标准有几条关于 realloc 函数的规则:
-
如果申请新内存块不成功,那么 realloc 函数会返回空指针;并且旧内存块的数据不会发生改变。
-
如果新内存块比旧内存块大,那么超过的那部分内存是不会被初始化的。
-
如果 realloc 的第一个参数为空指针,那么它的行为和 malloc 一样。
-
如果 realloc 的第二个参数为 0,那么它会释放 ptr 指向的内存块。
代码示例
当调用 realloc 函数时,ptr 必须指向先前通过 malloc , calloc 或者 realloc 获得的内存块(即堆内存空间)。size 表示新内存块的大小。
#include <stdio.h>
#include <stdlib.h>
int main() {
int n = 5;
int new_n = 10;
// 分配初始内存
int* p = (int*)malloc(n * sizeof(int));
if (p == NULL) {
return 1;
}
// 初始化前 5 个元素
for (int i = 0; i < n; i++) {
p[i] = i + 1;
}
// 调整内存大小
int* new_p = (int*)realloc(p, new_n * sizeof(int));
if (new_p == NULL) {
free(p); // realloc 失败,原内存仍有效
return 1;
}
p = new_p;
// 扩大的部分需要手动初始化
for (int i = n; i < new_n; i++) {
p[i] = 0;
}
// 释放内存
free(p);
return 0;
}
C 标准没有明确指明 realloc 的工作原理。不过,在实现上我们一般会让它尽可能地高效。
-
当新内存块比旧内存块小的时候,我们会直接截断旧内存块。
-
当新内存块比旧内存块大的时候,我们会试图原地扩大旧内存块;如果这样不可行,再在别处申请内存块,并把旧内存块里的数据复制到新内存块,同时释放旧内存块。
注意:因为 realloc 可能会移动内存块,所以一定要记得更新所有指向旧内存块的指针。
p = realloc(ptr, new_size);
if (!p) {
// handle error
}
ptr = p;
free
malloc , calloc 和 realloc 函数都是在堆区申请内存空间的,如果频繁地调用这些函数,那么堆上的空间总会被消耗殆尽。
-
如果丢失了对这些内存块的跟踪,那这些内存块就无法再被程序使用,出现了内存泄漏现象。
-
不可再被访问的内存块被称为垃圾。如果程序中留有垃圾,这种现象被称为内存泄漏。
-
一些语言有自动管理内存空间。不过C 语言要求我们自己负责垃圾的回收,所以提供了 free 函数。
函数原型:
void free(void *ptr);
参数说明:
-
ptr:指向先前通过malloc、calloc或realloc分配的内存块的指针。如果ptr为NULL,free什么都不做。
代码示例
使用 free 函数很容易,只需要把指向不再需要的内存块的指针传递给 free 函数就可以了。
#include <stdio.h>
#include <stdlib.h>
int main() {
// 分配内存
int* p = (int*)malloc(5 * sizeof(int));
if (p == NULL) {
printf("内存分配失败\n");
return 1;
}
// 使用内存
for (int i = 0; i < 5; i++) {
ptr[i] = i * 10;
printf("%d ", p[i]);
}
printf("\n");
// 释放内存
free(p);
// 释放后,p 变成野指针,建议置为 NULL
p = NULL;
// 错误:不能再使用 p
// *p = 100; // 未定义行为
return 0;
}
使用 free 函数有两个要注意的事情:
-
传递给 free 的参数必须是由内存分配函数返回的指针,否则 free 函数的行为是未定义的。
-
同一片内存空间不能被 free 两次,否则会出现 double-free 现象。
空指针
空指针是什么
调用内存分配函数时,如果找不到足够大的内存空间,函数就会返回空指针。空指针是"不指向任何对象的指针"——在实现上,我们通常是将它指向一个特殊的地址(0x00000000),并且用宏 NULL 表示。
在 C 语言中,对空指针进行解引用,其行为是未定义的。不过在实现上,一般会报空指针异常,导致程序崩溃。当函数可能返回空指针的时候,在使用指针之前,我们应该进行判断。
代码示例
#include <stdio.h>
#include <stdlib.h>
int main() {
// 尝试分配大量内存(可能失败)
int* p = (int*)malloc(1024 * 1024 * 1024); // 申请 1GB 内存
// 使用前必须判断是否为空指针
if (p == NULL) {
printf("内存分配失败!\n");
return 1; // 分配失败,退出程序
}
// 分配成功,可以安全使用
printf("内存分配成功!\n");
p[0] = 100;
printf("p[0] = %d\n", p[0]);
// 释放内存
free(p);
// 释放后,也可以将指针置为 NULL
p = NULL;
return 0;
}
正如我们前面所说的,空指针其实就是值为 0 的指针。
悬空指针
悬空指针是什么
虽然我们可以使用 free 函数释放不再需要的内存空间,但是也会引入一个新的问题:悬空指针问题。
代码示例
调用 free(p) 会释放 p 指向的内存空间,但并不会改变 p 的值。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main() {
// 分配内存
int* p = (int*)malloc(5 * sizeof(int));
int* q = p; // q 和 p 指向同一块内存
// 使用内存
for (int i = 0; i < 5; i++) {
p[i] = i * 10;
}
// 释放内存
free(p); // 内存已释放,但 p 和 q 仍然保存着原来的地址
// 危险!p 和 q 现在是悬空指针
// 以下操作是未定义行为(可能崩溃、可能读到错误数据)
// *p = 100; // 危险!
// printf("%d\n", *q); // 危险!
// 正确做法:释放后将指针置为 NULL
p = NULL;
q = NULL;
return 0;
}
悬空指针是很难被发现的,因为可能有几个指针指向相同的内存块,在释放该内存块后,所有的指针都"悬空"了。
15、结构体
结构体的概念
结构体是什么
结构体是一系列不同类型的数据组合在一起,形成的一个新的复合数据类型。可以把它理解为一个“模板”或“蓝图”,用来定义一类事物共同拥有的属性。结构体使得C语言有能力描述复杂类型。
语法结构:
struct 结构体名 {
数据类型 变量1;
数据类型 变量2;
// ...
};
好了就等于是把变量1,变量2,管你有多少个变量打包在一起,当成一个数据类型去看。就等于我们创建了一个新数据类型
结构体变量的定义
数据类型是描述变量的,知道了数据类型,就知道这个变量该占多大内存、能存什么值。那么,结构体作为一种新的数据类型,自然也可以用来定义变量。用结构体类型定义的变量,就叫结构体变量。
语法结构
struct 结构体名 结构体变量名;
这和我们前面的普通变量的定义没什么两样,结构体名就是那个新数据类型的名字。直接数据类型后面跟变量名就好了。struct是明确告诉编译器:“后面这个名字是一个结构体类型名”。
代码示例
#include <stdio.h>
// 定义结构体,用于表示学生的各个属性
struct Student {
int number; // 学生排名
char name[25]; // 学生名字
char gender; // 学生性别
int chinese; // 语文成绩
int math; // 数学成绩
int english; // 英语成绩
};
// 结构体变量的定义(全局定义)
struct Student s1;
struct Student s2;
int main(){
return 0;
}
结构体变量的初始化
我们把定义在结构体中的那些成员变量成为他的属性,那么结构体变量初始化有两种,可以单独就给属性初始化,也可以一口气全部初始化。还是以上面结构体变量的那个例子。
初始化方式
-
初始化列表:先来看一口气初始化结构体变量的吧,用的初始化列表,这和数组非常类似。
// 结构体变量的定义并初始化 struct Student s1 = {1, "changli", 'm', 150, 150, 150}; // 未被初始化的成员会被赋值为0 struct Student s2 = {2, "wyl", 'f'}; // 语文数学英语都是0 // 按顺序初始化 struct Student s3 = {0, "", '', 150, 0, 0}; // 按成员顺序,math是第4个 -
逐个赋值:给指定的某个属性初始化。我们使用
.成员访问运算符去访问到各个属性、// 结构体变量定义 struct Student s1; // 对其中的属性初始化 s1.chinese = 149; s1.math = 149;注意两个误区:
-
误区1:不能在定义变量的同时,用点运算符去访问成员。
// 这是不允许的bro struct Student s1.math = 150; -
误区2:逐行赋值中字符数组需要使用。
// 需要用 strcpy 进行赋值 strcpy(s1.name, "changli");
-
关于初始化的问题
逐行赋值中字符数组并不能像初始化列表那样直接就初始化了,初始化列表可以,是因为那是定义的同时给初值,这叫“初始化”。而先定义后逐个给属性赋值,这是赋值。
C语言对初始化有特殊照顾,允许这样做。要从内存的角度上去理解
-
初始化:变量还没诞生,编译器在“创造”它的时候,可以直接在内存里摆好数据。
-
赋值:变量已经存在了,数组名代表的是地址(一个常量),你不能改变一个常量的值。
代码示例
#include <stdio.h>
#include <string.h>
// 定义结构体类型
struct Student {
int number; // 学生排名
char name[25]; // 学生名字
char gender; // 学生性别
};
// 定义全局结构体变量(在函数外面)
struct Student s1;
int main() {
// 定义局部结构体变量并初始化
struct Student s2 = {1, "邓某", 'M'};
// 给全局变量赋值
s1.number = 2;
strcpy(s1.name, "粒某");
s1.gender = 'M';
// 给局部变量修改值
s2.number = 3;
strcpy(s2.name, "代某");
s2.gender = 'M';
// 输出查看
printf("全局变量: 排名=%d, 姓名=%s, 性别=%c\n", s1.number, s1.name, s1.gender);
printf("局部变量: 排名=%d, 姓名=%s, 性别=%c\n", s1.number, s2.name, s2.gender);
return 0;
}
结构体类型与typedef
给结构体取别名
结构体是一种聚合类型。我们可以用 typedef 给结构体类型起别名,避免每次声明结构体类型变量时都要加上 struct 关键字。
在前面数据类型中的自定义类型中我也总结了typedef的,用typedef给结构体取别名才是最常见。
代码示例
#include <stdio.h>
#include <string.h>
// 定义结构体类型,并用 typedef 起别名
typedef struct Student {
int number; // 学生排名
char name[25]; // 学生名字
char gender; // 学生性别
} Stu; // Stu 就是 struct Student 的别名
int main() {
// 使用别名 Stu 定义变量,不需要写 struct 关键字
Stu s1 = {1, "changli", 'M'};
Stu s2;
// 给 s2 赋值
s2.number = 2;
strcpy(s2.name, "周杰伦");
s2.gender = 'M';
// 输出
printf("学生1: 排名=%d, 姓名=%s, 性别=%c\n", s1.number, s1.name, s1.gender);
printf("学生2: 排名=%d, 姓名=%s, 性别=%c\n", s2.number, s2.name, s2.gender);
return 0;
}
结构体数组与指针
我们现在可以把结构体看成一个数据类型了,那就有该类型的数组和指针啊。结构体数组用于批量管理同类型结构体变量,结构体指针用于间接访问结构体成员,常配合->运算符在函数传参时提高效率。
结构体数组
用来存储多个相同结构体类型的变量,比如一个班级的50个学生。
语法结构
typedef struct 结构体名 {
数据类型 成员1;
数据类型 成员2;
// ...
} 别名;
别名 数组名[数组长度];
访问的话还是和前面的数组一样可以用下标去访问。
代码示例
#include <stdio.h>
// 定义结构体类型,并用 typedef 起别名
typedef struct Student {
int number; // 学生排名
char name[25]; // 学生名字
char gender; // 学生性别
} Stu;
int main() {
// 定义结构体数组并初始化(3个学生)
Stu class[3] = {
{1, "changli", 'M'},
{2, "cay", 'M'},
{3, "wyl", 'F'}
};
// 通过下标访问成员
printf("第2个学生:%s,性别:%c\n", class[1].name, class[1].gender);
// 遍历数组
for(int i = 0; i < 3; i++) {
printf("排名%d:%s\n", class[i].number, class[i].name);
}
return 0;
}
结构体指针
指向结构体变量的地址,常用于函数传参(避免拷贝整个结构体,提高效率),通常与数组搭配实现复杂的数据结构。
语法结构
typedef struct 结构体名 {
// 成员列表
} 别名;
别名 变量名;
别名 *指针名 = &变量名; // 指针指向结构体变量
成员运算符和剪头运算符
这是两个易混点。
| 运算符 | 名称 | 左边是什么 | 用法 |
|---|---|---|---|
. |
成员运算符 | 结构体变量本身 | 变量名.成员名 |
-> |
箭头运算符 | 指向结构体的指针 | 指针名->成员名 |
箭头 -> 像一个箭头射向指针指向的结构体,相当于 (*指针).成员 的简写。
代码示例
结构体指针操作数组
#include <stdio.h>
// 定义结构体类型,并用 typedef 起别名
typedef struct Student {
int number; // 学生排名
char name[25]; // 学生名字
char gender; // 学生性别
} Stu;
int main() {
// 普通结构体变量
Stu s1 = {1, "changli", 'M'};
// 结构体指针指向s1
Stu *p = &s1;
// 两种访问方式等价
printf("用点运算符:%s\n", s1.name); // 变量.成员
printf("用箭头运算符:%s\n", p->name); // 指针->成员
printf("先解引用再点:%s\n", (*p).name); // 箭头是这玩意的简写
return 0;
}
综合使用
结构体数组用下标 [i] 和点 . 访问,结构体指针用箭头 -> 访问;两者结合可以高效批量操作数据。
#include <stdio.h>
typedef struct Student {
int number;
char name[25];
char gender;
} Stu;
int main() {
// 定义结构体数组
Stu class[3] = {
{1, "changli", 'M'},
{2, "cay", 'M'},
{3, "wyl", 'F'}
};
// 指针指向数组首元素
Stu *p = class; // 数组名即地址,等价于 &class[0]
// 通过指针遍历数组
for(int i = 0; i < 3; i++) {
printf("第%d个学生:%s,排名:%d\n", i+1, (p+i)->name, (p+i)->number);
// 等价写法:p[i].name
}
return 0;
}
结构体的嵌套
结构体的嵌套
结构体可以包含其他结构体作为成员,实现更复杂的数据结构。他的理解非常重要。
语法结构
// 先定义内层结构体
typedef struct Date {
int year;
int month;
int day;
} Date;
// 再定义外层结构体,包含内层结构体作为成员
typedef struct Student {
int number; // 学生排名
char name[25]; // 学生名字
char gender; // 学生性别
Date birthday; // 嵌套:生日(结构体成员)
} Stu;
嵌套结构体就是“层层点下去”,用点运算符一层一层访问到底。
代码示例
#include <stdio.h>
// 定义生日结构体
typedef struct Date {
int year;
int month;
int day;
} Date;
// 定义学生结构体,嵌套包含生日
typedef struct Student {
int number; // 学生排名
char name[25]; // 学生名字
char gender; // 学生性别
Date birthday; // 嵌套:生日
} Stu;
int main() {
// 初始化嵌套结构体
Stu s1 = {
1,
"changli",
'M',
{2004, 2, 26} // 生日的初始化
};
// 或者分步赋值
Stu s2;
s2.number = 2;
strcpy(s2.name, "wyl");
s2.gender = 'F';
s2.birthday.year = 2003;
s2.birthday.month = 1;
s2.birthday.day = 20;
// 访问嵌套成员(用点运算符一层一层往下点)
printf("学生1:%s,生日:%d年%d月%d日\n",
s1.name,
s1.birthday.year,
s1.birthday.month,
s1.birthday.day);
printf("学生2:%s,生日:%d年%d月%d日\n",
s2.name,
s2.birthday.year,
s2.birthday.month,
s2.birthday.day);
return 0;
}
16、文件操作
文件操作是C语言中用于数据持久化存储的重要机制,C语言里面提供了一系列标准库函数,使得C语言能对文件进行操作。
文件的打开与关闭
在C语言中,任何文件操作都必须先打开文件,操作完成后必须关闭文件。
文件的类型
C 语言支持两种类型的文件:文本文件和二进制文件。文本文件中存储的是字符数据,人类是可以看懂的;二进制文件中的数据,人类是看不懂的。
文件可以分为文本文件和二进制文件,它们除了打开方式以外,其他没区别。
-
文本文件:人类可读,数据量大。
-
二进制文件:人类不可读,数据量小 。
文件的打开
函数原型:
FILE *fopen(const char *filename, const char *mode);
参数说明:
-
filename:要打开的文件名(可包含路径),如"data.txt"或"C:\\test\\data.txt" -
mode:文件的打开模式,指定文件的访问方式 -
FILE*:返回值是FILE*(文件指针)。
一般打开项目文件用相对路径,打开系统文件用绝对路径。
文件的打开模式
| 模式 | 读/写 | 文件不存在 | 文件存在 | 初始读写位置 |
|---|---|---|---|---|
"r" |
只读 | 报错返回NULL | 正常打开 | 文件开头 |
"w" |
只写 | 自动创建 | 内容被清空(覆写) | 文件开头 |
"a" |
只写(追加) | 自动创建 | 保留原内容 | 写入在末尾 |
"r+" |
读写 | 报错返回NULL | 正常打开 | 文件开头 |
"w+" |
读写 | 自动创建 | 内容被清空 | 文件开头 |
"a+" |
读写(追加) | 自动创建 | 保留原内容 | 读:开头 / 写:末尾 |
二进制文件打开模式:就是在上述模式中间加个 b,如 "rb","wb","ab","rb+","wb+","ab+",用于处理图片、视频等非文本文件。
代码示例
这个是文本文件操作示例,二进制文件放在文件关闭的示例
#include <stdio.h>
int main() {
// 以写入模式打开文件(不存在则创建,存在则清空)
FILE *fp = fopen("test.txt", "w");
if (fp == NULL) {
printf("文件打开失败\n");
return 1;
}
// 写入内容
fprintf(fp, "Hello, World!\n");
fprintf(fp, "这是第二行\n");
// 关闭文件
fclose(fp);
// 以追加模式打开(保留原内容,写在末尾)
fp = fopen("test.txt", "a");
if (fp == NULL) {
printf("文件打开失败\n");
return 1;
}
fprintf(fp, "这是追加的内容\n");
fclose(fp);
return 0;
}
文件的关闭
每次成功打开文件后,都必须确保文件关闭,防止内存泄露。
函数原型
int fclose(FILE *stream);
参数说明:
-
stream:fopen()返回的文件指针,指向要关闭的文件 -
返回值:关闭成功返回0,关闭失败返回EOF(通常为 -1)
注意事项
-
文件关闭后,文件指针
fp变为野指针,建议置为NULL。 -
如果程序正常结束(
return 0或exit()),系统会自动关闭所有打开的文件,但显式调用fclose()是好习惯 -
关闭失败可能原因:文件已被关闭、磁盘错误、文件指针无效等
代码示例
这个是二进制文件操作示例,文本文件放在文件打开的示例
#include <stdio.h>
int main() {
// 二进制写入模式打开文件
FILE *fp = fopen("data.bin", "wb");
if (fp == NULL) {
printf("文件打开失败\n");
return 1;
}
// 写入二进制数据
int num = 12345;
fwrite(&num, sizeof(int), 1, fp);
printf("已写入二进制文件 data.bin\n");
// 关闭文件
if (fclose(fp) != 0) {
printf("文件关闭失败\n");
return 1;
}
fp = NULL; // 避免野指针
// 二进制读取模式打开文件
fp = fopen("data.bin", "rb");
if (fp == NULL) {
printf("文件打开失败\n");
return 1;
}
// 读取二进制数据
int read_num;
fread(&read_num, sizeof(int), 1, fp);
printf("从二进制文件读取到:%d\n", read_num);
// 关闭文件
fclose(fp);
fp = NULL;
return 0;
}
文件读写操作
前面介绍了如何打开和关闭文件,那打开之后的操作应该是如何读写文件。C语言仍然提供了多种函数用于文本文件和二进制文件的读写操作。
读写函数
这些读写函数不区分文本文件和二进制文件,它们都可以操作任何类型的文件。
-
字符读取操作:
fgetc和fputc -
字符串读取操作:
fgets和fputs -
格式化读取操作:
fscanf和fprintf -
块读写操作:
fread和fwrite
这里在次提一下两类文件区别
-
文本文件:存储的是字符数据,每个字节代表一个字符(如 ASCII 码),用记事本打开人能看懂。
-
二进制文件:存储的是数据的原始二进制形式,直接存储内存中的内容,用记事本打开人看不懂。
字符读取操作
fgetc和fputc
-
fgetc可以从输入流中读取一个字符,如果读取成功,返回读取的字符;如果读到文件末尾,或者读取失败,返回EOF。 -
fputc可以向输出流中写入一个字符,如果写入成功,返回写入的字符;如果写入失败,返回EOF。
fgetc函数原型
// 读取字符
int fgetc(FILE* stream);
参数说明:
-
stream:输入流(文件指针或stdin)
fputc函数原型
// 写入字符
int fputc(int c, FILE* stream);
参数说明:
-
c:要写入的字符(以int形式传递) -
stream:输出流(文件指针或stdout)
与 getchar / putchar 的关系
-
fgetc和getchar类似。不同的是getchar只能从标准输入流(stdin)中读取字符,而fgetc可以从任意一个输入流中读取字符。 -
fputc和putchar类似。不同的是putchar只能向标准输出流(stdout)中写入字符,而fputc可以向任意一个流中写入字符。
代码示例
#include <stdio.h>
int main() {
FILE *fp;
char ch;
// 写入文件
fp = fopen("test.txt", "w");
if (fp == NULL) {
printf("打开失败\n");
return 1;
}
fputc('A', fp);
fputc('B', fp);
fputc('C', fp);
fclose(fp);
// 读取文件
fp = fopen("test.txt", "r");
if (fp == NULL) {
printf("打开失败\n");
return 1;
}
while ((ch = fgetc(fp)) != EOF) {
putchar(ch); // 输出:ABC
}
fclose(fp);
return 0;
}
字符串读取操作
fgets和fputs
前面的fgetc和fputc是一个字符一个字符地读写文本文件,效率太慢了。C 语言提供了一次性可以读写一行的函数 fgets 和fputs。
-
fgets可以从输入流中读取一行字符串,最多读取 count-1 个字符,遇到换行符或文件末尾停止,会存储换行符,并自动添加\0。 -
fputs可以向输出流中写入一个字符串,原样输出,不会自动添加换行符。
fgets函数原型
char* fgets(char* str, int count, FILE* stream);
参数说明:
-
str:指向一个字符数组,用来存放读取的内容 -
count:最多读取的字符数(通常填数组长度) -
stream:输入流(文件指针或stdin) -
返回值:成功返回
str,失败返回NULL -
特点:
-
遇到换行符
\n或文件末尾停止 -
会存储换行符
\n -
自动在末尾添加
\0
-
fputs函数原型
int fputs(const char *str, FILE *stream);
参数说明:
-
str:要写入的字符串(以\0结尾) -
stream:输出流(文件指针或stdout) -
返回值:成功返回非负值,失败返回
EOF -
特点:原样输出,不会自动添加换行符
与 gets / puts 的关系
-
fgets和gets类似。不同的是gets只能从标准输入流(stdin)中读取数据,而fgets可以从任意一个输入流中读取数据。fgets也比gets更安全,因为它限制了读取字符的最大数目。此外,fgets会存储换行符\n,而gets不会存储换行符。 -
fputs和puts类似。不同的是puts只能向标准输出流(stdout)中写入数据,而fputs可以向任意一个输出流中写入数据。此外,fputs是原样输出字符串,而puts会在字符串后面额外输出一个换行符\n。
代码示例
#include <stdio.h>
int main() {
FILE *fp;
char buffer[100];
// 写入文件
fp = fopen("test.txt", "w");
if (fp == NULL) {
printf("打开失败\n");
return 1;
}
fputs("Hello, World!\n", fp);
fputs("这是第二行", fp);
fclose(fp);
// 读取文件
fp = fopen("test.txt", "r");
if (fp == NULL) {
printf("打开失败\n");
return 1;
}
while (fgets(buffer, sizeof(buffer), fp) != NULL) {
printf("%s", buffer);
}
fclose(fp);
return 0;
}
格式化读取操作
fscanf和fprintf
-
fscanf可以从输入流中格式化读取数据,按照指定的格式从流中读取内容并存储到变量中。 -
fprintf可以向输出流中格式化写入数据,按照指定的格式将内容写入到流中。
fscanf函数原型
int fscanf(FILE *stream, const char *format, ...);
参数说明:
-
stream:输入流(文件指针或stdin) -
format:格式控制字符串(如"%d %s") -
...:可变参数,指向存储数据的变量地址 -
返回值:成功返回成功匹配并赋值的参数个数,失败返回
EOF
fprintf函数原型
int fprintf(FILE *stream, const char *format, ...);
参数说明:
-
stream:输出流(文件指针或stdout) -
format:格式控制字符串(如"%d %s") -
...:可变参数,要输出的数据 -
返回值:成功返回输出的字符数,失败返回负值
与 scanf / printf 的关系
-
fscanf和scanf类似,不同的是scanf只能从标准输入流(stdin)中读取数据,而fscanf可以从任意一个输入流中读取数据。当fscanf的第一个参数为stdin时,效果等价于scanf。 -
fprintf和printf类似,不同的是printf只能向标准输出流(stdout)中写入数据,而fprintf可以向任意一个输出流中写入数据。当fprintf的第一个参数为stdout时,效果等价于printf。
代码示例
#include <stdio.h>
int main() {
FILE *fp;
int num;
char name[50];
// 写入文件(格式化输出)
fp = fopen("data.txt", "w");
if (fp == NULL) {
printf("打开失败\n");
return 1;
}
fprintf(fp, "%d %s\n", 1001, "张三");
fprintf(fp, "%d %s\n", 1002, "李四");
fclose(fp);
// 读取文件(格式化输入)
fp = fopen("data.txt", "r");
if (fp == NULL) {
printf("打开失败\n");
return 1;
}
while (fscanf(fp, "%d %s", &num, name) != EOF) {
printf("学号:%d,姓名:%s\n", num, name);
}
fclose(fp);
return 0;
}
块读写操作
fread和fwrite
-
fread可以从输入流中批量读取数据,最多读取 count 个元素,并依次存放到 buffer 指向的数组中。 -
fwrite可以向输出流中批量写入数据,将 buffer 指向的数组中的 count 个元素写入到输出流中。
fread 和 fwrite 更多的是用来处理二进制文件的。 fread 可以每次读取一大块数据,fwrite 可以每次写入一大块数据。
fread函数原型
size_t fread(void *buffer, size_t size, size_t count, FILE *stream);
参数说明:
-
buffer:指向存放数据的数组 -
size:每个元素的大小(以字节为单位) -
count:最多可以读取的元素个数 -
stream:输入流(文件指针或stdin) -
返回值:成功返回实际读取的元素个数。当读到文件末尾或发生错误时,返回值可能小于
count。可通过feof和ferror判断具体原因。
fwrite函数原型
size_t fwrite(const void *buffer, size_t size, size_t count, FILE *stream);
参数说明:
-
buffer:指向存放数据的数组 -
size:每个元素的大小(以字节为单位) -
count:要写入的元素个数 -
stream:输出流(文件指针或stdout) -
返回值:成功返回实际写入的元素个数。当发生错误时,返回值可能小于
count。
代码示例
上面函数原型的参数有点鸡肋,我们直接上代码把。
#include <stdio.h>
#include <stdlib.h>
typedef struct {
char name[25];
int age;
char gender;
} Student_t;
int main() {
Student_t s = {"张三", 20, 'M'};
Student_t t;
FILE *fp;
// 二进制写入
fp = fopen("student.bin", "wb");
if (fp == NULL) {
printf("打开失败\n");
return 1;
}
fwrite(&s, sizeof(Student_t), 1, fp);
fclose(fp);
// 二进制读取
fp = fopen("student.bin", "rb");
if (fp == NULL) {
printf("打开失败\n");
return 1;
}
fread(&t, sizeof(Student_t), 1, fp);
fclose(fp);
printf("姓名:%s,年龄:%d,性别:%c\n", t.name, t.age, t.gender);
return 0;
}
序列化与反序列化
序列化与反序列化是什么
-
序列化:将程序中的对象转换成可以保存的格式(二进制或文本),方便存储到文件或通过网络传输。
-
反序列化:序列化的逆过程,将存储的数据转换成程序中的对象。
上面四类读取函数都能做序列化和反序列化,只是方式和适用场景不同。
| 函数 | 序列化结果 | 适用场景 |
|---|---|---|
fgetc / fputc |
字符形式 | 简单字符数据 |
fgets / fputs |
字符串形式 | 字符串数据(一行) |
fscanf / fprintf |
格式化文本(人能看懂) | 文本配置文件、日志 |
fread / fwrite |
原始二进制(人看不懂) | 结构体、数组、高效存储 |
我们进行的文件读取操作就很接近这个过程,但是在文件读取操作上,还要按照特定规则把数据解析成程序中的对象,或者按规则把对象变成字节流。
序列化/反序列化 = 读写 + 规则。没有规则就是普通读写,有了约定就是序列化。那个规则就是我们双方规定都以字符形式,或者以原始二进制格式。
代码示例
这个给伪代码了,说白了就是读写操作,在读写操作上加了个语义化的概念,教材还有视频这些防自学机制真的恶心。
// 任意对象(比如一个结构体)
Student_t s = {"张三", 20, 'M'};
// 序列化:不管用什么函数,本质都是把 s 变成字节流写出去
fwrite(&s, sizeof(s), 1, fp); // 二进制方式
fprintf(fp, "%s %d %c", ...); // 文本方式
fputc(..., fp); // 字符方式
// 反序列化:不管用什么函数,本质都是把字节流变回 t
fread(&t, sizeof(t), 1, fp); // 二进制方式
fscanf(fp, "%s %d %c", ...); // 文本方式
fgetc(fp); // 字符方式
文件定位操作
文件定位
文件定位就是在文件内部移动读写位置指针,控制下一次读写从文件的哪个位置开始。实现对文件内容的随机访问。
-
默认情况下,读写文件是顺序的(读/写完一个字符,指针自动后移)
-
但有时需要随机访问文件的任意位置(比如跳过大段数据、修改中间某处内容)
常用函数
| 函数 | 作用 |
|---|---|
rewind() |
将位置指针重置到文件开头 |
ftell() |
获取当前位置(距离文件开头的字节数) |
fseek() |
将位置指针移动到指定位置 |
rewind函数原型
void rewind(FILE *stream);
参数说明:
-
stream:文件指针
代码示例
#include <stdio.h>
int main() {
FILE *fp = fopen("test.txt", "r");
if (fp == NULL) return 1;
// 读取第一个字符
char ch = fgetc(fp);
printf("第一个字符:%c\n", ch);
// 重置到文件开头
rewind(fp);
// 再次读取第一个字符
ch = fgetc(fp);
printf("rewind后读取:%c\n", ch);
fclose(fp);
return 0;
}
ftell函数原型
long ftell(FILE *stream);
参数说明:
-
stream:文件指针 -
返回值:成功就返回当前读写位置(距离文件开头的字节数),失败返回
-1L
代码示例
#include <stdio.h>
int main() {
FILE *fp = fopen("test.txt", "r");
if (fp == NULL) return 1;
// 读取5个字符
for (int i = 0; i < 5; i++) {
fgetc(fp);
}
// 获取当前位置
long pos = ftell(fp);
printf("当前位置:%ld 字节\n", pos); // 输出:5
fclose(fp);
return 0;
}
fseek函数原型
int fseek(FILE *stream, long offset, int whence);
参数说明:
-
stream:文件指针 -
offset:偏移量(字节数,正数向后移,负数向前移) -
whence:起始位置(决定offset从哪开始计算)-
SEEK_SET(0):从文件开头开始偏移 -
SEEK_CUR(1):从当前位置开始偏移 -
SEEK_END(2):从文件末尾开始偏移
-
-
返回值:成功返回0,失败返回非0;
代码示例
#include <stdio.h>
int main() {
FILE *fp = fopen("test.txt", "r");
if (fp == NULL) return 1;
// 从文件开头偏移3个字节
fseek(fp, 3, SEEK_SET);
printf("位置3的字符:%c\n", fgetc(fp)); // 第4个字符
// 从当前位置向后偏移2个字节
fseek(fp, 2, SEEK_CUR);
printf("再偏移2后的字符:%c\n", fgetc(fp));
// 从文件末尾向前偏移3个字节
fseek(fp, -3, SEEK_END);
printf("倒数第3个字符:%c\n", fgetc(fp));
fclose(fp);
return 0;
}
文件读取结束的判定
在读取文件时,需要准确判断何时读取结束,以及结束的原因是正常到达文件末尾还是发生错误。这决定了程序是继续执行还是进行错误处理。
判定方案
精髓:用读取函数的返回值判断结束,用 feof / ferror 判断原因。
各读取函数结束标志
| 函数 | 结束返回值 |
|---|---|
fgetc |
EOF |
fgets |
NULL |
fscanf |
EOF |
fread |
实际读取 < count |
判定函数
-
feof:检查是否到达文件末尾 -
ferror:检查是否发生错误
feof函数原型
int feof(FILE *stream);
参数说明:
-
stream:文件指针(fopen的返回值) -
返回值:文件读取到末尾返回非0,未到末尾返回0;
ferror函数原型
int ferror(FILE *stream);
参数说明
-
stream:文件指针(fopen的返回值) -
返回值:文件读取错误返回非0,文件正常读取返回0;
代码示例
这两个函数一般配合使用,在循环结束后判断结束原因:
#include <stdio.h>
int main() {
FILE *fp = fopen("test.txt", "r");
if (fp == NULL) {
printf("打开失败\n");
return 1;
}
int ch;
while ((ch = fgetc(fp)) != EOF) {
putchar(ch);
}
// 配合使用,判断结束原因
if (feof(fp)) {
printf("\n正常读到文件末尾\n");
} else if (ferror(fp)) {
printf("\n读取过程中发生错误\n");
}
fclose(fp);
return 0;
}
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)