C 傻鸟指针

C 傻鸟指针

Lucas Lv5

指针的声明

C 语言的指针语法(int *p;*p)在编程语言历史上一直备受争议,被称为“反人类设计”之一。很多人都觉得它容易混淆。 (其中int *p;是声明指针,*p是取指针指向的对象),如下:

1
int *p;

这个声明相比int* p;是更优秀的!为什么?我来解释给你听。

  • 想想,如果你要声明一个int变量,该怎么写?
  • 这样写:int a;
  • 这样写的含义就是声明一个类型是int的变量a

那如果我声明指针呢?看如下这张图:

一级指针.png

  • 它其实声明了两个变量的类型
    1. 声明了p的类型是int *
    2. 还声明了*p的类型是int

虽然int *p;int* p;都正确,p的类型是int *int*也都正确。

但是,int *p;是更优秀的写法,这对从没有指针的语言转来写C的程序员来说是十分困扰的,我再举个例子你就明白了。

举个例子:int* x,y;
此时这个x的类型和y的类型一样吗?
必然不一样!xint *类型,而yint类型
但是在观感上,就好像xy都是int *类型

  • 号是跟着变量名走的,不是跟着类型名走的

当你声明成int *p;,然后在调用指针的时候,也就非常清晰了*p是指针指向的对象(在声明时其实也是如此),p是指针(声明时候也是如此)。

这样逻辑就完整了,而且在二级指针中显得更加清晰。

C 语言设计哲学

以上我讲的其实正是 C 语言之父 丹尼斯·里奇对 C 语言的设计初衷!
在 C 语言圣经《The C Programming Language》中,作者更是提出了一个概念:“Declaration mimics use”(声明模仿使用)。

这意味着,声明一个变量,应该和你使用它的时候一致。

  • 使用时:你想获取一个整数,你通过*p来获取。
  • 声明时:为了告诉编译器*p得到的是int,所以你写int *p

这类似于:

  • 声明数组:int arr[10],是因为你使用时是用arr[i]得到int
  • 声明函数:int func(),是因为你使用时是用func()得到int

二级指针

二级指针.png

这个不仅声明了**p的类型是int
还声明了*p的类型是int *
又声明了p的类型是int **

指针是操作内存的工具

1. 指针也在内存中,指针也有内存地址

我们先打印一下指针的一些信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>

int main() {
int a = 10;
int *a_pointer = &a;
int a_size = sizeof(a);
int a_pointer_size = sizeof(a_pointer);
printf("a 是 %d\n", a);
printf("a 在 %p\n", (void *) &a);
printf("a 占 %d\n", a_size);
printf("a_pointer 是 %p\n", a_pointer);
printf("a_pointer 在 %p\n", (void *) &a_pointer);
printf("a_pointer 占 %d\n", a_pointer_size);
}
1
2
3
4
5
6
a 是 10
a 在 0x16fbfaf3c
a 占 4
a_pointer 是 0x16fbfaf3c
a_pointer 在 0x16fbfaf30
a_pointer 占 8
pointer.png

如图,你看这个 指针 方框更大,也代表着占内存更大,而int只占4字节,这是有些反常识的

记住这个图,每次难以理解指针的时候,就看看这个图

2. 指针的类型及其初始化

首先,一个int *n;,表明了这个指针类型是int *

所以,通用的指针类型就是dataType *

1
2
int a = 10;
int *p = &a;

这里p的类型是int*而不是int

指针的赋值,就是对象的地址

注意这里的&不是引用

引用是 C++ 里的概念,C 语言里是没有的

3. 指针通过->调用内部成员

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
#include <stdlib.h>

typedef struct {
const char *name;
} Person;

int main() {
Person *p = (Person *) malloc(sizeof(Person));

if (p == NULL) {
return 1;
}

p->name = "C Language";
printf("Name: %s\n", p->name);
free(p);

return 0;
}

这里这个->其实是一个形象的小语法糖,但是并不像现在很多“先进”的语言的语法糖那么抽象,反而非常直观。

这里p->name(*p).name是等价的。
注意!这里的()不能省略,因为.的优先级要比*更高。

