【C语言】深入理解指针

🔥铅笔小新z:个人主页

🎬博客专栏:C语言

💫滴水不绝,可穿石;步履不休,能至渊。


目录

  • 深入理解指针(1)
    • 内存和地址
    • 指针变量和地址
    • 指针变量类型的意义
    • 指针运算
  • 深入理解指针(2)
    • const 修饰指针
    • 野指针
    • assert 断言
    • 指针的使用和传址调用
  • 深入理解指针(3)
    • 数组名的理解
    • 使用指针访问数组
    • 一维数组传参的本质
    • 冒泡排序
    • 二级指针
    • 指针数组
  • 深入理解指针(4)
    • 字符指针变量
    • 数组指针变量
    • 二维数组传参的本质
    • 函数指针变量
    • 函数指针数组与转移表
  • 深入理解指针(5)
    • 回调函数
    • qsort 使用举例
    • qsort 的模拟实现
  • 深入理解指针(6)
    • sizeof 和 strlen 的对比
    • 数组和指针笔试题解析
    • 指针运算笔试题解析

一、深入理解指针(1)

这一讲是地基,我们只搞清楚一件事:指针到底是什么,为什么它这么重要。先不急着写复杂代码,把"内存和地址"这个根本问题想明白,后面全是顺理成章。

1. 内存和地址

1.1 内存

先讲一个生活场景。

假设有一栋宿舍楼,楼里住着很多人,你的朋友来找你玩。如果每个房间都没有门牌号,那朋友只能一间一间地敲门,效率极低。可如果给每个房间都编上号:

复制代码
一楼:101,102,103,104 ...
二楼:201,202,203,204 ...

那朋友只要拿到你的房间号,就能直接找到你,又快又准。

把这件事搬到计算机里,道理一模一样。

我们知道,CPU(中央处理器)在处理数据时,数据是从内存里读出来 的,处理完再放回内存。我们买电脑时看到的 8GB / 16GB / 32GB,说的就是内存的大小。这么大一片内存空间,怎么高效地管理呢?

答案就是:把内存划分成一个个内存单元,每个内存单元的大小是 1 个字节(1Byte),然后给每个单元都编上号。

先把计算机里的单位理清楚(这是基础中的基础):

单位 换算关系
bit(比特位) 能存一个二进制位 0 或 1
Byte(字节) 1 Byte = 8 bit
KB 1 KB = 1024 Byte
MB 1 MB = 1024 KB
GB 1 GB = 1024 MB
TB 1 TB = 1024 GB
PB 1 PB = 1024 TB

一个内存单元(1 字节)就像一个"八人间宿舍",里面能住 8 个比特位(每个人是一个比特位)。而每个内存单元都有一个编号,这个编号就相当于宿舍的门牌号。

我们把内存单元的编号,叫做 地址(address) 。而 C 语言给"地址"起了一个新的名字,就叫------指针(pointer)。

所以这里有一个非常重要的等式,请牢牢记住:

复制代码
内存单元的编号  ==  地址  ==  指针

用一张图来表示内存长什么样:

一句话总结:计算机把内存划分成一个个大小为 1 字节的内存单元,每个单元都有一个唯一的编号,这个编号叫"地址";C 语言中地址也叫"指针",所以"内存单元的编号 == 地址 == 指针"。指针本质就是一个编号、一个地址,用来定位内存中的某一块空间。

1.2 究竟该如何理解编址

计算机内部有非常多的硬件单元(CPU、内存、硬盘......),它们要互相协作,就必须能互相传递数据。而硬件之间是各自独立的,怎么通信?用"线"连起来。

CPU 和内存之间有大量的数据交互,所以它们之间也连着很多线。其中有一组线特别关键,叫 地址总线(Address Bus)。

CPU 要访问内存中的某个字节,就必须知道这个字节在内存的哪个位置。因为内存里的字节非常多,所以需要给内存编址------就像宿舍很多,需要给宿舍编号一样。

画一张 CPU 和内存的连接图:

地址总线:告诉内存"我要找哪个编号的空间"

数据总线:真正传输数据

控制总线:传输读写等控制信号

这里有一个很巧妙的设计:编址不是把每个字节的地址用软件记录下来的,而是通过硬件设计完成的。

打个比方:钢琴、吉他上面并没有写着"哆、来、咪、发、唆"这些字,但演奏者照样能准确找到每一根弦、每一个音。为什么?因为制造商已经在硬件层面把位置设计好了,演奏者之间也约定俗成------这本质是一种约定出来的共识。硬件编址也是这样。

以 32 位机器为例:它有 32 根地址总线。每根线只有两种状态,表示 0 或 1(有没有电脉冲)。于是:

  • 1 根线 → 2 种含义(0、1)
  • 2 根线 → 4 种含义(00、01、10、11)
  • 32 根线 → 2^32 种含义

每一种含义,就对应一个地址。所以 32 位机器最多能表示 2^32 个地址(也就是大约 4GB 的寻址空间)。

CPU 把地址信息下达给内存,内存就能根据这个地址找到对应的数据,再通过数据总线把数据传回 CPU 的寄存器。

一句话总结:CPU 和内存之间通过地址总线、数据总线、控制总线相连;给内存编址是硬件层面的约定。32 位机器有 32 根地址线,能表示 2^32 个地址;64 位机器有 64 根地址线,能表示 2^64 个地址。CPU 通过地址总线把地址发给内存,再通过数据总线完成数据的读写。

2. 指针变量和地址

2.1 取地址操作符(&)

回到 C 语言。在 C 语言里创建一个变量,其实就是向内存申请了一块空间。比如:

c 复制代码
#include <stdio.h>
int main()
{
    int a = 10;   // 创建整型变量 a,向内存申请 4 个字节,用来放整数 10
    return 0;
}

int a = 10; 这行代码,就是让内存腾出 4 个字节(int 占 4 字节)的空间来存放 10。这 4 个字节,每个都有自己的地址,假设分别是:

复制代码
0x006FFD70
0x006FFD71
0x006FFD72
0x006FFD73

(内存地址通常是十六进制,0x 开头表示十六进制数)

那我们怎么拿到变量 a 的地址呢?就要用到 取地址操作符 &:

c 复制代码
#include <stdio.h>
int main()
{
    int a = 10;
    &a;                   // 取出 a 的地址(单独写没啥意义,一般要存起来或打印)
    printf("%p\n", &a);   // %p 专门用来打印地址(指针)
    return 0;
}

按照上面的图,程序会打印出类似 006FFD70 这样的值。

这里有个关键点:&a 取出来的是 a 占的 4 个字节中地址最小的那个字节的地址。

用图表示变量 a 在内存中的存储:

虽然 int 占了 4 个字节,但我们只要知道第一个字节(最低地址)的地址,就能"顺藤摸瓜"访问到这 4 个字节的数据。

一句话总结 :创建变量就是向内存申请空间;用取地址操作符 & 可以拿到变量的地址,它取的是该变量所占内存中地址最小的那个字节的地址。用 %p 可以打印地址。

2.2 指针变量和解引用操作符(*)

2.2.1 指针变量

&a 拿到的是一个地址值,比如 0x006FFD70。这个地址值本身也是一个数据,有时候需要把它存起来,方便以后再用。那这个地址值存到哪里呢?答案是:指针变量。

c 复制代码
#include <stdio.h>
int main()
{
    int a = 10;
    int * pa = &a;   // 取出 a 的地址,存到指针变量 pa 中
    return 0;
}

指针变量也是一种变量,只不过它是专门用来存放地址的。 存放在指针变量里的值,都会被理解为"地址"。

2.2.2 如何拆解指针类型

看到 int * pa = &a;,这个 int * 类型该怎么理解呢?我们拆开看:

c 复制代码
int a = 10;
int * pa = &a;   // pa 左边的 * 说明 pa 是指针变量
                 // 前面的 int 说明 pa 指向的是 int 类型的对象
  • *:说明 pa 是一个指针变量。
  • int:说明 pa 这个指针指向的是 int 类型的数据。

那如果有一个 char 类型的变量 ch,它的地址应该放进什么类型的指针变量呢?

c 复制代码
char ch = 'w';
char * pc = &ch;   // pc 指向 char 类型,所以类型是 char*

指针的类型要跟它指向的对象的类型匹配,这样才能正确解释它指向的那块内存。

2.2.3 解引用操作符

我们把地址存起来,将来是要用的。怎么用?

现实生活中,拿到门牌号,是为了找到那个房间,进房间去取东西或放东西。C 语言里也一样:拿到地址(指针),就可以通过这个地址找到它指向的对象。这里就需要解引用操作符 *。

c 复制代码
#include <stdio.h>
int main()
{
    int a = 100;
    int * pa = &a;   // pa 里存的是 a 的地址
    *pa = 0;         // *pa 就是通过 pa 里的地址找到的空间,也就是 a 本身
    return 0;        // 所以 *pa = 0 等价于 a = 0
}

第 7 行的 *pa,意思就是"通过 pa 中存放的地址,找到它指向的那块空间"。因为 pa 指向 a,所以 *pa 其实就是 a。*pa = 0; 就是"把 a 改成 0"。

有同学会想:直接把 a 改成 0 的话,写 a = 0; 不就行了吗?为什么还要绕一圈用指针?

原因是:用指针就把"修改 a"这件事交给了 pa 来操作,这样对 a 的修改就多了一种途径,写代码会更灵活。等到后面讲函数传参、链表、动态内存时,你会越来越感受到这种"间接操作"的强大。

可以用图表示 pa 和 a 的关系:

一句话总结 :指针变量是专门存放地址的变量;声明时 int *pa 中的 * 表示 pa 是指针,int 表示它指向 int 类型数据。用 & 取地址,用 *(解引用操作符)通过地址找到并操作它指向的对象,*pa 就等价于 a 本身。

2.3 指针变量的大小

前面说过,32 位机器有 32 根地址线,一个地址就是 32 个二进制位(bit),也就是 4 个字节。既然指针变量是专门存放地址的,那么指针变量的大小就必须能装下一个地址,所以是 4 字节。

同理,64 位机器的地址是 64 个 bit,也就是 8 个字节,所以指针变量的大小是 8 字节。

c 复制代码
#include <stdio.h>
// 指针变量的大小取决于地址的大小
// 32 位平台:地址是 32 个 bit,即 4 字节
// 64 位平台:地址是 64 个 bit,即 8 字节
int main()
{
    printf("%zd\n", sizeof(char *));    // 打印各种指针类型的大小
    printf("%zd\n", sizeof(short *));
    printf("%zd\n", sizeof(int *));
    printf("%zd\n", sizeof(double *));
    return 0;
}

程序的输出结果(根据环境不同):

复制代码
X86(32位)环境输出:   4 4 4 4
X64(64位)环境输出:   8 8 8 8

结论:

  • 32 位平台下,指针变量大小是 4 字节;
  • 64 位平台下,指针变量大小是 8 字节;
  • 指针变量的大小和它的类型无关 ------不管 char*、int* 还是 double*,在同一个平台下大小都相同。

一句话总结:指针变量的大小取决于地址的大小,与指针的类型无关。32 位平台是 4 字节,64 位平台是 8 字节,同一平台下所有指针类型大小都一样。

3. 指针变量类型的意义

既然指针大小和类型无关,那为什么还要有 int*、char*、double* 各种各样的指针类型呢?因为指针类型有它特殊的意义,主要体现在两方面:解引用时的权限、加减整数时的步长。

3.1 指针的解引用(权限)

对比下面两段代码,重点是在调试时观察内存的变化。

