指针闲谈
本文将采用循序渐进的方式,来简要谈一谈C语言中指针的定义和分析。
谈到c语言,就绕不开c语言中的一把利器–指针。
指针可以直接指向物理内存地址,对内存进行操作。在计算机中,我们把内存划分为一个个小的单元,每个单元对应一个编号(或者地址),而指针可以利用地址,直接找到该地址对应的变量值。通俗的理解就是指针像门牌号,我们可以通过门牌号找到对应房间,从而找到房间里的人。
因此,指针也是一个变量,用于存放地址的变量。在《c语言数据存储》一文中已经分析了,物理内存中是以一个字节为一个单元,因此,我们可以把内存想象成一辆在铁轨上的火车,每节车厢就相当于一个内存单元,在车厢内部,我们安装上八张连续的椅子。对每个车厢进行编号,第一节车厢为0X00000000
,第二节车厢为0X00000001
,依次类推,该编号为16
进制,最后一节车厢编号为0XFFFFFFFF
。可以看出一个指针变量所占的空间为8
字节。(以32
位平台为例,下文如果没有特殊指明,均按32
位平台)
1指针类型
1.1如何定义指针变量?
首先,让我们来看一看c语言中是如何定义变量的,例如:
int i = 10;
我们定义了一个变量,其中i
为变量的名称,int
为该变量的类型,10
为该变量的值。可以看出,在等号的左边,我们去掉变量的名称i
,剩下的即为变量的类型。变量的类型决定了该变量所占字节大小。int
类型的变量占用4
个字节。
同样的,让我们来定义一个数组:
int arr[5] = {0,1,2,3,4};
按照上述分析,在等号左边,arr
为该变量的名称,去掉arr
后剩下int [5]
,此即为变量arr
的类型,其中[]
表示变量arr
为一个数组,[5]
里面的5
表示该数组有5
个元素,int
表示这5
个元素都为整型,所以int [5]
类型所占的字节数是
4
×
5
=
20
4×5=20
4×5=20个字节。由于"[]
"里的数字可以任意指定,所以我们称数组为自定义类型数据,与这种定义方式相似的还有结构体,枚举体以及联合体。
如果我们要定义一个指针变量,只需在变量名前加上
∗
*
∗,所以我们可以定义如下指针变量:
int *pi = &i;
同上述分析,在等号的左边,pi
为变量名称,去掉pi
剩下的int *
为该变量的类型,其中*
表示变量为指针,int
表示该指针变量指向的元素是整型。等号右边,"&
"表示取地址的意思,即取到i
所在的车厢号,然后把该号码(地址)放到指针变量pi
中。
同理,对于其他类型的变量(如:char,short,long,long long,float,double
以及它们的无符号(unsigned
)类型)也有上述定义方式:
type *指针变量名 = &与type对应类型的变量;
对于字符指针char *
,除上述用法外,还有另外一种用法,例如:
char *pc = "Hello World";
此段代码不能简单理解为把"Hello World
“放入变量pc
中,而是在内存中有一块连续的内存,存放了字符串”Hello World
",这句代码的意思是把其中字符"H
"的地址放入到指针pc
中。
1.2如何使用地址?(解引用)
按照正常的思维,拿到地址后只需按图索骥通过地址上的车厢号找到对应的车厢,然后对其进行其他操作,所以有
* pi = 0;
这句代码的含义是把指针pi
指向的变量的值改为0
,pi
存放的地址是i
的地址,所以此句代码的含义是把i
的值改为0
。即等价于i = 0
。*
在此处表示的含义为解引用,指针的类型决定了对应指针的权限,如此处pi
为int *
,所以pi
可以访问和操作四个字节的内容,对于char *
类型的指针,则每次只能访问和操作一个字节的内容。
指针类型不仅仅可以决定该指针一次能访问字节的大小,也决定了每次能够跨多大步子,例如,对于一个int *
类型的指针pi
,如果pi
里存放的地址是0X11FF2350
,那么pi+1
对应的地址就为0X11FF2354
,对于一个char *
类型的指针pc
,如果pc
里存放的地址是0X11FF2350
,那么pi+1
对应的地址就为0X11FF2351
,其他类型同理,即指针加上(或减去)一个整数,那么指针对应的地址就移动(该整数)乘(该指针指向类型所占字节数)。
1.3野指针
对于指针,在定义时必须对其进行初始化,即必须给指针一个明确的地址,否则会形成野指针。野指针就是指针指向位置是不可知的(随机的、不正确的、没有明确限制的),未初始化的指针就是随机的。而此时如果对该随机值指向的地址进行访问和操作,就会造成非法访问。正如你在大街上捡到一张地址(随机),而你自己也不清楚该地址里具体有谁,如果强行进入该地址,就会造成非法访问。
因此我们要避免野指针:对指针进行初始化,防止指针越界访问,指针指向的空间释放时把指针及时置NULL
,使用指针之前检查指针的有效性。
2指针与数组
首先,数组名表示首元素的地址(有两个例外,一个是数组名单独放在sizeof()
函数的括号里,一个是放在&
后边。这两种情况都表示整个数组的地址),即
int arr[5]={0,1,2,3,4};
int *p = arr;
此时p
里存放的是数组的第一个元素arr[0]
的地址。而p+1
就表示数组的第二个元素arr[1]
的地址,依次类推。即p+i=&arr[i]
。因此我们可以通过指针来访问数组中的元素,即*(p+i)
就等价于arr[i]
。前文说到,arr
是数组名表示的是首元素的地址,p
也是地址,arr+1
表示的是第二个元素的地址,所以*(arr+1)
与arr[1]
等价,同理,*(p+1)
也与p[1]
等价。因此,下文中,我们不区分*(p+i)
与p[i]
,因为两者是等价的。
如前文所述,当我们想获取某个整型i的地址时,直接&i
,那么,&arr
则取到的是整个数组的地址,对整型的指针变量的定义,我们直接在变量名称前添加*
,即int *pi = &i
,同理,对于数组的指针变量定义,我们也直接在变量名称前添加*
,但是由于[]
的优先级高于*
,即在计算机编译代码时,首先把变量名与[]
结合在一起进行处理(而先与[]
结合,编译器就会认为该变量是个数组),为了防止这种情况出现,我们将*
和变量名用()
括起来,即
int (*parr)[5] = &arr;
用上边的分析:parr
为变量名,parr
前边有*
,所以parr
是一个指针,去掉变量名parr
,剩余的即为该变量的类型:int (*) [5]
,*
表示为指针类型,从(*)
向右看,是[5]
,说明该指针指向的是一个大小为5
的数组,从()
向左看,是int
,说明这个数组里的5
个元素类型都为int
。对于其他类型的数组,分析也同上,例如
char str[3] = {'a', 'b', 'c'};
char(* pstr)[3] = &str;
再来思考另一个问题,对于数组,首元素的地址和整个数组的地址一样,即
int *p = arr;
int (*parr)[5] = &parr;
如果对p
和parr
进行输出,显然两者的值相同,那么两者又有何区别?前边在引入和讨论整型指针变量时,我们提到过,对于不同类型的指针,类型决定了该指针每次能够访问和操作字节数的大小。在这里,p
的类型为int *
,即该指针指向的元素为整型,是4
个字节,parr
的类型为int (*) [5]
,即该指针指向的元素为数组,有
4
×
5
=
20
4×5=20
4×5=20个字节,所以p
每次只能访问和操作4
个字节,而parr
可以访问和操作20
个字节,对指针进行加减整数操作,移动的字节数也不相同,假设该数组的地址为0X1FC65804
,那么p+1
的值就为0X1FC65808
,parr+1
的值为0X1FC65818
(20的十六进制为0X14
)。
对于多维数组,分析同一维数组,以二维数组为例。假设有这样一个两行三列的二维数组:
int arr[2][3] ={{1,2,3},{4,5,6}};
对于多维数组在内存中可以看成按行存放(实际上因为内存是连续的所以实际中内存没有行的概念),即该二维数组可以认为是由两个一维数组组合而成的,其中第一个一维数组为{1,2,3}
(记为a1
),第二个一维数组为{4,5,6}
(记为a2
)。所以二维数组的首元素为arr[0]=a1={1,2,3}
。数组名表示首元素地址即arr
为arr[0]={1,2,3}
的地址,也就是说该地址指向的的是一个存有有三个元素一维数组,所以对应的指针变量为一维数组指针变量,即:
int (*pa1) [3]= arr;//那么pa1+1即为a2的地址。
把arr[0]
看作数组名,那么它表示的是arr[0]
首元素的地址,即{1,2,3}
中1
的地址。同理arr[1]
表示{4,5,6}
中4
的地址。所以*(arr[0])
就和arr[0][0]
等价,*(arr[0]+1)
和arr[0][1]
等价,依次类推。
按照上边的定义方法,该二维数组的指针就可定义为:
int (*parr)[2][3] = &arr;
其含义为:parr
为变量名称,类型为int (*)[2][3]
,*
表示parr
为指针,从(*)
向右看,是[2][3]
,说明了指针指向的是一个两行三列的二维数组,从(*)
向左看,是int
,说明这个二维数组的元素为int
。
3函数指针
3.1函数指针定义
函数指针,顾名思义,是用来存放函数地址的指针。那么函数指针该如何来定义?首先让我们从定义函数看起:例如我们要定义一个算两个整数加法的函数add
,那么我们需要给这个函数传入两个参数,而函数算完加法后,则向我们返回计算结果(整数)。所以:
int add(int x, inty)
{
//实现主要功能的代码,不是本主题的讨论关键,略去不写
}
上述代码中,add
为函数名,add
后边的()
说明add
为一个函数,()
里两个int
变量说明该函数接收两个类型为int
的变量,add
前边的int
说明函数的返回参数类型为int
。
同数组的指针定义方式一样,我们采取同样的方式定义函数指针变量,即在变量前加上*
,同样的由于()
优先级更高,所以我们需要把*
函数指针变量放在一个()
里,即
int (*padd) (int,int) = &add;
上述代码意思:等号左边padd
为一个变量,去掉变量名padd
后剩下int (*) (int,int)
,此即为变量的类型,(*)
说明变量padd
是一个指针,(*)
的右边为(int,int)
说明了该指针指向的是一个函数,这个函数接收两个类型为int
的变量,(*)
的左边是int
,说明该指针指向的函数返回类型为int
。等号右边取函数add
的地址表示指针变量padd
里存放的是函数add
的地址。(由于不同函数功能不同,函数内部定义的变量不同,而函数的作用主要是用来被调用以实现其功能,所以我们不讨论函数指针可以访问和操作的字节数)。
在c语言中,函数名表示函数地址,所以上述代码也可以写为
int (*padd) (int,int) = add;
我们调用函数时一般直接使用函数名即add(x,y)
,我们在使用地址时一般要解引用,即(*padd)(x,y)
。既然函数名表示函数地址,而padd
也为地址,所以也可以写为(*add)(x,y)
,padd(x,y)
。即这几种情况含义相同,都是调用函数add
。因此下文将不区分(*padd)(x,y)
和padd(x,y)
这两种表达方式。
3.2案例
3.2.1案例1
有了上述基础,让我们来看一下如下代码:
(*(void (*)( ))0)( );//来源《c陷阱与缺陷》一书
首先,让我们来梳理一下()
在C语言中的含义:
(1).改变优先级,即在算数运算中,例如一个有加减乘除的式子,先算乘除,再算加减,如果有()则先算()里的,再算其它的。这我们在初等数学中都学过,因此不再赘述。
(2).强制类型转换,一般放在变量或者数据的前边,把变量或数据强制转换为括号里对应的类型。例如(float)3
,含义为把整数3
强制转换为浮点数3
;假设p
为整型指针,(char *)p
即把p
强制转换为字符型指针。
(3).跟在控制语句后,例如if()…
,while()…
,for()…
等。
(4).跟在函数名后,()
里放函数的参数。例如add(x,y)
。
(5).c语言中,有一类表达式叫做逗号表达式,即括号里有一系列以逗号隔开的表达式,运算方式从左到右。例如:
a=1;
b=2;
a=(3,5,b=7,6);
运用逗号表达式的算法,最后a=6,b=7
。
显然代码(*(void (*)( ))0)( )
;中的()
没有跟在控制语句后面,也不是逗号表达式。在上边分析函数指针padd
时提到过去掉padd
后剩下的部分是变量类型,所以void(*)()
是一个函数指针类型,让我们从中间的(*)
开始分析,(*)
说明是个指针类型,从(*)
向它的后边看,紧跟着一个()
,说明这个指针指向的是一个函数,这个函数不需要传参,从(*)
向前看,是void
,说明这个函数返回的参数类型是void
(即没有返回参数)。所以void(*)()
是一个"指向没有参数,返回值为void
的函数指针"类型。它加了一个括号放在0
前面,即(void(*)())0
,含义是把0
强制转换为该类型的函数指针,我们可以把此记为p1
,所以p1
是个函数指针。
在c语言中,*
有如下两种含义:
(1).在定义变量时放在变量前边跟变量结合表示变量为指针;例如:int *p = &i
;
(2).在使用指针变量时放在指针变量前表示解引用。例如:*p=2
。
可以看出,这里我们没有定义变量,而是放在了指针变量(void(*)())0
的前面,即*(void(*)())0
也就是*p1
,前边分析函数指针变量时,提到过对函数指针解引用,相当于调用函数,调用的这个函数没有参数,返回类型为void
,即*p1()
。而由于()
优先级较高,会先与0结合,所以要把*(void(*)())0
括起来即(*(void(*)())0)
以防止0
和后边的()
先结合。
综上所述,因为0
是数字,强制类型转换为指针后就表示地址,所以这句代码的含义为调用0
地址处的函数,这个函数不需要传入参数,这个函数的返回类型是void
。
3.2.2案例2
再来看另一个案例:
void (*signal(int,void(*)(int)))(int); //来源《c陷阱与缺陷》一书
初看代码感觉很复杂,但是可以由内到外逐层分析。让我们再次回忆一下add
函数的定义,我们在定义add
函数时,其格式如下int add(int x,int y)
,其中add
为函数名,(int x,int y)
为给函数传入的参数类型,去掉函数名add
和(int x,int y)
,剩下的int
即为函数add
的返回类型。
同样的,在上边这段代码中,由于()
的优先级更高,所以signal
先与()
结合,表明signal
是个函数,即signal
即为函数名,该函数接收两个参数(int,void(*)(int))
,这两个参数的类型一个是int
,一个是void(*)(int)
(可以看出这是个函数指针类型,其指向的函数接收一个int
类型的参数,返回值为void
,即这个类型是“指向一个接收int
类型返回值为void
的函数指针类型”),去掉函数名signal
和其接收的参数类型(int,void(*)(int))
后,剩下void (*)(int)
,所以signal
函数返回值的类型为void (*)(int)
。所以上述代码是一个函数声明。
4指针数组
4.1整型指针数组
数组指针,指针数组,听起来像是在玩文字游戏,但由前边的介绍,数组指针是用来存放数组地址的指针,函数指针是用来存放函数地址的指针,整型数组是用来存放一组整型的数组,所以指针数组就是用来存放一组指针的数组。可以看出来,谁放在前面,谁就是一个修饰作用。那么,指针数组该如何定义?
让我们来回忆一下整型数组的定义,例如定义一个可以放五个整型变量的数组,我们有如下代码:
int arr[5] = {1,2,3,4,5};
其中arr
是数组名,[5]
表示数组放了五个元素,int
表示这些元素的类型是整型。所以,如果我们要定义一个可以存放三个整型指针的数组,则有:
int* arr1[3] = {&i,&j,&k};
arr1
表示变量名,因为[]
的优先级更高,所以arr1
优先与[]
结合,表明arr1
是个数组,去掉数组名arr1
,剩下的int * [3]
即为arr1
的类型,从arr1
向右看是[3]
,表明这个数组放了3
个元素,向左看是int *
,表明这三个元素的类型都是int *
即整型指针。对于其他数据指针类型定义同理。
4.2数组指针数组
顾名思义,是用来存放多个数组指针的数组,如何定义呢?
让我们重新审视一下整型数组以及整型指针数组的定义。当我们需要定义一个整型变量时:
int i = 2;
当我们需要定义一组整型变量时,即要把一组整型变量放在一起变成数组时,在上述代码的基础上,只需在紧挨着变量名右侧加上[]
,在[]
里放上我们需要的整型个数,即
int arr1[3] = {0, 1, 2};
我们采用了同样的方式定义了整型指针数组:
int *pa = &a;
pa
为一个整型指针,我们定义整型指针数组,只需在紧跟着变量名后边加上[]
和需要的个数,即
int *parr[3] = {&a, &b, &c};
parr[3]
即为一个含有三个整型指针的整型指针数组。
因此,我们可以用同样的方法定义一个数组指针数组。例如:
int arr1[3] = {0, 1, 2};
int arr2[3] = {0, 1, 2};
int arr3[3] = {0, 1, 2};
这是三个元素个数相等的整型数组,它们对应的数组指针类型都相等(数组只能放同类型的数据),即为int (*)[3]
;数组arr1
的数组指针就可以定义为
int (*p1)[3] = &arr1;
我们紧跟着变量名加上[]
即可构造数组指针数组:
int (*parr[3])[3] = {p1, p2, p3};
上述代码的含义:[]
优先级较高,所以parr
先和[3]
结合,说明parr
是个数组,去掉变量名parr
后剩下int (*[3])[3]
,这个即为parr
的类型,与parr
紧挨的[3]
说明数组里有三个元素,去掉parr
和与parr
紧挨的[3]
,剩下int (*)[3]
,这即为parr
里边的元素类型,显然这个元素类型是数组指针类型。
4.3函数指针数组
函数指针数组是用来存放函数指针的一个数组。有了上边的分析,让我们来快速的写出一个函数指针数组(数组是用来存放同一类型的数据,所以存放的指针也要是同一类型)。
现在有四个函数,分别是加减乘除,它们的功能分别是用来计算两个整数的加减乘除,并将计算结果返回(返回值为整型),即:
int add(int x, int y);
int sub(int x, int y);
int mul(int x, int y);
int div(int x, int y);
这四个函数的参数接收类型都为两个int
,返回值为int
。所以它们的函数指针类型相同,都为int (*)(int, int)
,所以add
的指针可以写为
int (*padd)(int, int) = add;
在紧挨着变量名的右侧加上[]即可变为函数指针数组:
int (*pfun[4])(int, int) = {&add, sub, mul, div};//在上边介绍函数指针时提到过取地址函数名和函数名本身都代表函数地址,所以这里写成不同的就是为了再次提醒读者两者等价,实际使用时按一种风格书写即可。
5指向数组指针数组的指针
这句话乍一读很是拗口,让我们来细细分析,首先最后落向了指针,所以这是一个指针,然后这个指针指向的是一个(数组指针数组)。如何定义呢?
前文说过,在定义某数据类型变量对应的指针时,只需在变量名前加上*
即可,所以对于上文介绍的数组指针数组
int (*parr[3])[3] = {&arr1, &arr2, &arr4};
只需在变量名前加上*
即可变成对应类型的指针,即(由于[]
优先级高一点,所以要将*
和变量名括起来提高优先级)
int (*(*pparr)[3])[3] = &parr;
分析:*
与pparr
结合,说明pparr
是一个指针,去掉变量名pparr
,剩下的int (*(*)[3])[3]
即为pparr
的类型从(*)
向右看是[3]
说明pparr
指向了一个含有三个元素的数组,去掉(*)[3]
剩下的int (*)[3]
即为该数组里的数组元素类型(是指针,指向一个有三个元素的数组,元素类型为int
)。
同样,我们可以定义一个指向数组指针数组的指针数组,假设有三个与pparr
同类型的指针pparr1
,pparr2
,pparr3
.那么只需在指向数组指针数组的指针的变量后边加上[3]
即可定义一个指向数组指针数组的指针数组:
int (*(*pparr1[3])[3])[3] = {pparr1,pparr2,pparr3};
…
我们可以按照上述方式,不断的“套娃”下去,但此时已经意义不大,因为实际操作中很难会写出这样的代码,即使写出来,也会很难维护(容易把人绕晕)。
6指向函数指针数组的指针
同指向数组指针数组的方法一样,我们快速的写出
int (*pfun[4])(int, int) = {add, sub, mul, div};
数组pfun[4]
的指针:
int (*(*pfun1)[4])(int, int) = &pfun;
类似的,我们也可以写出指向函数指针数组的指针数组,此处就不在赘述。
7多级指针
聊完了上述让人头大的东西,让我们再来聊一些轻松愉快的东西。我们说,对于一个整型,可以定义一个指针,即
int a = 2;
int *pa = &a;
我们称pa
里存放了a
的地址,那我们也想把pa
的地址存起来,是否可以呢?答案是当然可以,按照我们前述的定义方式,我们在变量前加上*
即可表示一个指针:
int **ppa = &pa;
那么ppa
里存放的就是指针pa
的地址。pa
存放了变量的地址,我们把pa
称作一级指针,ppa
存放了一级指针pa
的地址,我们把ppa
称作二级指针,同理,我们把存放ppa
地址的指针就称为三级指针,以此类推。
我们对ppa
进行解引用,拿到了pa
,然后对pa
再解引用就可以找到a
,即
**ppa=3;
就等价于
a = 3;
由于时间问题,本文到此就结束了,对于结构体指针即其他指针相关的知识,有时间再叙。