C语言-指针操作

0.引入

在C语言中,对于任何类型T,我们都可以在T所在的内存地址处产生一个包含此对象地址的对应变量。如果用比较直观地方式来看待这种变量,它们实际上是一种指向对象的变量,因此,这些变量称为指针。在C语言中,指针的重要性不言而喻,但在很多时候指针又被认为是一把双刃剑。一方面,指针是构建数据结构和操作内存的精确而高效的工具。另一方面,它们又很容易误用,从而产生不可预知的软件bug。了解到这一点之后,就不奇怪为什么C语言程序员喜欢指针,而其他很多人对它却深恶痛绝。无论如何,想要有效地使用C语言,我们必须对指针有透彻的了解。

理解指针的最佳方法:可以采用画图表的方式。


1、指针基础

回想一下,一个指针其实只是一个变量,它存储数据在内存中的地址而不是存储数据本身。也就是说,指针包含内存地址。很多时候,即使是有经验的开发人员都很难形象表达这种不太直观的数据关系,特别是在处理类似于指向其他指针的指针这种更复杂的指针结构时就尤为明显了。因此,用来理解指针的最好方法之一就是绘制图表。指针通常都是按位置用箭头一个一个连接起来,而不是在图表中画出实际的地址。当指针不指向任何数据,也就是说指针被设置成NULL时,用两条竖线来表示。具体图示如下:

指针图示

对于其他类型的任何变量,除非我们显式地指定过,否则我们都不应该假设它指向一个有效的地址。同样需要注意,在C语言中,我们无法改变的一个事实就是指针能够指向一个无效的地址。指向无效地址的指针有时被称为悬空指针。可能产生悬空指针的一些编程错误示例包括:将任意的整型变量强制转换为指针变量;操作超出数组边界指针;释放一个或多个仍被引用的指针。


2、存储空间分配

当在C中声明一个指针时,与声明其他类型的变量类似,一定量的存储空间会被分配给这个指针。通常情况下,指针会占用一个机器字长的存储空间,但有些时候它们的大小也有所不同。因此,为了保证代码的可移植性,不应该假设每个指针都占有一个特定大小的存储空间。指针变量的大小通常与编译器的设定以及某些特定的C实现中的类型界定符有关。必须要记住一点:当声明一个指针时,仅仅只是为指针本身分配了空间,并没有为指针所引用的数据分配空间。而为数据分配存储空间有两种方法:一种是直接声明一个变量;另一种是在运行时动态地分配存储空间(例如:使用malloc或realloc)。

当声明一个变量时,编译器会根据变量的类型预留足够的内存空间。变量的存储空间是系统自动分配的,但此存储空间不会在程序的整个生命周期中永久存在,这一点在处理自动变量时尤为重要。自动变量是一种在进入或离开一个模块或函数时其存储空间能够自动分配和释放的变量。例如:在函数f中,iptr的赋值为变量a的地址,当函数f返回时,iptr变成了一个悬空指针。为什么会这样?因为当函数f返回时,变量a已经从函数栈中弹出,变成了一个不合法的变量。

int f(int **iptr) {
    int a = 30;
    *iptr = &a;

    return 0;
}

在C语言中,当想要动态分配存储空间时,我们会得到一个指向一个堆存储空间的指针。此存储空间由我们自行管理,并且会一直存在,除非我们显式地将它释放。例如:在下面这段代码中,用malloc分配的存储空间会一直有效直到调用函数free来释放它。所以,当函数g返回时,此存储空间仍然有效(见图2),这一点与之前自动分配存储空间的变量完全不同。参数iptr是一个指向我们想要改变其内容的对象的指针(此对象也是一个指针),所以当g返回时,iptr指向由malloc申请的地址空间。

#include <stdlib.h>

int g(int **iptr) {
    if((*iptr = (int *)malloc(sizeof(int))) == NULL)
        return -1;

    return 0;
}

动态分配内存

有些时候,我们甚至会认为指针和动态存储空间分配是C语言领域中不太好的特性。特别是当产生了由动态内存分配所造成的内存泄漏问题时。内存泄漏问题的产生是由于动态分类内存空间,但从未释放它(甚至在程序不再使用此数据空间时都不释放它)造成的。特别是在重复执行代码时,这种泄漏问题会表现得尤为严重。好在我们可以采用统一的内存管理方法来大大减少此类问题。

