指针庶事

有人说指针是C语言的精髓,但精髓可不是容易掌握的,特别是指针与数组、字符串、结构体和数据结构等的结合,往往会使许多新手摸不着头脑,这篇文章我将把我所掌握的一些基本知识好好地梳理一遍,也算是“温故而知新”吧!

指针变量的定义和引用

先从一段简单的代码开始:

上面的代码中第4行我们定义了a和b两个整型变量,并分别赋值为1和2;第5行我们定义了一个指向int型数据的指针变量pa,并将a的地址赋值给它;第6行定义了一个指向int型数据的指针变量pb,但没有给它赋任何的值,而是在第7行才将b的地址赋值给它;接下来的两行我们分别输出a的地址和pa的值以及b的地址和pb的值。运行的结果如下:

20160403

我们发现a的地址和pa的值是相等的,b的地址和pb的值也是相等的,这表明了许多道理,首先pa(pb)正确存储了a(b)的地址,像这样专门用来存放其他变量的地址(指针)的变量被称为指针变量,需要注意的是,指针和指针变量是两个完全不同的概念,指针是一个地址,一个变量的地址称为该变量的指针,而指针变量是存放地址(指针)的变量。我们用变量名前的符号“*”表示该变量是个指针变量,其实从语义上讲,修饰符*靠近数据类型会更直观,但对多个变量声明时容易引起误解,例如:

int* x, y;

上面的代码中定义了int*型变量x和int型变量y,但由于修饰符*靠近int,因此会让人误以为y的数据类型也是int*型的,这是不对的。基于上面示例代码造成的误解,人们提倡修饰符*靠近变量名,例如:

int *x, y;

上面的代码能使人们一眼看出,变量x是int*型的,而y是int型的,不会造成误解。

为什么需要指针变量?既然已经取得了变量的地址,而地址本身就是一个数据,为什么不能用int这样的数据类型去存储它呢?例如:

这样看似可以,实则不然,因为这取决于编译器以哪种架构去编译,倘若以32位架构编译,那么变量的地址必然是32位,倘若以64位的架构去编译,那么变量的地址必然是64位,而不论以哪种架构编译,int型变量所能存储的数据位数都为32位,所以这就会造成变量不能准确的存储所取得的变量地址,因此我们需要一种新的数据类型,即指针类型,如int*。

其次我们发现,文章开始时的代码中定义指针变量的两种方式是相同的效果,第一种是定义指针变量的同时对其赋值,第二种是先定义指针变量,再对其赋值,后面我们会讲到其实上面代码中的第二种方式是有缺点的,需要注意的是,当用“&”符号输出取址运算的结果时,需要使用“%p”作为格式输出符。同时在定义指针变量时,有以下几点需要注意:

  1. 指针变量是变量,它也占据一块内存空间。在32位程序里,因为内存地址长度都为32位,所以所有类型的指针都是一个32位的二进制数,“指针所指向的内存区域”就是从指针所代表的那个内存地址开始,长度为sizeof(指针基类型)的内存区域。
  2. 在为指针变量赋值时,千万不能写成“*pb=&b;”,这是因为变量b的地址是赋给指针变量pb本身的,而不是赋给“*pb”的。
  3. 定义指针变量时必须指定基类型。一个变量有两个属性:变量的地址和变量的存储形式,如果一个变量只有地址,编译器无法知道变量如何存储在内存中,也就无法获取该变量的值。相应的,指针变量若只知道其指向的变量的地址,也无法知道该变量是如何存储在内存中,更无法获取该变量的值,这就是我们后面将要说到的无类型指针。
  4. 变量的指针的含义包含两个方面,一是内存地址,二是该指针指向的变量的数据类型。相应的,在说明指针变量时不应说“p是一个指针变量”,而应完整的说“p是一个指向整型数据的指针变量”。
  5. 如何表示指针类型。指向整型数据的指针类型表示为“int*”,读作“指向int型数据的指针”或简称“int指针”。可以有int*、char*、float*等指针类型,而这是三种不同的数据类型,不能混淆。
  6. 指针变量中只能存放地址(指针),不要随便将一个整数赋给指针变量。例如:int *p = 100;除非你知道整数100是哪个变量的地址,否则不要将100赋给指针变量p。