c 复制代码
// 代码1
#include <stdio.h>
int main()
{
    int n = 0x11223344;   // n 的 4 个字节分别是 11 22 33 44
    int * pi = &n;
    *pi = 0;              // int* 解引用,能操作 4 个字节
    return 0;             // 结果:n 的 4 个字节全部变成 0
}
c 复制代码
// 代码2
#include <stdio.h>
int main()
{
    int n = 0x11223344;
    char * pc = (char *)&n;  // 把 &n 强制转换成 char* 存进 pc
    *pc = 0;                 // char* 解引用,只能操作 1 个字节
    return 0;                // 结果:n 只有第 1 个字节变成 0
}

调试时可以看到:代码 1 会把 n 的 4 个字节全部改成 0;而代码 2 只把 n 的第 1 个字节改成 0。

结论:指针的类型决定了,对指针解引用时有多大的权限(一次能操作几个字节)。

  • char* 解引用只能访问 1 个字节;
  • int* 解引用能访问 4 个字节。

一句话总结 :指针类型决定了"解引用时的权限",也就是通过这个指针一次能操作几个字节。char* 能操作 1 字节,int* 能操作 4 字节。

3.2 指针 ± 整数(步长)

再看一段代码,调试观察地址的变化。

c 复制代码
#include <stdio.h>
int main()
{
    int n = 10;
    char * pc = (char*)&n;   // pc 指向 n,但类型是 char*
    int * pi = &n;           // pi 指向 n,类型是 int*

    printf("%p\n", &n);      // 打印 n 的地址
    printf("%p\n", pc);      // pc 的值
    printf("%p\n", pc + 1);  // pc+1 跳过 1 个字节
    printf("%p\n", pi);      // pi 的值
    printf("%p\n", pi + 1);  // pi+1 跳过 4 个字节
    return 0;
}

运行结果(地址是示意值):

复制代码
&n   = 0x00AFF974
pc   = 0x00AFF974
pc+1 = 0x00AFF975   ← 比 pc 大了 1(1 字节)
pi   = 0x00AFF974
pi+1 = 0x00AFF978   ← 比 pi 大了 4(4 字节)

可以看到:char* 类型的指针 +1 跳过 1 个字节 ,int* 类型的指针 +1 跳过 4 个字节。

指针 +1,其实跳过的是"1 个它指向的元素"的大小。char 元素占 1 字节,所以 char* +1 走 1 字节;int 元素占 4 字节,所以 int* +1 走 4 字节。指针可以 +1,当然也可以 -1。

结论:指针的类型决定了指针向前或向后走一步的距离(步长)。

一句话总结 :指针类型决定了"指针 ± 整数"的步长,即指针加 1 或减 1 时跳过多少个字节。char* +1 走 1 字节,int* +1 走 4 字节,本质是跳过 1 个它指向类型的元素。

3.3 void* 指针

指针类型里有一种特殊类型叫 void*,可以理解为"无具体类型的指针"(也叫泛型指针)。它的特点是:可以接收任意类型的地址 。但它也有局限:void* 类型的指针不能直接进行加减整数和解引用的运算。

举例:

c 复制代码
#include <stdio.h>
int main()
{
    int a = 10;
    int* pa = &a;
    char* pc = &a;   // 把 int 变量的地址赋给 char*,编译器会给出警告(类型不兼容)
    return 0;
}

上面把 int 变量的地址赋给 char*,VS2022 编译会给出这样的警告:

复制代码
warning C4133: "初始化": 从 "int *" 到 "char *" 的类型不兼容

而使用 void* 类型接收地址,就不会有这种警告:

c 复制代码
#include <stdio.h>
int main()
{
    int a = 10;
    void* pa = &a;   // void* 可以接收任意类型的地址,不报警告
    void* pc = &a;

    *pa = 10;        // 错误:void* 不能直接解引用
    *pc = 0;         // 错误:void* 不能直接解引用
    return 0;
}

VS 编译这段代码会报错:

复制代码
error C2100: 非法的间接寻址
warning C4047: "=": "void" 与 "int" 的间接级别不同

可以看到:void* 能接收任意类型的地址,但无法直接进行指针运算。

那 void* 到底有什么用呢?它一般用在函数参数里,用来接收不同类型数据的地址,从而实现"泛型编程"的效果------让一个函数能处理多种类型的数据。

一句话总结 :void* 是无具体类型的指针(泛型指针),能接收任意类型地址,但不能直接解引用和加减整数,需要先强制转换成具体类型指针才能用;它主要用于函数参数,实现一个函数处理多种类型数据的泛型编程。

4. 指针运算

指针的基本运算有三种:

  • 指针 ± 整数
  • 指针 - 指针
  • 指针的关系运算

4.1 指针 ± 整数

因为数组在内存中是连续存放的,所以只要知道第一个元素的地址,就能"顺藤摸瓜"找到后面的所有元素。

先看数组元素和下标的关系:

复制代码
数组 int arr[10] = {1,2,3,4,5,6,7,8,9,10};

元素:  1   2   3   4   5   6   7   8   9   10
下标:  0   1   2   3   4   5   6   7   8   9
地址: arr  arr+1 arr+2 ...               arr+9
c 复制代码
#include <stdio.h>
// 用指针 +- 整数来访问数组
int main()
{
    int arr[10] = {1,2,3,4,5,6,7,8,9,10};
    int * p = &arr[0];                  // p 指向数组首元素
    int i = 0;
    int sz = sizeof(arr) / sizeof(arr[0]);  // 计算数组元素个数
    for (i = 0; i < sz; i++)
    {
        printf("%d ", *(p + i));        // p+i 就是指针 + 整数,*(p+i) 取出第 i 个元素
    }
    return 0;
}

4.2 指针 - 指针

两个指针相减,得到的是它们之间相隔的元素个数(前提是两个指针指向同一块空间,比如同一个数组)。

c 复制代码
#include <stdio.h>
// 用指针 - 指针来求字符串长度
int my_strlen(char * s)
{
    char * p = s;            // p 从字符串开头出发
    while (*p != '\0')       // 只要没到字符串结尾(\0)
        p++;                 // p 一直向后走
    return p - s;            // p 和 s 之间相隔的字符个数,就是字符串长度
}
int main()
{
    printf("%d\n", my_strlen("abc"));   // 输出 3
    return 0;
}

4.3 指针的关系运算

指针也可以用 <、>、== 等比较大小(本质是比较地址的大小),通常用来做边界判断。

c 复制代码
#include <stdio.h>
int main()
{
    int arr[10] = {1,2,3,4,5,6,7,8,9,10};
    int * p = &arr[0];
    int sz = sizeof(arr) / sizeof(arr[0]);
    while (p < arr + sz)    // 指针的大小比较:只要 p 没走到数组末尾之后
    {
        printf("%d ", *p);
        p++;
    }
    return 0;
}

一句话总结:指针有三大运算:① 指针 ± 整数,表示向前/向后移动若干个元素(步长由类型决定);② 指针 - 指针,得到两个指针相隔的元素个数(要求指向同一块连续空间);③ 指针的关系运算,比较地址大小,常用于循环的边界判断。


二、深入理解指针(2)

这一讲讲指针的"纪律"和安全问题:怎么用 const 保护数据,什么是野指针、怎么避免它,以及指针在函数传参里的大用处。

1. const 修饰指针

1.1 const 修饰变量

变量本来是可以修改的。如果把变量的地址交给一个指针变量,就能通过这个指针去修改那个变量。但如果我们希望某个变量被"保护"起来、不允许被修改,该怎么做呢?这就要用到 const。

c 复制代码
#include <stdio.h>
int main()
{
    int m = 0;
    m = 20;           // m 可以修改,没问题
    const int n = 0;
    n = 20;           // 错误!n 被 const 修饰,不能被修改
    return 0;
}

上面的 n 本质还是变量,只是被 const 修饰后,在语法上加了限制:只要在代码里直接修改 n,就不符合语法规则,会报错。

但是,如果我们"绕过" n,用 n 的地址去修改它,还是能做到的(虽然这是在打破语法规则):

c 复制代码
#include <stdio.h>
int main()
{
    const int n = 0;
    printf("n = %d\n", n);   // 打印 0
    int* p = &n;
    *p = 20;                 // 通过指针间接修改 n
    printf("n = %d\n", n);   // 打印 20,n 被改了!
    return 0;
}

程序运行结果:

复制代码
n = 0
n = 20

可以看到确实被改了。但我们要思考一下:为什么 n 要加 const?就是为了不被修改。如果 p 拿到 n 的地址就能改 n,那就打破了 const 的限制,这不合理。所以我们应该让 p 拿到 n 的地址后也不能修改 n,这就引出了 const 修饰指针变量。

1.2 const 修饰指针变量

const 修饰指针变量时,可以放在 * 的左边,也可以放在 * 的右边,两种写法的意义完全不同。

c 复制代码
int * p;          // 没有 const 修饰
int const * p;    // const 放在 * 的左边(等于 const int *p)
int * const p;    // const 放在 * 的右边

我们用四段代码来逐个验证:

c 复制代码
#include <stdio.h>

// 代码1:无 const 修饰
void test1()
{
    int n = 10;
    int m = 20;
    int* p = &n;
    *p = 20;      // ok:可以修改指向的内容
    p = &m;       // ok:也可以修改 p 本身(让它指向别处)
}

// 代码2:const 放在 * 的左边(const int* p)
void test2()
{
    int n = 10;
    int m = 20;
    const int* p = &n;
    *p = 20;      // 报错!const 在 * 左边,指向的内容不能通过 p 修改
    p = &m;       // ok:p 本身可以修改,指向别处
}

// 代码3:const 放在 * 的右边(int * const p)
void test3()
{
    int n = 10;
    int m = 20;
    int * const p = &n;
    *p = 20;      // ok:可以修改指向的内容
    p = &m;       // 报错!const 在 * 右边,p 本身不能被修改
}

// 代码4:* 的左右两边都有 const
void test4()
{
    int n = 10;
    int m = 20;
    int const * const p = &n;
    *p = 20;      // 报错:不能修改内容
    p = &m;       // 报错:也不能修改 p 本身
}

int main()
{
    test1();   // 测试无 const
    test2();   // 测试 const 在 * 左边
    test3();   // 测试 const 在 * 右边
    test4();   // 测试左右都有 const
    return 0;
}

结论:

  • const 放在 * 的左边 :修饰的是指针指向的内容,保证指针指向的内容不能通过这个指针被改变,但指针变量本身(它存的地址)可以变。
  • const 放在 * 的右边 :修饰的是指针变量本身,保证指针变量里存的地址不能改,但指向的内容可以通过指针改变。

一个帮助记忆的小技巧:把 * 当成分界线,"const 在 * 左边,锁的是内容;const 在 * 右边,锁的是指针本身"。

一句话总结 :const 修饰指针有两种情况------const 在 * 左边(如 const int *p)修饰的是指针指向的内容,此时内容不能通过指针修改,但指针本身可以指向别处;const 在 * 右边(如 int * const p)修饰的是指针变量本身,此时指针不能再指向别处,但指向的内容可以修改。口诀:左锁内容,右锁指针。

2. 野指针

概念:野指针就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)。 用野指针去访问内存,是非常危险的。

2.1 野指针成因

成因 1:指针未初始化

c 复制代码
#include <stdio.h>
int main()
{
    int * p;      // 局部指针变量未初始化,默认是随机值
    *p = 20;      // 危险!p 指向一个随机地址,去访问它可能崩溃
    return 0;
}

局部变量 p 没初始化,里面是随机值。用这个随机值当地址去写数据,相当于"把 20 写到不知道哪里的内存",可能引发程序崩溃。

