《深入理解C指针》试读:1.1  指针和内存

C 程序员新手和老手的一大差别就在于是否对指针有深刻理解,能否高效利用指针。指针在C 语言中随处可见,也提供了极大的灵活性。指针为动态内存分配提供了重要支持,与数组表示法紧密相关,指向函数的指针也为程序中的流控制提供了更多的选择。 一直以来,指针都是学习C 语言的最大障碍。指针的基本概念很简单,就是一个存放内存地址的变量。然而,当我们开始应用指针操作符并试图看懂那些令人眼花缭乱的符号时,指针就开始变得复杂了。但情况并非总是如此,如果我们从简单的知识入手,打好扎实的基础,那么掌握指针的高级应用并不难。 理解指针的关键在于理解C 程序如何管理内存。归根结底,指针包含的就是内存地址。不理解组织和管理内存的方式,就很难理解指针的工作方式。为此,只要对解释指针的原理有帮助,我们就会说明内存的组织方式。牢牢掌握了内存及其组织方式,理解指针就会容易很多。 本章简要介绍指针、指针操作符以及指针如何与内存相互作用。1.1 节研究如何声明指针、基本的指针操作符和null 的概念。C 支持好几种不同类型的null,所以仔细研究null 会对我们有所启发。 1.2 节将细致地介绍几种不同的内存模型。毫无疑问,我们在使用C 的过程中肯定会遇到各种内存模型。特定编译器和操作系统下的内存模型会影响指针的使用方式。我们也将仔细研究跟指针和内存模型有关的几种预定义类型。 1.3 节会深入探讨指针操作符,包括指针的算术运算和比较。1.4 节探究常量和指针。众多的声明组合提供了有趣通常也很有用的方法。 无论你是C 程序员新手还是老手,本书都能帮助你深入理解指针,填补你知识结构中的空白。老手可以挑选感兴趣的主题,新手还是按部就班为好。 1.1  指针和内存 C 程序在编译后,会以三种形式使用内存。 • 静态/全局内存 静态声明的变量分配在这里,全局变量也使用这部分内存。这些变量在程序开始运行时分配,直到程序终止才消失。所有函数都能访问全局变量,静态变量的作用域则局限在定义它们的函数内部。 • 自动内存 这些变量在函数内部声明,并且在函数被调用时才创建。它们的作用域局限于函数内部,而且生命周期限制在函数的执行时间内。 • 动态内存 内存分配在堆上,可以根据需要释放,而且直到释放才消失。指针引用分配的内存,作用域局限于引用内存的指针,这是第2 章的重点。 表1-1 总结了这些内存区域中用到的变量的作用域和生命周期。 理解这些内存类型可以更好地理解指针。大部分指针用来操作内存中的数据,因此理解内存的分区和组织方式有助于我们弄清楚指针如何操作内存。 指针变量包含内存中别的变量、对象或函数的地址。对象就是内存分配函数(比如malloc)分配的内存。指针通常根据所指的数据类型来声明。对象可以是任何C 数据类型,如整数、字符、字符串或结构体。然而,指针本身并没有包含所引用数据的类型信息,指针只包含地址。 1.1.1  为什么要精通指针 指针有几种用途,包括: • 写出快速高效的代码; • 为解决很多类问题提供方便的途径; • 支持动态内存分配; • 使表达式变得紧凑和简洁; • 提供用指针传递数据结构的能力而不会带来庞大的开销; • 保护作为参数传递给函数的数据。 用指针可以写出快速高效的代码是因为指针更接近硬件。也就是说,编译器可以更容易地把操作翻译成机器码。指针附带的开销一般不像别的操作符那样大。 很多数据结构用指针更容易实现,比如链表可以用数组实现,也可以用指针实现。然而,指针更容易使用,也能直接映射到下一个或上一个链接。用数组实现需要用到数组下标,不直观,也没有指针灵活。 图1-1 比较形象地展示了用数组和指针实现员工链表时的情形。图中左边用了数组, head 变量表明链表的第一个元素在数组下标10 的位置,每一个数组元素都包含表示员工的数据结构。结构的next 字段存放下一个员工在数组中的下标。灰底的元素表示未使用。 图1-1:链表的数组形式和指针形式 右边显示了用指针实现的等价形式。head 变量存放指向第一个员工节点的指针。每个节点存放员工数据和指向链表中下一个节点的指针。 指针形式不仅更清晰,也更灵活。通常创建数组时需要知道数组的长度,这样就会限制链表所能容纳的元素数量。使用指针没有这个限制,因为新节点可以根据需要动态分配。 C 的动态内存分配实际上就是通过使用指针实现的。malloc 和free 函数分别用来分配和释放动态内存。动态内存分配可以实现变长数组和数据结构(如链表和队列)。不过,新的C11 标准也支持变长数组了。 紧凑的表达式有很强的表达能力,但也比较晦涩,因为很多程序员并不能完全理解指针表示法。紧凑的表达式应该用来满足特定的需要,而不是为了晦涩而晦涩。比如说,下面的代码用了两个不同的printf 函数调用来打印names 的第二个元素的第三个字符。如果对指针的这种用法感到困惑,不用担心,我们会在1.1.6 节中详细介绍解引(dereference)的工作原理。尽管两种方式都会显示字母n,但是数组表示法更简单。 char *names[] = {"Miller","Jones","Anderson"}; printf("%c\n",*(*(names+1)+2)); printf("%c\n",names[1][2]); 指针是创建和加强应用的强大工具,不利之处则是使用指针过程中可能会发生很多问题,比如: • 访问数组和其他数据结构时越界; • 自动变量消失后被引用; • 堆上分配的内存释放后被引用; • 内存分配之前解引指针。 我们会在第7 章深入研究这几类问题。 指针的语法和语义在C 规范(http://www.open-std.org/jtc1/sc22/WG14/www/docs/ n1256.pdf)中讲得很清楚了,但还是有一些情况下规范没有明确定义指针的行为。这类情况下,指针的行为定义为如下之一。 • 实现定义 有具体的实现,并且有文档描述。实现定义行为的一个例子就是当整数做右移操作时如何补充符号位。 • 未确定 有某种实现,但是没有文档描述。未确定行为的一个例子是当malloc 函数的参数为0 时所分配的内存大小。在CERT Secure Coding Appendix DD 有一个未确定行为的列表( 参见https://www.securecoding.cert.org/confluence/display/ seccode/DD.+Unspecified+Behavior)。 • 未定义 没有规定,任何事情都有可能发生。这种行为的一个例子是被free 函数释放的指针的值。CERT Secure Coding Appendix CC 有一个未定义行为的列表(参见https:// www.securecoding. cert.org/confluence/display/seccode/CC.+Undefined+Behavior)。 有时候还会有语言环境相关的行为,这些行为一般由编译器厂商的文档说明。提供语言环境相关的行为能够为编译器作者生成更高效的代码提供更多空间。 1.1.2  声明指针 通过在数据类型后面跟星号,再加上指针变量的名字可以声明指针。下面的例子声明了一个整数和一个整数指针: int num; int *pi; 星号两边的空白符无关紧要,下面的声明都是等价的: int* pi; int * pi; int *pi; int*pi; 空白符的使用是个人喜好。 星号将变量声明为指针。这是一个重载过的符号,因为它也用在乘法和解引指针上。 对于以上声明,图1-2 说明了典型的内存分配是什么样的。三个方框表示三个内存单元,每个方框左边的数字是地址,地址旁边的名字是持有这个地址的变量,这里的地址100 只是为了说明原理。就这个问题来说,指针或者其他变量的实际地址通常是未知的,而且在大部分的应用程序中,这个值也没什么用。三个点表示未初始化的内存。 图1-2:内存图解 指向未初始化的内存的指针可能会产生问题。如果将这种指针解引,指针的内容可能并不是一个合法的地址,就算是合法地址,那个地址也可能没有包含合法的数据。程序没有权限访问不合法地址,否则在大部分平台上会造成程序终止,这是很严重的,会造成一系列问题,第7 章将讨论这些问题。 变量num 和pi 分别位于地址100 和104。假设这两个变量都占据4 字节空间,就像1.2 节中所说,实际的长度取决于系统配置。除非特别指明,我们所有的例子都使用4 字节长的整数。 本书用100 这样的地址来解释指针如何工作,这样会简化例子。当你运行示例代码时会得到不同的地址,而且这些地址甚至在同一个程序运行几次的时候都可能变化。 记住这几点: • pi 的内容最终应该赋值为一个整数变量的地址; • 这些变量没有被初始化,所以包含的是垃圾数据; • 指针的实现中没有内部信息表明自己指向的是什么类型的数据或者内容是否合法; • 不过,指针有类型,而且如果没有正确使用,编译器会频繁抱怨。 说到垃圾,我们是指分配的内存中可能包含任何数据。当内存刚分配时不会被清理,之前的内容可能是任何东西。如果之前的内容是一个浮点数, 那把它当成一个整数就没什么用。就算确实包含了整数,也不大可能是正确的整数。所以我们说内容是垃圾。 尽管不经过初始化就可以使用指针,但只有初始化后,指针才会正常工作。 1.1.3  如何阅读声明 现在介绍一种阅读指针声明的方法,这个方法会让指针更容易理解,那就是:倒过来读。尽管我们还没讲到指向常量的指针,但可以先看看它的声明: const int *pci; 倒过来读可以让我们一点点理解这个声明(见图1-3)。 图1-3:倒过来的声明 很多程序员都发现倒过来读声明就没那么复杂了。 遇到复杂的指针表达式时,画一张图,我们在很多例子中就是这样做的。 1.1.4  地址操作符 地址操作符& 会返回操作数的地址。我们可以用这个操作符来初始化pi 指针,如下所示: num = 0; pi = # num 变量设置为0,而pi 设置为指向num 的地址,如图1-4 所示。 图1-4:内存赋值 可以在声明变量pi 的同时把它初始化为num 的地址,如下所示: int num; int *pi = # 有了以上声明,下面的语句在大部分编译器中都会报语法错误: num = 0; pi = num; 错误看起来可能是这样的: error: invalid conversion from 'int' to 'int*' pi 变量的类型是整数指针,而num 的类型是整数。这个错误消息是说整数不能转换为指向整数类型的指针。 把整数赋值给指针一般都会导致警告或错误。 指针和整数不一样。在大部分机器上,可能两者都是存储为相同字节数的数字,但它们不一样。不过,也可以把整数转换为指向整数的指针: pi = (int *)num; 这样不会产生语法错误。不过运行起来后,程序可能会因为试图解引地址0 处的值而非正常退出。在大部分操作系统中,在程序中使用地址0 是不合法的。我们会在1.1.8 节中详细讨论这个问题。 尽快初始化指针是一个好习惯,如下所示: int num; int *pi; pi = # 1.1.5  打印指针的值 我们实际使用的变量几乎不可能有100 或104 这样的地址。不过,变量的地址可以通过打印来确定,如下所示: int num = 0; int *pi = # printf("Address of num: %d Value: %d\n",&num, num); printf("Address of pi: %d Value: %d\n",&pi, pi); 运行后,会得到下面的输出。在这个例子中我们用了真实的地址,你的地址可能会不一样: Address of num: 4520836 Value: 0 Address of pi: 4520824 Value: 4520836 printf 函数还有其他几种格式说明符在打印指针的值时比较有用,如表1-2 所示。 表1-2:格式说明符 这些说明符的用法如下: printf("Address of pi: %d Value: %d\n",&pi, pi); printf("Address of pi: %x Value: %x\n",&pi, pi); printf("Address of pi: %o Value: %o\n",&pi, pi); printf("Address of pi: %p Value: %p\n",&pi, pi); 这样就会显示pi 的地址和内容,如下所示。在这个例子中,pi 持有num 的地址: Address of pi: 4520824 Value: 4520836 Address of pi: 44fb78 Value: 44fb84 Address of pi: 21175570 Value: 21175604 Address of pi: 0044FB78 Value: 0044FB84 %p 和%x 的不同之处在于:%p 一般会把数字显示为十六进制大写。如果没有特别说明,我们用%p 作为地址的说明符。 在不同的平台上用一致的方式显示指针的值比较困难。一种方法是把指针转换为void 指针,然后用%p 格式说明符来显示,如下: printf("Value of pi: %p\n", (void*)pi); void 指针会在1.1.8 节的“void 指针”中解释。为了保证示例简单,我们会用%p 说明符,而不把地址转换为void 指针。 虚拟内存和指针 让打印地址变得更为复杂的是,在虚拟操作系统上显示的指针地址一般不是真实的物理内存地址。虚拟操作系统允许程序分布在机器的物理地址空间上。应用程序分为页(或帧),这些页表示内存中的区域。应用程序的页被分配在不同的(可能是不相邻的)内存区域上,而且可能不是同时处于内存中。如果操作系统需要占用被某一页占据的内存,可以将这些内存交换到二级存储器中,待将来需要时再装载进内存中(内存地址一般都会与之前的不同)。这种能力为虚拟操作系统管理内存提供了相当大的灵活性。 每个程序都假定自己能够访问机器的整个物理内存空间,实际上却不是。程序使用的地址是虚拟地址。操作系统会在需要时把虚拟地址映射为物理内存地址。 这意味着页中的代码和数据在程序运行时可能位于不同的物理位置。应用程序的虚拟地址不会变,就是我们在查看指针内容时看到的地址。操作系统会帮我们将虚拟地址映射为真实地址。 操作系统处理一切事务,程序员无法控制也不需要关心。理解这些问题就能解释在虚拟操作系统中运行的程序所返回的地址。 1.1.6  用间接引用操作符解引指针 间接引用操作符(*)返回指针变量指向的值,一般称为解引指针。下面的例子声明和初始化了num 和pi: int num = 5; int *pi = # 然后下面的语句就用间接引用操作符来显示5,也就是num 的值: printf("%p\n",*pi); // 显示5 我们也可以把解引操作符的结果用做左值。术语“左值”是指赋值操作符左边的操作数,所有的左值都必须可以修改,因为它们会被赋值。 下面的代码把200 赋给pi 指向的整数。因为它指向num 变量,200 会被赋值给num。图1-5 说明了这个操作如何影响内存。 *pi = 200; printf("%d\n", num); // 显示200 图1-5:利用解引操作符给内存赋值 1.1.7  指向函数的指针 指针可以声明为指向函数,声明的写法有点难记。下面的代码说明如何声明一个指向函数的指针。函数没有参数也没有返回值。指针的名字是foo: void (*foo)(); 指向函数的指针有很多值得讨论的地方,详见第3 章。 1.1.8  null的概念 null 很有趣,但有时候会被误解。之所以会造成迷惑,是因为我们会遇到几种类似但又不一样的概念,包括: • null 概念; • null 指针常量; • NULL 宏; • ASCII 字符NUL; • null 字符串; • null 语句。 NULL 被赋值给指针就意味着指针不指向任何东西。null 概念是指指针包含了一个特殊的值,和别的指针不一样,它没有指向任何内存区域。两个null 指针总是相等的。尽管不常见,但每一种指针类型(如字符指针和整数指针)都可以有对应的null 指针类型。 null 概念是通过null 指针常量来支持的一种抽象。这个常量可能是也可能不是常量0,C 程序员不需要关心实际的内部表示。 NULL 宏是强制类型转换为void 指针的整数常量0。在很多库中定义如下: #define NULL ((void *)0) 这就是我们通常理解为null 指针的东西。这个定义一般可以在多种头文件中找到, 包括stddef.h、stdblib.h 和stdio.h。 如果编译器用一个非零的位串来表示null,那么编译器就有责任在指针上下文中把NULL 或0 当做null 指针,实际的null 内部表示由实现定义。使用NULL 或0 是在语言层面表示null 指针的符号。 ASCII 字符NUL 定义为全0 的字节。然而,这跟null 指针不一样。C 的字符串表示为以0 值结尾的字符序列。null 字符串是空字符串,不包含任何字符。最后, null 语句就是只有一个分号的语句。 接下来我们会看到,null 指针对于很多数据结构的实现来说都是很有用的特性,比如链表经常用null 指针来表示链表结尾。 如果要把null 值赋给pi,就像下面那样用NULL: pi = NULL; null 指针和未初始化的指针不同。未初始化的指针可能包含任何值,而包含NULL 的指针则不会引用内存中的任何地址。 有趣的是,我们可以给指针赋0,但是不能赋任何别的整数值。看一下下面的赋值操作: pi = 0; pi = NULL; pi = 100; // 语法错误 pi = num; // 语法错误 指针可以作为逻辑表达式的唯一操作数。比如说,我们可以用下面的代码来测试指针是否设置成了NULL。 if(pi) { // 不是NULL } else { // 是NULL } 下面两个表达式都有效,但是有冗余。这样可能更清晰,但是没必要显式地跟NULL 做比较。 如果这里pi 被赋了NULL 值,那就会被解释为二进制0。在C 中这表示假,那么倘若pi 包含NULL 的话,else 分支就会执行。 if(pi == NULL) ... if(pi != NULL) ... 任何时候都不应该对null 指针进行解引,因为它并不包含合法地址。执行这样的代码会导致程序终止。 1. 用不用NULL 使用指针时哪一种形式更好,NULL 还是0 ?无论哪一种都完全没问题,选择哪种只是个人喜好。有些开发者喜欢用NULL,因为这样会提醒自己是在用指针。另一些人则觉得没必要,因为NULL 其实就是0。 然而,NULL 不应该用在指针之外的上下文中。有时候可能有用,但不应该这么用。如果代替ASCII 字符NUL 的话肯定会有问题。这个字符没有定义在标准的C 头文件中。它等于字符'\0',其值等于十进制0。 0 的含义随着上下文的变化而变化,有时候可能是整数0,有时候又可能是null 指针。看一下这个例子: int num; int *pi = 0; // 这里的0 表示null 的指针NULL pi = # *pi = 0; // 这里的0 表示整数0 我们习惯了重载的操作符,比如星号可以用来声明指针、解引指针或者做乘法。0也被重载了。我们可能觉得不舒服,因为还没习惯重载操作数。 2. void指针 void 指针是通用指针,用来存放任何数据类型的引用。下面的例子就是一个void 指针: void *pv; 它有两个有趣的性质: • void 指针具有与char 指针相同的形式和内存对齐方式; • void 指针和别的指针永远不会相等,不过,两个赋值为NULL 的void 指针是相等的。 任何指针都可以被赋给void 指针,它可以被转换回原来的指针类型,这样的话指针的值和原指针的值是相等的。在下面的代码中,int 指针被赋给void 指针然后又被赋给int 指针: int num; int *pi = # printf("Value of pi: %p\n", pi); void* pv = pi; pi = (int*) pv; printf("Value of pi: %p\n", pi); 运行这段代码后,指针地址是一样的: Value of pi: 100 Value of pi: 100 void 指针只用做数据指针,而不能用做函数指针。在8.4.2 节中,我们将再次研究如何用void 指针来解决多态的问题。 用void 指针的时候要小心。如果把任意指针转换为void 指针,那就没有什么能阻止你再把它转换成不同的指针类型了。 sizeof 操作符可以用在void 指针上,不过我们无法把这个操作符用在void 上, 如下所示: size_t size = sizeof(void*); // 合法 size_t size = sizeof(void); // 不合法 size_t 是用来表示长度的数据类型,会在1.2.2 节中讨论。 3. 全局和静态指针 指针被声明为全局或静态,就会在程序启动时被初始化为NULL。下面是全局和静态指针的例子: int *globalpi; void foo() { static int *staticpi; ... } int main() { ... } 图1-6 说明了内存布局,栈帧被推入栈中,堆用来动态分配内存,堆上面的区域用来存放全局/ 静态变量。这只是原理图,静态和全局变量一般放在与栈和堆所处的数据段不同的数据段中。栈和堆将在3.1 节讨论。 图1-6:全局和静态指针的内存分配

>深入理解C指针

深入理解C指针
作者: [美] Richard Reese
原作名: Understanding and using C pointers
isbn: 7115344485
书名: 深入理解C指针
页数: 204
译者: 陈晓亮
定价: 45.00
出版社: 人民邮电出版社
装帧: 平装
出版年: 2014-2