前面讲过的指针都有一定的数据类型,如int型指针、char型指针等,有一类指针被void*修饰,称为无类型指针。这类指针指向一块内存,却没有告诉应用程序按什么类型去解读这块内存,所以无类型指针不能直接进行数据的存取操作,必须先转换成其他类型的指针才能将内容解读出来,如以下示例代码所示:

上述代码中,将无类型指针变量p转换成int类型指针变量后,就可以将内存中的数据以int类型解读出来。

还有一种指针叫做空指针,是指针没有指向任何内存单元。构造空指针有两种方法:将指针赋值为0或赋值为NULL。

一般在编程时,定义一个指针都要将指针初始化为NULL:

讲到这里时,你可能已经发现了我在前面提到的那段代码中第二种定义方式的缺点:没有初始化为NULL!也许你会问,反正迟早都要为指针p赋值的,为什么一定先要初始化为NULL呢?这是因为万一如果你忘记了为指针p赋值,这时它就可能会变成一个野指针。当一个指针指向不可用的区域时,它便被称为野指针。对野指针进行操作通常会发生不可预知的错误,野指针的形成原因有两种:

  1. 指针变量没有被初始化。定义一个指针,若没有初始化,就无法预知它会指到哪个地方,如果指到被操作系统使用的内存,则操作它时可能会造成系统崩溃。所以在定义指针时要初始化指针为NULL或让指针指向合法的内存。
  2. 指针与内存使用完毕之后,调用相应函数将内存释放,指针却没有被置为NULL。此时指针就变为野指针,指向“垃圾内存”。

在编程时,指针是否为NULL可以进行检测,如if(p == NULL),但是野指针是无法进行检测的,所以要避免野指针出现。还有一个小点,在开始时的那段代码中,我定义指针是用p开头的变量名,通常都是这样的,毕竟p是pointer的首字母。定义完指针变量后就要进行指针变量的引用了,所谓引用指针变量指向的变量,就是根据指针变量中存放的地址,访问该地址对应的变量。访问指针变量指向变量的方式非常简单,只需要在指针变量前加一个“*”(取值运算符)即可。其实到现在为止,我们已经掌握了两种访问变量的方式,即直接访问和间接访问:

直接访问就是直接通过变量名获取该变量的值,例如下面的代码:

间接访问就是先获得变量的地址,然后访问该地址指向的内存空间,最后获得该内存空间存放的值,例如下面的代码:

间接访问虽然比较麻烦,但在某些场合却非常有用,下面举两个例子来说明间接访问的用处:

  1. 用户申请一块内存空间时,由于该内存空间没有任何变量名,所以无法进行操作,此时就可以通过间接访问的方式来操作该内存空间。
  2. 被调函数想要改变主调函数的变量的值时,由于实参向形参传值是单向传递,所以被调函数直接改变形参的值是无法影响到主调函数的,此时主调函数可以向被调函数传入指针,被调函数通过间接访问指针指向的内存空间来改变主调函数的变量的值。

你可能对这两个例子还不太明白,我会在后面介绍完指针与数组、指针与函数后,再回过头来仔细的分析这两个例子。

本地变量与全局变量

在接下来的内容之前,不妨先停下来,仔细回顾第一个程序的运行结果,我们发现变量a的地址与变量b的地址相差4,即它们在同一块内存空间存储。事实上,C语言中,本地变量存储在栈中,而栈又是由高地址空间向低地址空间增长的,所以先定义的变量a的首地址为9FFE3C,后定义的变量b的首地址为9FFE38。进一步的,看下一个程序:

我们定义了一个全局变量a,两个本地变量b、c,和一个静态本地变量d,最后我们分别输出a,b,c,d的地址及值。结果如下:

可以看出,全局变量与本地变量存在不同的内存空间,事实上,C语言中,全局变量存储在静态区中;另外,没有做初始化的全局变量会得到0值,指针会得到NULL值,而没有做初始化的本地变量则会得到一个随机值。全局变量具有全局的生存期和作用域,而本地变量的生存期与作用域相同,都在函数内部。如果在函数内部存在与全局变量同名的变量,则全局变量被隐藏。最后,我们发现,所谓的静态本地变量实际上是特殊的全局变量,因为它们位于相同的内存区域,并且没有做初始化的静态本地变量也会得到0值,事实上,静态本地变量具有全局的生存期,函数内的局部作用域。在编程中,应尽量避免使用全局变量,因为使用全局变量和静态本地变量的函数是线程不安全的,同时不要使用全局变量来在函数间传递参数和结果。