一种统一的内存管理方法例子就是数据结构实例。每种实例所遵循的理念是,由用户来管理存储空间以及与存储空间相关的实际的数据结构,而数据结构自身只用于维护数据内部变量的存储空间分配。所以在数据结构中,只使用指针所指向数据结构,而不是此数据的私有副本。这种应用的一个重要意义在于,一个数据结构的实现并不依赖于它所存储的数据的类型和大小。同时,多个数据结构能够以单个数据形态表现,这个特性在组织大量数据时非常有用。

此外,初始化和销毁数据结构的操作也很重要。初始化可能会涉及很多步骤,其中之一便是内存分配。销毁数据结构通常包括删除它所有的数据,并释放数据结构所用到的内存。释放数据结构的内存往往也包含释放与数据结构本身相关联的所有内存。这里有一个例外,那就是让用户自己管理数据的存储。之所以每个数据结构在初始化的时候都需要使用由用户提供的初始化函数,是因为数据存储的管理实际上是一种与具体应用相关的操作。


3、数据集合与指针的算术运算

指针在C语言中最常见的用途就是用来引用数据集合。数据集合是由多个相关联的元素构成的数据。C语言支持两种数据集合:结构和数组。(虽然联合与结构类似,但一般它单独被归为一类。)

结构

结构通常是由各种各样的有序的元素组成的,从而它可以被看做单个连续的数据类型。结构指针是构建一个数据结构的重要组成部分。结构使我们能把数据捆绑在一起,指针使我们能够让这些捆绑包在内存中一个一个连接起来。用这些连接起来的结构,我们可以对它们加以组织并用来解决实际的问题。

这里有一个例子,考虑把内存中一些元素组合起来形成一个链表。要做到这一点,我们可能会使用下面的代码中所示的像listElmt一样的结构,用每个元素的next来指向下一个元素,并把最后一个元素的next设定为NULL来表示链表的结尾。同时,每个元素的data指向此元素所包含的数据。一旦生成了这样一个列表,就可以用next指针遍历整个链表。

typedef struct ListElmt_ {
	void             *data;
	struct ListElmt_ *next;
} ListElmt;

结构ListElmt也指出了关于结构指针的另一个重要方面:结构不允许包含自身的实例,但可以包含指向自身实例的指针。这种编程思想非常重要,因为很多数据结构都可能是由它自身的结构变量所组成,例如,在一个链表中,每个ListElmt结构都指向另一个ListElmt结构。有些数据结构甚至会包含多个由自身结构类型组成的结构,例如,在一个二叉树中,每个节点同时指向其他两个二叉树的节点。

数组

数组是在内存中连续排列的同类元素的序列。在C语言中,数组与指针密不可分。事实上,当一个数组标识符在表达式中出现时,C语言显然会把数组转换为一个指向数组第一个元素的固定指针。考虑到这一点,以下两个函数是等价的。

数组固定指针

为了理解C语言中指针与数组的关系,我们做如下解释。我们知道要访问一个数组的第i个元素,用表达式:a[i];

之所以此表达式能够访问a的第i个元素,是因为在C语言中,这个表达式与指向a的第i元素的指针表达意思相同,也就是说,该表达式等同于以下表达式:*(a+i);

此表达式实际上使用指针运算的规则来访问元素。简单来说,当对指针进行加一个整数i操作时,实际得到了一个地址,这个地址由a所在的地址加上数据类型a所包含字节数乘以i得到;而并不是简单地在a所在的地址上加i个字节。当从指针减去一个整数时也是执行类似的操作。这样我们也就解释了为什么数组的索引是从0开始的,因为数组的第一个元素在位置0。

例如:如果一个指针或数组包含5个4字节的整数,并且起始地址为0x10000000,那么a[3]访问的地址为0x1000000c。这个地址是由0x10000000加上3×4=12=0xc得到的。另一方面,当数组或指针引用的是20个字符变量时,a[3]将访问地址0x10000003处的字符。这个地址是由0x10000000加上3×1=3=0x3得到的。当然,通过数组或指针引用一块数据与引用多块数据并没有什么不同,因此,很重要的一点是必须对数组或指针所引用的数据空间大小保持警惕,绝不能越雷池半步。

