ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

第六章 指针

第六章 指针

第六章 指针

一、指针与指针变量

1.1 地址是什么

内存是一整条按字节编号的街。每个字节一个编号,从零开始往上排。变量占连续的几间房,int 占四间,double 占八间,char 占一间。首字节那个编号,就是这个变量的地址。

┌────────────────────────────────────────────┐ │ 地址就是门牌号 │ ├────────────────────────────────────────────┤ │ 内存按字节编号,变量占连续的几间 │ │ │ │ +------------+----------+--------------+ │ │ |地址 |住着 |值 | │ │ +------------+----------+--------------+ │ │ |4096 |a |78 | │ │ |4100 |b |92 | │ │ +------------+----------+--------------+ │ │ │ │ & 记下首字节的编号 │ │ 门牌只在这趟程序里作数 │ └────────────────────────────────────────────┘

地址本身是个整数,一长串,通常按十六进制写,前面带 0x。& 是一元取地址运算符,放在变量前面,拿它的地址。

int a = 78; cout << &a << endl; // 一长串十六进制 cout << (void*)&a << endl; // 转 void* 再打,稳妥姿势

cout 见到指针得分类型对待。char* 会被当成字符串,打内容不打地址,别的指针类型主流编译器给的是地址。想让所有指针都打地址,转成 void* 是通用做法。

还有个细节,每次运行地址可能都不一样。操作系统有个地址空间随机化的机制,专门防着有人摸清内存布局。所以地址看个模样就行,别把某次运行的具体地址当常量写进代码。

1.2 指针变量声明与初始化

指针变量是专门存地址的变量。声明在类型和名字之间加一个星号。

int a = 78; int *p = &a; // p 存 a 的地址 double d = 1.5; double *q = &d;

星号贴哪儿有两种风格,int* p 和 int *p 都合法,意思一模一样。社区为这个吵过很多年,没有结论。本书用贴着变量名的写法,理由只有一个,一行声明多个指针时不看错。

int *p, *q; // 两个都是指针 int* p, q; // p 是指针,q 是 int,星号只管一个

类型不是装饰,是编译器干活的依据。p 加一跳多少字节,*p 读几个字节,全靠 int 这两个字母。类型看错,读出来的数就是乱的。

初始化比普通变量更要紧。没初始化的普通变量读出来是垃圾数,没初始化的指针去解引用,等于往一个随机地址写数据,程序当场炸都是轻的。所以指针声明的时候,要么给地址,要么给 nullptr。

int *p = nullptr; // 空指针,不指向任何东西 int *bad; // 野指针,谁都不知道它指哪 *bad = 5; // 大概率当场崩溃

1.3 取地址与解引用

& 记下门牌,* 照门牌推门。两个操作互为反作用,*(&a) 就是 a。

┌─────────────────────────────────────────────────┐ │ 一对反操作 │ ├─────────────────────────────────────────────────┤ │ & 记门牌,* 按门牌进屋 │ │ │ │ int a = 78; a 住在 4096 │ │ int *p = &a; p 记下 4096 │ │ *p = 80; 顺着门牌进屋,78 换成 80 │ │ │ │ & 和 * 配成一对 │ │ 左边写右边读,两条路 │ └─────────────────────────────────────────────────┘

解引用在等号两边是两个方向,左边改数据,右边读数据。

int a = 78; int *p = &a; cout << *p << endl; // 读,78 *p = 80; // 写,a 变成 80 cout << a << endl; // 80

星号在声明里和在使用里干两个活儿。int *p 里的星号是说 p 的类型是指针,*p 里的星号是说去取 p 指向的那份数据。记住一条,声明处看类型,使用处看动作。

空指针解引用是经典事故。nullptr 就是给这种情况准备的,解引用之前先查一眼,这个习惯能省一堆调试时间。

1.4 指针的大小

指针变量自己占多大,三十二位系统四字节,六十四位系统八字节。关键是它跟指向的类型无关,int* 和 double* 一样大。

int *p = nullptr; double *q = nullptr; char *r = nullptr; cout << sizeof(p) << " " << sizeof(q) << " " << sizeof(r) << endl; // 64 位系统下 8 8 8