成因 2:指针越界访问

c 复制代码
#include <stdio.h>
int main()
{
    int arr[10] = {0};
    int * p = &arr[0];
    int i = 0;
    for (i = 0; i <= 11; i++)   // 注意:循环条件写成了 <= 11,超出了数组范围
    {
        *(p++) = i;             // 当 p 越出数组 arr 时,p 就是野指针
    }
    return 0;
}

数组只有 10 个元素(下标 0~9),循环却访问到了下标 10、11,越界了。越界后的 p 就是野指针。

成因 3:指针指向的空间被释放

c 复制代码
#include <stdio.h>
int* test()
{
    int n = 100;
    return &n;      // n 是局部变量,函数结束就销毁了,返回它的地址是危险的
}
int main()
{
    int* p = test();
    printf("%d\n", *p);   // p 指向的空间已经被释放,此时 p 就是野指针(悬空指针)
    return 0;
}

n 是 test 函数里的局部变量,函数一返回,n 所占的内存就被释放了。返回 &n 后,main 里的 p 指向的是一块已经"作废"的空间,这时 p 也叫"悬空指针",是野指针的一种。

2.2 如何规避野指针

(1)指针初始化

  • 如果明确知道指针该指向哪里,就直接赋给它一个有效的地址;
  • 如果暂时不知道指向哪里,就给它赋 NULL。

NULL 是 C 语言定义的一个标识符常量,值就是 0。0 也可以看作地址,但这个地址是不能使用的,读写它会报错。

c 复制代码
#ifdef __cplusplus
    #define NULL 0            // C++ 中 NULL 就是 0
#else
    #define NULL ((void *)0)  // C 中 NULL 是 (void*)0
#endif

初始化示例:

c 复制代码
#include <stdio.h>
int main()
{
    int num = 10;
    int * p1 = &num;   // 知道指向哪里,直接赋值有效地址
    int * p2 = NULL;   // 不知道指向哪里,赋 NULL
    return 0;
}

(2)小心指针越界

程序向内存申请了哪些空间,指针就只能访问哪些空间,不能超出范围。超出了就是越界访问。

(3)指针不再使用时及时置 NULL,使用前检查有效性

当指针不再用来访问某块空间时,及时把它置为 NULL。约定俗成的规则是:只要是 NULL 指针就不去访问,同时使用指针前先判断它是否为 NULL。

这里有一个非常形象的比喻:把野指针想象成野狗 。野狗放任不管非常危险,所以我们可以找一棵树把它拴起来,就相对安全了。给指针及时赋 NULL,就像把野狗拴起来------把野指针暂时管理起来。

不过,野狗即使拴起来了,我们也要绕着走,不能去挑逗它。对于指针也一样:使用之前先判断是否为 NULL,如果是(被拴住的野狗),就不能直接用;如果不是,才去使用。

c 复制代码
int main()
{
    int arr[10] = {1,2,3,4,5,6,7,8,9,10};
    int * p = &arr[0];
    int i = 0;
    for (i = 0; i < 10; i++)
    {
        *(p++) = i;   // 使用 p 访问数组
    }
    // 此时 p 已经越界了,把它置为 NULL
    p = NULL;

    // 下次使用时,先判断 p 是否为 NULL,再使用
    p = &arr[0];      // 重新让 p 获得有效地址
    if (p != NULL)    // 使用前检查有效性
    {
        // 安全地使用 p ...
    }
    return 0;
}

(4)避免返回局部变量的地址

也就是成因 3 里的例子,不要返回局部变量的地址。

一句话总结:野指针是指针指向了不可知的位置,成因主要有三种:指针未初始化、指针越界访问、指针指向的空间已释放。规避方法:① 指针要初始化(不知道指向哪里就赋 NULL);② 小心越界;③ 指针不用时及时置 NULL、用前先判断是否为 NULL;④ 不要返回局部变量的地址。

3. assert 断言

assert.h 头文件里定义了一个宏 assert(),用于在运行时确保程序符合指定条件。如果条件不符合,就报错并终止运行。这个宏常被称为"断言"。

c 复制代码
assert(p != NULL);   // 断言 p 不等于 NULL

程序运行到这一行时,会验证 p 是否等于 NULL:

  • 如果 p 确实不等于 NULL,assert 什么都不做,程序继续运行;
  • 如果 p 等于 NULL(条件为假),assert 会报错,在标准错误流 stderr 里写出一条错误信息,显示没通过的表达式 ,以及包含这个表达式的文件名和行号。

assert 对程序员非常友好,好处有:能自动标识出问题和行号,而且还有一种无需改代码就能开关 assert 的机制。如果已经确认程序没问题,不想再做断言,就在 #include <assert.h> 的前面定义一个宏 NDEBUG:

c 复制代码
#define NDEBUG          // 定义了 NDEBUG 后,编译时就会禁用所有 assert
#include <assert.h>

重新编译,编译器就会禁用文件里所有的 assert() 语句。如果程序又出问题了,删掉这行 #define NDEBUG(或注释掉)再编译,就重新启用了 assert。

assert 的缺点是:引入了额外检查,会增加程序运行时间。

一般我们会在 Debug(调试)版本 里使用 assert,在 Release(发布)版本里禁用 assert。在 VS 这类集成开发环境中,Release 版本会直接把 assert 优化掉。这样,Debug 版本有利于排查问题,Release 版本不影响用户使用时的效率。

一句话总结:assert 是断言宏(在 assert.h 中),用于在运行时检查条件是否为真,为假就报错终止并提示文件名和行号。定义 NDEBUG 可以一键禁用所有 assert。通常 Debug 版本开启、Release 版本关闭,方便调试又不影响性能。

4. 指针的使用和传址调用

4.1 strlen 的模拟实现

库函数 strlen 的功能是求字符串长度,统计的是字符串中 \0 之前字符的个数。

c 复制代码
size_t strlen ( const char * str );

参数 str 接收字符串的起始地址,然后向后逐个统计 \0 之前的字符个数,最后返回长度。

模拟实现:从起始地址开始向后逐个遍历字符,只要不是 \0 就让计数器 +1,直到遇到 \0 停止。

c 复制代码
#include <stdio.h>
#include <assert.h>

int my_strlen(const char * str)
{
    int count = 0;
    assert(str);            // 使用前断言 str 不为 NULL,防止野指针
    while (*str)            // *str 非 0(不是 '\0')就继续
    {
        count++;            // 每遇到一个字符,计数 +1
        str++;              // 指针向后移动一位
    }
    return count;           // 返回统计到的字符个数
}

int main()
{
    int len = my_strlen("abcdef");   // 计算字符串长度
    printf("%d\n", len);             // 输出 6
    return 0;
}

4.2 传值调用和传址调用

学习指针的目的是用指针解决问题。那什么问题,非用指针不可呢?

举个例子:写一个函数,交换两个整型变量的值。

一番思考后,我们可能先写出这样的代码:

c 复制代码
#include <stdio.h>
void Swap1(int x, int y)   // 传值调用:把 a、b 的值拷贝给 x、y
{
    int tmp = x;
    x = y;
    y = tmp;               // 这里交换的是 x、y,不是 a、b
}

int main()
{
    int a = 0;
    int b = 0;
    scanf("%d %d", &a, &b);                 // 输入 a 和 b,比如 10 20
    printf("交换前:a=%d b=%d\n", a, b);    // 交换前:a=10 b=20
    Swap1(a, b);
    printf("交换后:a=%d b=%d\n", a, b);    // 交换后:a=10 b=20(没交换成功)
    return 0;
}

运行结果:

复制代码
10 20
交换前:a=10 b=20
交换后:a=10 b=20

我们发现,根本没产生交换的效果。为什么?调试一下看看:

在 main 函数里创建了 a 和 b,假设:

  • a 的地址是 0x00cffdd0
  • b 的地址是 0x00cffdc4

调用 Swap1 时,把 a、b 的值传给了 Swap1,Swap1 内部创建了形参 x、y 来接收 a、b 的值。但是:

  • x 的地址是 0x00cffcec
  • y 的地址是 0x00cffcf0

x、y 确实收到了 a、b 的值,但 x 的地址和 a 不一样,y 的地址和 b 也不一样。也就是说,x、y 是两块独立的空间。在 Swap1 内部交换 x、y,自然影响不到 a、b。等 Swap1 调用结束回到 main,a、b 还是老样子。

像 Swap1 这样,把变量本身直接传给函数,就叫做传值调用。

结论:实参传给形参时,形参会单独创建一份临时空间来接收实参的值,对形参的修改不影响实参。 所以 Swap1 失败了。

那怎么解决呢?我们希望调用 Swap 时,Swap 内部操作的就是 main 里的 a、b。这时就要用指针了:在 main 里把 a、b 的地址传给 Swap,Swap 里通过地址间接操作 main 里的 a、b,就能完成交换。

c 复制代码
#include <stdio.h>
void Swap2(int* px, int* py)   // 传址调用:把 a、b 的地址传给 px、py
{
    int tmp = 0;
    tmp = *px;   // tmp = *px,也就是 tmp = a
    *px = *py;   // *px = *py,也就是 a = b
    *py = tmp;   // *py = tmp,也就是 b = tmp
}
int main()
{
    int a = 0;
    int b = 0;
    scanf("%d %d", &a, &b);                 // 输入 10 20
    printf("交换前:a=%d b=%d\n", a, b);    // 交换前:a=10 b=20
    Swap2(&a, &b);                          // 传地址
    printf("交换后:a=%d b=%d\n", a, b);    // 交换后:a=20 b=10
    return 0;
}

运行结果:

复制代码
10 20
交换前:a=10 b=20
交换后:a=20 b=10

成功交换了!

像 Swap2 这样,把变量的地址传给函数,就叫做传址调用。

传址调用可以让函数和主调函数之间建立真正的联系,在函数内部就能修改主调函数里的变量。

所以今后写函数时记住这条原则:

  • 函数只需要用到主调函数里的变量值 来做计算,就用传值调用;
  • 函数内部要修改 主调函数里的变量的值,就必须用传址调用。

一句话总结:传值调用时,形参是实参的一份临时拷贝,两者是独立空间,所以修改形参不影响实参;传址调用时,把变量的地址传给函数,函数通过指针解引用就能直接操作主调函数里的变量。当函数需要修改主调函数里的变量时,必须用传址调用。


三、深入理解指针(3)

这一讲开始把指针和数组结合,还会讲到二级指针和指针数组,是指针进阶的关键一步。

1. 数组名的理解

前面我们用指针访问数组时,写过这样的代码:

c 复制代码
int arr[10] = {1,2,3,4,5,6,7,8,9,10};
int *p = &arr[0];   // 用 &arr[0] 拿到数组首元素的地址

这里用 &arr[0] 拿到了数组第一个元素的地址。但其实数组名本身就是地址,而且是数组首元素的地址。我们来测试一下:

c 复制代码
#include <stdio.h>
int main()
{
    int arr[10] = {1,2,3,4,5,6,7,8,9,10};
    printf("&arr[0] = %p\n", &arr[0]);   // 首元素地址
    printf("arr     = %p\n", arr);       // 数组名
    return 0;
}

输出结果(地址是示意值):

复制代码
&arr[0] = 004FF9CC
arr     = 004FF9CC

结果一模一样,说明数组名就是数组首元素(第一个元素)的地址。

这时有同学会问:如果数组名就是首元素的地址,那下面的代码怎么解释?

c 复制代码
#include <stdio.h>
int main()
{
    int arr[10] = {1,2,3,4,5,6,7,8,9,10};
    printf("%d\n", sizeof(arr));   // 输出 40
    return 0;
}