另外,这里的namechar*类型,那为什么没有像一开始我们打印指针内的地址一样打印出来name的存放的地址呢?

因为这里我们在调用printf的时候,填写了%s的格式说明符,这个格式说明符会自动解引用指针类型并循环读出里面的字符,直到遇见结束符\0

4. 作为参数传递

4.1 参数是一级指针

特别重要的提示

对于 C 语言,函数的参数传递只有值传递
也就是说,所有的参数都只会在传入函数的时候,拷贝一份传到函数内部
不存在 C++ 语言的引用传递

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
31
32
#include <stdio.h>
#include <stdlib.h>

typedef struct {
char *name;
} Person;

void print_p(Person *p) {
printf("函数内部指针存放的地址:%p\n", p);
printf("函数内部指针的地址:%p\n", &p);
printf("函数内部指针访问的内部对象的地址:%p\n", p->name);
}

int main() {
Person *person_p = (Person *) malloc(sizeof(Person));

if (person_p == NULL) {
return 1;
}

person_p->name = "C Language";

printf("指针存放的地址:%p\n", person_p);
printf("指针的地址:%p\n", &person_p);
printf("指针访问的内部对象的地址:%p\n\n", person_p->name);

print_p(person_p);

free(person_p);

return 0;
}

我们来看输出结果

1
2
3
4
5
6
7
指针存放的地址:0x600003b98030
指针的地址:0x16d516f30
指针访问的内部对象的地址:0x1028e8a15

函数内部指针存放的地址:0x600003b98030
函数内部指针的地址:0x16d516f08
函数内部指针访问的内部对象的地址:0x1028e8a15

我知道有的技术文章讲的天花乱坠,好像很复杂一样
但其实,参数传指针就是在函数内部有一个一样的指针,指向传入参数的所指的对象
意思就是,外面的person_p和函数内的p都指向同一个对象(同一块内存)
自然操作结果会好像被带出来了

4.2 参数是二级指针

为什么要传入二级指针呢?
我们观察刚才的代码,发现是先给指针分配了指向的内存空间,然后再将指针传入,将所需数据写入内存空间

那如果我不知道需要多少内存空间呢?
比如一个字符串。此时就需要二级指针,我们看看实现。

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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void copyStringToDynamicMemory(char **str_ptr, char *content) {
unsigned long len = strlen(content) + 1;
*str_ptr = (char *) malloc(sizeof(char) * len);
if (*str_ptr == NULL) {
fprintf(stderr, "Memory allocation failed.\n");
return;
}
strcpy(*str_ptr, content);
}

int main() {
char *my_string = NULL;
copyStringToDynamicMemory(&my_string,"aaaaaaaaaaaaa");

if (my_string != NULL) {
printf("%s\n", my_string);
free(my_string);
}
return 0;
}

// 输出结果
// aaaaaaaaaaaaa

这个代码可能对于一些新手有一些难度了

我们回看一开始的声明,里面写了二级指针声明的含义。
如果参数需要一个一级指针,传入的时候,就要传入对象的地址。
如果参数需要一个二级指针,传入的时候,就要传入一级指针的地址。

一级指针目的是为了操作对象,当然要传入对象的地址。
二级指针目的是为了操作一级指针,当然要传入一级指针的地址。

所以当我们不知道要给当前指针所指的地方申请多少内存空间的时候,就需要使用二级指针,将一级指针的地址传进来,为这个一级指针申请内存空间。

*str_ptr = (char *) malloc(sizeof(char) * len);

我们看下这行代码,*str_ptr就是这二级指针指向的一级指针(我们将一级指针的地址传进来,赋值给了二级指针)

*str_ptr并不是在函数里生成的,所以不会随着函数的退出而消失。

函数栈

了解过函数栈的肯定都知道,方法退出的时候,清空的是函数栈的内存。
而我们将外面的一个内存地址传了进来,再通过*找到了这个内存地址上的指针(不是指向这块内存的指针)

我们之所以对二级指针有很多困惑,其中的一个原因就是p->name这个语法糖
name并不是p指向的对象,而是指向对象的内部成员
p->name等同于(*p).name

4.3 [扩展]黑客写法:整数地址