把一个多维数组转换为指针与把一维数组转换为指针的过程类似。但是同时要知道在C语言中,多维数组其实是以行主序的方式存储的,这也就说明多维数组右边下标变化速度要比左边下标变化来的更快。要访问一个二维数组第i行第j列的元素,用以下表达式:a[i][j];

C语言在表达式中将a当做是指向该数组第1行第1列中元素的指针。整个表达式等价于:*(*(a+i)+j)。


4、作为函数参数的指针

在C语言的函数调用中指针起着至关重要的作用。最重要的是,指针支持将参数作为引用传递给函数(即按引用调用)。按引用传递参数时,当函数改变此参数时,这个被改变参数的值会一直存在,甚至函数退出后都仍然存在。相对而言,当按值调用传递函数参数时,此时值的改变只能持续到函数返回时。无论是否要改变函数的输入输出参数,使用指针传递大容量复杂的函数参数也是十分高效的手段。这种方法高效的原因在于,我们只是传递一个指针而不是一个数据的完整副本到函数中,这样就可以大大地节省内存空间。

按引用调用传递参数

在形式上,C语言只支持按值来传递参数。在按值调用传递参数的过程中,函数参数的一份私有副本将会用到函数的执行体中。然而,我们可以模仿按引用调用传递参数将一个指向参数的指针(而不是参数本身)传递给函数,这样函数调用者就可以得到一个指针的私有副本用于函数体的执行过程。

要了解按引用调用是如何实现的,我们来看看swap1。swap1是一个实现将两个整型变量相互交换的函数,函数参数是通过按值调用传递的,所以得到的结果是错误的。下图给出了为什么交换函数不起作用。

swap1图解swap1

swap2同样是一个交换函数吗,只是它的参数是按引用调用传递的。下图说明如何使用指针来修正swap1中的错误。

swap2图解swap2

关于C语言中按引用调用传递参数,其好的一面是语言本身赋予了我们精确控制参数传递的能力。不好的方面是,这种控制有时候会显得很麻烦,因为我们常常需要在函数中多次解引用按引用调用的参数。

另一个在函数调用时会用到指针的地方,就是把数组传递给函数的时候。回顾之前我们所说的,C语言显然把数组名当做一个不可变的指针来使用,当向函数传递一个类型为T的数组对象时,其实就等同于向函数传递一个指向类型为T的对象的指针。所以,我们可以交替使用这两种方法。例如:函数f1和函数f2是功能相同的。

数组引用        指针引用

具体使用哪种方式来传递参数取决于约定俗成或函数处理参数的方法。当使用一个数组作为参数时,数组的边界信息并不重要,因此此时编译器并不要求数组有边界信息。但是,提供边界信息对于表达出函数内部处理该参数具有一定的局限性是一种很有用的方法。在使用多维数组作为参数的函数中,边界信息显得尤为重要。

当把一个多维数组传递给函数时,除了第一维以外,其他维的长度必须指定,这样函数才能通过指针算术运算访问具体元素,如以下代码所示:

int g(int a[][2]) {
	a[2][0] = 5;
	return 0;
}

为了更清楚地理解为什么必须指定其他维度的大小,设定有一个3行2列的整型二维数组。在C语言中,此二维数组的元素在内存中按照地址的递增一行一行顺序排列。就是说,第1行的两个整数存储在前两个位置,接着是第2行的两个整数,再接着是第3行的两个整数。所以,如果想访问到任意一行的元素(除了第一行)时,我们首先必须确定达到这一行我们需要跳过多少个连续的元素,故此需要知道后面其他维度的大小。

作为参数指向指针的指针

实际中,有很多把指针当做参数传递给函数的地方,这是由于函数想改变传递给它的指针。想做到这一点,向函数传递一个待改变的指向指针的指针。例如:

int list_rem_next(List *list, listElmt *element, void **data);

假设这个函数的功能是从链表中删除一个元素。当此函数返回时,data指向链表中被删除的元素。由于此函数需要改变data使data指向被删除的那个元素,因此必须将指针data的地址传递给函数以模仿按引用传递参数。所以,函数接受一个指向指针的指针作为它的第三个参数。具体图示如下:

改变指向指针的指针