指针里存的是地址,地址长度固定,所以指针大小固定。类型信息编译期用完就扔,运行时指针里只有地址。知道这条能避一个错觉,别以为 double* 比 int* 大,它俩一样大。

二、指针的运算

指针不是整数,不能随便加减乘除。能做的运算只有几种,每一种都围着地址打转。

2.1 加减整数

指针加一,不是地址加一字节,是跳到下一个元素。跳多远,看它指向什么类型。int* 加一跳四字节,char* 加一跳一字节,double* 加一跳八字节。

┌──────────────────────────────────────────────┐ │ 加一跳多远 │ ├──────────────────────────────────────────────┤ │ 同一个加一,跳过的字节数不一样 │ │ │ │ +------------+--------------+------------+ │ │ |指针类型 |指向的类型 |加一跳 | │ │ +------------+--------------+------------+ │ │ |int * |int |4 字节 | │ │ |char * |char |1 字节 | │ │ |double * |double |8 字节 | │ │ +------------+--------------+------------+ │ │ │ │ 地址一样长,步长不一样 │ │ 类型看错,一步跨错一片 │ └──────────────────────────────────────────────┘
int scores[5] = {78, 92, 55, 100, 88}; int *p = scores; // p 指向首元素 cout << *(p + 2) << endl; // 55,跳到第三格 cout << p[2] << endl; // 同一个数,两种写法

单位是元素,不是字节,这是指针运算最容易记错的一条。字节数是编译器算的,写代码的时候心里数的是第几个。加减负数往回跳,加减零原地不动,都合法。

字符串上的指针加减最直观,一个字符一跳,正好一格。

char s[] = "hello"; char *p = s; cout << *p << endl; // h cout << *(p + 1) << endl; // e cout << *p << endl; // 还是 h,p 自己没动

注意最后一行。p + 1 是个新指针,p 自己没动,想挪窝得写 p = p + 1,或者干脆 p 自增。

2.2 指针相减

两个同类型指针可以相减,结果是中间差几个元素,不是差几个字节。结果类型叫 ptrdiff_t,有符号,能表示前后关系。

int scores[5] = {78, 92, 55, 100, 88}; cout << &scores[4] - &scores[0] << endl; // 4 cout << &scores[0] - &scores[4] << endl; // -4

五个元素的数组,首尾下标差四,指针相减也是四。这个操作在老代码里常用来算一段数据的元素个数,现在有标准库,手写的场合不多,认得出就行。相加没意义,两个地址加一块儿不知道指向哪儿,编译器直接拦。

2.3 指针比较

指针之间能比大小,等不等、谁在前谁在后,都合法。同一数组内部,下标小的地址也小,这个顺序靠得住。

int scores[5] = {78, 92, 55, 100, 88}; int *p = &scores[1]; int *q = &scores[3]; cout << (p < q) << endl; // 1 cout << (p == &scores[1]) << endl; // 1

不同数组之间的指针比大小,标准没给保证,比出来可能是真的,但没意义。要比就比同一段内存里的位置关系,或者干脆只比相等。指针运算合法不合法,一张表收拢。

运算合法吗结果
p + n合法往后跳 n 个元素
p - n合法往前跳 n 个元素
p2 - p1合法差几个元素
p1 + p2不合法两个地址相加没意义
p 乘个数不合法地址乘二没意义
p == q合法比相等
p 小于 q同段内存合法比位置前后

三、用指针处理一维数组

3.1 数组名就是首元素地址

数组名的本质,是首元素的地址。把它和 &scores[0] 打印出来,一模一样。

┌───────────────────────────────────────────────────────┐ │ 方括号是糖 │ ├───────────────────────────────────────────────────────┤ │ 数组名就是首元素的地址 │ │ │ │ +---------+---------+---------+---------+---------+ │ │ |scores[0]|scores[1]|scores[2]|scores[3]|scores[4]| │ │ +---------+---------+---------+---------+---------+ │ │ |78 |92 |55 |100 |88 | │ │ |4096 |4100 |4104 |4108 |4112 | │ │ +---------+---------+---------+---------+---------+ │ │ │ │ scores 和 &scores[0] 是同一个地址 │ │ 想挪窝,先拷给指针变量 │ └───────────────────────────────────────────────────────┘
int scores[5] = {78, 92, 55, 100, 88}; cout << (scores == &scores[0]) << endl; // 1