输出结果是 40。如果 arr 是首元素的地址,那 sizeof(arr) 应该是 4 或 8 才对(一个指针的大小)。为什么是 40 呢?

原来,"数组名就是首元素地址"这个说法是对的,但有两个例外:

  1. sizeof(数组名):sizeof 里单独放数组名,这里的数组名表示整个数组 ,计算的是整个数组的大小 (单位字节)。所以 sizeof(arr) 是 10 个 int = 10×4 = 40。
  2. &数组名:这里的数组名表示整个数组 ,取出的是整个数组的地址(整个数组的地址和首元素的地址是有区别的)。

除了这两个例外,任何地方使用数组名,数组名都表示首元素的地址。

有好奇的同学可能再试一下这段代码:

c 复制代码
#include <stdio.h>
int main()
{
    int arr[10] = {1,2,3,4,5,6,7,8,9,10};
    printf("&arr[0] = %p\n", &arr[0]);
    printf("arr     = %p\n", arr);
    printf("&arr    = %p\n", &arr);
    return 0;
}

三个打印结果一模一样!那 arr 和 &arr 到底有什么区别呢?继续往下看:

c 复制代码
#include <stdio.h>
int main()
{
    int arr[10] = {1,2,3,4,5,6,7,8,9,10};
    printf("&arr[0]   = %p\n", &arr[0]);
    printf("&arr[0]+1 = %p\n", &arr[0]+1);
    printf("arr       = %p\n", arr);
    printf("arr+1     = %p\n", arr+1);
    printf("&arr      = %p\n", &arr);
    printf("&arr+1    = %p\n", &arr+1);
    return 0;
}

输出结果:

复制代码
&arr[0]   = 0077F820
&arr[0]+1 = 0077F824    ← 相差 4 字节
arr       = 0077F820
arr+1     = 0077F824    ← 相差 4 字节
&arr      = 0077F820
&arr+1    = 0077F848    ← 相差 40 字节!

分析:

  • &arr[0] 和 arr 都是首元素的地址 ,+1 就是跳过一个元素(4 字节),所以 &arr[0] 与 &arr[0]+1 差 4,arr 与 arr+1 也差 4。
  • &arr 是整个数组的地址 ,+1 操作是跳过整个数组 (10×4 = 40 字节),所以 &arr 与 &arr+1 相差 40。

虽然 arr、&arr、&arr[0] 打印出来的地址值一样,但它们的类型不一样,所以 +1 的步长不一样。

一句话总结 :数组名在绝大多数情况下表示数组首元素的地址,只有两个例外------sizeof(数组名) 计算的是整个数组的大小,&数组名 取出的是整个数组的地址。虽然 arr、&arr、&arr[0] 的地址值相同,但类型不同,导致它们 +1 的步长不同:首元素地址 +1 走一个元素,数组地址 +1 走整个数组。

2. 使用指针访问数组

有了前面的知识,再结合数组连续存放的特点,就能很方便地用指针访问数组了。

c 复制代码
#include <stdio.h>
int main()
{
    int arr[10] = {0};
    int i = 0;
    int sz = sizeof(arr) / sizeof(arr[0]);   // 元素个数

    // 输入:用指针给数组赋值
    int* p = arr;                            // arr 就是首元素地址,赋给 p
    for (i = 0; i < sz; i++)
    {
        scanf("%d", p + i);                  // p+i 是第 i 个元素的地址
        // 也可以写成 scanf("%d", arr + i);
    }

    // 输出
    for (i = 0; i < sz; i++)
    {
        printf("%d ", *(p + i));             // *(p+i) 取出第 i 个元素
    }
    return 0;
}

把这个代码搞明白后,我们再思考:数组名 arr 是首元素地址,可以赋给 p,说明在这里 arr 和 p 是等价的 。那我们能用 arr[i] 访问数组元素,那 p[i] 能访问吗?

c 复制代码
#include <stdio.h>
int main()
{
    int arr[10] = {0};
    int i = 0;
    int sz = sizeof(arr) / sizeof(arr[0]);
    int* p = arr;
    for (i = 0; i < sz; i++)
    {
        scanf("%d", p + i);
    }
    for (i = 0; i < sz; i++)
    {
        printf("%d ", p[i]);   // 用 p[i] 代替 *(p+i),也能正常打印
    }
    return 0;
}

把 *(p+i) 换成 p[i] 也能正常打印。这说明本质上是等价的:

复制代码
p[i]  ==  *(p + i)
arr[i] == *(arr + i)

其实数组元素的访问,在编译器处理时,也是转换成"首元素地址 + 偏移量"求出元素地址,再解引用来访问的。所以 [] 运算符本质上就是一种指针运算。

一句话总结 :用指针访问数组时,p[i] 和 *(p+i) 完全等价,arr[i] 和 *(arr+i) 也等价。数组元素访问本质就是"首元素地址 + 偏移量再解引用",[] 运算符底层就是指针运算。

3. 一维数组传参的本质

数组是可以传给函数的。这一小节讨论数组传参的本质。

先从一个问题开始:我们之前在函数外部计算数组元素个数,那能不能把数组传给函数后,在函数内部求数组元素个数呢?

c 复制代码
#include <stdio.h>
void test(int arr[])                       // 形参写成数组形式
{
    int sz2 = sizeof(arr) / sizeof(arr[0]);  // 在函数内部求元素个数
    printf("sz2 = %d\n", sz2);
}
int main()
{
    int arr[10] = {1,2,3,4,5,6,7,8,9,10};
    int sz1 = sizeof(arr) / sizeof(arr[0]);  // 在函数外部求元素个数
    printf("sz1 = %d\n", sz1);
    test(arr);
    return 0;
}

输出结果(X86 环境下):

复制代码
sz1 = 10
sz2 = 1

在函数外部 sz1 正确算出了 10,但在函数内部 sz2 却是 1(因为指针是 4 字节,int 是 4 字节,4/4=1)。这说明在函数内部无法正确获得数组元素个数。

为什么?这就要学数组传参的本质了。

上一节我们学过:数组名是数组首元素的地址。那么数组传参时,传递的是数组名,本质上就是传递数组首元素的地址。

所以函数形参部分,理论上应该用指针变量 来接收首元素的地址。在函数内部写 sizeof(arr),计算的是一个地址的大小(单位字节),而不是整个数组的大小。正是因为函数的参数部分本质是指针,所以在函数内部没法求数组元素个数。

c 复制代码
#include <stdio.h>
void test(int arr[])              // 参数写成数组形式,本质上还是指针
{
    printf("%d\n", sizeof(arr));  // 计算的是指针变量的大小
}
void test2(int* arr)              // 参数写成指针形式
{
    printf("%d\n", sizeof(arr));  // 同样计算的是指针变量的大小
}
int main()
{
    int arr[10] = {1,2,3,4,5,6,7,8,9,10};
    test(arr);
    test2(arr);
    return 0;
}

总结:一维数组传参,形参部分可以写成数组形式,也可以写成指针形式,本质都是指针。

一句话总结 :一维数组传参时,传递的其实是数组首元素的地址,形参本质上是指针。所以形参可以写成数组形式(int arr[])也可以写成指针形式(int* arr),并且在函数内部用 sizeof 求不到数组大小,只能得到指针大小。这就是数组传参的本质。

4. 冒泡排序

冒泡排序的核心思想是:两两相邻的元素进行比较,把较大的往后"冒"。

比如要对数组从小到大排序:每一轮从头开始,比较相邻两个元素,如果前面的比后面的大,就交换。这样一轮下来,最大的元素就"冒"到了最后。然后对剩下的元素重复这个过程。

c 复制代码
#include <stdio.h>
// 方法1:基本冒泡排序
void bubble_sort(int arr[], int sz)   // sz 是数组元素个数
{
    int i = 0;
    for (i = 0; i < sz - 1; i++)      // 一共需要 sz-1 轮
    {
        int j = 0;
        for (j = 0; j < sz - i - 1; j++)  // 每轮比较 sz-i-1 次
        {
            if (arr[j] > arr[j + 1])   // 前一个比后一个大,就交换
            {
                int tmp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = tmp;
            }
        }
    }
}
int main()
{
    int arr[] = {3,1,7,5,8,9,0,2,4,6};
    int sz = sizeof(arr) / sizeof(arr[0]);  // 元素个数
    bubble_sort(arr, sz);                   // 排序
    int i = 0;
    for (i = 0; i < sz; i++)
    {
        printf("%d ", arr[i]);              // 打印排序后的数组
    }
    return 0;
}

方法 2(优化版):如果某一轮比较下来一次都没交换,说明数组已经有序了,后面的轮次就可以提前结束,节省时间。

c 复制代码
#include <stdio.h>
// 方法2:优化版冒泡排序
void bubble_sort(int arr[], int sz)
{
    int i = 0;
    for (i = 0; i < sz - 1; i++)
    {
        int flag = 1;              // 假设这一轮已经有序了
        int j = 0;
        for (j = 0; j < sz - i - 1; j++)
        {
            if (arr[j] > arr[j + 1])
            {
                flag = 0;          // 一旦发生交换,说明还没排好序
                int tmp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = tmp;
            }
        }
        if (flag == 1)             // 这一轮没交换,说明已经有序
            break;                 // 提前退出
    }
}
int main()
{
    int arr[] = {3,1,7,5,8,9,0,2,4,6};
    int sz = sizeof(arr) / sizeof(arr[0]);
    bubble_sort(arr, sz);
    int i = 0;
    for (i = 0; i < sz; i++)
    {
        printf("%d ", arr[i]);
    }
    return 0;
}

一句话总结:冒泡排序的核心是"两两相邻元素比较,大的往后冒"。共进行 sz-1 轮,每轮比较 sz-i-1 次相邻元素并交换,时间复杂度 O(n²)。可以加一个 flag 标记,若某轮没有发生交换则提前结束,减少不必要的比较。

5. 二级指针

指针变量也是变量,是变量就有地址。那指针变量的地址存放在哪里呢?答案就是二级指针。

先看一张图:

c 复制代码
#include <stdio.h>
int main()
{
    int a = 10;
    int * pa = &a;      // pa 是一级指针,存 a 的地址
    int ** ppa = &pa;   // ppa 是二级指针,存 pa 的地址
    return 0;
}

二级指针的运算:

  • *ppa:对 ppa 里的地址解引用,找到的是 pa,所以 *ppa 访问的就是 pa。
c 复制代码
int b = 20;
*ppa = &b;   // 等价于 pa = &b
  • **ppa:先通过 *ppa 找到 pa,再对 pa 解引用(*pa),找到的就是 a。
c 复制代码
**ppa = 30;   // 等价于 *pa = 30
              // 等价于 a = 30

一句话总结 :二级指针就是"指向指针的指针",用来存放指针变量的地址。*ppa 得到它指向的一级指针,**ppa 通过两次解引用最终得到原始变量。二级指针常用来在函数中修改一级指针本身。

6. 指针数组

指针数组,是数组还是指针?答案是:数组。

类比一下:整型数组是存放整型的数组,字符数组是存放字符的数组。那指针数组,就是存放指针的数组。

复制代码
整型数组:  int arr[5]     ------ 每个元素是 int
字符数组:  char arr[5]    ------ 每个元素是 char
指针数组:  int* arr[5]    ------ 每个元素是 int*(指针)

指针数组的每个元素都是用来存放地址(指针)的,每个元素又是一个地址,可以指向一块区域。

一句话总结 :指针数组本质是数组,数组的每个元素都是指针。比如 int* arr[5] 就是一个存放 5 个 int* 指针的数组,其中每个元素都可以指向一个 int(或一块 int 内存)。

