【C/C++】一文带你彻底玩转C/C++中的指针!(万字解读,非常详细!适合初学者或老手回顾)_c++指针
目录
- 一、指针简介
- 二、指针入门
-
- 1.初见指针
- 2.指针的解引用
- 3.指针的类型
- 4.野指针和空指针
-
- (1)野指针
- (2)空指针
- 5.指针的简单应用
- 6.结构体与指针
- 三、指针进阶
-
- 1.指针与数组
- 2.指针的运算
- 3.常量指针与指针常量
-
- (1)常量指针
- (2)指针常量
- (3)总结
- 3.字符指针与字符串、字符数组
- 4.指针数组与数组指针
-
- (1)指针数组
- (2)数组指针
- (3)总结
- 5.函数指针和指针函数
-
- (1)函数指针
- (2)指针函数
- 6.多级指针
- 四、指针高级
-
- 1.复杂指针类型的辨认
- 2.数组名隐式转换
- 3.void型指针
- 4.回调函数
- 5.零长数组(柔性数组)
- 6.数组与指针的补充
- 五、总结
一、指针简介
在C/C++编程中,**指针是一种极为特殊且关键的变量类型,它存储的是数据的内存地址而非数据值本身。**尽管指针的概念相对抽象,用法也颇为复杂,使得不少初学者感到晕头转向,但一旦能灵活运用指针,你便能实现对程序内存的精准把控,甚至细化到字节级别的控制。因此,学会并熟练使用指针,无疑是掌握C/C++编程的必经之路。
二、指针入门
1.初见指针
在正式介绍指针之前,需要先简单介绍一下计算机内存。首先需要明确的一点是,程序是运行在计算机内存里的,你可以将内存看成一个个可以存储数据的小盒子(内存单元),而且这些小盒子有自己在内存中唯一的编号(地址),而指针实际上存储的就是这些编号的值。
接下来介绍一下指针的基础语法。
声明一个指针的语法很简单:类型* 变量名,然后因为指针存放的是变量的内存地址,所以C/C++中可以通过 “&” 这个关键字来获取一个变量的地址,具体用法为:&变量 。具体使用如下代码所示:
#include using namespace std;int main() { int a = 10; //声明一个变量 int* p = &a; //通过&对变量a取地址,并将地址赋值给指针p cout << p << endl; // 0xfffaa8,变量a在内存中的地址编号 // C语言输出指针值可以使用printf(\"%p\", p); return 0;}
然后是指针占用的内存大小。
指针变量的大小取决于你所使用的平台以及编译器设置,一般来说,32位平台的指针大小就为32位(4字节),不会因为指针的类型不同而改变。同样,64位平台的指针大小就为64位(8字节),也不会因为指针的类型不同而改变。这里笔者使用的是默认的32位,所以指针大小为4字节。
cout << sizeof(p) << endl; // 4
2.指针的解引用
我们之前说过,指针是存储变量地址的变量类型,直接输出指针的值,得到的也只是一个地址编号,但是我们可以使用“解引用”这一操作来获取指针所指向的变量的值。
解引用的方法也很简单,通过*这一关键字来实现。具体语法为“*指针变量名”,具体使用如下所示:
int main() { int a = 10; //声明一个变量 int* p = &a; //通过&对变量a取地址,并将地址赋值给指针p cout << p << endl; // 0xfffaa8,变量a在内存中的地址编号 cout << *p << endl; // 10,p所指向的内存的值,即a的值 return 0;}
需要注意的是,指针解引用后也可以被赋值,当你对解引用的指针赋值时,会改变其所指向的内存中的值,而不会改变指针本身的值(因为指针本身存放的是地址),这个非常重要,需要尤其注意。
int main() { int a = 10; int *p = &a; cout << p << endl; // 0x125fad8 cout << a << endl; // 10 *p = 20; // p解引用后对其赋值,即修改p所指向的内存中的值 cout << p << endl; // 0x125fad8 cout << a << endl; // 20}
可以发现,我们在中途对
p
进行解引用并赋值,最终改变了a
的值,因为p指向的是变量a所在的内存,而p
的值(a
的地址)并没有改变。
补充:
很多同学容易将*p
和int* p
混淆,这里笔者再特意强调一下,p
是指针的变量名,可以任意(前提需符合变量命名规则),int*
是指针类型,代表p
是一个指向int类型变量的指针,而*p
则表示对指针p进行解引用,以获取其指向的变量的值,可以把解引用后得到的值赋值给其他相同类型的变量,也可以将值赋值给解引用后的指针,即修改指向的内存中的值。
3.指针的类型
虽然上述例子全是使用int
变量来举例,但实际上指针可以指向任意类型的数据。有的同学可能会感到疑惑,既然指针的大小都是固定的4字节或8字节,那么为什么声明指针时,还要指明指针的类型?接下来将介绍指针类型的作用。
指针可以指向任意类型的数据,所以指针有多种多样的类型。
int main() { int a = 10; char c = \'a\'; float f = 1.0f; int* p_a = &a; char* p_c = &c; float* p_f = &f; return 0;}
但我们都知道,不同的数据类型所占用的内存大小是不相同的,例如
int
占了4个字节,而char
只占一个字节,因此需要为指针区分类型的一大原因就是要保证指针在解引用时能获取正确大小内存的数据,本质目的还是为了内存安全。例如,int*
的指针解引用会得到4字节大小的数据,而char*
解引用则会得到1字节大小的数据,其获取多大内存的数据其实就是指针的类型。其他类型的指针也是同理。
int main() { int a = 10; char c = \'a\'; int* p_a = &a; char* p_c = &c; cout << sizeof(p_a) << endl; // 输出4,占用4字节 cout << sizeof(p_c) << endl; // 输出4,占用4字节 cout << *p_a << endl; // 10 cout << *p_c << endl; // a cout << sizeof(*p_a) << endl; // 输出4,占用4字节 cout << sizeof(*p_c) << endl; // 输出1,占用1字节 return 0;}
自定义复杂类型(结构体,c++的对象)也可进行取地址,并且尤其自己对应的指针类型。例如:
struct A { int id; char ch;};int main() { A a; A* p = &a; // 对结构体取地址,赋值给指针p return 0;}
4.野指针和空指针
(1)野指针
野指针是指向一个无效的内存地址的指针,即指针的值不是有效的内存地址。这些无效的内存地址可能已经被释放或者尚未分配,因此野指针的使用会导致程序崩溃或产生不可预料的错误。举个例子:
int main() { int* p = (int*) malloc(4); // 开辟4字节大小的内存,并将内存地址赋值给指针p *p = 10; // 将p指向的内存中的值设置为10 cout << *p << endl; 10 free(p); // 释放p所指向的内存 cout << *p << endl; // 输出随机的值,此时p为野指针 return 0;}
这里我们让
p
指向一个4字节的内存,并对其赋值,当我们将p
释放,将其使用权还给操作系统后,继续使用p
,p
就成为了野指针,对野指针的使用会出现各种各样的问题,严重的话可能导致程序崩溃,因此,实际开发中,应该避免出现野指针。
(2)空指针
空指针是一种特殊的指针,它指向的是内存的0号地址,值为NULL
(在C语言中,NULL
本质是一个宏定义,值是0),将指针赋值为NULL的操作就是指针置空,当一个指针为空时,我们默认它没有指向任何变量的内存。在指针初始化时,如果不能立即赋值那么可以将该指针置为空,或者在释放指针指向的内存后将指针置为空避免野指针的出现。C++更推荐使用nullptr
来置空指针。
int main() { int *p = NULL; return 0;}
c++则推荐使用nullptr
,它提供了更好的类型安全性。
int main() { // int* p = NULL; int *p = nullptr; return 0;}
5.指针的简单应用
接下来我们来举一个函数传参的经典例子来带各位更好的理解指针。
首先有以下代码,一个名为
change
函数接收一个参数a,在函数中改变a的值。
void change(int a) { a = 99; //给a赋值99}int main() { int a = 10; change(a); // 调用函数 cout << a << endl; // 10 return 0;}
但是运行结果显示,虽然调用了
change
函数,但是a
的值并没有被改变,依旧是10。因为函数传参默认是值传递,change函数只是把主函数中的a的值拷贝到了自己的栈中,也就是说,change
中的变量a只是主函数中变量a的一个副本。因此在change
中给变量a赋值99是不会影响主函数中的变量a
的。
想要改变main中a的值,就需要使用使用指针
根据前面所说的内容我们知道指针存储的是内存地址,可以抽象理解为指针指向的是变量的实际内存,可以通过解引用操作来获取所指向内存中存放的值。
也就是说,可以把函数的参数改为指针,通过对指针解引用来改变实际内存中的值。代码如下:
void change(int* p) { *p = 99; //把指针解引用,直接改变内存中的值}int main() { int a = 10; int* p = &a; change(p); // 调用函数,传入a的指针p cout << a << endl; // 99 return 0;}
这里需要注意的是,在传入指针参数时依旧发生了拷贝,但是拷贝的是指针的值,也就是变量
a
的地址,之后在解引用后修改值时,修改的便是实际指向的内存中的值,即实际a
的值。
6.结构体与指针
关于结构体与指针要注意的是结构体指针的解引用。假设我们有如下结构体,并声明一个该结构体的指针:
struct Student { int id; char name[100]; double score;};int main() { Student stu = {19, \"test\", 89.4}; Student* p = &stu; return 0;}
对于普通的结构体变量而言,要访问或修改结构体的成员变量可以通过.
来实现,例如stu.id = 129
,但是指针就需要先解引用:
struct Student { int id; char name[100]; double score;};int main() { Student stu = {19, \"test\", 89.4}; Student* p = &stu; (*p).id = 129; cout << stu.id << endl; // 129 return 0;}
但是(*p).id = 129
通过这样的方式来操作成员变量有点过于繁琐了,于是可以使用->
来代替结构体指针访问成员变量:
struct Student { int id; char name[100]; double score;};int main() { Student stu = {19, \"test\", 89.4}; Student* p = &stu; // (*p).id = 129; p->id = 129; cout << stu.id << endl; // 129 return 0;}
相比*
号解引用,这种方式则更加推荐,尤其是在面对链表一类的数据结构时,这种书写方式的优势尤为明显。
三、指针进阶
1.指针与数组
数组在C/C++中是一条连续的内存空间,存储着相同类型的多个数据。需要注意的是数组名,这里有一个误区,很多人认为数组名就是一个指针,然而这个说法并不正确。数组的数组名只是在一些情况下会被隐式转换成指向数组第一个元素的指针,因此大多数情况可以当作指针来进行使用,但数组名的本质并不是指针。但是为了方便读者理解,这里就暂时将数组名当作指针来看待。
如上所述,数组名可以隐式转换为一个指针,指向数组的第一个元素。
我们可以将数组名赋值给一个指针(指针类型须和数组存放的元素类型相同)
int main() { int a[5] = {1, 2, 3, 4, 5}; int* p = a; //将数组名赋值给一个指针 cout << p << endl; // 0xc3f8a8(数组首元素的地址) cout << *p << endl; // 解引用,输出1,说明数组名转换为指针后指向的是数组元素的首地址 return 0;}
ps:以上内容对存放任意类型数据的数组都适用。
2.指针的运算
指针支持对整数的加减运算,包括相同类型指针之间的加减,也就是说,指针也支持前后置++
、--
运算。这里需要注意的是,不同类型的指针之间进行相同的整数加减时,实际增加的数值并不相同,实际增长的数值由指针指向的变量类型的实际大小决定。例如,对double
类型的指针+1时,指针的值也会+8(因为double
的大小为8字节),但是对int
类型的指针+1时,指针的值会+4(因为int
的大小为4字节)。
int main() { int* p_int = NULL; double* p_double = NULL; cout << p_int << endl; // 0 cout << p_int + 1 << endl; // 0x4,增加了4 cout << p_double << endl; // 0 cout << p_double + 1 << endl; // 0x8,增加了8 return 0;}
换句话说,不同类型的指针会影响其在运算时地址跳过的步长,这个特点对数组先得尤其重要,现在我们在回头看数组,我们需要操作数组的元素时一般会通过arr[2]
,这种数组名+下标的形式来实现,但事实上,我们也可以通过指针的运算来实现。
举个例子,这里有一个数组arr,我们需要操作下标为3的元素,就可以通过数组下标以及指针运算这两种方式来实现。
int main() { int arr[5] = {1, 2, 3, 4, 5}; // 通过数组下标访问 cout << arr[3] << endl; // 4 // 通过指针访问 int* p = arr; cout << *(p + 3) << endl; // 4 // 通过指针解引用来修改元素的值 *(p + 3) = 99; cout << arr[3] << endl; // 99 return 0;}
在上面的代码中,我们可以把数组名赋值给一个指针
p
,该指针就指向了数组的第一个元素,这时我们对p
加3,因为p是int类型的指针,所以p
的值实际增加了12(4字节 * 3),也就是跳过了12字节的地址,刚好跳过三个字节指向第4个元素(下标为3)。但是指针经过运算后仍然是指针,要操作实际的值还需要解引用。因为数组名可以隐式转换为指针,所以上述的代码可以简写:
int main() { int arr[5] = {1, 2, 3, 4, 5}; // 直接将数组名当作指针进行运算 cout << *(arr + 4) << endl; // 5 *(arr + 4) = 99; cout << *(arr + 4) << endl; // 99 return 0;}
这里顺带一提,在C中scanf
要进行输入需要对变量取地址,所以我们在我们需要通过输入来修改数组中的某个值时,一般会使用scanf(\"%d\", &arr[2])
。但是我们前面也提到过,数组名可以隐式转换为指针,所以我们可以通过直接使用数组名来使用scanf
:scanf(\"%d\", arr + 2)
,上述的两个scanf
语句是等价的。
3.常量指针与指针常量
常量指针与指针常量这两个名字看起来相似,但完全不是一个东西,而且也很容易被人混淆。
(1)常量指针
常量指针,顾名思义,即“常量的指针”,因此,常量指针本质是一个指针,但指向的是常量。语法为const 变量类型* 变量名
或 变量类型 const* 变量名
。
int main() { int a = 10; // 声明一个指向常量的指针 const int* p = &a; //或者 int const* p_t = &a; return 0;}
这里需要注意的是,常量指针本身的值可以改变,也就是说常量指针可以被其它相同类型的指针或地址赋值,即其指向可以改变,但是无法通过常量指针解引用来修改指向的值。
int main() { int a = 10; int b = 20; const int* p = &a; p = &b; cout << *p << endl; // 20,说明可以改变常量指针的指向 *p = 99; // 编译报错,不能修改常量指针指向的值。 return 0;}
(2)指针常量
指针常量,顾名思义,即“指针的常量”,因此,指针常量本质是一个常量,语法为变量类型* const 变量名
。
int main() { int a = 10; int* const p = &a; return 0;}
和常量指针不同的是,指针常量无法被其它同类型的指针或地址赋值,即其指向无法改变,但是却可以通过解引用来修改其指向的内存中的值。
int main() { int a = 10; int* const p = &a; *p = 99; cout << a << endl; // 输出99,说明可以修改其指向的值 p = &a + 1; // 编译报错,无法修改其指向 return 0;}
ps:数组名隐式转换成的指针本质是一个指针常量,可以简单的把数组名看成一个指针常量,也就是说无法改变数组名的指向。
(3)总结
常量指针和指针常量这两个词经常让人分不清,这里给大家简单总结一下方便记忆。
①常量和指针哪个词在前哪个就不可变
常量指针,常量在前,说明是指针指向的变量不可变,而指针的指向可以变。
指针常量,指针在前,说明是指针的指向不可变,而指针指向的变量可以改变。
②const在前则为常量指针,*在前则为指针常量
常量指针:const int* p
或int const* p
指针常量:int* const p
③哪个词在后,哪个就是本质
常量指针,指针在后,所以本质是指针。
指针常量,常量在后,所以本质是常量。
3.字符指针与字符串、字符数组
我们前面也提到过字符指针,形式为char*
,和其它类型的指针用法区别并不大,同样可以进行赋值以及解引用等操作。
int main() { char c = \'A\'; char* p = &c; cout << *p << endl; // A return 0;}
这里需要提一下字符数组,和通常的数组一样,其数组名也可以被隐式转换为指向数组中第一个元素的指针,也就是说,字符数组的数组名可以变成一个字符指针。
int main() { char arr[5] = {\'h\', \'e\', \'l\', \'l\', \'o\'}; char* p = arr; cout << *(arr + 1) << endl; // 输出e return 0;}
然后我们在字符数组中加一个元素\\0
,这时,这个字符数组就可以当作字符串来使用,又因为数组名可以隐式转换为指向第一个元素的指针,所以有以下两种用法:
int main() { char arr[6] = {\'h\', \'e\', \'l\', \'l\', \'o\', \'\\0\'}; char* p = arr;// printf(\"%s\\n\", arr); cout << arr << endl; // 输出hello// printf(\"%s\\n\", p); cout << p << endl; // 输出hello return 0;}
由上面的代码可以知道,字符指针经常被用来指向以’\\0’结尾的字符数组,即字符串。当字符指针指向这样的数组时,它可以通过字符串处理函数来被当作字符串处理。
接下来是比较特殊的常量字符串。
需要注意的是,在C/C++中,字符串的字面量本质是一个常量,是由编译器在静态存储区中分配的一块不可修改的内存区域。而前面我们也提到过,要引用字符串就需要使用字符指针,这里要引用字符串字面量,也就是引用字符串常量,这里就需要使用常量字符指针,也就是一个常量指针。
int main() { const char* str = \"hello\"; cout << str << endl; // hello return 0;}
对于以上代码需要强调一点,有些同学可能会发现,char* str = \"hello\"
这个语句同样可以编译通过便运行,但是这时不安全的用法,而且某些编译器并不会通过这条语句的编译,所以在使用一个字符串字面量时尽量使用const char*
,即常量指针。
但是我们也知道,常量指针无法修改其指向的内存区域,如果你需要一个可以修改的字符串,那么可以使用字符数组。。
int main() { char str[] = \"hello\"; cout << str << endl; // hello str[4] = \'0\'; cout << str << endl; // hell0 return 0;}
在上面的代码中需要注意的是,虽然代码中的字符串“hello\"表面上只有5个字符,但字符数组的大小实际为6字节,也就是6个字符,因为字符串默认有一个’\\0’的字符结尾。
4.指针数组与数组指针
数组指针和指针数组是C/C++中两个非常重要的数据类型,但同样也经常容易混淆。
(1)指针数组
指针数组,顾名思义,即”指针的数组“,因此,指针数组本质上是一个数组,存放的元素是指针。其语法为变量类型* 变量名[]
。
int main() { int* p[5] = {}; // 声明一个指针数组 int a = 10; p[2] = &a; // 给数组中下标为2的指针赋值 printf(\"%d\\n\", *p[2]); cout << *p[2] << endl; // 输出10,将下标为2的指针解引用 return 0;}
这里需要注意的地方是,
*
号是左结合的,但[]
(以及()
)的优先级更高,所以变量名p
先和[]
结合,也就是先是数组,然后*
有和int也就是变量类型结合为int*
,也就是数组中存放的元素是int*
,也就是整型指针。完整分析,该变量的意思就是p
是一个数组,存放的是整型指针。
(2)数组指针
数组指针,即”数组的指针“,也常被叫做行指针。因此,数组指针本质上是指针,指向一个数组。语法为:变量类型 (*变量名)[]
。
int main() { int arr[] = {1, 2, 3, 4, 5}; int (*p)[5] = &arr; // 将数组的地址赋值给数组指针 cout << (*p)[2] << endl; // 输出3,将指针解引用获取下标为2的元素 // 等价于: cout << *((*p) + 2) << endl; // 3 // 将数组指针解引用以获取数组的首元素地址,因为还是地址所以可以进行指针加减再解引用获取元素 return 0;}
这里强调一下数组名和数组指针的区别,数组名所隐式转换的指针指向的是数组的第一个元素的内存区域,而数组指针指向的是整个数组内存区域的首地址,这两个指针的值其实是相同的,都是数组中首元素的地址。其区别如下:
①数组指针指向的类型是数组,而数组名隐式转换为指针后指向的类型是数组存放的变量类型。不同的指针类型会影响指针解引用后所能操作的内存大小。以上述的代码举例,
arr
数组名隐式转换后指向的是首元素,也就是说是一个int*
,解引用后只能操作4字节的内存区域(一个int
的大小)。但是int (*p)[5]
是数组指针,指向的是整个长度为5的数组,所以当解引用时可以操作20字节的内存区域(五个int
的大小),这就是为什么代码中的数组指针在解引用后还可以将其当作数组来使用。
②在进行指针运算时,两者加减相同整数时所跳过的内存大小不同。前面我们提到过,指针的类型会影响在运算时跳过的内存大小。数组名隐式转换的指针和数组指针并不是相同类型的指针,以上述代码为例,arr数组名隐式转换而成的指针+1会跳过4字节的内存(一个int的大小),而int (*p)[5]则会跳过20字节的内存(五个int的大小)。
int main() { int arr[] = {1, 2, 3, 4, 5}; int (*p)[5] = &arr; // 将数组的地址赋值给数组指针 cout << arr << \" \" << p << endl; // 0xfff6b8 0xfff6b8,两个指针的值相同 // 对两个指针+1 cout << arr + 1 << endl; // 0xfff6bc,比之前增加了4 cout << p + 1 << endl; // 0xfff6cc, 比之前增加了20 return 0;}
数组指针一般会在多维数组中使用,举个二维数组的例子。
int main() { int arr[][3] = { {1, 2, 3}, {4, 5, 6} }; // 二维数组的首元素是数组,所以要用数组指针 int (*p)[3] = arr; cout << p[0][1] << endl; // 2 //或者: cout << *(p[0] + 1) << endl; // 2 cout << p[1][1] << endl; // 5 // 或者: cout << *(p[1] + 1) << endl; // 5 return 0;}
可以将二维数组理解为存放一维数组的数组,那么二维数组的首元素自然就是一维数组,那么指向二维数组首元素的指针自然就是数组指针。同样,三维数组是存放二维数组的数组,那么三维数组首元素自然就是二维数组,那么指向三维数组首元素的指针自然就是二维数组指针。
ps:这其实也解释了为什么二维数组在声明时可以省略行的长度而不能省去列的长度。因为如果省去了列的长度,在进行数组指针的运算时以及解引用时,编译器就不知道具体跳跃的内存大小以及可以控制的内存大小。
(3)总结
①指针数组即指针的数组,本质是数组,存放的是指针,语法为变量类型* 变量名[]
,例如int* p[5]
。
②数组指针即数组的指针,本质是指针,指向的是数组本身,语法为变量类型 (*变量名)[]
,例如int (*p)[5]
。
③相同数组,该数组名所隐式转换的指针和该数组指针不是相同类型的指针。数组名指向的是首元素,数组指针指向的是整个数组,虽然两两者存储的地址相同,但是解引用后能操作的内存大小以及指针运算所能跳跃的内存大小并不相同。
④哪个词在后哪个就是本质,例如指针数组,数组在后,说明本质是数组,而数组指针,指针在后,所以是指针。
5.函数指针和指针函数
函数指针和指针函数是C/C++中的一个重要概念,同样也是经常让人难以分清,以下是两者的详细介绍。
(1)函数指针
函数指针即函数的指针,本质是一个指针,指向的是一个函数。语法为返回值类型 (*函数名) (参数列表...)
。
int add(int x, int y) { return x + y;}int main() { // 声明函数指针 int (*p)(int, int) = add; // 函数名可以隐式转换为函数指针 // int (*p)(int, int) = &add,也可以这样写 int sum = (*p) (4, 5); // 解引用指针并调用函数 // 等价于 add(4, 5) cout << sum << endl; // 9 return 0;}
在上面的代码中需要注意的是,函数名可以隐式转换成指向函数的指针,所以我们可以直接用函数名为函数指针赋值。
*
和指针名要用()
括起来,因为*
是左结合,不用括号语义就会发生变化。
这里顺带一提,使用typedef关键字可以对函数指针进行重命名,也就是起个别名,这样就可以少写冗长的类型声明,直接用别名替换,减少代码量。具体语法就是typedef 返回值 (*别名) (参数列表...)
。
// 给该类型的函数指针起一个别名typedef int (*add_func_ptr)(int, int);int add(int x, int y) { return x + y;}int main() { // 直接用别名替代类型 add_func_ptr p = add; int sum = (*p) (4, 5); // 解引用指针并调用函数 // 等价于 add(4, 5) cout << sum << endl; // 9 return 0;}
(2)指针函数
指针函数即指针的函数,本质是一个函数,返回值是指针。这个概念比较简单,形如返回值* 函数名(参数列表....)
的函数都是指针函数,更简单点来说就是返回值是指针的函数。
// 全局静态变量int static_val = 10;// 返回指针的函数int* get_val() { static_val += 5; return &static_val;}int main() { int* p = get_val(); cout << *p << endl; // 15 return 0;}
这里需要注意的一个点是,函数返回的指针其指向的值要么是全局静态变量,要么是放在堆上的数据,不要让返回的指针指向函数栈中的值,否则返回的就是野指针,容易出问题。
6.多级指针
多级指针,即指向指针的指针,例如int** p
,该指针即为指向int型指针的指针,为二级指针。同理类推int*** p
为三级指针。
int main() { int a = 10; int* p = &a; int** p2 = &p; return 0;}
四、指针高级
1.复杂指针类型的辨认
面对C/C++的指针,一个最大的困难就是辨别不出,或是说读不懂变量的类型。例如int* p[10][5](int, int)
,相信很多人都会对这个类型感到一头雾水,压根不知道这个变量到底是什么类型。其实辨别这种指针相关的复杂类型其实很简单,只需要记住下面这段话:
找到变量名,以
()
为单位,从变量名开始先右后左,依次读出类型,即为该变量的类型。
我们先来看看最简单的例子:
int* p;
按步骤来:
①找出变量名:
变量名为p
②以()
为单位:
没有(),先不管
③从变量名开始先右后左,依次读出类型:
因为p的右边已经没有东西了,所以向左读,*是指针,所以p是指针,*左边是int,说明指针指向的是int。
依次读出就是,p是指针,指向的变量类型是int。
再来看个例子:
int* p[5]
这里需要注意,遇到[]
就读为数组。我们接着按步骤来:
①找到变量名:
变量名为p
②以()
为单位:
没有(),先不管
③从变量名开始先右后左,依次读出类型:
p
右边为[5]
,所以p
是长度为5的数组,数组存放的是什么?我们接着向右,但右边没有东西了,所以向左(已经读过的就忽略),然后是*
,说明数组存放的是指针。指针指向什么?再向左看,是int
,所以指向的是int
类型。
依次读出来:p是长度为5的数组,数组存放的是指针,指针指向的是int。
稍微加工一下:p是一个存放int指针且长度为5的数组,也就是我们之前提到过的指针数组。
带有()
的例子:
int (*p)[5];
老样子,按步骤来:
①找出变量名:
变量名为p
②以()
为单位:
有一个圆括号,读的时候要注意以圆括号为单位,即整体上先右后左读,但是圆括号里的要单独先右后左读。
③从变量名开始先右后左,依次读出类型:
先向右读,但是右边是()
,要先将圆括号看成一个整体,先将里面的内容先右后左的读完,但是在()
里右边没有东西了,所以这里我们要向左,读到*说明p是指针,指向的是什么?我们再向左,但()
里的左边已经没有东西了,那么()
整体就读完了,我们从()
开始接着从右边读,是[5]
说明是数组,也就是指针指向的是数组,存放的是什么?因为右边没有东西了,我们向左读(已经读过的忽略),左边是int,也就是说数组存放的是int数据。
依次读出来:p
是指针,指向长度为5的数组,数组里存放的是int
。
加工一下:p是一个指向长度为5的int数组的指针。也就是之前提到的数组指针。
来个稍微复杂点的例子:
int* (*p[5])[10];
如果上面的内容都看懂了那这个其实就很简单了,我们再来按步骤读一遍:
①找出变量名:
变量名为p
②以()
为单位:
有一个圆括号,读的时候要注意以圆括号为单位,即整体上先右后左读,但是圆括号里的要单独先右后左读。
③从变量名开始先右后左,依次读出类型:
从变量名也就是p
开始先右后左读,右边是[5]
说明p是数组,数组存放的是什么?因为括号里面右边没东西了,所以向左读,是*
,说明存放的是指针,指针指向的是什么呢?因为括号里面的已经读完,所以从()
开始接着向右读。右边是[10]
,说明指向的是长度为10的数组,数组存的是什么?因为右边没东西了,所以向左读,读到*,是指针,说明数组存放的是指针,指针指的是什么?再向左,是int
,说明指向的是int
。
依次读出来:p
是长度为5的数组,数组存放的是指针,这些指针指向的是长度为10的数组,这个数组里存放的是指针,这些指针指向的是int
。
加工一下:p
是一个存放着指向长度为10的数组的指针的指针数组,长度为10的数组里存放的又是int型指针。
带有函数的例子:
int* (*func)(int, char);
这里需要额外注意的一点是,带有变量类型的(),以及空的(),要整体读为函数,函数重点关注的是其返回值。我们只需按步骤来即可:
①找出变量名:
变量名为func
②以()
为单位:
有一个圆括号,读的时候要注意以圆括号为单位,即整体上先右后左读,但是圆括号里的要单独先右后左读,注意带有变量类型的()或空的()看作类型,读为函数。
③从变量名开始先右后左,依次读出类型:
从func
开始先右后左,因为括号里面右边没东西了,所以我们向左读,读到*
,说明func
是指针,指向的是什么?括号里的已经读完,所以我们从括号开始接着向右读。读到一个带有参数列表的括号,读为函数,说明指向的是一个参数列表为int
和char
的函数,函数返回的是什么?右边没有东西了,我们向左读(读过的忽略),读到*
,是指针,说明返回的是指针,指针指向的是什么?再向左读,读到int
,说明指向int
。
依次读出来:func
是一个指针,指向一个参数列表为int
,char
的函数,该函数的返回值是指向int
的指针。
加工一下:func
是一个指向参数列表为int
,char
,返回值为int*
的函数的指针,是之前提到过的函数指针。
最后给一个复杂点的例子:
char* (*(*func)[5])(float);
虽然有括号嵌套,但是依旧是从变量名开始从里向外,先左后右的读出来。大家可以试着自己分析一下这个变量到底是什么,如果你能搞定这个,那么你几乎就已经可以应对C/C++中的任何复杂类型。
这里就不带大家一步一步分析了,该变量依次读出来是这样:func
是一个指针,指向一个长度为5的数组,数组里存放的是指针,指针指向参数列表为float
的函数,函数返回的是指针,指向的是char
。
总结:找到变量名,以()为单位,从变量名开始从里到外,先右后左,依次读出类型,即为该变量的类型。
[]
读为数组,*
读为指针,带参数列表的()
或空的()
读为函数,函数要侧重看返回值。
ps:上述内容的原理是*
是左结合,但()
和[]
的优先级最高。
2.数组名隐式转换
前面我们提到过,我们经常习惯性的将数组名当成指向数组首元素的指针来使用是因为数组名在某些情形下会自动隐式转换为数组首元素的指针。但两者并不是同一个东西,数组名是指针这个说法本身就不严谨。接下来,我将列举数组名隐式转换为指针的情形:
(1)函数传参
arr在传参后会转换为指针
void printArray(int arr[]) { // arr 实际上是一个指向 int 类型的指针 for (int i = 0; i < 10; i++) { printf(\"%d \", arr[i]); }}
(2)赋值
int arr[10];int *ptr = arr; // arr 隐式转换为指向第一个元素的指针
(3)数组名作为地址
被当做地址使用时会隐式转换为指针
int arr[] = {1, 2, 3};printf(\"%p\\n\", arr); //00bff884
当进行大小相关的操作时数组名是不会隐式转换的,例如使用sizeof时:
int main() { int arr[] = {1, 2, 3}; int* p = arr; cout << sizeof(arr) << endl; // 12 cout << sizeof(p) << endl; // 4 return 0;}
3.void型指针
void类型指针是一种特殊的指针,与空指针NULL的区别在于:
(1)NULL
空指针表示指针为空,没有指向任何变量。
(2)void
类型指针表示其指向的变量类型不确定,而且在使用时一般需要强制转换成其它类型的指针。
关于void
指针,有两点需要注意:
(1)void
指针不能直接解引用,必须得强转为其它某一具体类型的指针后才能解引用。(因为不知道具体类型就不知道解引用后能操纵多少内存大小的数据)
(2)void
指针不能进行加减运算。(理由同上)
int main() { void* p = malloc(4); // p++; 报错,void指针无法加减运算 // cout << *p << endl; 报错,void指针无法直接解引用 int *p2 = (int*) p; *p2 = 89; cout << *p2 << endl; // 89 free(p2); p2 = NULL; p = NULL; return 0;}
4.回调函数
回调函数是实现事件驱动编程的重要部分,C语言要实现回调函数,就需要使用函数指针。
例如,我们定义一个test函数,该函数接受两个int参数,以及一个名为operation的函数指针,可以在test中调用operation。
void add_operation(int a, int b) { cout << a + b << endl;}void test(int a, int b, void (*operation) (int, int)) { cout << \"invoked test\" << endl; operation(a, b); cout << \"invoked operation\" << endl;}int main() { test(3, 4, add_operation); return 0;}
如上代码所示,我们定义了一个add_operation函数,将其当作参数传入test函数并调用。我们可以指针传入函数名,因为函数名会自动转换成函数指针。
ps:C++实现回调函数建议使用lambda。
5.零长数组(柔性数组)
注:C/C++标准并不支持零长数组,需要看自己使用的编译器是否有相关的扩展!GUN支持,但VS系列不支持。
零长数组,顾名思义,就是长度为零的数组。主要使用于需要改变长度的结构体,需放在结构体的最后一个位置。
struct buffer { int id; int len; char data[0]; // 长度为零};
在上述代码中要注意的是,结构体中的零长数组是不会占用任何内存空间的,因为数组名本身就不占用空间,而且数组长度是零。
可以这样使用:
#include #include #include struct buffer { int id; int len; char data[0]; // 长度为零};int main() { buffer* buf = (buffer*) malloc(sizeof(buffer) + 6); // 结构体开始时的长度加上要扩展的长度 strcpy(buf->data, \"hello\"); printf(\"%s\\n\", buf->data); // hello free(buf); return 0;}
从上述代码我们可以看出,使用零长数组可以为结构体动态开辟内存,也就是可以在运行时决定结构体的大小,相比起直接使用指针,使用零长数组的结构体在释放内存时直接释放结构体指针即可,而内部使用指针则需要单独释放内部指针指向的内存区域,没有零长数组方便。
6.数组与指针的补充
前面我们多次提到过数组名可以隐式转换为指针,在进行数组索引时,arr[i]
实际上等价于*(arr + i)
,也等价于*(i + arr)
。这乍一看像是句废话,但其实它代表着我们最开始的写法arr[i]
其实和i[arr]
相同。虽然平时一般不会这么写,但是以防其他人搞“防御性编程”,所以还是要知道这种写法。
int main() { int arr[5] = {1, 2, 3, 4, 5}; for (int i = 0; i < 5; i++) cout << arr[i] << endl; // 等价于: for (int i = 0; i < 5; i++) cout << i[arr] << endl; return 0;}
arr[i]⇔∗(arr+i)⇔∗(i+arr)⇔i[arr] arr[i] \\Leftrightarrow *(arr + i) \\Leftrightarrow *(i + arr)\\Leftrightarrow i[arr] arr[i]⇔∗(arr+i)⇔∗(i+arr)⇔i[arr]
五、总结
简单来说指针存放的是内存地址,大小固定,取决于使用的平台,一般固定为4字节或8字节,可以通过解引用来操纵其指向的内存区域。指针使C/C++的开发变得更加灵活多变,对于学习C/C++的人来说,指针的相关内容需要熟练掌握。