5、泛型指针与类型转换

回想一下,在C语言中指针变量拥有与其他变量一样的类型。之所以指针变量会有类型是因为当我们想获取指针变量的值时,编译器已经知道指针所指向的数据的类型,从而可以访问相应的数据。但是,有些时候我们并不关心指针所指向的变量的类型。在这种情况下,就可以使用泛型指针,泛型指针并不指定具体的数据类型。

泛型指针

通常情况下,C只允许相同类型的指针之间进行转换。例如:一个字符型指针sptr(一个字符串)和一个整型指针iptr,我们不允许把sptr转换为iptr或把iptr转换为sptr。

但是泛型指针能够转换为任何类型的指针,反之亦然。因此,如果有一个泛型指针gptr,就可以把sptr转换为gptr或者把gptr转换为sptr。在C语言中,通常声明一个void指针来表示泛型指针。

很多情况下,void指针都是非常有用的。例如:C标准函数库中的memcpy函数,它将一段数据从内存中的一个地方复制到另一个地方。由于memcpy可能用来赋值任何类型的数据,因此,将它的指针参数设定为void指针是非常合理的。void指针同样也可以用到其他普通的函数中。例如:之前提到的交换函数swap2,可以把函数参数改为void指针,这样swap2就变成一个可以交换任何类型数据的通用交换函数,代码如下:

#include <stdlib.h>
#include <string.h>

int swap2(void *x, void *y, int size) {
	void *tmp;

	if((tmp = malloc(size)) == NULL)
		return -1;

	memcpy(tmp, x, size);
	memcpy(x, y, size);
	memcpy(y, tmp, size);
	free(tmp);

	return 0;
}

void指针在用来实现数据结构时是非常有用的,因为可以通过void指针存储和检索任何类型的数据。我们再来看一下之前提到过的链表结构ListElmt,回想一下,这个结构包含两个成员:data和next。如果data被声明为一个void指针,那么data就可以指向任何类型的数据。从而,我们就可以使用ListElmt结构来建立各种不同数据类型的链表。

假设定义了一个链表的操作函数list_ins_next,它的功能是将一个指向data的指针元素插入链表中:

int list_ins_next(List *list, ListElmt *element, void *data);

要将指针iptr引用的整数插入名为list的整型链表中,element引用的元素后面,使用以下调用。C语言允许将整型指针iptr赋值给参数data,因为data是一个void指针。

retval = list_ins_next(&list, element, iptr);

当然,当从一个链表中删除数据时,必须使用正确的指针类型来检索要删除的数据。这样做是为了保证当我们想要对数据进行操作时数据的类型是正确的。如前所述,从一个链表中删除元素的函数是list_rem_next,它的第三个参数是一个指向void指针的指针:

int list_rem_next(List *list, ListElmt *element, void **data);

想要从list中element引用的元素后面删除一个整型变量,用如下调用方式。当函数返回时,iptr指向已删除的数据。这是由于此操作改变了指针本身,使其指向已删除的数据,因此传递iptr指针的地址:

retval = list_rem_next(&list, element, (void **)&iptr);

同时,此函数调用包含一个将iptr临时转换为指向void指针的指针的过程。正如我们之后要讲到的,类型转换是C语言中一种特殊的转换机制,它允许我们临时把一种类型的变量转换为另一种类型的变量。在这里,类型转换是必须的,因为C语言中虽然一个void指针与其他类型的指针相兼容,但一个指向void指针的指针并不一定与其他类型的指针兼容。

类型转换

要将类型为T的变量t转换成S类型,只需要在t前加上用圆括号括上的S。例如,要将一个整型指针iptr转换为一个浮点型指针fptr,在整型指针前面加上一个用圆括号括起来的浮点指针即可,如下所示:

fptr = (float *)iptr;

(通常来说,将一个整型指针转换为一个浮点型指针是一种危险的做法,但是在这里仅仅用这个例子做一个类型转换的实例而已。)在类型转换之后,iptr与fptr都指向同一块内存地址。但是,从这个地址取到什么类型的值是由我们用什么类型的指针访问它所决定的。