7. 指针数组模拟二维数组

c 复制代码
#include <stdio.h>
int main()
{
    int arr1[] = {1,2,3,4,5};
    int arr2[] = {2,3,4,5,6};
    int arr3[] = {3,4,5,6,7};

    // 三个数组名分别是各自首元素地址,类型是 int*
    // 把它们都存进指针数组 parr 里
    int* parr[3] = {arr1, arr2, arr3};

    int i = 0;
    int j = 0;
    for (i = 0; i < 3; i++)       // 3 行
    {
        for (j = 0; j < 5; j++)   // 每行 5 个元素
        {
            printf("%d ", parr[i][j]);   // 打印第 i 行第 j 个元素
        }
        printf("\n");
    }
    return 0;
}

parr 数组的结构可以画成:

复制代码
parr(int* 类型的数组)
┌─────────┐
│ parr[0] │──────→ arr1:  1 2 3 4 5
├─────────┤
│ parr[1] │──────→ arr2:  2 3 4 5 6
├─────────┤
│ parr[2] │──────→ arr3:  3 4 5 6 7
└─────────┘

parr[i] 访问的是 parr 数组的元素,parr[i] 找到的数组元素指向了一个整型一维数组,parr[i][j] 就是那个一维数组里的第 j 个元素。

上面的代码模拟出了二维数组的效果,但要注意:它实际上并非真正的二维数组,因为每一行在内存中并不是连续的(arr1、arr2、arr3 是三块独立的空间)。

一句话总结 :指针数组可以用来模拟二维数组------用一个 int* parr[3] 存放三个一维数组的首地址,再用 parr[i][j] 访问第 i 行第 j 个元素。但要注意这只是在模拟二维数组,因为各行之间在内存中并不连续。


四、深入理解指针(4)

这一讲是指针的高阶篇:字符指针、数组指针、函数指针、函数指针数组,以及它们的经典应用------转移表。

1. 字符指针变量

在指针类型中,有一种类型叫字符指针 char*。

一般用法:

c 复制代码
int main()
{
    char ch = 'w';
    char * pc = &ch;   // pc 指向字符变量 ch
    *pc = 'w';         // 通过 pc 修改 ch
    return 0;
}

还有一种用法:

c 复制代码
int main()
{
    const char* pstr = "hello bit.";   // 这里是把整个字符串放进 pstr 了吗?
    printf("%s\n", pstr);              // 打印 hello bit.
    return 0;
}

代码 const char* pstr = "hello bit."; 特别容易让人误以为是把字符串 hello bit. 放进了 pstr 里。其实本质是把字符串 hello bit. 首字符的地址放进了 pstr。

也就是说,pstr 里存的是常量字符串 hello bit. 中第一个字符 h 的地址。

用图表示:

复制代码
pstr(const char*)
┌──────────────┐
│ 0x0030012F44 │──────→ "hello bit."(常量字符串,内存中连续存放)
└──────────────┘

下面是一道和字符串相关的经典笔试题(出自《剑指offer》):

c 复制代码
#include <stdio.h>
int main()
{
    char str1[] = "hello bit.";           // 数组,各自独立开辟空间
    char str2[] = "hello bit.";           // 数组,各自独立开辟空间
    const char * str3 = "hello bit.";     // 指向常量字符串
    const char * str4 = "hello bit.";     // 指向常量字符串

    if (str1 == str2)
        printf("str1 and str2 are same\n");
    else
        printf("str1 and str2 are not same\n");

    if (str3 == str4)
        printf("str3 and str4 are same\n");
    else
        printf("str3 and str4 are not same\n");

    return 0;
}

程序运行结果:

复制代码
str1 and str2 are not same
str3 and str4 are same

分析:str3 和 str4 指向的是同一个常量字符串 。C/C++ 会把常量字符串单独存储到一块内存区域,当多个指针指向同一个字符串时,它们实际上会指向同一块内存 。但用相同的常量字符串去初始化不同的数组 时,就会开辟出不同的内存块。所以 str1 和 str2 不同,str3 和 str4 相同。

(注意:这里比较的是地址值,不是字符串内容。str1、str2 是两个不同数组的首地址,所以不等;str3、str4 都指向同一块常量字符串,所以相等。)

一句话总结 :字符指针 char* 可以指向字符,也可以指向字符串常量;const char* p = "hello" 本质是把字符串首字符的地址存进 p。多个指针指向同一个字符串常量时地址相同;但用字符串常量初始化多个数组时,每个数组会各自开辟独立空间,地址不同。

2. 数组指针变量

2.1 数组指针变量是什么?

之前学过指针数组(本质是数组,存的是指针)。那数组指针变量是数组还是指针?答案是:指针变量。

我们已经熟悉:

  • 整型指针变量:int * pint; 存放整型变量的地址,能指向整型数据。
  • 浮点型指针变量:float * pf; 存放浮点型变量的地址,能指向浮点型数据。

那数组指针变量应该是:存放数组的地址,能够指向数组的指针变量。

下面两个哪个是数组指针变量?

c 复制代码
int * p1[10];     // 指针数组:p1 是数组,每个元素是 int*
int (*p2)[10];    // 数组指针:p2 是指针,指向一个含 10 个 int 的数组

数组指针变量的写法:

c 复制代码
int (*p)[10];

解释:p 先和 * 结合,说明 p 是一个指针变量 ;然后它指向的是一个大小为 10 个整型的数组。所以 p 是一个指针,指向一个数组,叫"数组指针"。

这里要特别注意:[] 的优先级高于 * ,所以必须加上 () 来保证 p 先和 * 结合。如果不加括号,int *p[10] 就变成指针数组了。

2.2 数组指针变量怎么初始化

数组指针变量是用来存放数组地址的,那怎么获得数组的地址呢?就是我们之前学的 &数组名。

c 复制代码
int arr[10] = {0};
&arr;   // 得到的就是整个数组的地址

要存放整个数组的地址,就得用数组指针变量:

c 复制代码
int (*p)[10] = &arr;   // p 指向整个数组 arr

调试时也能看到 &arr 和 p 的类型是完全一致的(都是 int[10]* 类型,即指向 int10 的指针)。

数组指针类型解析:

复制代码
int  (*p)  [10] = &arr;
 |     |     |
 |     |     └── p 指向的数组的元素个数(10 个)
 |     └──────── p 是数组指针变量名
 └────────────── p 指向的数组的元素类型(int)

一句话总结 :数组指针是指向数组的指针,写法是 int (*p)[10]。因为 [] 优先级比 * 高,所以必须加括号让 p 先和 * 结合。int (*p)[10] = &arr; 表示 p 指向一个含 10 个 int 的数组,类型是"指向 int10 的指针"。区分记忆:int (*p)[10] 是数组指针(是指针),int *p[10] 是指针数组(是数组)。

3. 二维数组传参的本质

有了数组指针的理解,我们就能讲清二维数组传参的本质了。

以前二维数组需要传参给函数时,我们这样写:

c 复制代码
#include <stdio.h>
void test(int a[3][5], int r, int c)   // 形参写成二维数组形式
{
    int i = 0;
    int j = 0;
    for (i = 0; i < r; i++)
    {
        for (j = 0; j < c; j++)
        {
            printf("%d ", a[i][j]);
        }
        printf("\n");
    }
}
int main()
{
    int arr[3][5] = {{1,2,3,4,5}, {2,3,4,5,6}, {3,4,5,6,7}};
    test(arr, 3, 5);
    return 0;
}

这里实参是二维数组,形参也写成了二维数组形式。那还有其他写法吗?

先重新理解一下二维数组:二维数组可以看作"每个元素是一维数组"的数组。也就是说,二维数组的每个元素本身是一个一维数组。

那么二维数组的首元素 就是第一行(一个一维数组)。

画出二维数组 arr35 的结构:

复制代码
arr 的每一行都是一个 int[5] 的一维数组
┌─────────────────────────────┐
│ 第0行: 1  2  3  4  5  (int[5]) │  ← 这是二维数组的"首元素"
├─────────────────────────────┤
│ 第1行: 2  3  4  5  6  (int[5]) │
├─────────────────────────────┤
│ 第2行: 3  4  5  6  7  (int[5]) │
└─────────────────────────────┘

根据"数组名是数组首元素的地址"这个规则,二维数组的数组名表示的就是第一行的地址(一个一维数组的地址)。

第一行的一维数组类型是 int[5],所以第一行地址的类型就是数组指针类型 int(*)[5]。

这就意味着:二维数组传参本质上也是传递地址,传递的是第一行这个一维数组的地址。所以形参也可以写成指针形式:

c 复制代码
#include <stdio.h>
void test(int (*p)[5], int r, int c)   // 形参写成数组指针形式
{
    int i = 0;
    int j = 0;
    for (i = 0; i < r; i++)
    {
        for (j = 0; j < c; j++)
        {
            printf("%d ", *(*(p + i) + j));   // *(*(p+i)+j) 等价于 a[i][j]
        }
        printf("\n");
    }
}
int main()
{
    int arr[3][5] = {{1,2,3,4,5}, {2,3,4,5,6}, {3,4,5,6,7}};
    test(arr, 3, 5);
    return 0;
}

总结:二维数组传参,形参部分可以写成数组形式,也可以写成指针(数组指针)形式。

一句话总结 :二维数组可以看成"元素是一维数组的数组",它的数组名表示第一行(一个一维数组)的地址,类型是数组指针。所以二维数组传参本质是传递第一行的地址,形参既可以写成 int a[3][5],也可以写成数组指针 int (*p)[5],两者等价。

4. 函数指针变量

4.1 函数指针变量的创建

什么是函数指针变量呢?类比整型指针、数组指针,不难得出结论:函数指针变量是用来存放函数地址的,未来通过地址就能调用函数。

那函数有地址吗?测试一下:

c 复制代码
#include <stdio.h>
void test()
{
    printf("hehe\n");
}
int main()
{
    printf("test:  %p\n", test);    // 打印函数名
    printf("&test: %p\n", &test);   // 打印 &函数名
    return 0;
}

输出结果:

复制代码
test:  005913CA
&test: 005913CA

确实打印出了地址!所以函数是有地址的,函数名就是函数的地址 ,当然也可以通过 &函数名 得到函数的地址。

要把函数的地址存起来,就得创建函数指针变量。函数指针的写法和数组指针非常类似:

c 复制代码
void test()
{
    printf("hehe\n");
}
void (*pf1)() = &test;          // pf1 指向 test 函数
void (*pf2)() = test;           // 函数名就是地址,这样写也行

int Add(int x, int y)
{
    return x + y;
}
int (*pf3)(int, int) = Add;         // pf3 指向 Add
int (*pf3)(int x, int y) = &Add;    // 参数名 x、y 写上或省略都可以

函数指针类型解析:

复制代码
int  (*pf3)  (int x, int y)
 |     |       ------------
 |     |            |
 |     |            └── pf3 指向函数的参数类型和个数(int, int)
 |     └─────────────── 函数指针变量名
 └───────────────────── pf3 指向函数的返回类型(int)

int (*) (int, int)   // pf3 这个函数指针变量的类型

4.2 函数指针变量的使用

通过函数指针调用它指向的函数:

c 复制代码
#include <stdio.h>
int Add(int x, int y)
{
    return x + y;
}
int main()
{
    int (*pf3)(int, int) = Add;   // pf3 指向 Add

    printf("%d\n", (*pf3)(2, 3)); // 通过函数指针调用:先解引用再调用,得 5
    printf("%d\n", pf3(3, 5));    // 也可以直接写 pf3(...),得 8
    return 0;
}