这条一认,前几章攒下的迷糊散了一半。为什么数组传进函数能改原数组,因为传的根本就是地址。为什么方括号里能写变量当下标,因为底层就是地址加法。

数组名和指针变量只差一条,数组名是常量,改不了指向。scores 自增,编译直接报错,指针变量 p 自增,随便跳。一个是钉死的门牌,一个是随身带的小本本。

int scores[5] = {78, 92, 55, 100, 88}; scores++; // 编译错,数组名不能改 int *p = scores; p++; // 合法,p 指向 scores[1]

3.2 三种等价写法

同一个格子,三种写法,编译出来一样。

┌──────────────────────────────────────────────────────┐ │ 指的都是第三格 │ ├──────────────────────────────────────────────────────┤ │ 写法有三个,指的是同一格 │ │ │ │ +----------------+----------------+--------------+ │ │ |写法 |样子 |指的是 | │ │ +----------------+----------------+--------------+ │ │ |下标 |scores[2] |第三格 | │ │ |地址加法 |*(scores + 2) |同一格 | │ │ |指针下标 |p[2] |还是同一格 | │ │ +----------------+----------------+--------------+ │ │ │ │ p 指哪儿,指针写法跟到哪儿 │ │ 下标锚在数组上,指针锚在指针上 │ └──────────────────────────────────────────────────────┘
int scores[5] = {78, 92, 55, 100, 88}; cout << scores[2] << endl; // 55 cout << *(scores + 2) << endl; // 55 int *p = scores; cout << p[2] << endl; // 55

方括号只是糖。scores[i] 到机器码里就是 *(scores + i),先算地址再取数,一步都没多。知道这个有两个好处。读老代码的时候,指针写法混着下标写法出现,不会发懵。写泛型代码的时候,指针配加减法比下标通用,下标只对数组有意义,指针对任何一段连续内存都有意义。

3.3 用指针遍历

遍历不一定非写下标,指针一样能走。

int scores[5] = {78, 92, 55, 100, 88}; for (int *p = scores; p < scores + 5; p++) { cout << *p << " "; } cout << endl; // 78 92 55 100 88

p 小于 scores 加五 是循环条件,p 走到末元素的后一位停。这个后一位只拿来比大小,不解引用,合法。标准库里的 begin 和 end 就是这个思路,一个指头,一个指尾后一位。

日常读写代码,下标和范围 for 够用,可读性更好。指针遍历的场合在另外两处。一段内存不知道是什么容器,只有首地址和长度,比如下一章要讲的函数参数。手写底层数据结构,链表和树都指着它。

3.4 数组进函数

现在收拾开篇那颗雷。为什么 swap 传值不灵,数组传参就灵。