指针与数组

前面都只是说了一些指针的基础,接下来将指针与数组综合起来进行理解,同样先从一段简单的代码开始:

上面这段代码第4行定义了int型的变量a和数组arr,并向数组arr赋予了初值;第5行定义了一个int型的数组指针p并将arr数组的首地址赋给它;第6-8行分别输出指针p的值、数组arr的首地址、数组arr中第一个元素的地址;第9-13行循环遍历输出数组arr中的每个值

运行结果如下图所示:

20160330

观察运行结果我们可以发现,数组名默认指向数组在内存中的首地址,即第一个元素的地址,因此arr的值与&arr[0]的值相等,所以我们在为p指针赋值时以及在输出arr的值时,都没有加&取址运算符。在遍历输出数组中的值时,我们用的是移动指针的方法,同样我们也可以用下标法进行输出,即像下面这样:

上面我们看到的指针p被称为数组指针,因为它是一个指向数组的指针,接下来我们将要遇到指针数组,当一个数组的元素均为指针类型数据时,该数组称为指针数组,也就是说,指针数组中的每一个元素都存放一个地址,该数组元素相当于一个指针变量,例如:int *p[5],表明一个长度为5的指针数组p,且数组元素指向的变量的数据类型是int型,需要注意的是,数组指针和指针数组不是同一个概念。

现在我们已经知道了数组变量其实就是指针,那么为什么数组变量之间不能互相赋值呢,即像这样操作:int b[] = arr;编译会报错:[Error] invalid initializer(无效的初始化),而int *p = arr;却可以正常编译,其实数组变量是一种const的指针,即int b[]实际上就等于int *const b,所以数组变量之间不能互相赋值。说到这里就会自然而然的引入指针与const的相关知识,指针与const的相关组合可分为3种,分别是指针常量、常量指针和指向常量的常指针,先来看指针常量:指针常量其实就是一个常量,该指针存放的地址不能被改变,如下面代码所示:

常量指针的作用是使当前指针所指向变量的值在程序运行时不能被更改,如下面代码所示:

常量指针还有另一种写法,即int const *p;其实判断哪个被const了的标志是const在*的前面还是后面。最后一种情况就是指针所指向的地址不能被改变,且所指向地址中的值也不能被改变,我们称其为指向常量的常指针,如下面代码所示:

在上一篇文章《浅析“HelloWorld”的结构》中,我们说了只有int main(void){……}和int main(int argc, char *argv[]){……}这两种main函数的定义方式是正确的,但是其实我们一直使用的main()函数都是无参的,这次让我们见识一下有参的main()函数。main()函数是程序的入口,通常用来接收来自系统的参数,它有两个参数,argc参数表示在命令行中输入的参数个数,argv参数是字符串指针数组,其各元素值为命令行中各字符串的首地址,数组第一个元素指向当前运行程序文件名的字符串,指针数组的长度即为参数个数,数组元素初值由系统自动赋予。我们来看一段main()函数外部传参的简单代码:

我们创建了一个show_args.c的源文件,编译之后,在该程序的根目录中会生成可执行文件show_args.exe,然后在其目录下打开命令行窗口,输入命令“show_args.exe arg1 arg2 arg3”,则形参argc被赋值为4,形参argv指向长度为4的指针数组,该指针数组存入了指向4个字符串的指针,这4个字符串分别是“show_args.exe”“arg1”“arg2”“arg3”,运行结果如下所示:

20160402

指针与函数

关于指针与函数的部分,首先我们考虑的是指针作为函数参数的用法,其中最典型的用法就是前面提到的间接访问的第二个例子,我们来看下面这段代码:

上面这段代码的作用很简单,就是交换输入的两个数的值并输出,在代码的第3-8行,我们定义了一个swap()函数,它就是交换的主要程序,我们在后面的第14行调用了该函数,现在我们来细看这个swap()函数,它的形参为两个int型指针变量,在调用的时候我们将a和b的地址作为实参传进swap()函数,在swap()函数内部,我们将a地址所存放的值赋给t,再将b地址所存放的值赋给a地址,最后将t的值赋给b地址,这样就完成了a和b两个数的交换,这就是指针作为函数参数的典型应用,即函数要返回多个值,某些值就只能通过指针返回,传入的参数实际上是需要保存带回的结果的变量,这个时候我们仍然坚持完成的是值的传递,只不过这里的值是地址而已。运行结果如下所示:

2016040302

类似于指针作为函数参数,数组指针也可以作为函数参数使用,我们来看下面这段代码:

在第3-6行我们定义了一个形参类型为int[5]型的函数,第10行我们声明了一个长度为5的整型数组,第12行我们调用函数并将数组作为实参传入,观察如下的运行结果我们可以发现,实参arr和形参a的值是相同的,说明将数组作为函数参数其实是将数组的首地址作为函数参数,并且实参向形参传递的是个指针;另外sizeof(arr)的值是20,而sizeof(a)的值是4,说明变量arr是一个长度为5的整形数组,而变量a是一个指针变量,即func()函数的形参a的类型是int*型。由此可以得出结论:函数声明语句“void func(int a[5])”与语句“void func(int *a)”是等价的,将数组作为函数参数其实就是将数组指针变量作为函数参数。需要注意的是,将数组指针作为函数参数时,由于无法获取数组的长度,所以应根据需求传入数组的长度。

20160404

当一个函数的返回值是一个指针(地址)时,这种返回指针的函数称为指针函数,例如:

上面的代码定义了func()函数,该函数是一个返回指针的指针函数,它返回的指针指向一个整型变量。在指针函数中,我们要注意,返回本地变量的地址是危险的。因为当函数退出时,本地变量的地址不再受控,如下代码:

虽然这段程序执行时会报warning,但仍然可以执行。

相应的,返回全局变量或静态本地变量的地址是安全的,返回在函数内malloc的内存是安全的,但是容易造成问题,最好的做法是返回传入的指针,关于这一点,下文会有详细介绍。

我们知道了数组中的元素在内存中是连续的,且数组名代表数组首元素的地址,那么类似的,如果在程序中定义了一个函数,在编译时,编译器为函数代码分配了一段存储空间,这段存储空间的起始地址(又称入口地址)称为这个函数的指针。可以定义一个指向函数的指针变量,用来存放某一函数的起始地址,这就意味着此指针变量指向该函数。分析下面这段简单的代码:

我们在第3-6行定义了一个func()函数,它的功能很简单,就是实现传进的两个数的相加并返回出去结果;第11行我们定义了一个函数指针变量p,该指针变量只能指向返回值类型为int型且有两个int型参数的函数,需要注意的是,由于优先级的关系,“*变量名”要用圆括号括起来;第12行将指针变量p指向func()函数,在程序中把哪一个函数(该函数的返回值是int型且有两个int型参数)的地址赋给该指针变量,它就指向哪一个函数;第13行我们通过函数指针调用函数,方法与使用函数名调用类似,唯一的不同之处就是将函数名替换成“*指针变量名”;最后打印相加的结果,如下:

201604021

显然是符合我们的预期结果的,用函数名调用函数,只能调用指定的一个函数,而通过函数指针变量调用函数比较灵活,可以根据不同情况先后调用不同的函数。

指针与结构

先从一段程序说起:

上面的程序中,我们声明了一个结构类型struct point,之后定义了一个struct point结构类型的变量a,紧接着我们为a中的成员xy赋予输入值,最后打印出结构变量a的成员。在这里我们发现了一个问题,即没有直接的方式可以一次scanf一个结构,我们必须不断地为结构中的每个成员赋值,这显得有些繁琐,倘若我们打算写一个函数来读入结构呢?不妨就定义为getStruct(),如下:

getStruct()函数负责读入结构,output()函数负责输出结构,但是上面的程序是达不到我们要求的,因为读入的结构送不回来,C在函数调用时是传值的,所以函数中的bmain中的a是不同的,在函数读入了b的数值之后,没有任何东西回到main,所以a还是{0, 0},从这里可以看出,传入结构和传入数组是不同的。解决这个问题的一种办法是:我们可以在读入函数中创建一个临时的结构变量,然后把这个结构返回给调用者,如下:

虽然不失为一种解决办法,但这种办法效率并不高,在《TCPL》中,明确的建议使用结构指针作为函数的参数,即把a的地址传入函数,不过那样的话,访问结构的成员的方式需要做出调整,具体而言,有两种方式:

因为.运算符的优先级大于*运算符,所以要加括号,为了简化输入,C语言提供了->运算符,它表示指针所指的结构变量中的成员。这样之后,便可以更新getStruct()函数:

这样做的好处是传入传出只是一个指针的大小,同样的,如果需要保护传入的结构不被函数修改,可以使用const struct point *p,最后,返回传入的指针是一种套路,这样做的好处是将来可以把它串在其他函数中,就好像17行所做的一样。

内存申请、操作与回收

现在我们来看一下前面提到的间接访问的第一个例子:关于内存空间的申请,C语言提供了malloc()calloc()realloc()这3个函数来向内存申请空间,malloc()函数用于向内存申请指定字节的空间,它将返回一个任意类型的指针,该指针指向申请的内存空间的首地址。看下面的示例代码:

因为要使用内存申请函数,所以要包含头文件“stdlib.h”,要使用内存操作函数,所以要包含头文件“string.h”,在第8行我们定义了一个指向int型的指针arr,用它来指向申请的内存空间的首地址,由于我们用malloc()函数申请的空间是void*型的,所以在第9行我们要将其强制转换为int*型。这其实就是指针在内存空间申请时的作用。上面代码的运行结果如下所示:

20160405

calloc()函数的功能和malloc()函数类似,一个显著地不同是,calloc()函数得到的内存空间是经过初始化的,其数据全为0,而malloc()函数得到的内存空间未经过初始化操作,该内存空间中存放的数据未知。我们可以将上面代码中第10-14行注释掉,即取消内存空间的初始化和数组元素的赋值操作,直接输出内存空间中存放的原始数据,运行结果如下:

2016040502

显然,我们可以看出malloc()函数申请的内存空间中的数据是未知的,倘若我们在上面修改的基础上,再将第9行的malloc()函数申请改为calloc()函数申请,即arr = (int*)calloc(arr_len, sizeof(int));这里我们要注意,calloc()函数需要两个参数,第一个参数为申请的单位空间的数量,第二个参数为单位空间所占字节数,然后再运行,结果如下:

2016040503

显然,我们可以看出calloc()函数申请的内存空间中的数据是经过初始化的,其数据全为0。因此calloc()函数更适合为数组申请空间,可以将第一个参数设置为数组的容量,第二个参数设置为数组元素的空间长度。

realloc()函数的功能比malloc()函数和calloc()函数的功能更为丰富,可以实现内存分配和内存释放的功能,其函数声明如下所示:

void *realloc(void *memory, unsigned int newSize);

在上述函数声明中,参数memory为指向堆内存空间的指针,即由malloc()函数、calloc()函数或realloc()函数分配的内存空间的指针。参数newSize为新的内存空间的大小。realloc()函数的功能是将指针memory指向的内存块的大小改变为newSize字节。如果newSize小于或等于memory之前指向的空间大小,那么将会造成数据丢失;如果newSize大于原来memory之前指向的空间大小,那么系统将试图从原来内存空间的后面直接扩大内存至newSize,若能满足需求,则内存空间地址不变,如果不满足,则系统重新为memory分配一块大小为newSize的内存空间,同时将原来内存空间的内容依次复制到新的内存空间上,原来的内存空间被释放。需要注意的是,realloc()函数分配的空间也是未初始化的。下面我们来看一段使用realloc()函数扩展一块内存空间的代码:

正如我们所预料的,结果如下:

2016040504

为方便记忆,可将malloc记作memory allocation(内存分配)、calloc记作clear allocation(明确分配)、realloc记作reset allocation(重置分配)。需要注意的是,使用malloc()函数、calloc()函数和realloc()函数这些动态存储分配函数动态开辟的内存空间都要使用free()函数来释放,倘若我们在使用完毕后未释放,结果会导致一直占据该内存单元,直到程序结束(即该内存空间使用完毕之后未回收),这就是所谓的“内存泄漏”,也称作“存储渗漏”。从用户使用程序的角度来看,内存泄漏本身不会产生什么危害,作为一般的用户,根本感觉不到内存泄漏的存在。真正有危害的是内存泄漏的堆积,导致的最终结果是程序运行时间越长,占用存储空间越多,最终用尽全部存储空间,整个系统崩溃。(PS:曾经Android 5.0就出现过内存泄漏的BUG,以致于用户在使用一段时间后必须要重启才能暂时解决问题。cheeky)



发表评论

电子邮件地址不会被公开。 必填项已用*标注