输出结果:

复制代码
5
8

两种写法都可以:(*pf3)(2,3) 或 pf3(2,3)。

4.3 两段有趣的代码

代码 1:

c 复制代码
(*(void (*)())0)();

这段代码出自《C陷阱和缺陷》。它把 0 强制转换成 void (*)() 类型(函数指针类型),然后解引用,得到地址为 0 处的"函数",再调用它。实际含义是"调用地址为 0 处的函数"(这是一个危险的示范,说明 C 语言指针表达力的强大)。

代码 2:

c 复制代码
void (*signal(int , void(*)(int)))(int);

这也是个"天书"般的声明:signal 是一个函数,参数是 int 和 void(*)(int)(一个函数指针),返回值是一个 void(*)(int)(函数指针)。理解起来很绕,所以需要 typedef 来简化。

4.4 typedef 关键字

typedef 是用来给类型重命名的,可以把复杂的类型简单化。

比如,你觉得 unsigned int 写起来不方便,想写成 uint,就可以:

c 复制代码
typedef unsigned int uint;   // 把 unsigned int 重命名为 uint

指针类型也能重命名,比如把 int* 重命名为 ptr_t:

c 复制代码
typedef int* ptr_t;          // 之后 ptr_t 就代表 int*

但对于数组指针和函数指针,稍微有点区别:新的类型名必须写在 * 的右边。

比如把数组指针类型 int(*)[5] 重命名为 parr_t:

c 复制代码
typedef int(*parr_t)[5];     // parr_t 就是 int(*)[5] 类型

函数指针类型的重命名也一样,把 void(*)(int) 类型重命名为 pf_t:

c 复制代码
typedef void(*pfun_t)(int);  // pfun_t 就是 void(*)(int) 类型

这样,上面"天书"般的代码 2 就可以简化为:

c 复制代码
typedef void(*pfun_t)(int);
pfun_t signal(int, pfun_t);   // 清晰多了:signal 返回 pfun_t,参数是 int 和 pfun_t

一句话总结 :函数指针是存放函数地址的指针,函数名就是函数的地址。写法如 int (*pf)(int,int),用 (*pf)(...) 或 pf(...) 调用。typedef 可以给类型重命名来简化复杂类型,重命名函数/数组指针时,新类型名要放在 * 的右边。

5. 函数指针数组

数组是存放相同类型数据的存储空间。我们已经学过指针数组:

c 复制代码
int * arr[10];   // 数组的每个元素是 int*

那要把函数的地址存到一个数组里,这个数组就叫函数指针数组。怎么定义呢?看下面三个:

c 复制代码
int (*parr1[3])();   // 正确:parr1 是函数指针数组
int *parr2[3]();     // 错误写法
int (*)() parr3[3];  // 错误写法

答案是 parr1。

parr1 先和 [] 结合,说明 parr1 是数组;数组里存的内容是 int (*)() 类型的函数指针。

6. 转移表

函数指针数组的一个经典用途就是转移表。

举例:实现一个计算器。一般的实现方式是用 switch 分支:

c 复制代码
#include <stdio.h>
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;
    int input = 1;
    int ret = 0;
    do
    {
        printf("*************************\n");
        printf("  1:add           2:sub  \n");
        printf("  3:mul           4:div  \n");
        printf("  0:exit                 \n");
        printf("*************************\n");
        printf("请选择:");
        scanf("%d", &input);
        switch (input)
        {
        case 1:
            printf("输入操作数:");
            scanf("%d %d", &x, &y);
            ret = add(x, y);
            printf("ret = %d\n", ret);
            break;
        case 2:
            printf("输入操作数:");
            scanf("%d %d", &x, &y);
            ret = sub(x, y);
            printf("ret = %d\n", ret);
            break;
        case 3:
            printf("输入操作数:");
            scanf("%d %d", &x, &y);
            ret = mul(x, y);
            printf("ret = %d\n", ret);
            break;
        case 4:
            printf("输入操作数:");
            scanf("%d %d", &x, &y);
            ret = div(x, y);
            printf("ret = %d\n", ret);
            break;
        case 0:
            printf("退出程序\n");
            break;
        default:
            printf("选择错误\n");
            break;
        }
    } while (input);
    return 0;
}

可以看到 switch 里每个 case 都有大量重复代码。使用函数指针数组(转移表)可以大大简化:

c 复制代码
#include <stdio.h>
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;
    int input = 1;
    int ret = 0;
    // 转移表:下标 1~4 对应 add/sub/mul/div 四个函数的地址
    int (*p[5])(int x, int y) = { 0, add, sub, mul, div };
    do
    {
        printf("*************************\n");
        printf("  1:add           2:sub  \n");
        printf("  3:mul           4:div  \n");
        printf("  0:exit                 \n");
        printf("*************************\n");
        printf("请选择:");
        scanf("%d", &input);
        if ((input <= 4 && input >= 1))
        {
            printf("输入操作数:");
            scanf("%d %d", &x, &y);
            ret = (*p[input])(x, y);   // 用转移表调用对应函数
            printf("ret = %d\n", ret);
        }
        else if (input == 0)
        {
            printf("退出计算器\n");
        }
        else
        {
            printf("输入有误\n");
        }
    } while (input);
    return 0;
}

这里 p 是一个函数指针数组,下标 1~4 分别存放 add、sub、mul、div 的函数地址。用户输入的数字直接作为下标,p[input] 就找到对应的函数并调用,省去了一堆重复的 switch 分支。这种"根据输入查表调用函数"的数组就叫转移表。

一句话总结 :函数指针数组是存放函数地址的数组(如 int (*p[5])(int,int)),它的经典应用是转移表------用一个数组把多个函数地址按序号存起来,用户输入一个序号,直接通过下标找到对应函数调用,从而取代大量重复的 switch 分支,让代码更简洁。


五、深入理解指针(5)

这一讲讲指针的"高级用法"------回调函数,以及 C 标准库里的排序神器 qsort,最后手把手模拟实现一个 qsort。

1. 回调函数是什么?

回调函数就是一个通过函数指针调用的函数。

具体说:如果你把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用来调用它所指向的函数时,被调用的函数就是回调函数。

回调函数不是由它自己的实现方直接调用的,而是在特定事件或条件发生时,由另外一方调用的,用于对该事件或条件做出响应。

在上一讲计算器的 switch 实现里,每个 case 里都有大量重复的代码:虽然执行的计算逻辑不同(add/sub/mul/div),但"输入操作数、调用函数、打印结果"这套输入输出操作是冗余的。

因为只有"调用哪个函数"这一处有差异,我们可以把要调用的函数的地址以参数形式传过去,用函数指针接收。函数指针指向哪个函数,就调用哪个函数。这里用的就是回调函数的思想。

改造前(switch 版,重复代码多):

c 复制代码
#include <stdio.h>
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;
    int input = 1;
    int ret = 0;
    do
    {
        printf("*************************\n");
        printf("  1:add           2:sub  \n");
        printf("  3:mul           4:div  \n");
        printf("*************************\n");
        printf("请选择:");
        scanf("%d", &input);
        switch (input)
        {
        case 1:
            printf("输入操作数:");
            scanf("%d %d", &x, &y);
            ret = add(x, y);
            printf("ret = %d\n", ret);
            break;
        case 2:
            printf("输入操作数:");
            scanf("%d %d", &x, &y);
            ret = sub(x, y);
            printf("ret = %d\n", ret);
            break;
        case 3:
            printf("输入操作数:");
            scanf("%d %d", &x, &y);
            ret = mul(x, y);
            printf("ret = %d\n", ret);
            break;
        case 4:
            printf("输入操作数:");
            scanf("%d %d", &x, &y);
            ret = div(x, y);
            printf("ret = %d\n", ret);
            break;
        case 0:
            printf("退出程序\n");
            break;
        default:
            printf("选择错误\n");
            break;
        }
    } while (input);
    return 0;
}

改造后(回调函数版,把重复逻辑抽成一个 calc 函数):

c 复制代码
#include <stdio.h>
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; }

// calc 接收一个函数指针 pf,输入操作数,然后调用 pf 指向的函数
void calc(int (*pf)(int, int))
{
    int ret = 0;
    int x, y;
    printf("输入操作数:");
    scanf("%d %d", &x, &y);
    ret = pf(x, y);              // 通过函数指针调用(这里调用的就是回调函数)
    printf("ret = %d\n", ret);
}

int main()
{
    int input = 1;
    do
    {
        printf("*************************\n");
        printf("  1:add           2:sub  \n");
        printf("  3:mul           4:div  \n");
        printf("*************************\n");
        printf("请选择:");
        scanf("%d", &input);
        switch (input)
        {
        case 1:
            calc(add);           // 把 add 的地址传给 calc,add 就是回调函数
            break;
        case 2:
            calc(sub);
            break;
        case 3:
            calc(mul);
            break;
        case 4:
            calc(div);
            break;
        case 0:
            printf("退出程序\n");
            break;
        default:
            printf("选择错误\n");
            break;
        }
    } while (input);
    return 0;
}

这样,switch 分支里只剩一行 calc(add) 之类的调用,重复代码大大减少。被传进去的 add、sub、mul、div,就是在 calc 内部被"回调"的函数。

一句话总结:回调函数就是通过函数指针调用的函数------把某个函数的地址作为参数传给另一个函数,由后者在需要时通过这个指针调用它。回调函数的本质是"把函数当作参数传递",可以实现代码复用(如统一处理逻辑、事件响应、排序时让使用者自定义比较规则)。

2. qsort 使用举例

qsort 是 C 标准库提供的一个通用的快速排序函数,可以给任意类型的数据排序。它内部就大量使用了回调函数(比较函数)和 void*(泛型指针)。

qsort 的函数原型(大致)是:

c 复制代码
void qsort(void *base,       // 待排序数组的首地址
           size_t num,       // 元素个数
           size_t size,      // 每个元素的大小(字节)
           int (*cmp)(const void*, const void*));  // 比较函数的指针

使用者需要自己实现一个"比较函数" cmp,告诉 qsort 两个元素谁大谁小。qsort 会根据 cmp 的返回值排序:返回负数表示 e1 排在 e2 前,返回 0 表示相等,返回正数表示 e1 排在 e2 后。

2.1 使用 qsort 排序整型数据

c 复制代码
#include <stdio.h>
#include <stdlib.h>

// 比较函数:使用者实现,告诉 qsort 两个整数怎么比
int int_cmp(const void * p1, const void * p2)
{
    // void* 不能直接解引用,先强转成 int* 再解引用
    return (*(int *)p1 - *(int *)p2);
}

int main()
{
    int arr[] = { 1, 3, 5, 7, 9, 2, 4, 6, 8, 0 };
    int i = 0;

    qsort(arr, sizeof(arr) / sizeof(arr[0]), sizeof(int), int_cmp);
    //    数组首地址  元素个数                  每个元素大小  比较函数

    for (i = 0; i < sizeof(arr) / sizeof(arr[0]); i++)
    {
        printf("%d ", arr[i]);   // 打印排序后的结果:0 1 2 3 4 5 6 7 8 9
    }
    printf("\n");
    return 0;
}

2.2 使用 qsort 排序结构体数据

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

struct Stu   // 学生结构体
{
    char name[20];   // 名字
    int age;         // 年龄
};

// 按年龄比较
int cmp_stu_by_age(const void* e1, const void* e2)
{
    return ((struct Stu*)e1)->age - ((struct Stu*)e2)->age;
}

