【c】关于指针,你应该知道这些
🏐tips: 本文部分内容改编自《c和指针》。
📰引入
🏉内存与地址
在了解指针前,我们需要先理解内存
与地址
的含义。
在c和指针一书中,很形象的将计算机的内存比作是一排排的房屋
,至于房屋有多大,取决于你所处的环境。计算机内存由以亿记的位(bit)
组成,每一个bit都可以容纳下0或者1
,但如果将一个bit位作为一个房屋,那容纳的量未免太小了。所以我们通常会将许多位共同组成一栋房子
,这样的房子被我们称为字节(btye)。一个字节容纳八个位
。字节也可以由多个组成更大的单位——字
,每个字都有2字节或者4字节组成。需要注意的是,虽然字节或者字会包含多个比特位,但通常我们只认为他只有一个地址
。
上图将三者关系做了一个解释。
当我们知道了一个地址,就可以通过调用这个地址
来获得存储在这个地址上的值。但这样做是很笨拙的,因为我们在写程序时不会也不能记住每一个需要被调用的值的地址,所以我们常常使用一个名字来代替地址。
这里的1个单位代表1个字节。
正如上面所写的,他们只可以有一个地址。这个地址通常是左边
第一个位。
在介绍这一块时,c和指针一书还额外提了一点——不可以简单的通过检查一个值来判断其类型
,这点也是非常重要的,要判断其类型就需要去观察其使用方式
。例如,一个32位的数字串可以被解释为多种不同的类型。
🏸什么是指针
指针,其实是指针变量,只是口头上我们将其口语化为指针,真实的指针其实指的是地址
。指针变量,是用来存放地址
的,而地址呢,则是起到可以唯一的标识一块空间
的作用。
上图的c就可以被看做是一个指针,c中存储的值是a的地址。
1 | int* c=&a; |
&是一个取址符
,代表取出a这块空间的地址,*则代表c是一个指针
,int则是c指向的那块空间的类型。那么这个表达式的意思就是:定义了一个c,起作用是存储a的地址
,同时a是一块整形变量,那么这个指针就是指向整形
的指针。
一定要会区分c的值与c的地址。c的地址是108,但存储的地址是100。
但c并不可以
自动的去取得a中的值,这点非常重要。c仅仅是存储a的地址,想要通过地址访问地址里存储的数据我们称为解引用指针
或者间接访问
。用于这个操作的操作符叫做*
,*c作为左值时就可以得到a中所存储的数据,50。
这就是指针的基本使用。
指针的大小并不受其类型的控制,而是受到了平台的限制。在32
位平台,指针的大小为4
字节,在64
位平台,指针的大小就是8
字节。那他的类型有什么作用呢?答案是决定走的步长
。在很多时候,我们都会利用指针这个特性,去取到一些我们需要的数值。例如在int型的空间里,我们可以使用char型的指针去取其中的某个字节。这一特性使得指针灵活性大大提高。这个特性会放在后面去讲。
指针,是c语言中堪称核心的一环,也是c语言的灵魂。但指针并非是什么困难高深的东西,指针只是一扇门,当你推开它时,你才能真正的进入c语言。
🏑 非法指针与空指针
1 | int* p; |
很明显这是一个错误的示范,只是定义了一个指针,却没有给出指向的地址。那么接下来程序的操作是不可预知的。如果程序为其分配一个非法的地址
,那么程序报错终止,你很幸运知道了这里有问题。但要是分配的是一个合法的地址,你对指针进行操作就有可能修改
这个你不知道的地址,这种错误是很难去捕捉的,所以请确保在定义一个指针时,确保它被初始化
。
在指针里有一个十分特殊的存在,NULL指针。它并不指向
任何东西。同时,对NULL指针进行解引用是非法的。有些编译器会访问0
这个地址,但大多数的编译器会直接报错
,这样是更好的情况,有一句话是这么说的:宣布错误比隐藏错误要好得多,因为程序员会更容易的修正它。
📓各种指针
📔字符型指针
字符型指针的类型是char*。例如一个简单的例子:
1 | char a='a'; |
*代表了p是一个指针
,char则表示指针指向的类型。&则是取到了a的地址。
指针也可以直接指向一个常量字符串,例如
1 | char* p="chengzi"; |
此时的p更具体的来说,是指向了这个字符串的第一个字符---c
。同时需要注意的是,由于此时指向了常量字符串,所以p指向的内容是不应该被修改
的,那么更规范的写法应该是给char前面加一个const
,限制p,使其指向值不能通过p来解引用
而引起改变。
下面这个例子也是同样的道理。
1 | char arr1[]="chengzi"; |
上面一共给出了4个项,那么请问arr1是否等于arr2呢?arr3又是否等于arr4呢?
答案是 arr1不等于arr2,arr3等于arr4 。
首先我们要明白,等于在这里是什么意思?在程序里我们通常这样解释:
1 | if(arr1==arr2) |
这里的等于并非数值上的相等,而是地址是否相同
。那么arr1和arr2是不相同的。虽然两个字符串数组的初始化内容一样,但开辟的内存块
是不同的,也就是说,本质上arr1和arr2只是形状一样的处于不同地点的两栋房子,其本质上是具有区别的。而指针就不同了,指针可以理解为门牌号,arr3和arr4都是一个门牌号,他们都指向chengzi这栋房子。从内存角度来讲,c/c++会将常量字符串存储至单独的一块内存区域
,当有几个指针同时指向这个字符串
时,都是指向同一块内存
的。
📒数组指针
在讲数组指针之前,先谈谈指针数组
。
指针数组的本质是一个数组
而非指针。只是这个数组中存放的是指针型
而已。千万不要搞混淆了。
1 | int* arr[10]; |
这样类型的都是指针数组,存放着地址的数组
。
但数组指针就不一样了,数组指针是指针
。那么类比整型指针,字符型指针,不难得出结论:数组指针,就是指向数组
的指针。
其基本格式为:
1 | int (*p)[10]; |
括号不能去掉
,一旦去掉,就成为了指针数组。因为在一般情况下,[]的优先级会比*更高。
括起来之后,*就先与p结合
,说明了此时的p是一个指针,再将p取出来,剩下的就是int(*)[10]
,说明其指向的是一个存放了10个数的数组。
arr与&arr的区别
在这里提一下arr与&arr的区别,arr是一个数组的名字,在编译器上对二者进行printf,会发现两个值通过%p打印出来都是相同的地址
,这能否说明二者相同呢?
在编译器上运行这个代码:
1 | int arr[10]={0}; |
二者打印结果相同。再加上两行:
1 | printf("%p\n",arr+1); |
运行得到结果,会发现第三个第四个的打印结果不一样。
这里参照我的运行实例。
00000080F2B8FB78
00000080F2B8FB78
00000080F2B8FB7C
00000080F2B8FBA0
第三个和第四个相减,转换为十进制,得到36。
所以,不难猜到,&arr表示的是数组的地址
,而并非数组首元素
的地址。
那么联系到上文提到的指针的步长,&arr其实就是指向的就是int(*)[]
类型,它加1就使得整个指针跳过了数组
。而arr单单只可以表示首元素
的地址。那么,其+1就不过是将指针指向arr[1]而已。二者差距9个元素,一个元素4字节,也就是36个字节了。
📕使用示例
那到底数组指针有什么意义呢?它在写的代码里又能起到什么作用呢?
要记住的是:数组指针中存放着数组的地址。
1 | int main() |
这里传递的arr实际上就是相当于第一行的那个一维数组的地址
,这很重要。在一维数组中arr是第一个元素的地址,同时也是整个数组
的地址,二维数组中,arr则是第一个元素的地址,也是第一个一维数组
的地址。
实现一个函数,让PrintArr打印出这个二维数组。其中的一个参数就可以用数组指针来接收这个一维数组的地址。
1 | void PrintArr(int(*arr)[3],int a,int b) { |
类比一下一维数组传参,可以传入int arr[],也可以传入int* arr,此时的arr就是指针,arr+1就会跳过4字节,也就可以得到数组中第二个数,实际上,arr[1]就等同于arr+1
。
二维数组传参可以传入整个数组,也就是换成普通的int arr[3][3]。
也可以使用指针的写法。那么此时就需要传入一个指针,这个指针应该是指向一维数组的,例如arr[3][3],就代表有3个一维数组,每个一维数组都有3个整形数据,那么传入一维数组的指针即可。
int(*arr)[3] 不正是这个例子吗,传入的arr是第一个一维数组的地址,也正好作为指针使用,arr[1]就代表直接跳到了第二个一维数组,arr[2]就跳到了第三个一维数组,想要访问其中某一个一维数组,直接arr[i][j]即可。
一旦遇到上面这种情况,就一定要睁大眼睛咯,毕竟这种写法在平常较为少见,也不是很好理解。
📗函数指针
函数指针,也就是指向函数的指针。
先上一个示例:
1 | printf("%p",hanshu1); |
在编译器上运行,得到的结果也是相同的。二者所得到的都是函数的地址,并且在这里意义相同
。
函数指针就是用来保存函数地址的,其格式如下:
1 | void (*p)(); |
和上文的数组指针一个道理,这个指针指向一个函数,并且本代码的指针指向函数的类型是void,并且无参。
来看一个很有意思的例子,来自于书《c陷阱与缺陷》。
1 | (*(void(*)())0)(); |
第一眼看起来是个很复杂的式子,但只要拆开来也不是很复杂。首先把void(*)()单独提出来,就是刚才才提到的函数指针类型,它将0强制转换成了一个函数指针的类型,那么整体的来看其实就是将0当作一个函数地址,然后通过解引用的方法去调用这个函数,最后跟的()意思是因为0被调用后是无参类型的,所以()也就不用写参数。
既然指针数组都存在,那么函数指针数组也肯定存在了。放在这里简单的提一下。
其基本格式为
1 | int (*p[10])(); |
根据优先级的规定,p会先与[]结合,说明其是一个数组
,去掉这个数组,剩下了int(*)(),不正好就是函数指针类型吗?说明这是一个存放着函数指针的数组。
函数指针数组常用来精简代码量。让代码看起来更简洁且易读。
例如,如果实现一个简单的计算器,就需要做多个函数,例如就添加加减乘除四个功能,就需要单独的四个函数,add,sub,mul,div。然后通过switch结构调用不同的函数来实现。
但可以使用函数指针数组来实现代码的精简化。
例如:
1 | int(*p[4])(int x ,int y)={add,sub,mul,div}; |
这样就将四个函数放进了一个函数指针的数组。(&函数名和函数名是同样作用)
然后通过控制p[i]中的i,就能调用不同的函数,就实现了对switch结构的精简化。
📗指向函数指针数组的指针
正在逐步的套娃化·····
简单的对定义做一个拆分,这个指针指向一个数组,其数组里装的元素都是函数指针类型。
该如何去书写呢?
1 | void(*p)()=test; //函数指针p指向test函数 |
拆分一下,* 先与pparr结合,说明了pparr是一个指针。把 * pparr丢掉,得到void(*()[5])(),其是一个函数指针数组类型。
但是并不怎么用到,只是简单的介绍。
到这里,就将c中常见的指针介绍完毕了。
📘传参
接下来也是个重点内容—传参。在构建函数时,我们总是会遇到一个问题—参数到底怎么写?
在上文就已经简单的介绍过数组的传参了,想要看懂还是需要对一维和二维数组有一定的理解与掌握。
简单的就像普通的数据类型,int,char,稍复杂的会遇到二级指针之类的,在这里我们简单的盘点一下常见的函数传参怎么个写法。
📙数组传参
这块可以分为一维和二维来处理。
先说一维的。
1 | int arr1[10]={0}; |
在这里定义了两个数组,arr1存放int型,arr2存放int*型,那么二者该如何放进函数里进行传参呢?
常见的有这五种。
1 | //arr1 |
简单的讲一下。
对于arr1而言,就是一个简单的int型数组,那么最普通的写法也就是照猫画虎直接传,[]里面的可写可不写。
也可以写成指针的形式,传入arr1这个数组的首地址。
arr2也是同样的道理,要不就照猫画虎,要不就采用指针的形式来传入。这里的int* *arr2,第一个 * 和int一起,代表指针指向类型为int *,第二个 *则代表这是一个指针
。这也是一个二级指针
,应用也是非常广泛,当需要对指针进行更改时,由于传参进入的指针仅仅是一份拷贝,那么就可以考虑传入指针的指针,也就是二级指针。
讲完一维再来说说二维。
1 | int arr[2][2]={0}; |
下面哪几种是可行的呢?
1 | void test(int arr[2][2]); |
这里提供了七种写法,一种一种的来分析下。
第一种肯定是没问题的,原样传入。第二种就不行了,二维数组由于其特殊性,至少需要要求列的数值
,如果没有列的数值,是无法计算
的。而行可以省略。那么第三种也是可行的。
第四种是一个典型的一维数组传法,所以不正确。
第五种则是上文讲到的指针数组,其本质是数组
,存放的是指针。不正确。
第六种是一个数组指针,在这里想一下二维数组的相关知识点。二维数组可以看成多个一维数组
排列而成的。如果传入像第四种,那么传入的实际就是第一行那个一维数组的首元素地址
,也就无法接收到整个二维数组了。
那么第六种显然是可行的,使用数组指针来接收数组。
第七种采用的是二级指针的写法,在这里书写显然是不合适的。int**arr本质上用于接收一级指针的地址
。
📚指针传参
接下来就讲讲一级和二级指针传参。
先说一级。
1 | void test(int* p); |
当你看到这样的例子,你能写出它能接收哪些形式的传参吗?
大概常见的有以下几种。
1 | test(&p); |
指针为二级的时候呢?
1 | void test(int* *p); |
答案如下:
1 | int* *prr; |
稍微提一下指针数组。当传入arr时,p就可以作为指向int*型的指针。其本质与传入一维数组时,参数写成int *p是一样的道理。与p相结合的 * 说明p为指针。
🎿指针表达式
下面介绍一些常用的指针表达式。在运用指针时,指针的各种表达式被当做左值或者右值是常有的事,那么理解其意义并运用就是我们的基本功了。
接下来会用到左值与右值的概念。
左值:指表达式结束后依然存在的
持久
对象,可以取地址
,具名变量或对象
右值:表达式结束后就不再存在的临时
对象,不可以取地址
,没有名字。
比如 int a = b + c;,a 就是一个左值,可以对a取地址,而b+c 就是一个右值,对表达式b+c 取地址
会报错。左值指既能够出现在等号左边
,也能出现在等号右边
的变量;右值则是只能出现在等号右边
的变量。
1 | char ch='a'; |
给出了一个字符型指针cp,指向ch,ch中存储的值是字符a。
1 | ch |
ch作为右值,其就是代表’a’,这个没什么好说的。
ch作为左值,就代表被命名为ch的这块空间
。那么此时为ch赋值就可以更改其存储的数据了。
1 | &ch |
&ch作为左值,进行求值时,这是非法的,因为&ch确实存在,但你不知道他在哪里。而且左值本就不可以取地址。
&ch作为右值,就是取ch的地址
。
1 | cp |
cp作为左值,则代表着cp所处的内存空间。
1 | cp=NULL; |
我们把cp置为了NULL,这个表达式改变的就是cp这整块空间。
cp作为右值,代表cp的值。
1 | int * p=cp; |
也就是将p这个指针也指向了cp指向的那个空间
。那个空间的地址就是cp这块空间里所存储的值
。
1 | &cp |
&cp作为左值同样是非法的,其地址对程序员而言是透明的。
&cp作为右值,则是取得了cp的地址,并会将其放到某个变量中存储起来。
1 | *cp |
\cp是间接引用操作,\cp作为左值,代表对cp这块空间里的地址的值进行操作。
*\cp作为右值,则是则是取出了所指向空间的值,并赋值给某个变量。
1 | *cp+1 |
*的优先级是高于
+的。那么*先与cp结合,+1后作为左值是非法的。
作为右值时,*cp取出了’a’,再加1就得到了’b’。
1 | *(cp+1) |
括起来后,cp+1优先级更高,作为左值时,cp+1代表跳过了一个字节(char类型为一个字节),指向了原先cp后面的那个空间,然后对其进行解引用。
作为右值时,取得原cp后面那个空间的值。
还有许多类似与++cp,cp++等表达式,搞清楚前置与后置的区别即可。
📖一些例题
❤️题1
声明一个指向含有10个元素的数组的指针,其中每个元素是一个函数指针,该函数的返回值是int,参数是int*,正确的是( )
A.(int p[10])(int)
B.int [10]*p(int )
C.int ((*p)[10])(int *)
D.int ((int *)[10])*p
题解:整体上去看是一个存放数组的指针。那么其指向类型可以写为 int(*)[],到这里基本就已经确定答案为c了。再去分析下c,将存放这个数组的指针剥离,留下了(int)( * )(int *),和题的后文相吻合。答案确定为c。
💛题2
1 | int main() |
求输出结果。
题解: 这里主要考察了对数组取地址
的相关知识点。aa+1和&aa+1有着本质上的区别。在上文我们已经介绍过,&aa代表着取出了整个数组
的地址,而aa仅代表着取出数组首元素的地址
。那么&aa+1就跳过了整个数组,由于指针是int型,那么ptr-1只会减去4个字节,对于int型数组而言,也就是向前挪动一个元素
。再来看ptr2,aa在这里是首元素地址,但这里和一维数组不同的是,此时的aa+1是相对于行而言的,他跳过的是第一行,而不是第一个元素,这是需要留意的点。*(aa+1)其实就等价于aa[1],此时它指向的就是第二行首元素,也就是5,减1减去的是4个字节,也就得到了6。那么答案就是输出1,6。
💚题3
1 | int main() |
题解:&a取到整个数组,ptr指向&a+1,也就指向了数组的末尾。减一就指向了元素1。a+1则是单纯从首元素+1,在这里就不过多解释了,得出答案,输出4,1。
💙题4
1 | int a[]={1,2,3}; |
判断以上示例的输出。
题解:
1 | int a[]={1,2,3}; |
总结几点做这种题的关键,也是后面几道题需要掌握的核心知识。
sizeof(数组名)和&数组名,两种情况其中的数组名都是代表整个数组,所以sizeof会计算整个数组的大小,&会取出整个数组的地址。
其他情况遇到数组名都是代表数组的首元素。
遇到地址时,一律判断其大小为4/8,具体取决于你的编译器。如x86平台就是4字节。
&a+1会跳过整个数组,因为&a取出的是整个数组。
💜题5
就不再过多浪费篇幅,题目和答案一同给出。
1 | char a[]={'a','b','c','d'}; |
🖤题6
1 | char arr[]="abcdef"; |
💗题7
1 | char *p="abcdef"; |
💖题8
1 | int a[3][3]={0}; |
请牢记,二维数组的名字,相当于是二维数组的第一行的地址,是一个
一维数组的地址
。
💘题9
1 | int a[5]={1,2,3,4,5}; |
题解:&a是取出了数组的整个地址,+1会跳过整个数组。而(a+1)仅代表其从首元素位置前进4个字节,解引用得到2,*(ptr-1)得到5。
💝题10
假设p为结构体变量指针,结构体大小为20个字节,p的值为0x100000
1 | printf("%p\n",p+0x1); |
题解:p自己加1,意味着加一个结构体的大小,所以是0x100014,记得是十六进制哦。
p被强转为unsigned long型,是个整型,整型+1直接加1即可。得到0x100001。
p被强转为指针型,+1就加一个该指针类型的大小,所以+4,得到0x100004。
💕题11
1 | int a[]={1,2,3,4}; |
题解: ptr1跳过数组,指向数组尾部。ptr1[-1]可以转化为*(ptr1-1),也就是指向了4,要注意题目是要求十六进制输出。第二个就有点意思了。a被强转成了(int),也就意味着此时+1也不过移动了一个字节
而已。那么ptr2解引用取到的就是从首元素的第二个字节开始取四个字节
,也就取出了00 00 00 02,
默认环境为小端,还原为02 00 00 00 ,转换为十六进制是多少就不多赘述。
💞题12
1 | int a[3][2]={(0,1),(2,3),(4,5)}; |
题解: 本题存在一个易错点,就在二维数组那里。他并非是一个初始化了三行二列的数组,实际上他只初始化了一排,其中的(0,1)这些是逗号表达式,其值为1,后面的值为3,5,要想完全初始化是这样的:
1 | int a[3][2]={{0,1},{2,3},{4,5}}; |
一定要看仔细了。那么p[0]得到的就是1了。
❣️题13
1 | int a[5][5]; |
题解:这道题稍难一些。a是一个二维数组,上文讲过,a是第一行的数组地址。那么a的类型就是int(*)[5],p的类型是int( * )[4],二者的类型是不匹配的,但是仍然可以实现强转。但是强转之后就要注意了,二者类型是不同的,那就意味着+1的步长是不同
的。这点明白了就很好做题了,也就是,p+1一次跳过4个子节,而a+1一次会跳过五个字节,图就在下方,自行理解。
剩下的也就是%d和%p打印的区别了,%d打印出来就是-4,而当作地址打印时要转换为原码(-4被当作是一个地址),然后换为16进制才行。
<有好题再不定期更新到这来。>