请思考:既然我们将指针的地址传入函数,是不是意味着我们可以将指针的地址使用整数传进来?
答案是:当然!!!只不过这是一种有点黑客的写法。

1
2
3
4
5
6
void copyStringToDynamicMemory2(uintptr_t str_ptr_address, char *content) {
unsigned long len = strlen(content) + 1;
char **str_ptr = (char **) str_ptr_address;
*str_ptr = (char *) malloc(len * sizeof(char));
strcpy(*str_ptr, content);
}

我们使用了unsigned long long这个超长整数来传递,然后我们在函数内部将这个指针的地址还原成他原本的身份————二级指针,后面的操作就照旧了。

5. 作为返回值

5.1 内存的所有权发生转移

我们直接在函数里申请了一块堆内存,最后将指向这块内存的指针返回,这指针本身会被销毁,但是我们返回值接收后,直接使用里面存的堆内存地址即可,这叫做“内存的所有权发生转移”。函数生产,主调函数负责销毁(free)。

1
2
3
4
5
6
7
8
// 类似于面向对象的 new String("...")
char* create_string(const char* src) {
char* new_str = (char*)malloc(strlen(src) + 1);
if (new_str) {
strcpy(new_str, src);
}
return new_str; // 返回堆内存地址,离开函数后依然有效
}

5.2 “定位器”:返回数据结构中的某个节点

1
2
3
4
5
6
7
8
9
10
// 模拟 strchr:查找字符 c 在 str 中第一次出现的位置
char* find_char(char* str, char c) {
while (*str != '\0') {
if (*str == c) {
return str; // 返回指向该字符的指针
}
str++;
}
return NULL; // 没找到
}

返回的指针存放着找到的字符串(内存在函数外面申请)的某个字符的地址。

注意事项

  1. 禁止返回指向局部栈变量的指针

    1
    2
    3
    4
    5
    6
    char* dangerous_func() {
    char buffer[100] = "hello"; // 局部变量,存在栈(Stack)上
    return buffer;
    } // 函数结束 -> 栈帧销毁 -> buffer 所在的内存被标记为可回收

    // 主函数接到这个指针时,指向的是一块“废弃之地”,数据可能已经被覆盖。
  2. 返回 static 变量的地址要小心
    为了避免 malloc 和 free,有人会用 static 局部变量。

    • 好处:static 变量存在静态数据区,函数结束内存不销毁,返回地址是安全的。
    • 坏处:非线程安全,且不支持递归或连续调用。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    char* format_time() {
    static char buffer[100]; // 静态变量,全局只有这一份
    // ... 写入时间到 buffer ...
    return buffer;
    }

    // 问题场景:
    char *t1 = format_time();
    char *t2 = format_time();
    // 此时 t1 和 t2 指向的是同一个地址!t2 的内容会覆盖 t1 的内容。
  3. 明确“谁负责 Free” (Ownership)
    这是 C 语言 API 设计的核心哲学。

    • 如果函数返回的是malloc出来的指针,必须在文档中注明:“Caller takes ownership” (调用者负责释放)。
    • 如果函数返回的是内部缓存或静态数据,必须注明:“Do not free”。
  4. 必须处理 NULL
    既然是返回指针,就有可能失败(比如内存耗尽、没找到目标)。

    • 原则:永远不要假设返回的指针是有效的,一定要判空。

6. 饱含信心地使用指针

指针绕不开的话题就是,现在指针指的是谁???
因此,我最害怕的就是指针改变指向和改变指向的值,有时候甚至难以区分它们两个,或许你也是。

所以我们应该尝试使用指针,来减小你对使用指针的恐惧,比如最直接的:写一个swap函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
#include <stdlib.h>

void swap(int *x, int *y);

int main(void) {
int x = 1;
int y = 2;
swap(&x, &y);
printf("x = %d, y = %d\n", x, y);
return EXIT_SUCCESS;
}

void swap(int *x, int *y) {
int temp = *x;
*x = *y;
*y = temp;
}
  • 我们写了两个变量,x=1,y=2

  • 然后调用 swap 将他们交换。

  • 最终得到答案,x变为了2y变为了1

  • 其中最令人恐惧的就是*x = *y;这一步意味着什么?

    • 很多教材教程说:这是让指向x的指针指向y
      • int temp = *x;岂不就是将*x复制进temp,然后x再指向y指向的空间,那之前x指向的内存空间岂不是丢了,内存泄漏了。