// strcmp 是库函数,专门用来比较两个字符串的大小
// 按名字比较
int cmp_stu_by_name(const void* e1, const void* e2)
{
    return strcmp(((struct Stu*)e1)->name, ((struct Stu*)e2)->name);
}

// 按年龄排序
void test2()
{
    struct Stu s[] = { {"zhangsan", 20}, {"lisi", 30}, {"wangwu", 15} };
    int sz = sizeof(s) / sizeof(s[0]);
    qsort(s, sz, sizeof(s[0]), cmp_stu_by_age);
}

// 按名字排序
void test3()
{
    struct Stu s[] = { {"zhangsan", 20}, {"lisi", 30}, {"wangwu", 15} };
    int sz = sizeof(s) / sizeof(s[0]);
    qsort(s, sz, sizeof(s[0]), cmp_stu_by_name);
}

int main()
{
    test2();
    test3();
    return 0;
}

可以看到,qsort 配合不同的比较函数,就能对整型、结构体等各种数据排序,非常灵活。这正是 void*(接收任意类型地址)和回调函数(把比较逻辑交给使用者)结合的威力。

一句话总结:qsort 是通用排序函数,参数包括数组首地址、元素个数、每个元素大小和一个比较函数指针。使用者只需自定义比较函数(返回负数/0/正数表示大小关系),qsort 就能给任意类型的数据排序,这是 void* 泛型指针和回调函数的典型应用。

3. qsort 函数的模拟实现

我们用回调函数,模拟实现一个 qsort(采用冒泡的方式,所以也叫通用冒泡排序)。

这里也是第一次真正用 void* 指针,重点理解 void* 的作用。

c 复制代码
#include <stdio.h>

// 比较函数:比较两个整数
int int_cmp(const void * p1, const void * p2)
{
    return (*(int *)p1 - *(int *)p2);
}

// 交换函数:按字节逐个交换两个元素(size 是每个元素的字节数)
void _swap(void *p1, void *p2, int size)
{
    int i = 0;
    for (i = 0; i < size; i++)
    {
        // 把 p1、p2 看成 char*,按字节逐个交换
        char tmp = *((char *)p1 + i);
        *((char *)p1 + i) = *((char *)p2 + i);
        *((char *)p2 + i) = tmp;
    }
}

// 通用冒泡排序:base 是数组首地址,count 是元素个数,size 是每个元素大小,cmp 是比较函数
void bubble(void *base, int count, int size, int (*cmp)(void *, void *))
{
    int i = 0;
    int j = 0;
    for (i = 0; i < count - 1; i++)
    {
        for (j = 0; j < count - i - 1; j++)
        {
            // (char*)base + j*size 是第 j 个元素的地址
            // (char*)base + (j+1)*size 是第 j+1 个元素的地址
            if (cmp((char *)base + j * size, (char *)base + (j + 1) * size) > 0)
            {
                // 如果前面元素比后面大,就交换
                _swap((char *)base + j * size, (char *)base + (j + 1) * size, size);
            }
        }
    }
}

int main()
{
    int arr[] = { 1, 3, 5, 7, 9, 2, 4, 6, 8, 0 };
    int i = 0;
    bubble(arr, sizeof(arr) / sizeof(arr[0]), sizeof(int), int_cmp);
    for (i = 0; i < sizeof(arr) / sizeof(arr[0]); i++)
    {
        printf("%d ", arr[i]);   // 输出 0 1 2 3 4 5 6 7 8 9
    }
    printf("\n");
    return 0;
}

关键点解释:

  • 因为不知道要排序的数据是什么类型,所以用 void* base 接收任意类型的数组首地址。
  • void* 不能直接做加减运算,所以要先把 base 强转成 char*,再按 元素大小 × 下标 的偏移量找到每个元素。(char*)base + j*size 就是第 j 个元素的起始地址。
  • 交换时因为不知道元素占几字节,就按字节逐个交换(把两个元素都当成 char 数组,逐字节交换)。
  • 比较时调用使用者传入的 cmp 回调函数,cmp 内部再强转回真正的类型去比较。

一句话总结 :模拟 qsort 的关键是通用化------用 void* base 接收任意数组首地址,用 size 记录每个元素大小,把 base 强转成 char* 后按 j*size 的偏移定位第 j 个元素;比较逻辑交给使用者传入的回调函数,交换则按字节逐字节进行,从而实现对任意类型数据的通用排序。


六、深入理解指针(6)

这一讲是"大杂烩"和"压轴题":先讲清 sizeof 和 strlen 的区别,再集中解析一堆数组、指针相关的经典题目。这些题目是检验指针功力的试金石。

1. sizeof 和 strlen 的对比

1.1 sizeof

sizeof 是操作符,用来计算变量(或类型)所占内存空间的大小,单位是字节。如果操作数是类型,计算的就是用该类型创建的变量所占内存的大小。

sizeof 只关注占用内存空间的大小,不在乎内存里存的是什么数据。

c 复制代码
#include <stdio.h>
int main()
{
    int a = 10;
    printf("%d\n", sizeof(a));    // 4:变量 a 的大小
    printf("%d\n", sizeof a);     // 4:sizeof 是操作符,变量名可以不带括号
    printf("%d\n", sizeof(int));  // 4:int 类型的大小(类型必须带括号)
    return 0;
}

1.2 strlen

strlen 是 C 语言库函数,功能是求字符串长度。函数原型:

c 复制代码
size_t strlen(const char * str);

它统计的是从参数 str 这个地址开始向后,\0 之前字符串中字符的个数。

strlen 会一直向后找 \0 字符,直到找到为止,所以可能存在越界查找(如果字符串里没有 \0)。

c 复制代码
#include <stdio.h>
#include <string.h>
int main()
{
    char arr1[3] = {'a', 'b', 'c'};   // 没有 \0
    char arr2[] = "abc";              // 等价于 {'a','b','c','\0'},有 \0

    printf("%d\n", strlen(arr1));   // 随机值:找不到 \0,一直越界往后找
    printf("%d\n", strlen(arr2));   // 3:找到 \0 之前有 3 个字符

    printf("%d\n", sizeof(arr1));   // 3:数组本身大小,3 个 char
    printf("%d\n", sizeof(arr2));   // 4:'a','b','c','\0' 共 4 个字节
    return 0;
}

1.3 sizeof 和 strlen 的对比

对比项 sizeof strlen
本质 操作符 库函数(需包含 string.h)
功能 计算操作数所占内存大小,单位字节 求字符串长度,统计 \0 之前的字符个数
关注点 只关心占多大空间,不管存什么 关心是否有 \0,没有就会一直往后找,可能越界

一句话总结 :sizeof 是操作符,在编译期就能确定结果,计算的是变量或类型所占内存的字节数,不关心内容;strlen 是库函数,在运行时从给定地址向后逐个找字符,统计 \0 之前的字符个数,若没有 \0 会越界查找、结果是随机值。

2. 数组和指针笔试题解析

做题前,请先记住数组名的三条意义(这是解所有题目的核心):

  1. sizeof(数组名):数组名表示整个数组,计算整个数组的大小。
  2. &数组名:数组名表示整个数组,取出整个数组的地址。
  3. 除此之外,所有的数组名都表示首元素的地址。

以下题目默认在 X86(32 位)环境下,指针大小为 4 字节;X64 环境下指针相关结果翻倍(8 字节)。

2.1 一维数组

c 复制代码
int a[] = {1,2,3,4};
printf("%d\n", sizeof(a));        // 16:整个数组,4 个 int = 4*4
printf("%d\n", sizeof(a+0));      // 4:a+0 是首元素地址(指针)
printf("%d\n", sizeof(*a));       // 4:*a 是首元素 a[0],int 类型
printf("%d\n", sizeof(a+1));      // 4:a+1 是第二个元素的地址(指针)
printf("%d\n", sizeof(a[1]));     // 4:a[1] 是 int 类型
printf("%d\n", sizeof(&a));       // 4:&a 是整个数组的地址(也是指针)
printf("%d\n", sizeof(*&a));      // 16:*&a 就是 a,整个数组
printf("%d\n", sizeof(&a+1));     // 4:&a+1 是跳过一个数组后的地址(指针)
printf("%d\n", sizeof(&a[0]));    // 4:首元素地址(指针)
printf("%d\n", sizeof(&a[0]+1));  // 4:第二个元素地址(指针)

2.2 字符数组

代码 1(sizeof):

c 复制代码
char arr[] = {'a','b','c','d','e','f'};   // 6 个字符,没有 \0
printf("%d\n", sizeof(arr));        // 6:整个数组,6 个 char
printf("%d\n", sizeof(arr+0));      // 4:首元素地址(指针)
printf("%d\n", sizeof(*arr));       // 1:首元素 'a',char 类型
printf("%d\n", sizeof(arr[1]));     // 1:'b',char 类型
printf("%d\n", sizeof(&arr));       // 4:整个数组的地址(指针)
printf("%d\n", sizeof(&arr+1));     // 4:跳过一个数组后的地址(指针)
printf("%d\n", sizeof(&arr[0]+1));  // 4:第二个元素地址(指针)

代码 2(strlen):

c 复制代码
char arr[] = {'a','b','c','d','e','f'};   // 没有 \0
printf("%d\n", strlen(arr));        // 随机值:找不到 \0,越界查找
printf("%d\n", strlen(arr+0));      // 随机值:同上
printf("%d\n", strlen(*arr));       // 错误:*arr 是 'a'(ASCII 97),被当地址用,非法访问
printf("%d\n", strlen(arr[1]));     // 错误:arr[1] 是 'b'(ASCII 98),同上
printf("%d\n", strlen(&arr));       // 随机值:&arr 的值等于首地址,从这往后找 \0(有类型警告)
printf("%d\n", strlen(&arr+1));     // 随机值:从跳过整个数组后的位置往后找
printf("%d\n", strlen(&arr[0]+1));  // 随机值:从第二个元素往后找

代码 3(sizeof,字符串带 \0):

c 复制代码
char arr[] = "abcdef";   // 等价于 {'a','b','c','d','e','f','\0'}
printf("%d\n", sizeof(arr));        // 7:6 个字符 + 1 个 \0
printf("%d\n", sizeof(arr+0));      // 4:首元素地址(指针)
printf("%d\n", sizeof(*arr));       // 1:首元素 'a'
printf("%d\n", sizeof(arr[1]));     // 1:'b'
printf("%d\n", sizeof(&arr));       // 4:整个数组的地址(指针)
printf("%d\n", sizeof(&arr+1));     // 4:地址
printf("%d\n", sizeof(&arr[0]+1));  // 4:地址

代码 4(strlen,字符串带 \0):

c 复制代码
char arr[] = "abcdef";
printf("%d\n", strlen(arr));        // 6:\0 之前 6 个字符
printf("%d\n", strlen(arr+0));      // 6:同上
printf("%d\n", strlen(*arr));       // 错误:'a'(97)被当地址
printf("%d\n", strlen(arr[1]));     // 错误:'b'(98)被当地址
printf("%d\n", strlen(&arr));       // 6:&arr 值等于首地址(有类型警告)
printf("%d\n", strlen(&arr+1));     // 随机值:跳过整个数组后往后找
printf("%d\n", strlen(&arr[0]+1));  // 5:从 'b' 开始到 \0,共 5 个字符

代码 5(sizeof,字符指针):

c 复制代码
char *p = "abcdef";                  // p 指向常量字符串首字符 'a'
printf("%d\n", sizeof(p));          // 4:p 是指针变量
printf("%d\n", sizeof(p+1));        // 4:p+1 是地址(指向 'b')
printf("%d\n", sizeof(*p));         // 1:*p 是 'a',char 类型
printf("%d\n", sizeof(p[0]));       // 1:p[0] 等价于 *p,是 'a'
printf("%d\n", sizeof(&p));         // 4:&p 是 p 这个指针变量的地址(二级指针)
printf("%d\n", sizeof(&p+1));       // 4:地址
printf("%d\n", sizeof(&p[0]+1));    // 4:&p[0]+1 是 'b' 的地址