对于泛型指针来说类型转换非常重要,因为只有告诉泛型指针是通过何种类型来访问地址时,泛型指针才能取到正确的值。这是由于泛型指针不会告诉编译器它所指向的是何种类型数据,因此编译器既不知道多少个字节要被访问,也不知道应该如何解析字节。当将泛型指针赋值给其他类型的指针时,使用类型转换也是一种很好的代码自注释方法。尽管这里的转换并不是必需的,但这样做能够大大提高程序的可读性。

当转换指针时,我们对内存中的数据对齐方式必须特别注意。具体来说,我们需要知道,指针的类型转换会破坏计算机本身的对齐方式。很多计算机对对齐方式有要求,以便某些硬件的优化可以使访问内存更有效率。例如,一个系统可能要求所有整数按字边界对齐。所以,如果有一个非按字对齐的void指针,当将它转换为一个整型指针并试图获取它的值时,程序可能在运行时出现异常。


6、函数指针

函数指针是指向可执行代码段或调用可执行代码段的信息块的指针,而不是指向某种数据的指针。函数指针将函数当做普通数据那样存储和管理。函数指针有一种固定的形式,就是包含一个确定的返回值类型和若干个函数参数。声明一个函数指针看起来与声明一个函数非常类似,只是在函数名之前有一个表示指针的星号(*),并且函数名和星号会用圆括号括起来。例如下面这段代码中,match被声明为一个函数指针,它接受两个void指针类型的参数,同时返回一个整型:

int (*match)(void *key1, void *key2);

以上函数声明的意思是,我们指定一个函数指针,它接受两个void指针,返回一个整型数,命名为match。例如:假设有一个match_int函数,它的两个void指针参数指向整型并返回1。考虑到之前的函数声明match,可以这样赋值:

match = match_int;

要执行一个由函数指针所引用的函数,只需要在正常调用普通函数的地方调用函数指针。例如:想要调用之前提到的函数指针match,执行下面的语句,假设x,y和retval都已经声明为整型:

retval = match(&x, &y);

常见的函数指针的重要用途:将函数封装到数据结构中。例如:在实现链式哈希表时,这个哈希表数据结构就包含一个成员,类似以上所提到的名为match的函数指针。此指针的作用是,当任何时候我们需要判断正在查找的元素是否匹配表中的元素时,都可以调用一个函数来完成。当哈希表初始化时,把某个函数赋给这个指针。赋给指针的这个函数与match有相同的原型,不同之处是,在内部进行两个元素的比较时,函数会根据哈希表中的数据类型进行具体类型的数据比较。使用指针把函数另存为数据结构的一部分是C语言一种非常好的特性,因为它可以使数据结构或函数变得更具通用性。


要明白什么是指针,必须先要弄清楚数据在内存中是如何存储的,又是如何被读取的。 如果在程序中定义了一个变量,在对程序进行编译时,系统就会为这个变量分配内存单元。编译系统根据程序中定义的变量类型分配一定长度的空间。内存的基本单元是字节,一字节有8位。每字节都有一个编号,这个编号就是“地址”,它相当于旅馆的房间号。在地址所标示的内存单元中存放的数据,就相当于在该旅馆房间中居住的旅客。 大家一定要弄清楚“内存单元的地址”和“内存单元的内容”这两个概念的区别,即“房间号”和“房间内所住客人”的区别。在程序中一般是通过变量名来对内存单元进行存取操作的。其实程序经过编译以后已经将变量名转换为变量的地址,对变量值的存取都是通过地址进行的。这种按变量地址存取变量的方式称为直接访问方式。 还有一种间接访问的方式,即变量中存放的是另一个变量的地址。也就是说,变量中存放的不是数据,而是数据的地址。就跟寻宝一样,可能你按藏宝图千辛万苦找到的宝藏不是金银珠宝,而是另一张藏宝图。按C语言的规定,可以在程序中定义整型变量、实型变量、字符型变量,也可以定义这样一种特殊的变量,它是存放地址的。 由于通过地址能找到所需的变量单元,所以可以说,地址“指向”该变量单元。如同一个房间号指向某一个房间一样,只要告诉房间号就能找到房间的位置。因此在C语言中,将地址形象地称为“指针”,意思就是通过它能找到以它为地址的内存单元。 所以,一个变量的地址就称为该变量的指针指针就是地址,而地址就是内存单元的编号。它是一个从零开始的、操作受限的非负整数。
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值