指针(核心)
一级指针
一级指针基础
定义指针变量
datatype *name;
// 或者
datatype *name = value;
通过指针变量取得数据
*pointer;
指针的数据类型决定了“读取权限”(语义)
当使用 *p(解引用)去访问数据时,编译器需要知道:“我要从这个地址开始,往后读取多少个字节?”
-
char *:编译器看到类型是char,就知道char占 1 个字节。所以*p只读取 1 个字节。 -
int *:编译器看到类型是int,就知道int通常占 4 个字节。所以*p会一口气读取 4 个字节,并把它们拼成一个整数。
数组指针(指向数组的指针)
- 定义
一个指针指向了数组,我们就称它为数组指针(Array Pointer)。
- 访问数组元素的两种方案
-
使用下标: arr[i]
-
使用指针:*(p+i)
- 关于数组指针的谜题
假设 p 是指向数组 arr 中第 n 个元素的指针,那么 *p++、*++p、(*p)++ 分别是什么意思呢?
*p++ 等价于 *(p++),表示先取得第 n 个元素的值,再将 p 指向下一个元素,上面已经进行了详细讲解。
*++p 等价于 *(++p),会先进行 ++p 运算,使得 p 的值增加,指向下一个元素,整体上相当于 *(p+1),所以会获得第 n+1 个数组元素的值。
(*p)++ 就非常简单了,会先取得第 n 个元素的值,再对该元素的值加 1。假设 p 指向第 0 个元素,并且第 0 个元素的值为 99,执行完该语句后,第 0 个元素的值就会变为 100。
字符串指针(指向字符串的指针)
- 写法
char *str = "http://c.biancheng.net";
// 或者
char *str;
str = "http://c.biancheng.net";
- 字符数组与字符串指针(字符串常量)区别
最根本的区别是在内存中的存储区域不一样
-
字符数组存储在全局数据区或栈区,全局数据区和栈区的字符串(也包括其他数据)有读取和写入的权限。
-
字符串指针的字符串存储在常量区,常量区的字符串(也包括其他数据)只有读取权限,没有写入权限。
指针变量作为函数参数
- 指针传参的必要性:
像数组、字符串、动态分配的内存等都是一系列数据的集合,没有办法通过一个参数全部传入函数内部,只能传递它们的指针,在函数内部通过指针来影响这些数据集合。
- 指针传参的好处:C 语言特有的高性能考量。
-
传值(按值传递):如果你直接传一个大结构体(比如一个包含 1000 个字节的配置项),CPU 需要把这 1000 个字节完整复制一份给函数。这非常慢,且浪费栈空间。
-
传指针:只复制一个地址(4 或 8 字节),极快。
- 为什么指针传参往往带上 const?
- 建立“只读契约”,防止“越权修改”
当你把一个指针传给函数时,本质上是你把一块内存的控制权交给了这个函数。
-
如果不加
const:你是在告诉编译器(以及看代码的人):“嘿,这个函数有权修改这块内存里的数据。” -
加上
const:你是在和编译器签订一份“只读契约”。你告诉编译器:“这个函数只需要读取数据,绝对不允许修改。”
为什么这很重要?
在大型项目中,数据流向非常复杂。如果函数 A 调用了函数 B,传入了一个指针,你作为 A 的开发者,你肯定不希望 B 偷偷把你传进去的数据改得面目全非(这叫副作用)。
- 语义清晰:区分“输入”与“输出”
在函数声明中,const 是一种极好的自文档化工具。
看下面两个函数声明,不用看实现,你就能明白它们的意图:
*// 意图一目了然:这个函数只读取学生信息,不会修改它*
void print_student(const Student *s);
*// 意图一目了然:这个函数可能会修改学生信息(比如更新分数)*
void update_student(Student *s);
-
兼容性:更通用的接口
-
普通指针 (
int*) 只能指向普通变量。 -
const指针 (const int*) 既能指向普通变量,也能指向常量变量。
-
如果你把参数定义为 const,你的函数就能接受更多种类的输入。
const int g_val = 100; // 全局常量
int a = 200; // 普通变量
void func(const int *p) { ... }
func(&g_val); // ✅ 合法:常量可以传给 const 指针
func(&a); // ✅ 合法:普通变量也可以传给 const 指针(承诺只读)
二级指针
二级指针基础
如果一个指针指向的是另外一个指针,我们就称它为二级指针,或者指向指针的指针。
假设有一个 int 类型的变量 a,p1是指向 a 的指针变量,p2 又是指向 p1 的指针变量,它们的关系如下图所示:
int a =100;
int *p1 = &a;
int **p2 = &p1;
常用的场景
- 在函数内部分配或修改动态内存
❌ 错误示范:使用一级指针
下面的代码无法成功为 main 函数中的 buffer 分配内存,因为函数内修改的是 buf 的一个副本。
void alloc_memory_wrong(char *buf) {
buf = (char *)malloc(100); // 只修改了副本,main函数中的指针不变
}
int main() {
char *buffer = NULL;
alloc_memory_wrong(buffer);
// buffer 仍然是 NULL,如果使用会导致段错误
// strcpy(buffer, "Hello");
return 0;
}
✅ 正确做法:使用二级指针
通过传递指针的地址(即二级指针),函数可以直接修改原始指针,使其指向新分配的内存。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void alloc_memory_correct(char **buf) {
*buf = (char *)malloc(100); // 修改原始指针的指向
if (*buf != NULL) {
strcpy(*buf, "Hello, World!");
}
}
int main() {
char *buffer = NULL;
alloc_memory_correct(&buffer); // 传递指针的地址
if (buffer != NULL) {
printf("%s\n", buffer); // 输出: Hello, World!
free(buffer); // 记得释放内存
}
return 0;
}
- 创建动态二维数组
当数组的行数和列数在编译时无法确定,需要在运行时动态决定时,二级指针是实现动态二维数组的理想工具。
#include <stdio.h>
#include <stdlib.h>
int main() {
int rows = 3, cols = 4;
// 1. 分配一个指针数组,用于存储每一行的地址
int **matrix = (int **)malloc(rows * sizeof(int *));
// 2. 为每一行分配具体的整数数组
for (int i = 0; i < rows; i++) {
matrix[i] = (int *)malloc(cols * sizeof(int));
}
// 3. 像普通二维数组一样使用
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
matrix[i][j] = i * cols + j;
printf("%d ", matrix[i][j]);
}
printf("\n");
}
// 4. 释放内存(顺序与分配时相反)
for (int i = 0; i < rows; i++) {
free(matrix[i]); // 先释放每一行
}
free(matrix); // 再释放行指针数组
return 0;
}
- 处理指针数组
指针数组(如 char *arr[])在作为函数参数传递时,会自动退化为二级指针(char **arr)。这使得函数可以方便地操作数组中的每一个字符串(或指针)。
一个最典型的例子就是 main 函数的命令行参数:
// 这两种写法是完全等价的
int main(int argc, char *argv[]) { ... }
int main(int argc, char **argv) { ... }
- 操作复杂数据结构(如链表、树)
函数指针
函数指针基础
- 写法
returnType (*pointerName)(param list);
//定义函数指针
int (*pmax)(int, int) = max; //也可以写作int (*pmax)(int a, int b)
- 用法
maxval = (*pmax)(x, y);
数组(指针的语法糖)
C语言数组的定义和使用
- 数组出现的背景
数组可以使用一个变量名来管理多个相同相同类型的数据,如果没有数据,存储多份数据就需要多个变量名,管理和使用起来都非常麻烦。
- 数组的定义
// dataType 是每个元素的类型,比如 int、float、char 等;
// arrayName 是数组的名字
// [ ]中的 length 是数组的长度,也即数组包含的元素的个数。
dataType arrayName[length];
// demo
int nums[4];
- 数组下标
数组的下标序号是从 0 开始的,而不是 1 开始的。
// arrayName 为数组名称,index 为下标。
arrayName[index]
//demo
// nums[0] 表示第 1 个元素
nums[0] = 20;
// nums[3] 表示第 4 个元素
nums[3] = 22;
- 数组初始化
- 只给部分元素赋值。当
{ }中值的个数少于元素个数时,只给前面部分的元素赋值
// demo:只给 a[0]~a[4] 这 5 个元素赋值,而后面的 5 个元素自动初始化为 0。
int a[10]={12, 19, 22 , 993, 344};
/*
当赋值的元素少于数组总体元素的时候,剩余的元素自动初始化为 0:
对于short、int、long,就是整数 0;
对于char,就是字符 '\0';
对于float、double,就是小数 0.0。
*/
- 只能给元素逐个赋值,不能给数组整体赋值。
// 给 10 个元素全部赋值为 1
// 正确写法
int a[10] = {1, 1, 1, 1, 1, 1, 1, 1, 1, 1};
// 错误写法
int a[10] = 1;
- 不指明数组长度。如果给全部元素赋值,编译器会根据元素的个数自动推断出数组长度。
// 如果给全部元素赋值,那么在定义数组时可以不指明数组长度,编译器会根据元素的个数自动推断出数组长度。
int a[] = {1, 2, 3, 4, 5};
// 等价于
int a[5] = {1, 2, 3, 4, 5};
- 数组内存是连续的
数组是一个整体,它的内存是连续的;也就是说,数组元素之间是相互挨着的,彼此之间没有一点缝隙。
下图演示了int a[4];在内存中的存储情形:
二维数组
- 二维数组定义
// dataType 为每个元素的类型
// arrayName 为数组名
// length1 为第一维下标的长度
// length2 为第二维下标的长度。
dataType arrayName[length1][length2];
// demo:定义了一个 3 行 4 列的二维数组,共有 3×4=12 个元素,数组名为 a
int a[3][4];
/*
a[0][0], a[0][1], a[0][2], a[0][3]
a[1][0], a[1][1], a[1][2], a[1][3]
a[2][0], a[2][1], a[2][2], a[2][3]
*/
// 如果想表示第 2 行第 1 列的元素,应该写作 a[1][0]。
- 二维数组的存储
在C语言中,二维数组是按行排列的:放完一行之后再放入第二行。
先存放 a[0] 行,再存放 a[1] 行,最后存放 a[2] 行;每行中的 4 个元素也是依次存放。数组 a 为 int 类型,每个元素占用 4 个字节,整个数组共占用 4×(3×4)=48 个字节。
- 二维数组的初始化
- 按行分段赋值
int a[5][3]={ {80,75,92}, {61,65,71}, {59,63,70}, {85,87,90}, {76,77,85} };
- 按行连续赋值
int a[5][3]={80, 75, 92, 61, 65, 71, 59, 63, 70, 85, 87, 90, 76, 77, 85};
- 可以只对部分元素赋值,未赋值的元素自动取“零”值。
int a[3][3] = {{1}, {2}, {3}};
/*
1 0 0
2 0 0
3 0 0
*/
- 如果对全部元素赋值,那么第一维的长度可以不给出。
int a[3][3] = {1, 2, 3, 4, 5, 6, 7, 8, 9};
// 等价于
int a[][3] = {1, 2, 3, 4, 5, 6, 7, 8, 9};
字符数组和字符串
在C语言中,用来存放字符的数组称为字符数组。字符数组实际上是一系列字符的集合,也就是字符串(String)。
- 字符串初始化
char a[10]; //一维字符数组
char b[5][10]; //二维字符数组
char c[20]={'c', ' ', 'p', 'r', 'o', 'g', 'r', 'a','m'}; // 给部分数组元素赋值
char d[]={'c', ' ', 'p', 'r', 'o', 'g', 'r', 'a', 'm' }; //对全体元素赋值时可以省去长度
char str[30] = {"c.biancheng.net"}; // 可以将字符串直接赋值给字符数组
char str[30] = "c.biancheng.net"; //这种形式更加简洁,实际开发中常用
char str[] = {"c.biancheng.net"}; // 可以不指定数组长度
char str[] = "c.biancheng.net"; //这种形式更加简洁,实际开发中常用
- 字符串定义完后的赋值只能一个字符一个字符地赋值
字符数组只有在定义时才能将整个字符串一次性地赋值给它,一旦定义完了,就只能一个字符一个字符地赋值了。
char str[7];
str = "abc123"; //错误
//正确
str[0] = 'a'; str[1] = 'b'; str[2] = 'c';
str[3] = '1'; str[4] = '2'; str[5] = '3';
- 字符串结束标志
在C语言中,字符串总是以'\0'作为结尾,所以'\0'也被称为字符串结束标志,或者字符串结束符。没有'\0'就意味着永远也到达不了字符串的结尾。
由" "包围的字符串会自动在末尾添加'\0'。
// 看起来只包含了 6 个字符,其实不然,C语言会在最后隐式地添加一个'\0'
char [] = "adb123";
// 内存中的存储情形
// adc123\0
逐个字符地给数组赋值并不会自动添加'\0'。
// 数组 str 的长度为 3,而不是 4,因为最后没有'\0'
char str[] = {'a', 'b', 'c'};
// 内存中的存储情形
// 123
注意:因为局部变量是没有初值的,所以在对局部变量的字符串操作时,最后一定要加上’\0’,否则可能会在字符串最后出现乱码或者打印的字符长度超过字符串本身长度。
- 字符串处理函数
- 字符串连接函数 strcat()
/*
1. strcat() 将把 arrayName2 连接到 arrayName1 后面,并删除原来 arrayName1 最后的结束标志'\0'。
这意味着,arrayName1 必须足够长,要能够同时容纳 arrayName1 和 arrayName2,否则会越界(超出范围)。
2. arrayName1、arrayName2 为需要拼接的字符串。
3. strcat() 的返回值为 arrayName1 的地址。
*/
strcat(arrayName1, arrayName2);
- 字符串复制函数 strcpy()
/*
1. 把 arrayName2 中的字符串拷贝到 arrayName1 中,字符串结束标志'\0'也一同拷贝。
2. strcpy() 要求 arrayName1 要有足够的长度,否则不能全部装入所拷贝的字符串。
*/
strcpy(arrayName1, arrayName2);
- 字符串比较函数 strcmp()
/*
1. arrayName1 和 arrayName2 是需要比较的两个字符串。
2. 字符本身没有大小之分,strcmp() 以各个字符对应的 ASCII 码值进行比较。
strcmp() 从两个字符串的第 1 个字符开始比较,如果它们相等,就继续比较下一个字符,直到遇见不同的字符,
或者到字符串的末尾。
3. 返回值:
若 arrayName1 和 arrayName2 相同,则返回 0;
若 arrayName1 大于 arrayName2,则返回大于 0 的值;
若 arrayName1 小于 arrayName2,则返回小于 0 的值。
*/
strcmp(arrayName1, arrayName2);
C语言数组是静态的,不能插入或删除元素
在C语言中,数组一旦被定义后,占用的内存空间就是固定的,容量就是不可改变的,既不能在任何位置插入元素,也不能在任何位置删除元素,只能读取和修改元素,我们将这样的数组称为静态数组。
C语言中的数组是静态的,一旦定义后长度就不能改变了,大家要注意这一点,不要尝试去插入或删除元素。
如果由于项目要求,必须要在数组中插入或者删除元素,该怎么办呢?没办法,只能再造一个新数组!
C语言变长数组:使用变量指明数组的长度
为什么要讨论这个问题呢?因为 C89 和 C99 对数组做出了不同的规定:
-
在 C89 中,必须使用常量表达式指明数组长度;也就是说,数组长度中不能包含变量,不管该变量有没有初始化。
-
而在 C99 中,可以使用变量指明数组长度。
下面的代码使用常量表达式指明数组长度,在任何编译器下都能编译通过:
int a[10]; //长度为10
int b[3*5]; //长度为15
int c[4+8]; //长度为12
下面的代码使用变量指明数组长度,在 GCC 和 Clang 下能够编译通过,而在 VC 和 VS(包括 VC 6.0、VS2015、VS2017、VS2022 等)下都会报错:
int m = 10, n;
scanf("%d", &n);
int a[m], b[n];
运算
算术运算
整数溢出
int a = INT_MAX;
a = a + 1;
问:
会发生什么? →
是UB吗? →
有符号 vs 无符号区别?
答:
有符号溢出 → ❗未定义行为(UB)
无符号溢出 → ✔ 模2^n循环
整数除法
int a = 5 / 2; // 结果 = 2(截断)
问:
负数除法呢? →
向0取整还是向下取整?
答:
// C99之后:**向0取整**
int b = -7 / 3; // 结果 = -2(截断)
隐式类型转换
int a = 10;
float b = a / 3; // 果不是3.33,而是3.0
- 问:为什么结果是 3.0 而不是 3.33?
答:运算发生在赋值之前,且运算类型由操作数决定。
虽然变量 b 被定义为 float,但在执行 a / 3 这一步时,编译器只看到了两个整数(int):
-
a是int类型。 -
3是整数字面量(默认为int)。
在 C 语言中,两个整数相除,结果依然是整数。因此,10 / 3 的结果是 3(小数部分被直接截断丢弃),然后这个整数 3 才被赋值给 b,并隐式转换为 3.0。
- 问:表达式类型是谁决定的?
答:操作数(运算符两边的变量/常量),而不是接收结果的变量。
C 语言处理表达式的逻辑是独立的。编译器在处理 a / 3 时,根本不关心等号左边是什么类型,它只看运算符两边的数据类型:
-
整数 / 整数 →→ 整数(截断小数)
-
浮点数 / 整数 →→ 浮点数(保留小数)
-
整数 / 浮点数 →→ 浮点数(保留小数)
-
浮点数 / 浮点数 →→ 浮点数(保留小数)
只有当运算完成得出结果后,才会进行赋值操作(此时可能发生类型转换)。
- 问:如何避免?(3种修正方案)
答:
++i vs i++
- 它们的区别?
虽然两者最终都会让变量 i 的值加 1,但在表达式中,它们返回的值截然不同。
-
++i(前置):先自增,后使用。-
逻辑:先把
i加 1,然后返回加 1 后的新值。 -
比喻:你先健身练出肌肉(自增),然后给别人看你的新肌肉(返回新值)。
-
-
i++(后置):先使用,后自增。-
逻辑:先保存
i的当前值(旧值),再把i加 1,最后返回保存的旧值。 -
比喻:你先给别人拍张照(保存旧值),然后自己去健身(自增),把照片给别人看(返回旧值)。
-
代码示例:
int i = 5;
int a = ++i; // i 变为 6,a 得到 6
int j = 5;
int b = j++; // b 得到 5,j 变为 6
- 编译器会优化吗?(性能差异)
这取决于你操作的是基本数据类型还是自定义对象(如 C++ 迭代器)。
🟢 对于基本数据类型(int, float 等)
-
结论:现代编译器会优化,性能几乎无差别。
-
原理:
-
理论上,
i++需要创建一个临时变量来保存旧值,确实多一步操作。 -
但在开启编译器优化(如
-O2)后,如果i++的返回值没有被使用(例如在for循环的自增部分),编译器会直接将其优化为和++i一样的汇编指令(如 x86 的inc指令)。 -
注意:在不开启优化(Debug 模式)时,
i++确实会比++i多几条指令。
-
🔴 对于自定义对象(C++ 类、迭代器)
-
结论:编译器很难完全优化,
++i明显更快。 -
原理:
-
++i:直接修改对象内部状态,返回对象自身的引用(*this),无额外开销。 -
i++:必须构造一个临时对象来保存修改前的状态,修改原对象后,再返回那个临时对象。这涉及拷贝构造函数和析构函数的调用,开销较大。 -
实测数据:在处理复杂迭代器(如
std::list)时,i++可能比++i慢 2 倍以上。
-
最佳实践:为了养成好习惯并兼容 C++ 风格,在循环中优先使用 ++i。
- 在表达式中有什么坑?(未定义行为)
- 混合使用导致结果不可预测
int i = 1;
// 这是一个经典的“未定义行为”陷阱
int result = i++ + ++i;
可能是 4,可能是 5,甚至可能导致程序崩溃。
- 函数参数求值顺序
void func(int a, int b);
int i = 0;
func(i++, ++i); // 极度危险!
C 语言标准没有规定函数参数的入栈/求值顺序。是先算 i++ 还是先算 ++i?这是未定义的。
位运算(嵌入式重点)
常见操作
a & b // 与
a | b // 或
a ^ b // 异或
~a // 取反
a << n // 左移
a >> n // 右移
经典应用
- 判断奇偶
a & 1
- 置位
a |= (1 << n);
- 清位
a &= ~(1 << n);
- 取某一位
(a >> n) & 1
异或
a ^ a = 0
a ^ 0 = a
位移
int a = -1;
a >> 1;
问:结果是多少?
在绝大多数现代编程环境和编译器中(如 GCC, Clang, MSVC 等),结果是 -1。
这要从负数的二进制表示——补码说起。
-
-1的补码表示:在一个 32 位的int中,-1的二进制形式是所有位都为 1。
11111111 11111111 11111111 11111111 -
右移操作:当执行
a >> 1时,所有位向右移动一位,最右边的一位被丢弃,左边会空出一个位置。 -
关键填充:为了保持数值的符号(即负数仍然是负数),编译器会在左边空出的位置填充符号位的值。因为
-1的符号位是1,所以左边补1。
11111111 11111111 11111111 11111111 -
最终结果:移动并填充后,二进制表示没有发生任何变化,所以结果仍然是
-1。
问:逻辑右移 vs 算术右移?
核心区别就在于对符号位的处理。算术右移的目的是为了保持数值的数学意义(相当于除以 2 的幂),而逻辑右移则纯粹是位模式的移动。
指针运算
- 写法
datatype *name = value;
name++;
name += 2;
- 指针的类型决定了“走路步长”的大小(尺度)
当你执行 p + 1 或者 p++ 时,地址并不是简单地加 1,而是加上“它指向的数据类型的大小”。为了方便遍历数组。如果你有一个 int 数组,你希望指针加 1 后,能精准地指向下一个整数,而不是指向当前整数的中间(那样会导致数据错乱)。有了类型,编译器就能自动帮你计算这个“步长”,让你不用手动去算 地址 + 4。
请看下面这个对比,假设地址从 0x1000 开始:
- 举例
int a = 10, *pa = &a, *paa = &a;
double b = 99.9, *pb = &b;
char c = '@', *pc = &c;
//最初的值
printf("&a=%#X, &b=%#X, &c=%#X\n", &a, &b, &c);
printf("pa=%#X, pb=%#X, pc=%#X\n", pa, pb, pc);
//加法运算
pa++; pb++; pc++;
printf("pa=%#X, pb=%#X, pc=%#X\n", pa, pb, pc);
//减法运算
pa -= 2; pb -= 2; pc -= 2;
printf("pa=%#X, pb=%#X, pc=%#X\n", pa, pb, pc);
//比较运算
**if**(pa == paa){
printf("%d\n", *paa);
}**else**{
printf("%d\n", *pa);
}
/*
&a=0X28FF44, &b=0X28FF30, &c=0X28FF2B
pa=0X28FF44, pb=0X28FF30, pc=0X28FF2B
pa=0X28FF48, pb=0X28FF38, pc=0X28FF2C
pa=0X28FF40, pb=0X28FF28, pc=0X28FF2A
2686784
*/