错误的swap解释.png

事实上*x = *y;改的是x指向的变量的实际值,所以正确的图如下:

正确的swap解释.png

如何交换指针的指向?

回答:需要用到二级指针。

上面我们已经提到了,C 语言只有值传递,所以指针也是拷贝一份传进了函数内部,所以如果我们需要将二者的数据进行交换,只修改函数内部指针的指向是不够的,因为外部的指针并没有修改指向。

为了交换两个指针变量的指向(而不是交换它们指向的数值),我们需要修改指针变量本身存储的内容(即地址)。根据 C 语言的规则:想修改谁的值,就传谁的地址。

想修改 int,就传 int *。
想修改 int *(指针),就传 int **(二级指针)。
下面是代码示例:

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
// 函数接受两个二级指针
// 意味着它接收的是“指针变量的地址”
void swap_pointers(int **p1, int **p2) {
// 1. 读取 p1 指向的那个指针变量里的当前地址,存入 temp
int *temp = *p1;

// 2. 把 p2 指向的那个指针变量里的地址,赋值给 p1 指向的指针变量
*p1 = *p2;

// 3. 把 temp 里的地址,赋值给 p2 指向的指针变量
*p2 = temp;
}

区分指针的改指向和改数值

看等号左边是谁:

  • *p = … :带星号。这是修改数值(去 p 指向的地方改数据)。
  • p = … :不带星号。这是修改指向(把 p 里的地址换成别人的)。

指针的改指向,往往只在链表,数组遍历,寻找最值之类的。
更多的还是跟=区别的不大。主要是为了避免重复创建过多指针。
所以最终,大部分情况,不太需要纠结是否需要指针改指向。只有需要大量创建指针,或者指针目前往后并不知道指向哪里的时候,才需要指针改指向。

1. 根本不知道有多少个东西(数量未知)

想象一下,如果给你一个数组,里面可能有 10 个数字,也可能有 10,000 个数字。

  • 如果不改指向:你需要预先定义 10,000 个指针变量吗?int *p1, *p2, ..., *p10000?这也是不可能写出来的代码。
  • 如果改指向:你只需要 1 个 指针变量,放在for循环里,让它像扫描仪一样,指完这个指下一个。
1
2
3
4
// 不管数组有多大,我只需要这一个指针 p
for (int *p = arr; p < arr + size; p++) {
printf("%d ", *p);
}
2. 根本不知道东西在哪里(位置未知/链表)

这是最硬核的理由。在**链表(Linked List)树(Tree)**这种数据结构中,数据在内存里是散乱分布的。

你站在第一个节点(A),手里只有一张写着第二个节点(B)地址的纸条。你必须:

  1. 读取纸条。
  2. 修改你的指针指向,跳到 B。
  3. 到了 B,你才能看到去 C 的纸条。

如果你不修改当前指针的指向,你就永远无法“顺藤摸瓜”找到下一个节点。你不可能一开始就创建好指向 C 的指针,因为在到达 B 之前,你根本不知道 C 在哪里。

3. 根据条件动态选择(逻辑选择)

有时候我们需要根据程序的运行情况,让一个指针代表不同的含义。

比如编写一个游戏,有一个指针叫currentTarget(当前目标)。

  • 玩家按“Tab”键,currentTarget指向小怪 A
  • 玩家再按“Tab”键,currentTarget改指向小怪 B
  • 玩家按攻击键,攻击*currentTarget

这里必须用同一个指针变量来不停地“改指向”。如果每选一个怪都创建一个新指针target1,target2… 你的攻击函数就不知道该攻击谁了(因为它只认currentTarget)。

  • 标题: C 傻鸟指针
  • 作者: Lucas
  • 创建于 : 2023-10-27 10:14:16
  • 更新于 : 2026-01-29 00:45:20
  • 链接: https://darkflamemasterdev.github.io/2023/10/27/C-傻鸟指针/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论