代码 6(strlen,字符指针):

c 复制代码
char *p = "abcdef";
printf("%d\n", strlen(p));          // 6:从 'a' 到 \0
printf("%d\n", strlen(p+1));        // 5:从 'b' 到 \0
printf("%d\n", strlen(*p));         // 错误:'a'(97)被当地址
printf("%d\n", strlen(p[0]));       // 错误:'a'(97)被当地址
printf("%d\n", strlen(&p));         // 随机值:&p 是 p 变量的地址,p 里存的是字符串地址,
                                    //        从这个二级指针指向的字节往后找 \0,结果不确定
printf("%d\n", strlen(&p+1));       // 随机值:同上
printf("%d\n", strlen(&p[0]+1));    // 5:从 'b' 到 \0

2.3 二维数组

c 复制代码
int a[3][4] = {0};
printf("%d\n", sizeof(a));          // 48:整个二维数组 3*4*4
printf("%d\n", sizeof(a[0][0]));    // 4:a[0][0] 是 int
printf("%d\n", sizeof(a[0]));       // 16:a[0] 是第一行(int[4]),4*4
printf("%d\n", sizeof(a[0]+1));     // 4:a[0]+1 是第一行第二个元素的地址(指针)
printf("%d\n", sizeof(*(a[0]+1)));  // 4:*(a[0]+1) 是第一行第二个元素,int
printf("%d\n", sizeof(a+1));        // 4:a+1 是第二行的地址(数组指针)
printf("%d\n", sizeof(*(a+1)));     // 16:*(a+1) 是第二行(int[4])
printf("%d\n", sizeof(&a[0]+1));    // 4:第二行的地址(指针)
printf("%d\n", sizeof(*(&a[0]+1))); // 16:*(&a[0]+1) 是第二行(int[4])
printf("%d\n", sizeof(*a));         // 16:*a 是第一行(int[4])
printf("%d\n", sizeof(a[3]));       // 16:a[3] 是 int[4] 类型;sizeof 只按类型算大小,
                                    //    不真正访问越界空间,所以是 4*4=16

一句话总结:解这类题的核心是分清"数组名"和"指针"------sizeof(数组名) 是整个数组大小,&数组名 是整个数组地址,其余数组名都是首元素地址(指针)。而 sizeof 只看类型算字节数、不真正求值(所以越界下标如 a3 也能算出大小),strlen 则是从地址开始实际找 \0,字符本身当地址会非法访问。

3. 指针运算笔试题解析

3.1 题目 1

c 复制代码
#include <stdio.h>
int main()
{
    int a[5] = { 1, 2, 3, 4, 5 };
    int *ptr = (int *)(&a + 1);     // &a 是数组地址,+1 跳过整个数组,指向 a[4] 之后
    printf("%d,%d", *(a + 1), *(ptr - 1));
    return 0;
}
// 结果:2,5

解析:

  • &a 是整个数组的地址,&a + 1 跳过整个数组(5 个 int = 20 字节),指向数组末尾之后的位置。
  • ptr 指向数组末尾之后,ptr - 1 就指回最后一个元素 a4,*(ptr-1) = 5。
  • *(a+1) = a1 = 2。

所以输出 2,5。

3.2 题目 2

c 复制代码
// X86 环境下,假设结构体大小是 20 字节
struct Test
{
    int Num;          // 4
    char *pcName;     // 4
    short sDate;      // 2
    char cha[2];      // 2
    short sBa[4];     // 8
    // 考虑内存对齐,整个结构体共 20 字节
} *p = (struct Test*)0x100000;

int main()
{
    printf("%p\n", p + 0x1);                  // 0x100014
    printf("%p\n", (unsigned long)p + 0x1);   // 0x100001
    printf("%p\n", (unsigned int*)p + 0x1);   // 0x100004
    return 0;
}
// 结果:0x100014、0x100001、0x100004

解析:

  • p 是 struct Test* 类型,p+1 跳过整个结构体 20 字节,所以 0x100000 + 20 = 0x100014。
  • (unsigned long)p 是把地址当整数,整数 +1 就是 +1,所以 0x100000 + 1 = 0x100001。
  • (unsigned int*)p 是 int 指针,+1 跳过 4 字节,所以 0x100000 + 4 = 0x100004。

3.3 题目 3

c 复制代码
#include <stdio.h>
int main()
{
    int a[3][2] = { (0, 1), (2, 3), (4, 5) };  // 注意:这是逗号表达式!
    // 逗号表达式的结果是最后一个值,所以实际初始化是 {1, 3, 5}
    int *p;
    p = a[0];        // a[0] 是第一行,p 指向 a[0][0]
    printf("%d", p[0]);
    return 0;
}
// 结果:1

解析:(0,1) 是逗号表达式,值为 1;(2,3) 值为 3;(4,5) 值为 5。所以数组实际是 {1, 3, 5},p[0] = 1。

3.4 题目 4

c 复制代码
// 假设环境是 x86 环境
#include <stdio.h>
int main()
{
    int a[5][5];
    int (*p)[4];    // p 是指向 int[4] 的数组指针
    p = a;          // 类型不匹配(a 是 int[5] 每行),但强制赋值
    printf("%p,%d\n", &p[4][2] - &a[4][2], &p[4][2] - &a[4][2]);
    return 0;
}
// 结果:FFFFFFFC,-4

解析:

  • p 每行按 4 个 int 走,p[4][2] 相对 p 的偏移是 4*4 + 2 = 18 个 int。
  • a 每行按 5 个 int 走,a[4][2] 相对 a 的偏移是 4*5 + 2 = 22 个 int。
  • 两者地址差 = 18 - 22 = -4(个 int)。
  • %d 打印出 -4;%p 把 -4 的补码按地址打印,32 位下是 FFFFFFFC。

所以输出 FFFFFFFC,-4。

3.5 题目 5

c 复制代码
#include <stdio.h>
int main()
{
    int aa[2][5] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
    int *ptr1 = (int *)(&aa + 1);      // &aa 是整个二维数组地址,+1 跳到末尾之后
    int *ptr2 = (int *)(*(aa + 1));    // aa+1 是第二行地址,*(aa+1) 是第二行首元素地址
    printf("%d,%d", *(ptr1 - 1), *(ptr2 - 1));
    return 0;
}
// 结果:10,5

解析:

  • &aa + 1 跳过整个二维数组,指向末尾之后;ptr1 - 1 指回最后一个元素 aa14 = 10。
  • *(aa+1) 是第二行首元素地址(aa10 的地址);ptr2 - 1 指向前一行最后一个元素 aa04 = 5。

所以输出 10,5。

3.6 题目 6

c 复制代码
#include <stdio.h>
int main()
{
    char *a[] = {"work", "at", "alibaba"};   // 指针数组,存三个字符串首地址
    char **pa = a;                           // 二级指针,指向 a[0]
    pa++;                                    // pa 指向 a[1]
    printf("%s\n", *pa);                     // *pa = a[1] = "at"
    return 0;
}
// 结果:at

3.7 题目 7

这是一道非常经典的"多级指针连环题",需要一步步跟踪指针的变化。

c 复制代码
#include <stdio.h>
int main()
{
    char *c[] = {"ENTER", "NEW", "POINT", "FIRST"};
    char **cp[] = {c + 3, c + 2, c + 1, c};
    char ***cpp = cp;

    printf("%s\n", **++cpp);        // POINT
    printf("%s\n", *--*++cpp + 3);  // ER
    printf("%s\n", *cpp[-2] + 3);   // ST
    printf("%s\n", cpp[-1][-1] + 1);// EW
    return 0;
}
// 结果:POINT、ER、ST、EW

初始状态:

复制代码
c 数组:  c[0]="ENTER"  c[1]="NEW"  c[2]="POINT"  c[3]="FIRST"
cp 数组: cp[0]=c+3     cp[1]=c+2    cp[2]=c+1     cp[3]=c
cpp 一开始指向 cp[0]

逐步解析:

  1. **++cpp:++cpp 让 cpp 指向 cp1;**cpp = *(cp[1]) = *(c+2) = c2 = "POINT" 。

    (此时 cpp 指向 cp1)

  2. *--*++cpp + 3:

    • ++cpp 让 cpp 指向 cp2;
    • *cpp 得到 cp2(即 c+1);
    • --(*cpp) 让 cp2 自减,变成 c(即 cp2 = c);
    • *(*cpp) = *(cp[2]) = *c = c0 = "ENTER";
    • +3 后从 'E' 往后 3 位,输出 "ER" 。
      (此时 cpp 指向 cp2,且 cp2 已被改成 c)
  3. *cpp[-2] + 3:

    • cpp[-2] = cp0(cpp 指向 cp2,往前 2 个是 cp0);
    • cp[0] = c+3;
    • *(c+3) = c3 = "FIRST";
    • +3 后输出 "ST"。
  4. cpp[-1][-1] + 1:

    • cpp[-1] = cp1(cpp 指向 cp2,往前 1 个是 cp1);
    • cp[1] = c+2;
    • (c+2)[-1] = *(c+2-1) = *(c+1) = c1 = "NEW";
    • +1 后输出 "EW"。

所以最终输出:POINT、ER、ST、EW。

一句话总结 :指针运算题的关键是分清"指针本身"和"指针指向的内容":p++、++p 改的是指针本身,*p 取的是指向内容,p[i] 等价于 *(p+i),而 &数组名 + 1 跳过的是整个数组。遇到多级指针连环题,就一步步画图跟踪每个指针当前指向的位置,切忌跳步。


结尾:给小白的三句话

  1. 指针不可怕 :它就是一个"门牌号",记住 编号 == 地址 == 指针,后面的一切都围绕这句话展开。
  2. 动手比看懂更重要:文中的每一段代码,都建议你自己敲一遍、调试一遍,观察内存的变化。
  3. 多画图:遇到二级指针、指针数组、多级指针时,先在纸上画出"谁指向谁",答案自然就出来了。

指针是 C 语言的灵魂。把这六讲吃透,你不仅指针过关了,对"内存"的理解也会上一个大台阶------这恰恰是 C 语言和其他高级语言最根本的区别。


相关推荐
余醉 | dtminer1 小时前
56%的保护效应藏在乳腺密度里:Nat Commun 高分中介孟德尔随机化全 R 管线拆解(附核心图)
开发语言·r语言·孟德尔随机化
2501_933923252 小时前
SpringCloud组件:Eureka服务注册与发现
java·开发语言·后端·spring cloud·eureka
染指11102 小时前
142.Agent-多Agent框架-通过Skills实现多Agent
开发语言·人工智能·python·langchain·agents
好好学习O(∩_∩)O2 小时前
[QT] 多线程moveToThread 和易踩的8个坑
开发语言·qt
我是人✓2 小时前
Java核心语法:作用域、循环与大数值处理
java·开发语言·后端
敲代码的乔帮主3 小时前
Python 3 现代类型标注入门
开发语言·python
‎ദ്ദിᵔ.˛.ᵔ₎3 小时前
c++11:fuction包装器
开发语言·c++
kimnoic3 小时前
Python 爬虫的原理
开发语言·python
Fluxproxy3 小时前
Python爬虫异步提速终极方案|线程池ThreadPoolExecutor实战,告别单线程慢速爬取
开发语言·爬虫·python