前言
很多人觉得指针难,但指针的本质就一句话:指针就是内存地址。搞懂这个,剩下所有花样都是围绕这个地址指向什么类型、加一减一怎么跳展开的。这篇把我学指针的几个关键突破点整理出来。
一、指针是什么
内存就像是一栋楼,每栋楼都有许多门牌号,通过门牌号就能找到你家,也就是地址。
int a = 10; int* p = &a; // & 取地址,p 存 a 的地址 printf("%d\n", *p); // * 解引用,通过地址拿到 a 的值 10不同的指针类型决定着解引用读几个字节,加一减一跳过几个字节,如int*就会跳过4个字节,char*就会跳过1个字节。
二、指针运算
指针 ± 整数:指针加 1,跳过的是"一个元素"的大小,不是 1 字节。
int arr[5] = {1,2,3,4,5}; int* p = arr; // 指向 arr[0] p + 1; // 指向 arr[1],地址加 4 字节(而不是 1 字节)指针 - 指针:两个指针相减,得到它们之间相隔的"元素个数"。前提是它们指向同一块连续空间,通常同一数组内,因为内存地址本质上是一串16进制的数字,俩个数相减就能得出它们之间差了多少个数。
int arr[5] = {1,2,3,4,5}; int* p1 = &arr[1]; // 指向第 2 个 int* p2 = &arr[4]; // 指向第 5 个 int n = p2 - p1; // 3,相隔 3 个元素指针的关系运算:同一数组内可以用 >和 <比较两个元素位置的前后。
注意:指针不能相加、不能乘除,这些没有意义。
三、野指针
野指针就是"指向位置不可知"的指针,主要三种成因:
- 未初始化就使用:
int* p; *p = 10;,p 的值是随机的,因为天知道p指向了哪。 - 越界访问:访问数组范围之外的内存。
- 指针指向的空间被释放后没有置空(学动态内存时会遇到)。
规避方法:定义时就初始化(暂时没有指向就设为NULL)、释放后置NULL、使用前先判断非空、绝不过界访问。
四、const 修饰指针
关键看const在*的左边还是右边:
int a = 10; const int* p1 = &a; // const 在 * 左边:*p1(指向的内容)不能改 int* const p2 = &a; // const 在 * 右边:p2(指针本身)不能改 const int* const p3 = &a; // 都不能改 // p1 = &b; // 可以,p1 本身能指向别的 // *p1 = 20; // 不行,内容不能改 // p2 = &b; // 不行,p2 不能再指向别的 // *p2 = 20; // 可以口诀:const 在*左边,修饰内容(*p);const 在*右边,修饰指针本身(p),反正就是*p是指向的内容因为有解引用符,p就是指针本身存的地址。
五、数组名的理解
数组名绝大多数情况下就是首元素的地址,但有两个例外:
sizeof(数组名):表示整个数组,算整个数组的字节大小。&数组名:取整个数组的地址。
最容易搞错的是arr、&arr[0]、&arr三者值相同、类型不同,加一步长就不同:
int arr[5] = {0}; arr + 1; // 加 4 字节(跳过一个 int) &arr + 1; // 加 20 字节(跳过整个数组)&arr的类型是int(*)[5],指向"含 5 个 int 的数组",所以加一就是跳一整个数组。
六、指针访问数组
核心等价关系:
p[i] == *(p + i) arr[i] == *(arr + i)[]下标运算,本质就是"首地址 + 偏移量,再解引用"。
七、数组传参的本质
数组传参传的是首元素地址,所以形参int arr[]和int* arr完全等价,函数里的arr是指针:
void test(int arr[]) // 本质是 int* arr { printf("%d\n", sizeof(arr)); // 指针大小,不是数组大小 }所以函数内部没法用sizeof求元素个数,必须把长度作为另一个参数一起传进去。二维数组同理:它的"首元素"是第一行(一个一维数组),指针形式是int (*p)[5],访问第 i 行第 j 列就是*(*(p+i)+j)。
八、二级指针
指针变量也是变量,也有地址。存"指针变量的地址"的,就是二级指针。
int a = 10; int* p = &a; int** pp = &p; *pp = &b; // 等价于 p = &b **pp = 30; // 等价于 a = 30记忆链条:pp存p的地址,p存a的地址,像套娃一样。
九、指针数组 vs 数组指针
靠运算符优先级区分:
int* p1[10]; // 指针数组:[] 优先级高,p1 是数组,元素是 int* int (*p2)[10]; // 数组指针:括号让 * 先结合,p2 是指针,指向 int[10]一句话:指针数组是"存指针的数组",数组指针是"指向数组的指针"。
十、字符指针
字符指针指向字符串字面量时,存的是字符串首字符的地址,不是整个字符串:
const char* pstr = "hello"; // 存的是 'h' 的地址经典区别:用同一个字符串字面量初始化两个数组,会开辟两块不同内存;而让两个指针指向同一个字符串常量,它们指向同一块内存。
十一、函数指针
函数也有地址,函数名就是函数的地址。存函数地址的变量是函数指针:
int add(int x, int y) { return x + y; } int (*pf)(int, int) = add; printf("%d\n", pf(2, 3)); // 通过函数指针调用*和变量名必须用括号包起来,否则就成了"返回指针的函数"。
十二、函数指针数组
既然能把函数地址存进指针,那也能把多个函数地址存进数组——这就是函数指针数组:
int (*parr[3])(int, int) = { add, sub, mul }; // parr 是数组,元素类型是 int(*)(int, int) printf("%d\n", parr[0](10, 5)); // 调用 add解析:parr先和[3]结合成数组,数组的每个元素是int(*)(int,int)这种函数指针。
十三、转移表
函数指针数组最典型的应用就是转移表:用下标直接"跳"到对应函数,替代又长又重复的switch。以计算器为例:
int add(int a, int b) { return a + b; } int sub(int a, int b) { return a - b; } int mul(int a, int b) { return a * b; } int div(int a, int b) { return a / b; } int main() { int x, y, input = 1, ret = 0; // 下标 0 放空,1~4 对应四个运算函数 int (*p[5])(int, int) = { 0, add, sub, mul, div }; do { printf("1:add 2:sub 3:mul 4:div 0:exit\n"); printf("请选择:"); scanf("%d", &input); if (input &gt;= 1 &amp;&amp; input &lt;= 4) { printf("输入操作数:"); scanf("%d %d", &amp;x, &amp;y); ret = (*p[input])(x, y); // 用 input 当下标直接跳转 printf("ret = %d\n", ret); } else if (input == 0) printf("退出\n"); else printf("输入有误\n"); } while (input); return 0; }原来要写四段几乎一样的case,现在一次(*p[input])(x, y)就搞定,代码短了、也更好扩展。
十四、回调函数
把函数指针当参数传给另一个函数,那个"被调用的函数"就是回调函数。它最大的价值是解耦——调用方不用知道具体调的是谁。C 标准库的qsort就是最典型的例子,下面详细拆解。
十五、qsort 的使用
在模拟实现之前,先看标准库的qsort怎么用。它声明在stdlib.h中,原型如下:
void qsort(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *));四个参数的含义分别是:base是待排序数组的首地址,nmemb是元素个数,size是单个元素的字节大小,最后一个是比较函数指针。排序结果会直接作用在原数组上。
比较函数返回int,规则和手写排序里的比较逻辑一样:返回负数表示第一个参数排在前面,返回正数表示第二个参数排在前面,返回 0 表示相等。以常见的int数组升序为例:
#include <stdio.h> #include <stdlib.h> int int_cmp(const void* p1, const void* p2) { return (int)p1 - (int)p2; // 升序:p1 - p2 } int main() { int arr[] = {1,3,5,7,9,2,4,6,8,0}; int n = sizeof(arr) / sizeof(arr[0]); qsort(arr, n, sizeof(int), int_cmp); for (int i = 0; i < n; i++) printf("%d ", arr[i]); return 0; }如果想让int数组降序,只要把比较函数的返回值反过来就行:return *(int*)p2 - *(int*)p1;。对于浮点数、结构体等类型,则不要直接相减,要通过分支返回明确的 1、-1 或 0,避免小数被截断。
十六、qsort 的模拟实现(重点)
qsort是通用排序函数,它不知道你要排什么类型,靠三样东西做到"通用":void* base(不假设类型)、size(每个元素几字节)、比较函数指针(把"怎么比较"交给使用者)。
第一步:void*是什么
void*是通用指针,能接收任何类型的地址,但不能直接解引用、不能直接加减(编译器不知道指向的数据多大)。要用它,必须先强转成具体类型。
第二步:怎么定位第 j 个元素
因为char恰好占 1 字节,所以把base强转成char*后,+ j * size就精确地前进了 j 个元素:
(char*)base + j * size // 第 j 个元素的起始地址第三步:怎么交换任意类型的数据
不知道元素多大,就一个字节一个字节地交换,共交换size次:
void _swap(void* p1, void* p2, int size) { for (int i = 0; i < size; i++) { char tmp = *((char*)p1 + i); *((char*)p1 + i) = *((char*)p2 + i); *((char*)p2 + i) = tmp; } }第四步:通用冒泡排序
外层内层循环和普通冒泡一样,只是"比较"交给回调函数、"交换"交给_swap:
void bubble(void* base, int count, int size, int (*cmp)(const void*, const void*)) { for (int i = 0; i < count - 1; i++) { for (int j = 0; j < count - i - 1; j++) { // 第 j 个元素地址 = (char*)base + j*size if (cmp((char*)base + j*size, (char*)base + (j+1)*size) > 0) _swap((char*)base + j*size, (char*)base + (j+1)*size, size); } } }第五步:使用(和真 qsort 一样)
int int_cmp(const void* p1, const void* p2) { return *(int*)p1 - *(int*)p2; // 升序:p1 - p2 } int arr[] = {1,3,5,7,9,2,4,6,8,0}; bubble(arr, sizeof(arr)/sizeof(arr[0]), sizeof(int), int_cmp);整个链条就是:bubble用void*+size做到"什么类型都能排",比较函数用回调交给使用者。注意比较函数返回int,所以浮点数不能直接用相减(小数会被截断成 0),要写成if/else返回 1、-1、0。
写在最后
指针是一套环环相扣的逻辑:数组名是地址 → 传参传的是地址 → 数组指针指向一行 → 函数指针指向函数 → 函数指针数组组成转移表 → 回调函数把函数指针当参数 → qsort 用 void* 做到通用。把这根链条理清,指针就不再可怕,反而成了很强的工具。