void swapValue(int a, int b) { int t = a; a = b; b = t; } int main() { int x = 10, y = 20; swapValue(x, y); cout << x << " " << y << endl; // 10 20,纹丝不动 }

参数是副本。函数里那对 a 和 b 是另外两间房,跟 x 和 y 各住各的,换得再干净也传不回来。想动原数据,得把地址传进去。

┌────────────────────────────────────────────────────┐ │ 门牌换家具 │ ├────────────────────────────────────────────────────┤ │ 值传递换副本,传地址才换得动 │ │ │ │ +-----------------------+----------------------+ │ │ |写法 |调完 x 和 y | │ │ +-----------------------+----------------------+ │ │ |void f(int a, int b) |纹丝不动,改的是副本 | │ │ |void f(int *a, int *b) |真的换了,进屋动的手 | │ │ +-----------------------+----------------------+ │ │ │ │ 副本改得再热闹,原值纹丝不动 │ │ 不递门牌,函数里白换 │ └────────────────────────────────────────────────────┘
void swapPtr(int *a, int *b) { int t = *a; *a = *b; *b = t; } int main() { int x = 10, y = 20; swapPtr(&x, &y); cout << x << " " << y << endl; // 20 10,真的换了 }

两个 & 在调用处,把门牌号递过去。两个 * 在函数里,顺着门牌推门,动的才是原数据。一门之隔,功夫全在星号和 & 上。

数组传参是同一件事的另一个面。形参写 int a[],编译器看成 int *a,地址传进来,函数里改的就是原数组。

void doubleAll(int a[], int n) { for (int i = 0; i < n; i++) { a[i] *= 2; } }

这里藏着一个坑。数组退化成指针之后,sizeof 就失灵了,main 里 scores 是二十字节,函数里 a 是八字节,编译器数不出长度。

┌───────────────────────────────────────────────┐ │ 数组名的隐身术 │ ├───────────────────────────────────────────────┤ │ 数组传进函数,名字悄悄变了 │ │ │ │ +--------------------+--------------------+ │ │ |main 里 |函数参数里 | │ │ +--------------------+--------------------+ │ │ |int scores[5] |int a[] 或 int *a | │ │ |sizeof 得 20 |sizeof 得 8 | │ │ |改它就是改原数组 |改的也是原数组 | │ │ +--------------------+--------------------+ │ │ │ │ sizeof 问不出长度,元素个数自己数 │ │ 退化从写进参数表那刻开始 │ └───────────────────────────────────────────────┘

所以长度必须单独传一个参数。这个 n 不是可选,是必选,所有数组参数的标准姿势。

int scores[5] = {78, 92, 55, 100, 88}; cout << sizeof(scores) << endl; // 20 doubleAll(scores, 5); for (int x : scores) { cout << x << " "; } // 156 184 110 200 176

四、动态内存分配

前面的数组,长度声明那刻定死。运行期想定长度,比如让用户输一个数当人数,数组做不到,得上动态内存。

4.1 new 与 delete

new 在堆上要一块内存,递回来一个指针。delete 把这块内存还给系统。一借一还,必须成对。

int *p = new int(42); // 要一间 int,放 42 cout << *p << endl; // 42 delete p; // 还回去 p = nullptr; // 置空,防止后面误用

栈和堆是两套规矩。函数里声明的变量住栈,离开作用域自动回收,不用操心。new 出来的住堆,没有作用域概念,活到 delete 那一刻,忘了还就一直占着。

┌─────────────────────────────────────┐ │ 两套内存规矩 │ ├─────────────────────────────────────┤ │ 栈上自动打扫,堆上自己动手 │ │ │ │ 栈 作用域内有效,出了门自动清 │ │ 堆 手动要手动还,忘了就一直在 │ │ │ │ 借还成对,方括号也成对 │ │ 忘了 delete,内存只进不出 │ └─────────────────────────────────────┘

数组版本要加方括号,借的时候加,还的时候也加,两边必须对上。

int *p = new int[5]{78, 92, 55, 100, 88}; cout << p[2] << endl; // 55 delete[] p;

new int[5] 后面那对花括号是 C++11 的初始化写法,跟数组的部分初始化一个规矩,不给的值补零。delete[] 的方括号告诉编译器这是连续一片,要逐个收拾,漏了是未定义行为,小类型看不出来,类类型出事。

4.2 动态数组

运行期定长度的数组,终于能写了。

int n; cout << "几个学生: "; cin >> n; int *scores = new int[n]; for (int i = 0; i < n; i++) { cout << "第 " << i + 1 << " 个: "; cin >> scores[i]; } int sum = 0; for (int i = 0; i < n; i++) { sum += scores[i]; } cout << "平均分 " << static_cast<double>(sum) / n << endl; delete[] scores;

输入 5,依次输 78 92 55 100 88,平均分八十二点六,跟第五章手写数组那个版本结果一样。摆法不同,算法一字不改,这就是指针把数据的摆法解耦出来的好处。

new int[n] 要出来的内存没初始化,跟数组一个规矩,读之前先写值。想让它全是零,写 new intn,一对空括号,全部置零。

4.3 内存泄漏与悬空指针

new 了不 delete,这块内存就漏了。指针变量没了,地址还没人记得,系统以为这块有人用,永远收不回去。

┌─────────────────────────────────────────────┐ │ 忘记归还的账 │ ├─────────────────────────────────────────────┤ │ 泄漏不崩,攒着才要命 │ │ │ │ 循环第一次 new 一块,忘记 delete │ │ │ │ │ v │ │ 循环第一千次 内存又少一块 │ │ │ │ │ v │ │ 内存耗尽 程序被杀,日志里全是无辜 │ │ │ │ 进了循环,小漏变大漏 │ │ 每块 new 都该有个 delete 在等它 │ └─────────────────────────────────────────────┘
void leaky() { int *p = new int[1000]; // 函数结束,p 没了,一千个 int 没人认领 }

单次调用看不出来,漏几千字节而已。放在循环里就是灾难,服务器跑几天内存就见底。防泄漏的规矩就一条,借了就得还,函数有几个出口,每个出口前对一遍。

比泄漏更急的是悬空指针。delete 之后,指针还指着那块已经归还的内存,这时候用它,读到什么全看系统把这块地分给谁了。

int *p = new int(42); delete p; // *p = 5; 错的,这块地已经还了 p = nullptr; // 置空,后面能用 if (p) 防一手

还有一条老代码里的常客,函数返回局部变量的地址。函数一返回,栈帧拆了,地址还在,内容已经换了人家,返回去就是个悬空指针。

int *bad() { int a = 42; return &a; // 错的,a 活不过这个函数 }

编译器对这种写法会给警告,看见 warning 别当耳旁风。想返回数据,返回值拷贝一份,或者在堆上 new 出来让调用方负责 delete,或者用后面会讲的引用和智能指针。

五、一些特殊指针

5.1 void 指针

void* 是不知道类型的地址。只知道有这么一块内存,在哪儿,多大不管,里面装什么也不管。

int a = 78; void *vp = &a; cout << (void*)vp << endl; // 能打地址

void* 不能直接解引用,不知道读几个字节。也不能加减,不知道步长。用之前必须先转到具体类型。

int *ip = static_cast<int*>(vp); cout << *ip << endl; // 78

第二章讲过四种强制转换,日常 static_cast 一把够用。void* 的场合还有个 reinterpret_cast,按位重新解释,转出来的东西对不对全看你自己。第二章那句到指针这章再见,现在见到了,记住一句,reinterpret 是把刀,能不拔就不拔,剩下的 cast 到类那章还有一批。C 风格写法 (int*)vp 也能编,新代码用 static_cast,意图清楚,编译器能查。

5.2 const 与指针

第二章留了个尾巴,const 指针那几种组合,四种写法摆一起看。

┌────────────────────────────────────────────────────────┐ │ 两种特殊指针 │ ├────────────────────────────────────────────────────────┤ │ void 不知道类型,const 分两种管法 │ │ │ │ +--------------------+--------------+--------------+ │ │ |写法 |管得住什么 |典型错法 | │ │ +--------------------+--------------+--------------+ │ │ |const int *p |管指向的数据 |*p = 5 不行 | │ │ |int * const p |管指针自己 |p = q 不行 | │ │ |const int * const p |两个都管 |只读不动 | │ │ +--------------------+--------------+--------------+ │ │ │ │ void* 只知道地在哪儿,不问里面装什么 │ │ const 站的位置,决定它管哪一头 │ └────────────────────────────────────────────────────────┘
int a = 78, b = 92; const int *p1 = &a; // 指向常量 int * const p2 = &a; // 常量指针 const int * const p3 = &a; // 两个都管 // *p1 = 5; // 错,数据不让改 p1 = &b; // 对,指向能换 // p2 = &b; // 错,指针不让改 *p2 = 5; // 对,数据能改 // p3 两个都不能动

判断方法一条,看 const 挨着谁。挨着星号,管的是数据,挨着指针名,管的是指针。

实用场合两个。传参不想让函数改数据,用 const int *p,调用方看得见这份承诺。存一份地址不打算换了,用 int * const p,写死在声明里。C++ 惯例是能加 const 就加,编译期挡错误比运行时调试便宜得多。

5.3 二级指针

指针变量自己的地址,用两个星号接。

int a = 78; int *p = &a; int **pp = &p; cout << **pp << endl; // 78 cout << (*pp == p) << endl; // 1,*pp 就是 p

**pp 拆两层读,先拆一层拿到 p,再拆一层拿到 a。改 *pp 就是改 p 的指向,p 从此指着别处。

int b = 92; *pp = &b; cout << *p << endl; // 92,p 被 pp 隔着改掉了

二级指针不是拿来炫技的。函数里要改一个指针变量本身,比如让它指向新申请的内存,就得传它的地址,也就是二级指针。动态二维数组也用它,一个指针数组,每个元素再指向一行。这两个场合下一章和数据结构那章都会见,这里先把拆两层的功夫练熟。

5.4 野指针与悬空指针

野指针是没初始化的指针,指向哪儿全看内存里恰好躺了什么。悬空指针是 delete 之后没置空的指针,指着一块已经归还的地。两个都是事故源,放一起收拾。

int *p1; // 野,内存残留 int *p2 = new int(42); delete p2; // p2 悬空 int *p3 = nullptr; // 空,安全

三条军规。声明就初始化,没值可给就给 nullptr。delete 之后紧跟一句置空,一行都不省。指针变量出了作用域就当他没了,别把里面的地址存到别处再用。做到这三条,九成的指针崩溃跟自己无关。

六、小结

┌──────────────────────────────────────────────────────────┐ │ 本章收口 │ ├──────────────────────────────────────────────────────────┤ │ 一章学完,手上该有这几样 │ │ │ │ ┌────────────────────────────────────────────────────┐ │ │ │ 地址 变量的门牌号,& 记 * 进屋 │ │ │ └────────────────────────────────────────────────────┘ │ │ ┌────────────────────────────────────────────────────┐ │ │ │ 运算 加减跳元素,相减数差距 │ │ │ └────────────────────────────────────────────────────┘ │ │ ┌────────────────────────────────────────────────────┐ │ │ │ 数组 数组名就是地址,三种写法等价 │ │ │ └────────────────────────────────────────────────────┘ │ │ ┌────────────────────────────────────────────────────┐ │ │ │ 堆 new 和 delete 成对,忘了就漏 │ │ │ └────────────────────────────────────────────────────┘ │ │ │ │ 门牌认熟了,C++ 这扇门才算推开 │ │ 函数的门,从指针这把钥匙开 │ └──────────────────────────────────────────────────────────┘
知识一句话最容易栽在哪
地址内存按字节编号把某次运行的地址当常量
指针变量存地址的变量声明了不初始化
解引用顺着地址读写数据空指针上解引用
指针大小固定,与类型无关以为 double* 更大
加减跳元素不跳字节拿字节当单位数
相减差几个元素拿两个不同数组的指针相减
数组名首元素地址拿数组名自增
三种写法下标地址糖等价见到指针写法不认识
传参退化数组参数变指针函数里 sizeof 失灵
new 与 delete成对出现漏 delete 漏方括号
void 指针先转类型再用直接解引用
const 组合就近管数据管指针四种组合记混

附录|指针速查卡

类别写法一句话提醒
取地址&a一元运算符
解引用*p星号在使用处
声明int *p = &a星号贴着变量名
空指针int *p = nullptr解引用前查一眼
加减p+1、p-1跳一个元素
相减p2 - p1差几个元素
比较p == q、p 小于 q同段内存内才可靠
数组名scores == &scores[0]常地址,不能自增
三种写法a[i]、*(a+i)、p[i]等价
单个 newnew int(42)配 delete
数组 newnew int5配 delete[]
置零new intn空括号全清零
void 指针static_cast<int*>(vp)先转再用
const 数据const int *p靠近星号管数据
const 指针int * const p靠近名字管指针
二级指针int **pp = &p拆两层读
常见现象病根药方
改完函数里没反应传的是副本改传地址
程序莫名崩溃野指针解引用声明就初始化
崩溃在 delete 之后悬空指针还在用delete 完置空
内存越跑越少new 了没 delete成对写,出口查
打印指针是字符串char* 被 cout 当串打转 void* 再打
函数里 sizeof 变小数组退化成指针长度单独传
步长数不对拿字节当单位记住跳的是元素
编译错在 scores 自增数组名是常量挪给指针变量
返回列表