IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 数据结构与算法 -> 深入理解 C 指针 -> 正文阅读

[数据结构与算法]深入理解 C 指针

Reference

指针的语法和语义规范

1.1 指针和内存

C 程序在编译后,会以三种形式使用内存

  • 静态/全局内存
    静态声明的变量和全局变量使用同一块内存。这些变量在程序开始运行时分配,直到程序终止才消失。所有函数都能访问全局变量,静态变量的作用域则局限在定义它们的函数内部。
  • 自动内存
    这些变量在函数内部声明,并且在函数被调用时才创建。它们的作用域局限于函数内部,而且生命周期限制在函数的执行时间内。
  • 动态内存
    内存分配在堆上,可以根据需要释放,而且直到释放才消失。 指针引用分配的内存,作用域局限于引用内存的指针

不同内存中变量的作用域和生命周期
在这里插入图片描述

1.1.1 为什么要精通指针

  1. 写出快速高效的代码;
  2. 为解决很多类问题提供方便的途径;
  3. 支持动态内存分配
  4. 使表达式变得紧凑和简洁;
  5. 提供用指针传递数据结构的能力而不会带来庞大的开销;
  6. 保护作为参数传递给函数的数据。

用指针可以写出快速高效的代码是因为指针更接近硬件。也就是说, 编译器可以更容易地把操作翻译成机器码。指针附带的开销一般不像别的操作符那样大。

很多数据结构用指针更容易实现,比如链表可以用数组实现,也可以用指针实现。然而,指针更容易使用,也能直接映射到下一个或上一 个链接。用数组实现需要用到数组下标,不直观,也没有指针灵活。

图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]);

指针是创建和加强应用的强大工具,不利之处则是使用指针过程中可 能会发生很多问题,比如:

  • 访问数组和其他数据结构时越界
  • 自动变量消失后被引用
  • 堆上分配的内存释放后被引用
  • 内存分配之前解引指针

1.1.2 声明指针

通过在数据类型后面跟星号,再加上指针变量的名字可以声明指针。 下面的例子声明了一个整数和一个整数指针:

int num; 
int *pi;

星号两边的空白符无关紧要,下面的声明都是等价的:

int* pi; 
int * pi; 
int *pi; 
int*pi;

注意 空白符的使用是个人喜好

星号将变量声明为指针。这是一个重载过的符号,因为它也用在乘法和解引指针上。

对于以上声明,图1-2 说明了典型的内存分配是什么样的。三个方框表示三个内存单元,每个方框左边的数字是地址地址旁边的名字是持有这个地址的变量,这里的地址 100 只是为了说明原理。就这个问题来说,指针或者其他变量的实际地址通常是未知的,而且在大部分的应用程序中,这个值也没什么用。三个点表示未初始化的内存。
在这里插入图片描述

图1-2:内存图解

指向未初始化的内存的指针可能会产生问题。如果将这种指针解引, 指针的内容可能并不是一个合法的地址,就算是合法地址,那个地址也可能没有包含合法的数据。程序没有权限访问不合法地址,否则在大部分平台上会造成程序终止,这是很严重的,会造成一系列问题,

变量 num 和 pi 分别位于地址 100 和 104。假设这两个变量都占据 4 字节空间,就像 1.2 节中所说,实际的长度取决于系统配置。除非特别指 明,我们所有的例子都使用4字节长的整数。

注意 本文用 100 这样的地址来解释指针如何工作,这样会简化例子。当你运行示例代码时会得到不同的地址,而且这些地址甚至在同一个程序运行几次的时候都可能变化。

记住这几点:

  • pi 的内容最终应该赋值为一个整数变量的地址;
  • 这些变量没有被初始化,所以包含的是垃圾数据;
  • 指针的实现中没有内部信息表明自己指向的是什么类型的数据或者内容是否合法;
  • 不过,指针有类型,而且如果没有正确使用,编译器会频繁出错。

注意 说到垃圾,我们是指分配的内存中可能包含任何数据。当内存刚分配时不会被清理,之前的内容可能是任何东西。如果之前的内容是一个浮点数,那把它当成一个整数就没什么用。就算确实包含了整数,也不大可能是正确的整数。所以我们说内容是垃圾。

尽管不经过初始化就可以使用指针,但只有初始化后,指针才会正常工作。

1.1.3 如何阅读声明

现在介绍一种阅读指针声明的方法,这个方法会让指针更容易理解, 那就是:倒过来读。尽管我们还没讲到指向常量的指针,但可以先看看它的声明:

const int *pci;

倒过来读可以让我们一点点理解这个声明
在这里插入图片描述
很多程序员都发现倒过来读声明就没那么复杂了。

注意 遇到复杂的指针表达式时,画一张图,我们在很多例子中 就是这样做的。

1.1.4 地址操作符

地址操作符 & 会返回操作数的地址。我们可以用这个操作符来初始化 pi 指针,如下所示:

num = 0; 
pi = #

num 变量设置为 0,而 pi 设置为指向 num 的地址
在这里插入图片描述

图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); // &pi 指针变量本身存放的内存地址,pi 指向的地址

运行后,会得到下面的输出。在这个例子中我们用了真实的地址,你的地址可能会不一样:

Address of num: -1917589532 Value: 0
Address of pi: -1917589528 Value: -1917589532

printf 函数还有其他几种格式说明符在打印指针的值时比较有用,如 表1-2所示。

在这里插入图片描述
表1-2:格式说明符
这些说明符的用法如下:

int num = 0; 
int *pi = # 
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: 1236852440 Value: 1236852436
Address of pi: 49b8ded8 Value: 49b8ded4
Address of pi: 11156157330 Value: 11156157324
Address of pi: 0x7fff49b8ded8 Value: 0x7fff49b8ded4

%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)();

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 a = 1;
printf("%d"); // 86508

size_t size = sizeof(void*); // 合法 
size_t size = sizeof(void); // 不合法

size_t 是用来表示长度的数据类型

3. 全局和静态指针

指针被声明为全局或静态,就会在程序启动时被初始化为 NULL。下面是全局和静态指针的例子:

int *globalpi; 
    
void foo() { 
    static int *staticpi; ... 
    
}

int main() { ... }

图1-6 说明了内存布局,栈帧被推入栈中,堆用来动态分配内存,堆上面的区域用来存放全局/静态变量。这只是原理图,静态和全局变量一般放在与栈和堆所处的数据段不同的数据段中。栈和堆将在3.1 节讨论。

在这里插入图片描述

图1-6:全局和静态指针的内存分配

1.2 指针的长度和类型

如果考虑应用程序的兼容性和可移植性,指针长度就是一个问题。在大部分现代平台上,数据指针的长度通常是一样的,与指针类型无关,char指针和结构体指针长度相同。尽管C标准没有规定所有数据类型的指针长度相同,但是通常实际情况就是这样。不过,函数指针长度可能与数据指针长度不同。

指针长度取决于使用的机器和编译器。比如,在现代Windows上, 指针是32位或64位长。对于DOS和Windows 3.1来说,指针则是16 位或32位长。

1.2.1 内存模型

64位机器的出现导致为不同数据类型分配的内存在长度上的差异变得明显。不同的机器和编译器在给C的基本数据类型分配空间上有不同的做法。用来描述不同数据模型的一种通用表示法总结如下:

I In L Ln LL LLn P Pn

每个大写字母对应整数、长整数或指针,小写字母表示为该数据类型分配的位数。表1-3总结了这些模型,其中数字表示位数。

表1-3:机器内存模型

在这里插入图片描述
模型取决于操作系统和编译器,一种操作系统可能支持多种模型,这通常是用编译器选项来控制的。

1.2.2 指针相关的预定义类型

使用指针时经常用到以下四种预定义类型。

  • size_t :用于安全地表示长度
  • ptrdiff_t:用于处理指针算术运算
  • intptr_t 和 uintptr_t:用于存储指针地址

下面将展示每种类型的用法,ptrdiff_t 除外,我们会在1.3.1节的“两 个指针相减”中讨论它。

1. 理解size_t

size_t类型表示C中任何对象所能达到的最大长度。 它是无符号整数,因为负数在这里没有意义。它的目的是提供一种可移植的方法来声明与系统中可寻址的内存区域一致的长度。size_t用做sizeof操作符的返回值类型,同时也是很多函数的参数类型,包括malloc和strlen。

注意 在声明诸如字符数或者数组索引这样的长度变量时用size_t是好的做法。它经常用于循环计数器、数组索引,有时候还用在指针算术运算上。

size_t 的声明是实现相关的。它出现在一个或多个标准头文件中,比 如stdio.h和stblib.h,典型的定义如下:

#ifndef __SIZE_T 
#define __SIZE_T 
typedef unsigned int size_t; 
#endif

define指令确保它只被定义一次。实际的长度取决于实现。通常在 32 位系统上它的长度是 32 位,而在64位系统上则是64位。一般来说,size_t 可能的最大值是 SIZE_MAX。

警告通常size_t可以用来存放指针,但是假定size_t和指针一样长不是个好主意。稍后的“使用sizeof操作符和指针”会讲到,intptr_t是更好的选择。

**打印 size_t 类型的值时要小心。这是无符号值,**如果选错格式说明 符,可能会得到不可靠的结果。推荐的格式说明符是 %zu。不过,某些情况下不能用这个说明符,作为替代,可以考虑%u或%lu

下面这个例子将一个变量定义为size_t,然后用两种不同的格式说明 符来打印:

size_t sizet = -5;
printf("%d\n",sizet); 
printf("%zu\n",sizet);

因为size_t本来是用于表示正整数的,如果用来表示负数就会出问 题。如果为其赋一个负数,然后用%d和%zu格式说明符打印,就得 到如下结果:

-5
4294967291

%d把size_t当做有符号整数,它打印出-5因为变量中存放的就是- 5。%zu把size_t当做无符号整数。当-5被解析为有符号数时,高位置为1,表示这个数是负数。当它被解析为无符号数时,高位的1被当做2的乘幂。所以在用%zu格式说明符时才会看到那个大整数。

正数会正常显示,如下所示:

sizet = 5; 
printf("%d\n",sizet); // 显示5 
printf("%zu\n",sizet); // 显示5

因为size_t是无符号的,一定要给这种类型的变量赋正数。

2. 对指针使用sizeof操作符

sizeof操作符可以用来判断指针长度。下面的代码显示char指针的长度:

printf("Size of *char: %d\n",sizeof(char*));

输出如下:

Size of *char: 4

注意 当需要用指针长度时,一定要用sizeof操作符。

函数指针的长度是可变的。通常,对于给定的操作系统和编译器组合,它是固定的。很多编译器支持创建32位和64位应用程序,所以 对于同一个程序来说,不同的编译选项可能会导致其使用不同的指针 长度。

在Harvard架构上,代码和数据存储在不同的物理内存中。比如 Intel 的 MCS-51(8051)微处理器就是Harvard架构。尽管Intel不再生产 这种芯片,但现在还是有很多二进制兼容的衍生品在使用。Small Device C Complier(SDCC)就支持这类处理器

1.3 指针操作符

指针有几类操作符。目前我们已经接触过解引和取地址操作符,本节将近距离研究指针算术运算和比较
表1-4:指针操作符
在这里插入图片描述

1.3.1 指针算术运算

数据指针可以执行以下几种算术运算:

  • 给指针加上整数;
  • 从指针减去整数;
  • 两个指针相减;
  • 比较指针

函数指针则不一定

1. 给指针加上整数

这种操作很普遍也很有用。给指针加上一个整数实际上加的数是这个 整数和指针数据类型对应字节数的乘积。

各个系统的基本数据类型长度可能不同,正如1.2.1节所述。表1-5显 示了大部分系统的常见长度,除非特别指定,本文的示例会使用这里的值。

表1-5:数据类型长度
在这里插入图片描述
为了说明给指针加上整数的效果,我们会使用一个整数数组,如下所示。每次pi加1,地址就加4。这些变量的内存分配如图1-7所示。指针是用数据类型声明的,以便执行算术运算。这种自动调整指针值的可移植方法之所以可能,前提就是知道数据类型的大小。

intvector[] = {28, 41, 7}; 
int *pi = vector; // pi: 100 
printf("%d\n",*pi); // 显示28 
pi += 1; // pi: 104 
printf("%d\n",*pi); // 显示41 
pi += 1; // pi: 108 
printf("%d\n",*pi); // 显示7

注意 如果这里使用数组的名字,返回的只是数组地址,也就是数组第一个元素的地址。

在这里插入图片描述
图1-7:vector数组的内存分配情况

在下面的代码中,我们给指针加3,pi变量会包含地址112,就是pi本身的地址:

pi = vector; 
pi += 3;

指针指向了自己,这样没什么用,但是说明了在做指针算术运算时要小心。访问超出数组范围的内存很危险,应该避免。没有什么能保证被访问的内存是有效变量,存取无效或无用地址的情况很容易发生。

下面的代码演示了short和char类型指针的加法操作:

short s; 
short *ps = &s; 
char c; 
char *pc = &c;

我们假设内存分配如图1-8所示,这里用到的地址以4字节为界。真实的地址可能涉及不同的字节数和不同的字节序。

在这里插入图片描述
图1-8:short和char指针

下面的代码给每个指针加1然后显示本身地址内容

printf("Content of ps before: %d\n",ps); 
ps = ps + 1; 
printf("Content of ps after: %d\n",ps); 
printf("Content of pc before: %d\n",pc); 
pc = pc + 1; 
printf("Content of pc after: %d\n",pc);

运行后,你应该能得到类似如下的结果:

Content of ps before: 120 
Content of ps after: 122 
Content of pc before: 128 
Address of pc after: 129

ps指针增加了2,因为short的长度是2字节。pc指针增加了1,因为它的数据类型长1字节。这些地址可能没有包含有用的信息。

2. void指针和加法

作为扩展,大部分编译器都允许给void指针做算术运算,这里我们假设void指针的长度是4。不过,试图给void指针加1可能导致语法错误。在下面的代码片段中,我们声明指针并试图给它加1:

int num = 5; 
void *pv = # 
printf("%p\n",pv); 
pv = pv+1; //语法警告

下面是警告信息:

warning: pointer of type 'void *' used in arithmetic [-Wpointerarith]

这不是标准C允许的行为,所以编译器发出了警告。不过,pv包含的地址增加了4字节。

3. 从指针减去整数

就像整数可以和指针相加一样,也能从指针减去整数。减去整数时,地址值会减去数据类型的长度和整数值的乘积。为了演示从指针减去整数的效果,我们使用如下所示的数组。这些变量的内存分配如图1- 7所示。

int vector[] = {28, 41, 7}; 
int *pi = vector + 2; // pi: 指向第三个元素
printf("%d\n",*pi); // 显示7 
pi--; // pi
printf("%d\n",*pi); // 显示41 
pi--; // pi
printf("%d\n",*pi); // 显示28

pi每次减1,地址都会减4。

4. 指针相减

一个指针减去另一个指针会得到两个地址的差值。这个差值通常没什么用,但可以判断数组中的元素顺序。

指针之间的差值是它们之间相差的“单位”数,差的符号取决于操作数的顺序。这和指针加法是一样的,加到指针上的是数据的长度。我 们把“单位”当做操作数。在下例中,我们声明一个数组和数组元素的指针,然后相减:

int vector[] = {28, 41, 7}; 
int *p0 = vector; 
int *p1 = vector+1; 
int *p2 = vector+2; 
printf("p2-p0: %d\n",p2-p0); // p2-p0: 2 
printf("p2-p1: %d\n",p2-p1); // p2-p1: 1 
printf("p0-p1: %d\n",p0-p1); // p0-p1: -1

在第一个 printf 语句中,我们看到数组的最后一个和第一个元素的位置相差2,就是说它们的索引值相差2。在最后一个printf语句中,差值是-1,表示p0在p1前面,而且它们紧挨着。图1-9说明了本例中内存的分配情况。

在这里插入图片描述

图1-9:指针相减

ptrdiff_t类型表示两个指针差值的可移植方式。在上例中,指针相减 的结果以ptrdiff_t类型返回。因为指针长度可能不同,这个类型简化
了处理差值的任务。

不要把这种技术和利用解引操作来做数字相减混淆。在下例中,我们用指针来确定数字中第一个元素和第二个元素中存储的值的差。

printf("*p0-*p1: %d\n",*p0-*p1); // *p0-*p1: -13

1.3.2 比较指针

指针可以用标准的比较操作符来比较。通常,比较指针没什么用。然而,当把指针和数组元素相比时,比较结果可以用来判断数组元素的相对顺序。

我们仍然用前面“指针相减”中使用的vector数组来说明指针的比 较。这里用到了几种比较操作符,结果为1表示真,为0表示假:

intvector[] = {28, 41, 7}; 
int *p0 = vector; 
int *p1 = vector+1; 
int *p2 = vector+2; 
printf("p2>p0: %d\n",p2>p0); // p2>p0: 1 
printf("p2<p0: %d\n",p2<p0); // p2<p0: 0 
printf("p0>p1: %d\n",p0>p1); // p0>p1: 0

1.4 指针的常见用法

指针可以用不同的间接引用层级。把变量声明为指针的指针并不少见,有时候称它们为双重指针。一个很好的例子就是用传统的 argv 和 argc 参数来给 main 函数传递程序参数,第5章将详细讨论。

下例使用了三个数组。第一个数组是用来存储书名列表的字符串数组:

char *titles[] = {"A Tale of Two Cities", 
        "Wuthering Heights","Don Quixote", 
        "Odyssey","Moby-Dick",
        "Hamlet", "Gulliver's Travels"};

还有两个数组分别用来维护一个“畅销书”列表和一个英文书列表。 这两个数组保存的是titles 数组里书名的地址,而不是书名的副本。 两个数组都声明为字符指针的指针。数组元素会保存 titles 数组中元素的地址,这样可以避免对每个书名重复分配内存,确保每个书名的位置唯一。如果需要修改书名,只改一个地方就可以了。

另外两个数组声明如下。每个数组元素包含一个指向 char 指针的指针。

char **bestBooks[3]; char **englishBooks[4];

接下来初始化这两个数组,然后打印其中一个元素,如下所示。在赋值语句中,右边的值是通过先做下标索引再取地址的操作得到的。比如说第二个语句把titles数组中第4个元素的地址赋给bestBooks的第 2个元素:

bestBooks[0] = &titles[0]; 
bestBooks[1] = &titles[3]; 
bestBooks[2] = &titles[5]; 
englishBooks[0] = &titles[0]; 
englishBooks[1] = &titles[1]; 
englishBooks[2] = &titles[5]; 
englishBooks[3] = &titles[6]; 
printf("%s\n",*englishBooks[1]); // Wuthering Heights

本例的内存分配如图1-10所示
在这里插入图片描述

图1-10:指针的指针

用多层间接引用可以为代码的编写和使用提供更多的灵活性,否则有些操作实现起来会困难一些。在本例中,如果书名的地址变了,那么只需要修改titles数组即可,不需要修改其他两个数组。

间接引用没有层数限制,当然,使用的层数过多会让人迷惑,很难维护。

1.4.2 常量与指针

C语言的功能强大而丰富,还表现在const关键字与指针的结合使用 上。对不同的问题,它能提供不同的保护。特别强大和有用的是指向常量的指针。在第3章和第5章,我们将看到如何用这种技术来阻止函数的使用者修改函数的参数。

1. 指向常量的指针

可以将指针定义为指向常量,这意味着不能通过指针修改它所引用的 值。下例声明了一个整数和一个整数常量,然后声明了一个整数指针和一个指向整数常量的指针,并分别初始化为对应的整数:

int num = 5; 
const int limit = 500; 
int *pi; // 指向整数 
const int *pci; // 指向整数常量 
pi = &num; 
pci = &limit;

图1-11是它们的内存分配情况
在这里插入图片描述

图1-11:指向整数常量的指针

下面的代码会打印这些变量的地址和值:

printf(" num - Address: %p value: %d\n",&num, num); 
printf("limit - Address: %p value: %d\n",&limit, limit); 
printf(" pi - Address: %p value: %p\n",&pi, pi); 
printf(" pci - Address: %p value: %p\n",&pci, pci);

运行代码会产生类似下面的输出:

num - Address: 100 value: 5 
limit - Address: 104 value: 500 
pi - Address: 108 value: 100 
pci - Address: 112 value: 104

如果只是读取整数的值,那么引用指向常量的指针就没事,读取是完全合法的,而且也是必要的功能,如下所示:

我们不能解引指向常量的指针并改变指针所引用的值,但可以改变指针。指针的值不是常量。指针可以改为引用另一个整数常量,或者普通整数。这样做不会有问题。声明只是限制我们不能通过指针来修改引用的值。

这意味着下面的赋值是合法的:

pci = &num;

我们可以解引pci来读取它,但不能解引它来修改它。
考虑下面的赋值语句:

*pci = 200;

这会导致如下语法错误:

'pci' : you cannot assign to a variable that is const

指针认为自己指向的是整数常量,所以不允许用指针来修改这个整数。我们还是可以通过名字来修改num变量,只是不能用pci来修改。

理论上来说,常量的指针也可以如图1-12那样可视化,普通方框表示变量,阴影方框表示常量。pci指向的阴影方框不能用pci来修改, 虚线表示指针可以引用的数据类型。在上例中,pci指向limit。
在这里插入图片描述

图1-12:指向常量的指针

把 pci 声明为指向整数常量的指针意味着:

  • pci 可以被修改为指向不同的整数常量;
  • pci 可以被修改为指向不同的非整数常量;
  • 可以解引 pci 以读取数据
  • 不能解引 pci 从而修改它指向的数据

注意 数据类型和const关键字的顺序不重要。下面两个语句是等价的:

const int *pci; 
int const *pci;

2. 指向非常量的常量指针

也可以声明一个指向非常量的常量指针。这么做意味着指针不可变, 但是它指向的数据可变。下面是这种指针的例子;

int num; 
int *const cpi = &num;

有了这个声明:

  • cpi 必须被初始化为指向非常量变量;
  • cpi 不能被修改;
  • cpi 指向的数据可以被修改。

从原理上说,这类指针可以用图1-13来说明。在这里插入图片描述

图1-13:指向非常量的常量指针

无论 cpi 引用了什么,都可以解引 cpi 然后赋一个新值。下面是两条合法的赋值语句:

*cpi = limit; 
*cpi = 25;

然而,如果我们试图把cpi初始化为指向常量limit,如下所示:

const int limit = 500; 
int *const cpi = &limit;

那么就会产生一个警告:

warning: initialization discards qualifiers from pointer target type

如果这里cpi引用了常量limit,那常量就可以修改了。这样不对,因为常量是不能被修改的。
在把地址赋给cpi之后,就不能像下面这样再赋给它一个新值了:

int num; 
int age; 
int *const cpi = &num;  // 相当于 const cpi 后地址后常量不能改变
cpi = &age;

如果采用这种做法会产生如下错误信息:

'cpi' : you cannot assign to a variable that is const

3. 指向常量的常量指针

指向常量的常量指针很少派上用场。这种指针本身不能修改,它指向的数据也不能通过它来修改。下面是指向常量的常量指针的一个例子:

const int *const cpci = &limit;

指向常量的常量指针可以用图1-14来说明。
在这里插入图片描述

图1-14:指向常量的常量指针

与指向常量的指针类似,不一定只能将常量的地址赋给cpci。如下所 示,我们其实还可以把 num 的地址赋给 cpci:

int num; 
const int *const cpci = &num;

声明指针时必须进行初始化。如果像下面这样不进行初始化:

const int *const cpci;

就会产生如下语法错误:

'cpci' : const object must be initialized if not extern

对于指向常量的常量指针,我们不能:

  • 修改指针;
  • 修改指针指向的数据。

重新赋给cpci一个新地址:

cpci = &num;

会导致如下语法错误:

'cpci' : you cannot assign to a variable that is const

像下面这样试图解引指针并赋新值:

*cpci = 25;

会产生如下错误:

'cpci' : you cannot assign to a variable that is const expression must be a modifiable lvalue

不过,指向常量的常量指针很少用到。

4. 指向“指向常量的常量指针”的指针

指向常量的指针也可以有多层间接引用。在下例中,我们声明一个指向上一节提到的cpci指针的指针。从右往左读可以帮助我们理解这个声明:

const int *const cpci = &limit; 
const int *const * pcpci;

指向“指向常量的常量指针”的指针可以用图1-15来说明。

在这里插入图片描述

图1-15:指向“指向常量的常量指针”的指针

下面说明它们的使用。这段代码的输出应该是两个500:

printf("%d\n",*cpci); 
pcpci = &cpci; 
printf("%d\n",**pcpci);

下表总结了本节讨论的前四种指针。
在这里插入图片描述

第2章 C的动态内存管理

2.1 动态内存分配

在 C 中动态分配内存的基本步骤有:

  1. 用 malloc 类的函数分配内存;
  2. 用这些内存支持应用程序;
  3. 用 free 函数释放内存。

这个方法在具体操作上可能存在一些小变化,不过这里列出的是最常见的。在下例中,我们用 malloc 函数为整数分配内存。指针将分配的内存赋值为 5,然后内存被 free 函数释放。

int *pi = (int*) malloc(sizeof(int)); 
*pi = 5; 
printf("*pi: %d\n", *pi); 
free(pi);

当这段代码执行时会打印数字5。图2-1说明了在 free 函数执行之前内存如何分配。为方便在本章说明问题,除非特别指出,我们假定示例 代码出现在main函数中。

在这里插入图片描述

图2-1:整数的内存分配

malloc 函数的参数指定要分配的字节数。如果成功,它会返回从堆上分配的内存的指针。如果失败则会返回空指针。测试所分配内存的指针是否有效在2.2.1节中讨论。sizeof操作符使应用程序更容易移植,还能确定在宿主系统中应该分配的正确的字节数。

在本例中,我们试图为整数分配足够多的内存。假定长度是4,我们可以这么写:

int *pi = (int*) malloc(4));

然而,依赖于系统所用的内存模型,整数的长度可能会发生变化。可移植的方法是使用sizeof 操作符,这样不管程序在哪里运行都会返回正确的长度。

注意 涉及解引操作的常见错误见下面的代码:

int *pi;
*pi = (int*) malloc(sizeof(int));

问题出在赋值符号的左边。我们在解引指针,这样会把 malloc 函数返回的地址赋给 pi 中存放的地址所在的内存单元。如果这是第 一次对指针进行赋值操作,那指针所包含的地址可能无效。正确的方法如下所示:

pi = (int*) malloc(sizeof(int));

这种情况下不应该用解引操作符。

稍后也会深入讨论free函数,它和malloc协同工作,不再需要内存时 将其释放。

注意 每次调用malloc(或类似函数),程序结束时必须有对应 的free函数调用,以防止内存泄漏。

一旦内存被释放,就不应该再访问它了。通常我们不会在释放内存后 有意去访问,不过,就像2.4节中所说的,这也有可能意外发生。在 这种情况下系统的行为将依赖于实现。通常的做法是总是把被释放的 指针赋值为NULL,2.3.1节会讨论这一点。

分配内存时,堆管理器维护的数据结构中会保存额外的信息。这些信 息包括块大小和其他一些东西,通常放在紧挨着分配块的位置。如果 应用程序的写入操作超出了这块内存,数据结构可能会被破坏。这可 能会造成程序奇怪的行为或者堆损坏,第7章会演示相关示例。

考虑如下代码段,我们为字符串分配内存,让它可以存放最多5个字 符外加结尾的NUL字符。for循环在每个位置写入0,但是没有在写入 6字节后停止。for语句的结束条件是写入8字节。写入的0是二进制0 而不是ASCII字符0的值。

char *pc = (char*) malloc(6); 
for(int i=0; i<8; i++) { 
    *pc[i] = 0; 
}

在图2-2中,6字节的字符串后面还分配了额外的内存,这是堆管理 器用来记录内存分配的。如果我们越过字符串的结尾边界写入,额外 的内存中的数据会损坏。在本例中,额外的内存跟在字符串后面。不 过,实际的位置和原始信息取决于编译器。
在这里插入图片描述

图2-2:堆管理器用到的额外内存

内存泄漏

如果不再使用已分配的内存却没有将其释放就会发生内存泄漏,导致 内存泄漏的情况可能如下:

  • 丢失内存地址;
  • 应该调用free函数却没有调用(有时候也称为隐式泄漏)。

内存泄漏的一个问题是无法回收内存并重复利用,堆管理器可用的内 存将变少。如果内存不断地被分配并丢失,那么当需要更多内存 而malloc又不能分配时程序可能会终止,因为它用光了内存。在极 端情况下,操作系统可能崩溃。

下面这个简单的例子可以说明这个问题:

char *chunk; 
while (1) { 
    
    chunk = (char*) malloc(1000000); 
    printf("Allocating\n"); 
    
}

chunk变量指向堆上的内存。然而,在它指向另一块内存之前没有释 放这块内存。最终,程序会用光内存然后非正常终止,即使没有终 止,至少内存的利用效率也不高。

1. 丢失地址

下面的代码段说明了当pi被赋值为一个新地址时丢失内存地址的例 子。当pi又指向第二次分配的内存时,第一次分配的内存的地址就会 丢失。

int *pi = (int*) malloc(sizeof(int)); 
*pi = 5; 
... 
pi = (int*) malloc(sizeof(int));

图2-3说明了这一点,“前”和“后”分别表示在执行第二 次malloc之前和之后的程序状态。由于没有释放地址500处的内存,
程序已经没有地方持有这个地址。

图2-3说明了这一点,“前”和“后”分别表示在执行第二 次malloc之前和之后的程序状态。由于没有释放地址500处的内存,程序已经没有地方持有这个地址。

在这里插入图片描述

图2-3:丢失地址

下面这个例子是为字符串分配内存,将其初始化,并逐个字符打印字符串:

char *name = (char*)malloc(strlen("Susan")+1); 
strcpy(name,"Susan"); 
while(*name != 0) { 
    printf("%c",*name); 
    name++; 
}

然而每次迭代name都会增加1,最后name会指向字符串结尾 的NUL字符,如图2-4所示,分配内存的起始地址丢失了。
在这里插入图片描述

图2-4:丢失动态分配的内存的地址

2. 隐式内存泄漏

如果程序应该释放内存而实际却没有释放,也会发生内存泄漏。如果 我们不再需要某个对象但它仍然保存在堆上,就会发生隐式内存泄 漏,一般这是程序员忽视所致。这类泄漏的主要问题是对象在使用的 内存其实已经不需要了,应该归还给堆。最差的情况是,堆管理器可 能无法按需分配内存,导致程序不得不终止。最好的情况是我们持有 了不必要的内存。

在释放用struct关键字创建的结构体时也可能发生内存泄漏。如果结
构体包含指向动态分配的内存的指针,那么可能需要在释放结构体之 前先释放这些指针,第6章会展示示例。

2.2 动态内存分配函数

有几个内存分配函数可以用来管理动态内存,虽然具体可用的函数取 决于系统,但大部分系统的stdlib.h头文件中都有如下函数:

  • malloc
  • realloc
  • calloc
  • free

表2-1:动态内存分配函数
在这里插入图片描述
动态内存从堆上分配,至于一连串内存分配调用,系统不保证内存的 顺序和所分配内存的连续性。不过,分配的内存会根据指针的数据类 型对齐,比如说,4字节的整数会分配在能被4整除的地址边界上。 堆管理器返回的地址是最低字节的地址。

在图2-3中,malloc函数在地址500处分配了4字节空间,第二次使用 该函数在地址600处分配了内存。它们都处于4字节地址边界上,而 且不是从相邻的内存位置上分配的。

2.2.1 使用malloc函数

malloc函数从堆上分配一块内存,所分配的字节数由该函数唯一的 参数指定,返回值是void指针,如果内存不足,就会返回NULL。此 函数不会清空或者修改内存,所以我们认为新分配的内存包含垃圾数 据。函数的原型如下:

void* malloc(size_t);

这个函数只有一个参数,类型是size_t,我们在第1章讨论过此类 型。传递参数给这个函数时要小心,因为如果参数是负数就会引发问 题。在有些系统中,参数是负数会返回NULL。

如果malloc的参数是0,其行为是实现相关的:可能返回NULL指 针,也可能返回一个指向分配了0字节区域的指针。如果malloc函数 的参数是NULL,那么一般会生成一个警告然后返回0字节。

以下是malloc函数的典型用法:

int *pi = (int*) malloc(sizeof(int));

执行malloc函数时会进行以下操作:

  1. 从堆上分配内存;
  2. 内存不会被修改或是清空;
  3. 返回首字节的地址。

注意 因为当malloc无法分配内存时会返回NULL,在使用它返回 的指针之前先检查NULL是不错的做法,如下所示:

int *pi = (int*) malloc(sizeof(int)); 
if(pi != NULL) { 
    // 指针没有问题 
} else { 
    // 无效的指针 
}

1. 要不要强制类型转换

C引入void指针之前,在两种互不兼容的指针类型之间赋值需要 对malloc使用显式转换类型以避免产生警告。因为可以将void指针 赋值给其他任何指针类型,所以就不再需要显式类型转换了。有些开 发者认为显式类型转换是不错的做法,因为:

  • 这样可以说明malloc函数的用意;
  • 代码可以和C++(或早期的C编译器)兼容,后两者需要显式 的类型转换。

如果没有引用malloc的头文件,类型转换可能会有问题,编译器可 能会产生警告。C默认函数返回整数,如果没有引用malloc的原型, 编译器会抱怨你试图把int赋值给指针。

2. 分配内存失败

如果声明了一个指针,但没有在使用之前为它指向的地址分配内存, 那么内存通常会包含垃圾,这往往会导致一个无效内存引用的错误。 考虑如下代码片段:

int *pi; 
... 
printf("%d\n",*pi);

内存分配如图2-5所示。这个问题会在第7章详细讨论。
在这里插入图片描述

图2-5:没有分配内存

执行这段代码可能会导致一个运行时异常。字符串中这类问题比较常 见,如下所示:

char *name; 
printf("Enter a name: "); 
scanf("%s",name);

这里使用的是name所引用的内存,看起来似乎可以正确执行,实际 上这块内存还没有分配。把图2-5中的pi换成name就可以说明这个 问题。

3. 没有给malloc传递正确的参数

malloc函数分配的字节数是由它的参数指定的,在用这个函数分配 正确的字节数时要小心。比如说要为10个双精度浮点数分配空间, 那就需要80字节,通过下面的代码可以做到:

double *pd = (double*)malloc(NUMBER_OF_DOUBLES * sizeof(double));

注意 为数据类型分配指定字节数时尽量用sizeof操作符。

下例尝试为10个双精度浮点数分配内存:

const int NUMBER_OF_DOUBLES = 10; 
double *pd = (double*)malloc(NUMBER_OF_DOUBLES);

这段代码实际只分配了10字节。

4. 确认所分配的内存数

没有标准的方法可以知道堆上分配的内存总数,不过有些编译器为此 提供了扩展。另外,也没有标准的方法可以知道堆管理器分配的内存 块大小。

比如说,如果我们为一个字符串分配64字节,堆管理器会分配额外 的内存来管理这个块。所分配内存的总大小,以及堆管理器所用到的 内存数,是两者的和。图2-2对此有说明。

malloc可分配的最大内存是跟系统相关的,看起来这个大小 由size_t限制。不过这个限制可能受可用的物理内存和操作系统的其 他限制所影响。

执行malloc时应该分配所请求数量的内存然后返回内存地址。如果 操作系统采用“惰性初始化”策略直到访问内存才真正分配的话会怎 样?这时候万一没有足够的内存用来分配就会有问题,答案取决于运 行时和操作系统。开发者一般不需要处理这个问题,因为这种初始化 策略非常罕见。

5. 静态、全局指针和malloc

初始化静态或全局变量时不能调用函数。下面的代码声明一个静态变 量,并试图用malloc来初始化:

static int *pi = malloc(sizeof(int));

这样会产生一个编译时错误消息,全局变量也一样。对于静态变量, 可以通过在后面用一个单独的语句给变量分配内存来避免这个问题。 但是全局变量不能用单独的赋值语句,因为全局变量是在函数和可执 行代码外部声明的,赋值语句这类代码必须出现在函数中:

static int *pi; 
pi = malloc(sizeof(int));

注意 在编译器看来,作为初始化操作符的=和作为赋值操作符 的=不一样。

2.2.2 使用calloc函数

calloc会在分配的同时清空内存。该函数的原型如下:

void *calloc(size_t numElements, size_t elementSize);

注意 清空内存的意思是将其内容置为二进制0。

calloc函数会根据numElements和elementSize两个参数的乘积来分 配内存,并返回一个指向内存的第一个字节的指针。如果不能分配内 存,则会返回NULL。此函数最初用来辅助分配数组内存。

如果numElements或elementSize为0,那么calloc可能返回空指 针。如果calloc无法分配内存就会返回空指针,而且全局变 量errno会设置为ENOMEM(内存不足),这是POSIX错误码,有 的系统上可能没有。

下例为pi分配了20字节,全部包含0:

int *pi = calloc(5,sizeof(int));

不用calloc的话,用malloc函数和memset函数可以得到同样的结 果,如下所示:

int *pi = malloc(5 * sizeof(int)); 
memset(pi, 0, 5* sizeof(int));

注意 memset函数会用某个值填充内存块。第一个参数是指向 要填充的缓冲区的指针,第二个参数是填充缓冲区的值,最后一个参数是要填充的字节数。

如果内存需要清零可以使用calloc,不过执行calloc可能比执 行malloc慢。

注意 cfree函数已经没用了。早期的C用cfree来释放calloc分配 的内存。

2.2.3 使用realloc函数

我们可能需要时不时地增加或减少为指针分配的内存,如果需要一个 变长数组这种做法尤其有用,第4章会讨论这一点。realloc函数会重 新分配内存,下面是它的原型:

void *realloc(void *ptr, size_t size);

realloc函数返回指向内存块的指针。该函数接受两个参数,第一个 参数是指向原内存块的指针,第二个是请求的大小。重新分配的块大 小和第一个参数引用的块大小不同。返回值是指向重新分配的内存的 指针。

请求的大小可以比当前分配的字节数小或者大。如果比当前分配的 小,那么多余的内存会还给堆,不能保证多余的内存会被清空。如果 比当前分配的大,那么可能的话,就在紧挨着当前分配内存的区域分 配新的内存,否则就会在堆的其他区域分配并把旧的内存复制到新区 域。

如果大小是0而指针非空,那么就释放内存。如果无法分配空间,那 么原来的内存块就保持不变,不过返回的指针是空指针,且errno会 设置为ENOMEM。

表2-2:realloc函数的行为
在这里插入图片描述

在下例中,我们使用两个变量为字符串分配内存。一开始分配16字 节,但只用到了前面的13字节(12个十六进制数字外加null结束字符 (0)):

char *string1; 
char *string2; 
string1 = (char*) malloc(16); 
strcpy(string1, "0123456789AB");

接着,用realloc函数指定一个范围更小的内存区域。然后打印这两 个变量的地址和内容:

string2 = realloc(string1, 8); 
printf("string1 Value: %p [%s]\n", string1, string1); 
printf("string2 Value: %p [%s]\n", string2, string2);

输出如下:

string1 Value: 0x500 [0123456789AB]
string2 Value: 0x500 [0123456789AB]

图2-6说明了内存如何分配。
在这里插入图片描述

图2-6:realloc示例

堆管理器可以重用原始的内存块,且不会修改其内容。不过程序继续 使用的内存超过了所请求的8字节。也就是说,我们没有修改字符串 以便它能装进8字节的内存块中。在本例中,我们本应该调整字符串 的长度以使它能装进重新分配的8字节。实现这一点最简单的办法是 将NUL赋给地址507。实际使用的内存超出分配的内存不是个好做 法,应该避免,第7章会详细讲解这一点。

在接下来的例子中,我们会重新分配额外的内存:

string1 = (char*) malloc(16); 
strcpy(string1, "0123456789AB"); 
string2 = realloc(string1, 64); 
printf("string1 Value: %p [%s]\n", string1, string1); 
printf("string2 Value: %p [%s]\n", string2, string2);

执行以上代码得到类似下面的结果:

string1 Value: 0x500 [0123456789AB] 
string2 Value: 0x600 [0123456789AB]

在本例中,realloc必须分配一个新的内存块。图2-7说明了内存的分配。
在这里插入图片描述

图2-7:分配额外内存

2.2.4 alloca函数和变长数组

alloca函数(微软为malloca)在函数的栈帧上分配内存。函数返回 后会自动释放内存。若底层的运行时系统不基于栈,alloca函数会很 难实现,所以这个函数是不标准的,如果应用程序需要可移植就尽量 避免使用它。

C99引入了变长数组(VLA),允许函数内部声明和创建其长度由变 量决定的数组。在下例中,我们分配了一个在函数内使用的char数 组:

void compute(int size) { 
   char* buffer[size]; 
   ... 
}

这意味着内存分配在运行时完成,且将内存作为栈帧的一部分来分 配。另外,如果数组用到sizeof操作符,也是在运行时而不是编译时 执行。

这么做只会有一点小小的运行时开销。而且一旦函数退出,立即释放 内存。因为我们没有用malloc这类函数来创建数组,所以不应该 用free函数来释放它。alloca函数也不应该返回指向数组所在内存的 指针,这个问题在第5章解决。

注意 VLA的长度不能改变,一经分配其长度就固定了。如果你 需要一个长度能够实际变化的数组,那么需要使用类似realloc的函数,2.2.3节讨论的正是这种方法。

2.3 用free函数释放内存

有了动态内存分配,程序员可以将不再使用的内存返还给系统,这样 可以释放内存留作他用。通常用free函数实现这一点,该函数的原型 如下:

void free(void *ptr);

指针参数应该指向由malloc类函数分配的内存的地址,这块内存会 被返还给堆。尽管指针仍然指向这块区域,但是我们应该将它看成指 向垃圾数据。稍后可能重新分配这块区域,并将其装进不同的数据。

在下面这个简单的例子中,pi指向分配的内存,这块内存最终会被释放:

int *pi = (int*) malloc(sizeof(int)); 
... 
free(pi);

图2-8说明了free函数执行前后瞬间内存的分配情况。地址500处的 虚线框表示内存已经释放,但仍然有可能包含原值,pi变量仍然指向 地址500。这种情况称为迷途指针,会在2.4节详细讨论。

在这里插入图片描述

图2-8:用free释放内存

如果传递给free函数的参数是空指针,通常它什么都不做。如果传入 的指针所指向的内存不是由malloc类的函数分配,那么该函数的行 为将是未定义的。在下例中,分配给pi的是num的地址,不过这不 是一个合法的堆地址:

int num; 
int *pi = &num; 
free(pi); // 未定义行为

注意 应该在同一层管理内存的分配和释放。比如说,如果是在函数内分配内存,那么就应该在同一个函数内释放它。

2.3.1 将已释放的指针赋值为NULL

已释放的指针仍然可能造成问题。如果我们试图解引一个已释放的指 针,其行为将是未定义的。所以有些程序员会显式地给指针赋NULL来表示该指针无效,后续再使用这种指针会造成运行时异常。

下面是该方法的示例:

int *pi = (int*) malloc(sizeof(int)); 
... 
free(pi); 
pi = NULL;

内存分配如图2-9所示。

在这里插入图片描述

图2-9:调用free后给指针赋值NULL

这种技术的目的是解决迷途指针类问题。不过,花时间处理造成这类 问题的条件要比粗暴地用空指针一刀切好,更何况除了初始化的情 况,都不能将NULL赋给指针。

2.3.2 重复释放

重复释放是指两次释放同一块内存。下面是一个简单的例子:

int *pi = (int*) malloc(sizeof(int)); 
*pi = 5; 
free(pi); 
... 
free(pi);

调用第二个free函数会导致运行时异常。另一个例子不那么明显,涉 及指向同一块内存的两个指针。如下所示,如果我们试图第二次释放 同一块内存会发生同样的运行时异常。

p1 = (int*) malloc(sizeof(int)); 
int *p2 = p1; 
free(p1); 
... 
free(p2);

内存分配如图2-10所示。
在这里插入图片描述

图2-10:重复释放

注意 两个指针引用同一个地址称为别名,这个概念将在第8章讨 论。

不幸的是,堆管理器很难判断一个块是否已经被释放,因此它们不会 试图去检测是否两次释放了同一块内存。这通常会导致堆损坏和程序 终止,即使程序没有终止,它意味着程序逻辑可能存在问题,同一块 内存没有理由释放两次。

有人建议free函数应该在返回时将NULL或其他某个特殊值赋给自身 的参数。但指针是传值的,因此free函数无法显式地给它赋 值NULL,3.2.7节会详细讨论这个问题。

2.3.3 堆和系统内存

堆一般利用操作系统的功能来管理内存。堆的大小可能在程序创建后 就固定不变了,也可能可以增长。不过堆管理器不一定会在调 用free函数时将内存返还给操作系统。释放的内存只是可供应用程序 后续使用。所以,如果程序先分配内存然后释放,从操作系统的角度 看,释放的内存通常不会反映在应用程序的内存使用上。

2.3.4 程序结束前释放内存

操作系统负责维护应用程序的资源,包括内存。当应用程序终止时, 操作系统要负责重新分配这块内存以便别的应用程序使用。已终止的 应用程序的内存状态不管是否损坏都无关紧要,事实上,内存损坏可 能正是应用程序终止的原因。异常终止的程序可能无法做清理工作, 因此没有理由在程序终止之前释放分配的内存。

话虽如此,可能又有一些原因要求我们在程序终止前释放内存。尽责 的程序员可能会把释放内存当成质量指标。即使应用程序正在终止, 不再使用内存后将其释放总归是好习惯。如果用工具来检测内存泄漏 或是类似问题,那么释放内存会让这类工具的输出是干净的。在有些 相对简单的操作系统上,操作系统本身可能不会自动回收内存,而是 需要程序在终止前回收内存。还有,新版的应用程序可能会在程序末 尾增加代码,如果之前的内存没有释放就可能出问题。

因此,确保程序终止前释放所有内存:

  • 可能得不偿失;
  • 可能很耗时,释放复杂结构也比较麻烦;
  • 可能增加应用程序大小;
  • 导致更长的运行时间;
  • 增加引入更多编程错误的概率。

是否要在程序终止前释放内存取决于具体的应用程序。

2.4 迷途指针

如果内存已经释放,而指针还在引用原始内存,这样的指针就称为迷 途指针。迷途指针没有指向有效对象,有时候也称为过早释放。

使用迷途指针会造成一系列问题,包括:

  • 如果访问内存,则行为不可预期;
  • 如果内存不可访问,则是段错误;
  • 潜在的安全隐患。

导致这几类问题的情况可能如下:

  • 访问已释放的内存;
  • 返回的指针指向的是上次函数调用中的自动变量(在3.2.5节中 会讨论)。

2.4.1 迷途指针示例

在下面这个简单的例子中我们用malloc函数为一个整数分配内存, 接下来,用free函数释放内存:

int *pi = (int*) malloc(sizeof(int)); 
*pi = 5; 
printf("*pi: %d\n", *pi); 
free(pi);

pi变量持有整数的地址,但堆管理器可以重复利用这块内存,且其中 存放的可能是非整数数据。图2-11说明了free函数执行前后的程序状 态。假设pi变量属于main函数,位于地址100,malloc函数分配的 内存位于地址500。

在这里插入图片描述

图2-11:迷途指针

执行free函数将释放地址500处的内存,此后就不应该再使用这块内 存了。但大部分运行时系统不会阻止后续的访问或修改。我们还是可 以向这个位置写入数据,如下所示。这么做的结果是不可预期的。

free(pi); 
*pi = 10;

还有一种迷途指针的情况更难觉察:一个以上的指针引用同一内存区 域而其中一个指针被释放。如下所示,p1和p2都引用同一块内存区 域(称为指针别名),不过p1被释放了:

int *p1 = (int*) malloc(sizeof(int)); 
*p1 = 5; 
... 
int *p2; 
p2 = p1; 
...
free(p1); 
... 
*p2 = 10; // 迷途指针

图2-12说明了内存分配情况,虚线框表示释放的内存。
在这里插入图片描述

图2-12:迷途指针和指针别名

使用块语句时也可能出现一些小问题,如下所示。这里pi被赋值 为tmp的地址,变量pi可能是全局变量,也可能是局部变量。不过当 包含tmp的块出栈之后,地址就不再有效:

int *pi; 
... 
{ 
    int tmp = 5; 
    pi = &tmp; 
    
}// 这里pi变成了迷途指针 
foo();

大部分编译器都把块语句当做一个栈帧。tmp变量分配在栈帧上,之 后在块语句退出时会出栈。pi指针现在指向一块最终可能被其他活跃 记录(比如foo函数)覆盖的内存区域。图2-13说明的就是这种情 形。
在这里插入图片描述

图2-13:块语句的问题

2.4.2 处理迷途指针

有时候调试指针诱发的问题会很难解决,以下方法可用来对付迷途指 针。

  • 释放指针后置为NULL,后续使用这个指针会终止应用程序。 不过,如果存在多个指针的话还是会有问题。因为赋值只会影响一个指针,2.3.2节中有相关说明。
  • 写一个特殊的函数代替free函数(参见3.2.7节)。
  • 有些系统(运行时或调试系统)会在释放后覆写数据(比如 0xDEADBEEF,取决于被释放的对象,Visual Studio会用 0xCC、0xCD或者0xDD)。在不抛出异常的情况下,如果程序 员在预期之外的地方看到这些值,可以认为程序可能在访问已 释放的内存。
  • 用第三方工具检测迷途指针和其他问题。

在调试迷途指针时打印指针的值可能会有所帮助,但需要注意打印的 方式。1.1.5节已经讨论过如何打印指针的值。确保用一致的方式打 印,从而避免比较指针的值时产生歧义。assert宏也可能有用,7.1.3 节中会讲到。

2.4.3 调试器对检测内存泄漏的支持

微软提供了解决动态分配内存的覆写和内存泄漏的技术。这种方法在 调试版程序里用了特殊的内存管理技术:

  • 检查堆的完整性;
  • 检查内存泄漏;
  • 模拟堆内存不够的情况。

微软是通过使用一种特殊的数据结构管理内存分配来做到这一点的。 这种结构维护调试信息,比如malloc调用点的文件名和行号,还会 在实际的内存分配之前和之后分配缓冲区来检测对实际内存的覆写。

2.5 动态内存分配技术

目前为止,我们已经讨论了如何使用堆管理器分配和释放内存。不 过,不同的编译器在技术实现上有所不同。大部分堆管理器把堆或数 据段作为内存资源。这种方法的缺点是会造成碎片,而且可能和程序 栈碰撞。尽管如此,它还是实现堆最常用的方法。

堆管理器需要处理很多问题,比如堆是否基于进程和(或)线程分 配,如何保护堆不受安全攻击。

2.5.1 C的垃圾回收

malloc和free函数提供了手动分配和释放内存的方法。不过对于很多 问题,需要考虑使用C的手动内存管理,比如性能、达到好的引用局 部性、线程问题,以及优雅地清理内存。

有些非标准的技术可以用来解决部分问题,本节将探讨其中一部分技 术。这些技术的关键特性在于自动释放内存。内存不再使用之后会被 收集起来以备后续使用,释放的内存称为垃圾,因此,垃圾回收就是 指这个过程。

鉴于以下原因,垃圾回收是有价值的:

  • 不需要程序员费尽心思决定何时释放内存;
  • 让程序员专注应用程序本身的问题。

2.5.2 资源获取即初始化

资源获取即初始化(Resource Acquisition Is Initialization,RAII) 是Bjarne Stroustrup发明的技术,可以用来解决C++中资源的分配 和释放。即使有异常发生,这种技术也能保证资源的初始化和后续的 释放。分配的资源最终总是会得到释放。

有好几种方法可以在C中使用RAII。GNU编译器提供了非标准的扩展 来支持这个特性,通过演示如何在一个函数中分配内存然后释放可以 说明这种扩展。一旦变量超出作用域会自动触发释放过程。

GNU的扩展要用到RAII_VARIABLE宏,它声明一个变量,然后给变 量关联如下属性:

  • 一个类型;
  • 创建变量时执行的函数;
  • 变量超出作用域时执行的函数。

这个宏如下所示:

#define RAII_VARIABLE(vartype,varname,initval,dtor) \ 
void _dtor_ ## varname (vartype *v) { dtor(*v); } \ 
vartype varname __attribute__((cleanup(_dtor_ ## varname))) = (initval)

在下例中,我们将name变量声明为字符指针。创建它时会执 行malloc函数,为其分配32字节。当函数结束时,name超出作用域 就会执行free函数:

void raiiExample() { 
        
    RAII_VARIABLE(char*, name, (char*)malloc(32), free); 
    strcpy(name,"RAII Example"); 
    printf("%s\n",name); 
    
}

函数执行后会打印"RAII_Example"字符串。

2.5.3 使用异常处理函数

这里的try块包含任何可能在运行时抛出异常的语句。不管有没有异 常抛出,都会执行finally块,因此也一定会执行free函数。

void exceptionExample() { 
    int *pi = NULL; 
    __try{ 
        pi = (int*)malloc(sizeof(int)); 
        *pi = 5; 
        printf("%d\n",*pi); 
        
    }
    __finally{ 
        free(pi); 
        
    } 
    
}

也可以用别的方法在C中实现异常处理。

第3章 指针和函数

指针对函数功能的贡献极大。它们能够将数据传递给函数,并且允许 函数对数据进行修改。我们可以将复杂数据用结构体指针的形式传递 给函数和从函数返回。如果指针持有函数的地址,就能动态控制程序 的执行流。在本章中,我们会探索指针与函数结合使用的能力,学习 如何用指针解决很多真实存在的问题。

要理解函数及其和指针的结合使用,需要理解程序栈。大部分现代的 块结构语言,比如C,都用到了程序栈来支持函数执行。调用函数 时,会创建函数的栈帧并将其推到程序栈上。函数返回时,其栈帧从 程序栈上弹出。

在使用函数时,有两种情况指针很有用。首先是将指针传递给函数, 这时函数可以修改指针所引用的数据,也可以更高效地传递大块信 息。

另一种情况是声明函数指针。本质上,函数表示法就是指针表示法。 函数名字经过求值会变成函数的地址,然后函数参数会被传递给函 数。我们将会看到,函数指针为控制程序的执行流提供了新的选择。

下面这一节将为理解和使用函数以及指针打好基础。鉴于函数和指针 的普及程度,有这个基础会对你有很大帮助。

3.1 程序的栈和堆

程序的栈和堆是C重要的运行时元素。在本节中,我们将仔细研究程 序栈和堆的结构以及用法,还会看一下栈帧的结构,它用于保存局部 变量。

注意 局部变量也称为自动变量,它们总是分配在栈帧上。

3.1.1 程序栈

程序栈是支持函数执行的内存区域,通常和堆共享。也就是说,它们 共享同一块内存区域。程序栈通常占据这块区域的下部,而堆用的则 是上部。

程序栈存放栈帧(stack frame),栈帧有时候也称为活跃记 录(activation record)或活跃帧(activation frame)。栈帧存放 函数参数和局部变量。堆管理动态内存,已经在2.2.1节中讨论过 了。

图3-1从原理上说明了栈和堆的结构。这个说明基于以下代码片段。

void function2() { 
        
    Object *var1 = ...; 
    int var2; 
    printf("Program Stack Example\n"); 
    
}
void function1() { 
    
    Object *var3 = ...; 
    function2(); 
    
}

int main() { 
    
    int var4; 
    function1(); 
    
}

在这里插入图片描述

图3-1:栈和堆

调用函数时,函数的栈帧被推到栈上,栈向上“长出”一个栈帧。当 函数终止时,其栈帧从程序栈上弹出。栈帧所使用的内存不会被清 理,但最终可能会被推到程序栈上的另一个栈帧覆盖。

动态分配的内存来自堆,堆向下“生长”。随着内存的分配和释放, 堆中会布满碎片。尽管堆是向下生长的,但这只是个大体方向,实际 上内存可能在堆上的任意位置分配。

3.1.2 栈帧的组织

栈帧由以下几种元素组成。

  • 返回地址
    函数完成后要返回的程序内部地址。
  • 局部数据存储
    为局部变量分配的内存。
  • 参数存储
    为函数参数分配的内存。
  • 栈指针和基指针
    运行时系统用来管理栈的指针。

普通C程序员不会关心支持栈帧的栈和基指针。不过,理解它们的概 念和用法能让你更深入地理解程序栈。

栈指针通常指向栈顶部。基指针(帧指针)通常存在并指向栈帧内部 的地址,比如返回地址,用来协助访问栈帧内部的元素。这两个指针 都不是C指针,它们是运行时系统管理程序栈的地址。如果运行时系 统用C实现,这些指针倒真是C指针。

我们以下面这个函数为例来了解栈帧的创建。该函数传递了一个整数 数组和一个表示数组长度的整数。三个printf语句用来打印参数和局 部变量的地址:

float average(int *arr, int size) {
        
    int sum; 
    printf("arr: %p\n",&arr); 
    printf("size: %p\n",&size); 
    printf("sum: %p\n",&sum); 
    
    for(int i=0; i<size; i++) { 
        sum += arr[i]; 
        
    }
    return (sum * 1.0f) / size; 
    
}

执行上述代码会得到类似下面的输出:

arr: 0x500
size: 0x504
sum: 0x480

参数地址和局部变量地址之间的空档,保存的是运行时系统管理栈所 需要的其他栈帧元素。

系统在创建栈帧时,将参数以跟声明时相反的顺序推到帧上,最后推 入局部变量,如图3-2所示。在这个例子中,size在arr之后被推入。 通常,接下来会推入函数调用的返回地址,然后是局部变量。推入它们的顺序跟其在代码中列出的顺序相反。

在这里插入图片描述

图3-2:栈帧示例

从原理上说,本例中的栈“向上”生长。不过栈帧的参数和局部变量 以及新栈帧被添加到了低内存地址。栈的实际生长方向跟实现相关。for语句中用到的变量i没有包含在栈帧中。C把块语句当做“微 型”函数,会在合适的时机将其推入栈和从栈中弹出。在本例中,块 语句在执行时被推到程序栈中average栈帧上面,执行完后又弹出。

精确的地址可能会变化,不过顺序一般不变。这一点很重要,因为它 可以解释参数和变量内存分配的相对顺序。在调试指针问题时这一点 会很有用。如果你不知道栈帧如何分配,这些地址在你看来也毫无意 义。

将栈帧推到程序栈上时,系统可能会耗尽内存,这种情况称为栈溢 出,通常会导致程序非正常终止。要牢记每个线程通常都会有自己的 程序栈。一个或多个线程访问内存中的同一个对象可能会导致冲突, 我们将在8.3.1节中讨论这个问题。

3.2 通过指针传递和返回数据

本节讨论将指针传递给函数和从函数返回指针。传递指针可以让多个 函数访问指针所引用的对象,而不用把对象声明为全局可访问。这意 味着只有需要访问这个对象的函数才有访问权限,而且也不需要复制 对象。

要在某个函数中修改数据,需要用指针传递数据。通过传递一个指向 常量的指针,可以使用指针传递数据并禁止其被修改,正如3.2.3节 中所展示的那样。当数据是需要被修改的指针时,我们就传递指针的 指针,这个话题在3.2.7节中讨论。

传递参数(包括指针)时,传递的是它们的值。也就是说,传递给函 数的是参数值的一个副本。当涉及大型数据结构时,传递参数的指针 会更高效。比如说一个表示雇员的大型结构体,如果我们把整个结构 体传递给函数,那么需要复制结构体的所有字节,这样会导致程序运 行变慢,栈帧也会占用过多内存。传递对象的指针意味着不需要复制 对象,但可以通过指针访问对象。

3.2.1 用指针传递数据

用指针来传递数据的一个主要原因是函数可以修改数据。下面的代码 段实现了一个交换函数,可以交换其参数所引用的值。这是很多排序 算法中的常用操作。我们在这里用整数指针,通过解引它们来实现交 换。

void swapWithPointers(int* pnum1, int* pnum2) { 
        
    int tmp; 
    tmp = *pnum1; 
    *pnum1 = *pnum2; 
    *pnum2 = tmp; 
    
}

下面的代码段说明了这个函数的用法:

int main() { 
        
    int n1 = 5; 
    int n2 = 10; 
    swapWithPointers(&n1, &n2); 
    return 0; 
    
}

指针pnum1和pnum2在交换操作中被解引,结果是修改 了n1和n2的值。图3-3说明了内存如何组织,左图表示swap函数开 始时程序栈的状态,而右图则是函数返回前的状态。

在这里插入图片描述

图3-3:用指针实现交换

3.2.2 用值传递数据

如果不通过指针传递参数,那么交换就不会发生。下面的函数通过值 来传递两个整数:

void swap(int num1, int num2) { 
        
    int tmp; 
    tmp = num1; 
    num1 = num2; 
    num2 = tmp; 
    
}

下面的代码将两个整数传递给函数:

int main() { 
        
    int n1 = 5; 
    int n2 = 10; 
    swap(n1, n2); 
    return 0; 
    
}

然而,这样并没有实现交换,因为整数是通过值而不是指针来传递 的。num1和num2中保存的只是实参的副本。修改num1,实 参n1不会变化。修改形参不会影响实参。图3-4说明了形参的内存分配。

在这里插入图片描述

图3-4:通过值传递数据

3.2.3 传递指向常量的指针

传递指向常量的指针是C中常用的技术,效率很高,因为我们只传了 数据的地址,能避免某些情况下复制大量内存。不过,如果只是传递 指针,数据就能被修改。如果不希望数据被修改,就要传递指向常量 的指针。

在本例中,我们传递一个指向整数常量的指针和一个指向整数的指 针。在函数内,我们不能修改通过指向常量的指针传进来的值:

void passingAddressOfConstants(const int* num1, int* num2) {     
    *num2 = *num1; 
}
int main() { 
    
    const int limit = 100; 
    int result = 5; 
    passingAddressOfConstants(&limit, &result); 
    return 0; 
    
}

这样不会产生语法错误,函数会把100赋给result变量。在下面这个 版本的函数中,我们试图修改两个被引用的整数:

void passingAddressOfConstants(const int* num1, int* num2) { 
    *num1 = 100; 
    *num2 = 200; 
}

如果我们把limit常量传递给函数的两个参数就会导致问题:

const int limit = 100; 
passingAddressOfConstants(&limit, &limit);

这样会产生一个语法错误,抱怨第二个形参和实参的类型不匹配。此 外,它还会抱怨我们试图修改第一个参数所引用的常量。

该函数期待一个整数指针,但是传进来的却是指向整数常量的指针。我们不能把一个整数常量的地址传递给一个指向常量的指针,因为这样会允许修改常量。

像下面这样试图传递一个整数字面量的地址也会产生语法错误:

passingAddressOfConstants(&23, &23);

这种情况错误信息会指出取地址操作符的操作数需要的是一个左值。 左值的概念在1.1.6节中讨论过了。

3.2.4 返回指针

返回指针很容易,只要返回的类型是某种数据类型的指针即可。从函 数返回对象时经常用到以下两种技术。

  • 使用malloc在函数内部分配内存并返回其地址。调用者负责释 放返回的内存。
  • 传递一个对象给函数并让函数修改它。这样分配和释放对象的 内存都是调用者的责任。

首先,我们介绍用malloc这类函数来分配返回的内存,随后的示例 中我们返回一个局部对象的指针,不推荐后一种方法。上面列出的第 二种技术在3.2.6节中说明。

在下面的例子中,我们定义一个函数,为其传递一个整数数组的长度 和一个值来初始化每个元素。函数为整数数组分配内存,用传入的值 进行初始化,然后返回数组地址:

int* allocateArray(int size, intvalue) { 
        
   int* arr = (int*)malloc(size * sizeof(int)); 
   for(int i=0; i<size; i++) { 
       arr[i] = value; 
       
   }
   return arr; 
   
}

下面说明如何使用这个函数:

int*vector = allocateArray(5,45); 
for(int i=0; i<5; i++) { 
    printf("%d\n", vector[i]); 
}

图3-5说明了这个函数的内存分配。左图显示return语句执行前的程 序状态,右图显示函数返回后的程序状态。vector变量包含了函数内 分配的内存的地址。当函数终止时arr变量也会消失,但是指针所引 用的内存还在,这部分内存最终需要释放。

在这里插入图片描述

图3-5:返回指针

尽管上例可以正确工作,但从函数返回指针时可能存在几个潜在的问 题,包括:

  • 返回未初始化的指针;
  • 返回指向无效地址的指针;
  • 返回局部变量的指针;
  • 返回指针但是没有释放内存。

最后一个问题的典型代表就是allocateArray函数。从函数返回动态 分配的内存意味着函数的调用者有责任释放内存。看一下这个例子:

int*vector = allocateArray(5,45); 
... 
free(vector);

最终我们必须在用完后释放内存,否则就会产生内存泄漏。

3.2.5 局部数据指针

如果你不理解程序栈如何工作,就很容易犯返回指向局部数据的指针 的错误。在下面的例子中,我们重写了3.2.4节中用到 的allocateArray函数。这次我们不为数组动态分配内存,而是用了 一个局部数组:

int* allocateArray(int size, intvalue) { 
    int arr[size]; 
    
    for(int i=0; i<size; i++) { 
        arr[i] = value; 
        
    }
    
    return arr; 
    
}

不幸的是,一旦函数返回,返回的数组地址也就无效了,因为函数的 栈帧从栈中弹出了。尽管每个数组元素仍然可能包含45,但如果调 用另一个函数,就可能覆写这些值。下面的代码段对此做了演示,重 复调用printf函数导致数组损坏:

int*vector = allocateArray(5,45); 
    
for(int i=0; i<5; i++) { 
    
    printf("%d\n", 
    vector[i]); 
    
}

图3-6说明了发生这种情况时内存的分配状态。虚线框表示其他的栈 帧(比如printf函数用到的),可能会被推到程序栈上,从而损坏数 组持有的内存。栈帧的实际内容取决于实现。
在这里插入图片描述

图3-6:返回局部数据的指针

还有一种方法是把arr变量声明为static。这样会把变量的作用域限制 在函数内部,但是分配在栈帧外面,避免其他函数覆写变量值。

int* allocateArray(int size, intvalue) { 
    static int arr[5]; 
    ... 
    
}

不过这种方法并不一定总是有用。每次调用allocateArray函数都会 重复利用这个数组。这样相当于每次都把上一次调用的结果覆盖掉。 此外,静态数组必须声明为固定长度,这样会限制函数处理变长数组 的能力。

如果函数只是返回一个可能的值,而且共享这些值也不会有什么坏 处,那么它可以维护一个这些值的列表,然后返回合适的值。如果我 们需要返回状态类型的消息,比如不大可能被修改的错误码,这么做 就很有用。

3.2.6 传递空指针

下面这个版本的allocateArray函数传递了一个数组指针、数组的长 度和用来初始化数组元素的值。返回指针只是为了方便。这个版本的 函数不会分配内存,但后面的版本会分配:

int* allocateArray(int *arr, int size, intvalue) { 
    if(arr != NULL) { 
        for(int i=0; i<size; i++) { 
            arr[i] = value; 
            
        } 
        
    }
    
    return arr; 
}

将指针传递给函数时,使用之前先判断它是否为空是个好习惯。
该函数可以像这样调用:

int*vector = (int*)malloc(5 * sizeof(int)); 
allocateArray(vector,5,45);

如果指针是NULL,那么什么都不会发生,程序继续执行,不会非正 常终止。

3.2.7 传递指针的指针

将指针传递给函数时,传递的是值。如果我们想修改原指针而不是指 针的副本,就需要传递指针的指针。在下例中,我们传递了一个整数 数组的指针,为该数组分配内存并将其初始化。函数会用第一个参数 返回分配的内存。在函数中,我们先分配内存,然后初始化。所分配 的内存地址应该被赋给一个整数指针。为了在调用函数中修改这个指 针,我们需要传入指针的地址。所以,参数被声明为int指针的指 针。在调用函数中,我们需要传递指针的地址:

void allocateArray(int **arr, int size, intvalue) { 
    *arr = (int*)malloc(size * sizeof(int)); 
    if(*arr != NULL) { 
        for(int i=0; i<size; i++) { 
            *(*arr+i) = value; 
        } 
    } 
}

这个函数可以用下面的代码测试:

int *vector = NULL; 
allocateArray(&vector,5,45);

allocateArray的第一个参数以整数指针的指针的形式传递。当我们 调用这个函数时,需要传递这种类型的值。这是通过传递vector地址 做到的。malloc返回的地址被赋给arr。解引整数指针的指针得到的 是整数指针。因为这是vector的地址,所以我们修改了vector。

内存分配说明如图3-7所示。左图显示malloc返回且初始化数组后的 栈状态。类似地,右图显示函数返回后的栈状态。
在这里插入图片描述

图3-7:传递指针的指针

注意 要方便地发现内存泄漏这样的问题,只需画一张内存分配 图。

下面这个版本的函数说明了为什么只传递一个指针不会起作用:

void allocateArray(int *arr, int size, intvalue) { 
        arr = (int*)malloc(size * sizeof(int)); 
        if(arr != NULL) { 
        for(int i=0; i<size; i++) { 
            arr[i] = value; 
        } 
    } 
}

下面的代码段说明了如何使用这个函数:

int *vector = NULL; 
allocateArray(&vector,5,45); 
printf("%p\n",vector);

运行后会看到程序打印出0x0,因为将vector传递给函数时,它的值 被复制到了参数arr中,修改arr对vector没有影响。当函数返回后, 没有将存储在arr中的值复制到vector中。图3-8说明了内存分配情 况:左图显示arr被赋新值之前的内存状态;中图显 示allocateArray函数中的malloc函数执行且初始化数组后的内存状 态,arr变量被修改为指向堆中的某个新位置;右图显示函数返回后 程序栈的状态。此外,这里有内存泄漏,因为我们无法再访问地址 600处的内存块了。

在这里插入图片描述

图3-8:传递指针

实现自己的free函数

由于free函数存在一些问题,因而某些程序员创建了自己的free函 数。free函数不会检查传入的指针是否是NULL,也不会在返回前把 指针置为NULL。释放指针之后将其置为NULL是个好习惯。

有了3.2节中的基础知识,我们给出下面这个free函数的实现,可以 给指针赋NULL。此处需要我们给它传递一个指针的指针:

void saferFree(void **pp) { 
    if (pp != NULL && *pp != NULL) { 
        free(*pp); *pp = NULL; 
    } 
}

saferFree函数调用实际释放内存的free函数,前者的参数声明 为void指针的指针。使用指针的指针允许我们修改传入的指针,而使 用void类型则可以传入所有类型的指针。不过,如果调用这个函数时 没有显式地把指针类型转换为void会产生警告,执行显式转换就不会 有警告。

下面这个safeFree宏调用saferFree函数,执行类型转换,并使用了 取地址操作符,这样就省去了函数使用者做类型转换和传递指针的地 址:

#define safeFree(p) saferFree((void**)&(p))

下面的代码片段说明了这个宏的用法:

int main() { 
        
    int *pi; 
    pi = (int*) malloc(sizeof(int)); 
    *pi = 5; 
    printf("Before: %p\n",pi); 
    safeFree(pi); 
    printf("After: %p\n",pi); 
    safeFree(pi); 
    return (EXIT_SUCCESS); 
    
}

假设malloc返回的内存位于地址1000,那么这段代码的输出是1000和0。第二次调用safeFree宏给它传递NULL值不会导致程序终止, 因为saferFree函数检测到这种情况并忽略了这个操作。

3.3 函数指针

函数指针是持有函数地址的指针。指针能够指向函数对于C来说是很 重要也很有用的特性,这为我们以编译时未确定的顺序执行函数提供 了另一种选择,而不需要使用条件语句。

人们使用函数指针的一个顾虑是这种做法可能会导致程序运行变慢, 处理器可能无法配合流水线做分支预测。分支预测是处理器用来推测 哪块代码会被执行的技术。流水线是常用的提升处理器性能的硬件技 术,通过重叠指令的执行来实现。在这种机制下,处理器会处理它认 为能执行的分支,如果预测正确,那么就不需要丢弃当前流水线中的 指令。

函数指针对性能的影响要视具体情况而定。在表查找等场景中使用函 数指针可以缓解性能问题。在本节中,我们会学习如何声明函数指 针,如何使用函数指针来支持其他的执行路径,还会探索能够充分发 挥函数指针潜能的技术。

3.3.1 声明函数指针

第一次看到声明函数指针的语法时你可能会感到迷惑。不过跟C的很 多方面一样,一旦熟悉这种表示法,理解起来就顺理成章了。下面我 们声明一个函数指针,该函数接受空参数,返回空值。

void (*foo)();

这个声明很像函数原型声明。如果去掉第一对括号,看起来就像函 数foo的原型,它接受void,返回void指针。不过,括号让这个声明 变成了一个名为foo的函数指针。星号表示这是个指针。图3-9说明 了函数指针声明的各个部分。
在这里插入图片描述

图3-9:函数指针声明

注意 使用函数指针时一定要小心,因为C不会检查参数传递是否 正确。

下面是声明函数指针的其他一些例子:

int (*f1)(double); //传入double,返回
int void (*f2)(char*); //传入char指针,没有返回值 
double* (*f3)(int, int); //传递两个整数,返回double指针

注意 我们对函数指针在命名约定上的建议是用fptr做前缀。

不要把返回指针的函数和函数指针搞混。下面的f4是一个函数,它返 回一个整数指针,而f5是一个返回整数的函数指针,变量f6是一个返 回整数指针的函数指针。

int *f4(); 
int (*f5)(); 
int* (*f6)();

可以调整这些表达式中的空白符,如下所示:

int* f4(); 
int (*f5)();

很明显,f4是个返回整数指针的函数,而f5的括号则明确地把表 示“指针”的星号和函数名绑定在一起,所以它是个函数指针。

3.3.2 使用函数指针

下面是使用函数指针的一个简单示例,其中函数接受一个整数参数并 返回一个整数。我们也定义了square函数,对一个整数求平方并返 回值。为了简化例子,假定整数不会溢出。

int (*fptr1)(int); 
int square(int num) { 
    return num*num; 
}

要用函数指针来调用square函数,需要把square函数的地址赋给函 数指针,如下所示。就像数组名字一样,我们用的是函数本身的名 字,它会返回函数的地址。我们还声明了一个整数并将其传递给函 数:

int n = 5; 
fptr1 = square; 
printf("%d squared is %d\n",n, fptr1(n));

执行代码后会显示”5 square is 25.“。我们也可以像下面那样用取 地址操作符对函数名进行操作,但是没必要这么做。在这种上下文环 境中编译器会忽略取地址操作符。

fptr1 = &square;

图3-10说明了本例的内存分配。我们把square函数放在程序栈下 方。这只是举例子,实际上函数会被分配在跟程序栈所用段不同的段 上。函数的实际地址通常对我们没用。
在这里插入图片描述

图3-10:函数的位置

为函数指针声明一个类型定义会比较方便,下面说明对于之前用到的 函数指针应该怎么做。类型定义看起来有点奇怪,通常,类型定义的 名字是声明的最后一个元素。

typedef int (*funcptr)(int); 
... 
funcptr fptr2; fptr2 = square; 
printf("%d squared is %d\n",n, fptr2(n));

5.5节提供了一个有趣的例子,讲的是用函数指针来控制字符串的排 序方式。

3.3.3 传递函数指针

传递函数指针很简单,只要把函数指针声明作为函数参数即可。我们 会用下面这个例子中的add、sub和compute函数来说明如何传递函 数指针:

int add(int num1, int num2) { 
    return num1 + num2; 
}

int subtract(int num1, int num2) { 
    return num1 - num2; 
}

typedef int (*fptrOperation)(int,int); 

int compute(fptrOperation operation, int num1, int num2) { 
    return operation(num1, num2); 
}

下面的代码片段说明如何使用这些函数:

printf("%d\n",compute(add,5,6)); 
printf("%d\n",compute(sub,5,6));

输出是11和-1。add和sub函数的地址被传递给compute函数,后者 使用这些地址来调用对应的操作。本例也说明了使用函数指针可以让 代码变得更灵活。

3.3.4 返回函数指针

返回函数指针需要把函数的返回类型声明为函数指针,为了说明如何 实现这一点,我们会沿用3.3.3节中的add和sub函数,以及类型定 义。

我们用下面的select函数基于输入的字符来返回一个指向对应操作的 函数指针。取决于传入的操作码,它要么返回add函数,要么返 回sub函数。

fptrOperation select(char opcode) { 
    switch(opcode) { 
        case '+': 
            return add; 
        case '-': 
            return subtract; 
    } 
}

evaluate函数把这些函数联系在一起,该函数接受两个整数和一个字 符,字符代表要做的操作,它会把opcode传递给select函数,后者 返回要执行的函数指针。在返回语句中,evaluate函数执行刚才返回 的函数并返回结果。

int evaluate(char opcode, int num1, int num2) { 
    fptrOperation operation = select(opcode); 
    return operation(num1, num2); 
}

evaluate函数及printf语句的用法如下所示:

printf("%d\n",evaluate('+', 5, 6)); 
printf("%d\n",evaluate('-', 5, 6));

3.3.5 使用函数指针数组

函数指针数组可以基于某些条件选择要执行的函数,声明这种数组很 简单,只要把函数指针声明为数组的类型即可,如下所示。这个数组 的所有元素都被初始化为NULL。如果数组的初始化值是一个语句 块,系统会将块中的值赋给连续的数组元素。本例中只有一个值,我 们会用这个值来初始化数组的所有元素。

typedef int (*operation)(int, int); 
operation operations[128] = {NULL};

也可以不用typedef来声明这个数组,如下:

int (*operations[128])(int, int) = {NULL};

这个数组的目的是可以用一个字符索引选择对应的函数来执行。比 如,如果存在*字符就表示乘法函数,我们可以用字符作为索引是因 为字符字面量其实是整数,128个元素对应前128个ASCII字符。我们 会把这个定义用在3.3.4节中实现的add、sub函数上。

数组初始化为NULL后,我们把add和sub函数赋给加号和减号对应 的元素:

void initializeOperationsArray() { 
    operations['+'] = add; 
    operations['-'] = subtract; 
}

将前面的evaluate函数改写为evaluateArray。接下来我们用操作字 符作为索引来使用operations,而不是调用select函数来获取函数指 针。

int evaluateArray(char opcode, int num1, int num2) { 
    fptrOperation operation; 
    operation = operations[opcode]; 
    return operation(num1, num2); 
}

用下面的代码测试这些函数:

initializeOperationsArray(); 
printf("%d\n",evaluateArray('+', 5, 6)); 
printf("%d\n",evaluateArray('-', 5, 6));

执行结果是11和-1。更健壮的evaluateArray函数版本需要在执行函 数之前检查空指针。

3.3.6 比较函数指针

我们可以用相等和不等操作符来比较函数指针。下例中用到 了fptrOperator类型定义和3.3.3节中的add函数。add函数被赋 给fptr1函数指针,然后和add函数的地址做比较:

fptrOperation fptr1 = add; 
if(fptr1 == add) { 
    printf("fptr1 points to add function\n"); 
} else { 
    printf("fptr1 does not point to add function\n"); 
}

执行这段代码后,通过输出可以看到指针确实指向了add函数。

可以说明比较函数指针用处的一个更现实的例子是,用函数指针数组 表示一系列任务步骤的情况。比如说,我们可能会有一系列函数维护 一个库存部件数组。可能用一组操作来对部件排序,计算总数,然后 打印出数组和总数;用另一组操作打印数组,找到最贵和最便宜的部件,然后显示差额。每种操作都可以用指向各自函数的指针的数组来 表示。日志操作可能同时出现在上述两组操作中。借助比较两个函数 指针,我们可以通过删除某个操作(比如日志)来动态修改操作,只 要从列表中找到并删除对应的函数指针即可。

3.3.7 转换函数指针

我们可以将指向某个函数的指针转换为其他类型的指针,不过要谨慎 使用,因为运行时系统不会验证函数指针所用的参数是否正确。也可 以把一种函数指针转换为另一种再转换回来,得到的结果和原指针相 同,但函数指针的长度不一定相等。下面的代码说明了这个操作:

typedef int (*fptrToSingleInt)(int); 
typedef int (*fptrToTwoInts)(int,int); 
int add(int, int); 

fptrToTwoInts fptrFirst = add; 
fptrToSingleInt fptrSecond = (fptrToSingleInt)fptrFirst; 
fptrFirst = (fptrToTwoInts)fptrSecond; 
printf("%d\n",fptrFirst(5,6));

这段代码执行后输出11。

警告 无法保证函数指针和数据指针相互转换后正常工作。

void* 指针不一定能用在函数指针上,也就是说我们不应该像下面这 样把函数指针赋给void* 指针:

void* pv = add;

不过在交换函数指针时,通常会见到如下声明所示的“基本”函数指 针类型。这里把fptrBase声明为指向不接受参数也不返回结果的函数 的函数指针。

typedef void (*fptrBase)();

下面的代码片段说明了基本指针的用法,跟上一个例子是一样的效 果:

fptrBase basePointer; 
fptrFirst = add; 
basePointer = (fptrToSingleInt)fptrFirst; 
fptrFirst = (fptrToTwoInts)basePointer; 
printf("%d\n",fptrFirst(5,6));

基本指针用做占位符,用来交换函数指针的值。

警告 一定要确保给函数指针传递正确的参数,否则会造成不确 定的行为。

第4章 指针和数组

4.1 数组概述

数组是C内建的基本数据结构,彻底理解数组及其用法是开发高效应 用程序的基础。曲解数组和指针的用法会造成难以查找的错误,应用 程序的性能也难以达到最优。数组和指针表示法紧密关联,在合适的 上下文中可以互换。

一种常见的错误观点是数组和指针是完全可以互换的。尽管数组名字 有时候可以当做指针来用,但数组的名字不是指针。数组表示法也可 以和指针一起使用,但两者明显不同,也不一定能互换。理解这种差 别可以帮助你避免错误地使用这些表示法。比如说,尽管数组使用自 身的名字可以返回数组地址,但是名字本身不能作为赋值操作的目 标。

数组在应用程序中随处可见,可能是一维,也可能是多维。在本章 中,我们会讲解数组跟指针相关的基础知识,以便你深入理解数组以 及使用指针操作数组的各种方法。本书其他章节还会展示在更高级的 环境中使用数组和指针。

本章首先概述数组,然后研究数组表示法和指针表示法的相同点和不 同点。可以用malloc类函数创建数组,这些函数提供比传统的数组 声明更灵活的机制。我们会看到如何用realloc函数来改变已经为一 个数组分配的内存大小。

为数组动态分配内存可以为代码带来很大的改变,特别是处理二维或 多维数组的情况,因为我们得确保为数组分配的内存是连续的。

我们也会探索传递和返回数组时可能发生的问题。大部分情况下,必 须传入数组长度以便函数正确处理数组。数组的内部表示不带有长度 信息,如果我们不传递长度,函数就没有标准的方法得到数组的终 点。即便并不常用,我们也会研究如何在C中创建不规则数组。不规 则数组是二维数组,每一行都可能包含不同的列数。

要说明这些概念,需要使用向量和矩阵,前者代表一维数组,后者代 表二维数组。向量和矩阵用途广泛,包括电磁场分析、天气预报和数 学上的应用。

4.1 数组概述
数组是能用索引访问的同质元素连续集合。这里所说的连续是指数组 的元素在内存中是相邻的,中间不存在空隙,而同质是指元素都是同 一类型的。数组声明用的是方括号集合,可以拥有多个维度。

二维数组很常见,我们一般用行和列来表述数组元素的位置。三维或 更多维的数组不是很常见,不过有些应用程序会用到。不要混淆二维 数组和指针的数组,它们很类似,但是行为有点差别,我们会在4.6 节中讲到。

C99标准引入了变长数组,在此之前,支持变长数组的技术是 用realloc函数实现的。我们会在4.4节中说明realloc函数。

注意 数组的长度是固定的,当我们声明数组时,需要决定该数 组有多大。如果指定过多元素就会浪费空间,而指定过少元素就会限制能够处理的元素数量。realloc函数和变长数组提供了应对 长度需要变化的数组的技术。只要略施小计,我们就能调整数组长度,只占用合适的内存。

4.1.1 一维数组

一维数组是线性结构,用一个索引访问成员。下面的代码声明了一个 5个元素的整数数组:

int vector[5];

数组索引从0开始,到声明的长度减1结束。vector数组的索引从0开 始,到4结束。不过,C并没有强制规定边界,用无效的索引访问数 组会造成不可预期的行为。图4-1说明了数组的内存如何分配,每个 元素4字节长,且没有初始化。就像1.2.1节中所解释的,取决于不同 的内存模型,数组的长度可能会不同。
在这里插入图片描述

图4-1:数组的内存分配

数组的内部表示不包含其元素数量的信息,数组名字只是引用了一块 内存。对数组做sizeof操作会得到为该数组分配的字节数,要知道元素的数量,只需将数组长度除以元素长度,如下所示,打印结果是 5:

printf("%d\n", sizeof(vector)/sizeof(int));

可以用一个块语句初始化一维数组,下面的代码把数组中的元素初始 化为从1开始的整数:

int vector[5] = {1, 2, 3, 4, 5};

4.1.2 二维数组

二维数组使用行和列来标识数组元素,这类数组需要映射为内存中的 一维地址空间。在C中这是通过行–列顺序实现的。先将数组的第一 行放进内存,接着是第二行、第三行,直到最后一行。

下面声明了一个2列3行的二维数组,用块语句对数组进行了初始 化。图4-2说明了这个数组的内存分配,左图说明内存如何映射,右 图显示数组在概念上的样子。

int matrix[2][3] = {{1,2,3},{4,5,6}};

在这里插入图片描述

图4-2:二维数组

我们可以将二维数组当做数组的数组,也就是说,如果只用一个下标 访问数组,得到的是对应行的指针。下面的代码说明了这个概念,它 会打印每一行的地址和长度:

for (int i = 0; i < 2; i++) { 
    printf("&matrix[%d]: %p sizeof(matrix[%d]): %d\n", i, &matrix[i], i, sizeof(matrix[i])); 
}

下面的输出假设数组位于地址100,因为每行有3个元素,每个元素4 字节长,所以组数长度是12:

&matrix[0]: 100 sizeof(matrix[0]): 12 
&matrix[1]: 112 sizeof(matrix[1]): 12

4.1.3 多维数组

多维数组具有两个及两个以上维度。对于多维数组,需要多组括号来 定义数组的类型和长度。下面的例子中,我们定义了一个具有3行、 2列、4阶的三维数组。阶通常用来标识第三维元素。

int arr3d[3][2][4] = { 
    {{1, 2, 3, 4}, {5, 6, 7, 8}}, 
    {{9, 10, 11, 12}, {13, 14, 15, 16}}, 
    {{17, 18, 19, 20}, {21, 22, 23, 24}} 
};

元素按照行–列–阶的顺序连续分配,如图4-3所示。
在这里插入图片描述

图4-3:三维数组

4.2 指针表示法和数组

指针在处理数组时很有用,我们可以用指针指向已有的数组,也可以 从堆上分配内存然后把这块内存当做一个数组使用。数组表示法和指 针表示法在某种意义上可以互换。不过,它们并不完全相同,后面 的“数组和指针的差别”中会详细说明。

单独使用数组名字时会返回数组地址。我们可以把地址赋给指针,如 下所示:

intvector[5] = {1, 2, 3, 4, 5}; 
int *pv = vector;

pv变量是指向数组第一个元素而不是指向数组本身的指针。给pv赋 值是把数组的第一个元素的地址赋给pv。

我们可以只用数组名字,也可以对数组的第一个元素用取地址操作 符,如下所示。这些写法是等价的,都会返回vector的地址。用取地 址操作符更繁琐一些,不过也更明确。

printf("%p\n",vector); 
printf("%p\n",&vector[0]);

有时候也会使用&vector这个表达式获取数组的地址,不同于其他表 示法,这么做返回的是整个数组的指针,其他两种方法得到是整数指 针。这种类型的用法会在4.8节解释。

我们可以把数组下标用在指针上,实际上,pv[i]这种表示法等价于:

*(pv + i)

pv指针包含一个内存块的地址,方括号表示法会取出pv中包含的地 址,用指针算术运算把索引i加上,然后解引新地址返回其内容。

就像我们在1.3.1节中讨论的那样,给指针加上一个整数会把它持有 的地址增加这个整数和数据类型长度的乘积,这一点对于给数组名字 加上整数也适用。下面两个语句是等价的:

*(pv + i) 
*(vector + i)

假设vector位于地址100,pv位于地址96,表4-1和图4-4说明了如何 利用数组下标和指针算术运算分别从数组名字和指针得到不同的值。

表4-1:数组/指针表示法
在这里插入图片描述
在这里插入图片描述

图4-4:数组/指针表示法

给数组地址加1实际加了4,也就是整数的长度,因为这是一个整数 数组。对于第一个和最后一个操作,我们越过了数组边界,这不是好 习惯,不过也提醒我们在用索引和指针访问数组元素时要谨慎。

数组表示法可以理解为“偏移并解引”操作。vector[2]表达式表示 从vector开始,向右偏移两个位置,然后解引那个位置获取其值,其 中vector是指向数组起始位置的指针。如果用取地址操作符和数组表 示法,就像&vector[-2],其实就是去掉了解引操作,可以解释为向 左偏移两个位置然后返回地址。

下面的代码说明了标量相加操作的实现中指针的使用。这个操作接受 一个值然后给vector的每个元素乘上这个值:

pv = vector; 
intvalue = 3; 
for(int i=0; i<5; i++) { 
    *pv++ *= value; 
}

数组和指针的差别

数组和数组指针在使用上有一些区别,本节使用的vector数组 和pv指针定义如下:

intvector[5] = {1, 2, 3, 4, 5}; 
int *pv = vector;

vector[i]生成的代码和*(vector+i)生成的不一样,vector[i]表示法生 成的机器码从位置vector开始,移动i个位置,取出内容。 而*(vector+i)表示法生成的机器码则是从vector开始,在地址上增 加i,然后取出这个地址中的内容。尽管结果是一样的,生成的机器 码却不一样,对于大部分人来说,这种差别几乎无足轻重。

sizeof操作符对数组和同一个数组的指针操作也是不同的。 对vector调用sizeof操作符会返回20,就是这个数组分配的字节数。 对pv调用sizeof操作符会返回4,就是指针的长度。

pv是一个左值,左值表示赋值操作符左边的符号。左值必须能修 改。像vector这样的数组名字不是左值,它不能被修改。我们不能改 变数组所持有的地址,但可以给指针赋一个新值从而引用不同的内存 段。

考虑如下代码:

pv = pv + 1; 
vector = vector + 1; // 语法错误

我们无法修改vector,只能修改它的内容。不过,vector+1表达式 本身没问题,如下所示:

pv = vector + 1;

4.3 用malloc创建一维数组

如果从堆上分配内存并把地址赋给一个指针,那就肯定可以对指针使 用数组下标并把这块内存当成一个数组。在下面的代码中,我们复制 之前用过的数组vector中的内容:

int *pv = (int*) malloc(5 * sizeof(int)); 
for(int i=0; i<5; i++) { 
    pv[i] = i+1; 
}

也可以像下面这样使用指针表示法,不过数组表示法通常更简单:

for(int i=0; i<5; i++) { 
    *(pv+i) = i+1; 
}

图4-5说明了本例的内存分配。
在这里插入图片描述

图4-5:从堆上分配数组

这种技术分配一块内存并把它当成数组,其长度在运行时确定。不 过,我们得记得用完之后释放内存。

警告 在上个例子中我们用的是*(pv+i)而不是*pv+i,因为解引操 作符的优先级比加操作符高,先解引第二个表达式的指针,得到指针所引用的值,然后再给这个整数加上i。这不是我们要的效 果,而且,如果我们把这个表达式作为左值,编译器会抱怨。所以,为了让代码正确工作,我们需要强制先做加法,然后才是解 引操作。

4.4 用realloc调整数组长度

用malloc创建的已有数组的长度可以通过realloc函数来调 整。realloc函数的基本知识已经在第2章详细探讨过了。C99标准支 持变长数组,有些情况下这种解决方案可能比使用realloc函数更 好。如果没有使用C99,那就只能用realloc。此外,变长数组只能在 函数内部声明,如果数组需要的生命周期比函数长,那也只能 用realloc。

为了说明realloc函数,我们会实现一个从标准输入读取字符并放入 缓冲区的函数,缓冲区会包含除最后的回车字符之外的所有字符。我 们无法得知用户会输入多少字符,因此也就无法知道缓冲区应该有多 长。我们会用realloc函数通过一个定长增量来分配额外空间。实现 该函数的代码如下所示:

char* getLine(void) { 
    const size_t sizeIncrement = 10; 
    char* buffer = malloc(sizeIncrement); 
    char*currentPosition = buffer; 
    size_t maximumLength = sizeIncrement; 
    size_t length = 0; 
    int character; 
    
    if(currentPosition == NULL) { 
        return NULL; 
    } 
    
    while(1) { 
        character = fgetc(stdin);   
        if(character == '\n') { 
            break; 
        } 
        
        if(++length >= maximumLength) { 
            char *newBuffer = realloc(buffer, maximumLength += sizeIncrement); 
            
            if(newBuffer == NULL) { 
                free(buffer); 
                return NULL; 
            }
            
            currentPosition = newBuffer + (currentPosition - buffer); 
            buffer = newBuffer; 
        }
        *currentPosition++ = character; 
    }
    *currentPosition = '\0'; 
    return buffer; 
}

表4-2:getLine函数的变量
在这里插入图片描述
在这里插入图片描述
缓冲区创建时的大小是sizeIncrement,如果malloc函数无法分配内 存,第一个if语句会强制getLine函数返回NULL。接着是一次处理一 个字符的无限循环,循环退出后,字符串末尾会添加上NUL,然后 返回缓冲区的地址。

在while循环内部,程序每次读入一个字符,如果是回车符,循环退 出。接着,if语句判断我们有没有超出缓冲区大小,如果没有超出, 字符就被添加到缓冲区中。

如果超出了缓冲区大小,realloc函数会分配一块新内存,这块内存 比旧内存大sizeIncrement字节。如果无法分配内存,我们会释放现 有的已分配内存,强制函数返回NULL;否则currentPosition会调整 为指向新分配的缓冲区。realloc函数不一定会让已有的内存保持在 原来的位置,所以必须用它返回的指针来确定调整过大小的内存块的 位置。

newBuffer变量持有已分配内存的地址,我们需要用别的变量而不 是buffer,这样万一realloc无法分配内存,我们也可以检测到这种 情况并进行处理。

如果realloc分配成功,我们不需要释放buffer,因为realloc会把原 来的缓冲区复制到新的缓冲区中,再把旧的释放。如果试图释 放buffer,十有八九程序会终止,因为我们试图重复释放同一块内 存。

图4-6说明了getLine函数面对Once upon a time there was a giant pumpkin这个输入字符串时的内存分配情况。我们简化了程序 栈,省略了除buffer和currentPosition之外的局部变量。根据包含 字符串的方框来看,buffer增长了四次。

在这里插入图片描述

图4-6:getLine函数的内存分配

realloc函数也可以用来减少指针指向的内存。为了说明这种用法, 如下所示的trim函数会把字符串中开头的空白符删掉:

char* trim(char* phrase) { 
    char* old = phrase; 
    char* new = phrase;
    char* new = phrase; 
    while(*old == ' ') { 
        old++; 
    }
    while(*old) { 
        *(new++) = *(old++); 
    }
    *new = 0; return (char*) realloc(phrase,strlen(phrase)+1); 
}


int main() { 
    char* buffer = (char*)malloc(strlen(" cat")+1); 
    strcpy(buffer," cat"); 
    printf("%s\n",trim(buffer)); 
}

第一个while循环跳过开头的空白符,第二个while循环把字符串中剩 下的字符复制到字符串的开头,它的判断条件一直是真,直到遇 到NUL字符,就会变成假,接着字符串末尾会添加0。然后我们会根 据字符串的长度用realloc函数调整内存大小。

图4-7说明了该函数接受" cat"字符串作为参数时的执行情况。字符 串在trim函数执行前后的状态如图所示,红色框内的内存是旧内存, 不应该访问。
在这里插入图片描述

图4-7:realloc示例

4.5 传递一维数组

将一维数组作为参数传递给函数实际是通过值来传递数组的地址,这 样信息传递就更高效,因为我们不需要传递整个数组,从而也就不需 要在栈上分配内存。通常,这也意味着要传递数组长度,否则在函数 看来,我们只有数组的地址而不知道其长度。

除非数组内部有信息告诉我们数组的边界,否则在传递数组时也需要 传递长度信息。如果数组内存储的是字符串,我们可以依赖NUL字 符来判断何时停止处理数组,第5章会深入探讨这部分内容。一般来 说,如果不知道数组长度,就无法处理其元素,最终导致的结果可能 是处理的元素太少,也可能是把数组边界以外的内存当成数组的一部 分,而这样经常会造成程序非正常终止。

我们可以使用下面两种表示法中的一种在函数声明中声明数组:数组 表示法和指针表示法。

4.5.1 用数组表示法

下面的例子将一个整数数组及其长度传递给函数,并打印其内容:

void displayArray(int arr[], int size) { 
    for (int i = 0; i < size; i++) { 
        printf("%d\n", arr[i]); 
    } 
} 
intvector[5] = {1, 2, 3, 4, 5}; 
displayArray(vector, 5);

这段代码的输出是数字1到5,我们给函数传递5来表明数组长度。也 可以传递任意正数,不管这个长度是否正确,函数都会试图打印相应 数量的元素。尝试越过数组边界寻址可能会导致程序终止。本例的内 存分配如图4-8所示。

在这里插入图片描述

图4-8:使用数组表示法

警告 为确定数组的元素数量对数组使用sizeof操作符是一种常见 的错误,如下所示。4.1.1节中已经解释过了,这样获取长度是不对的。在这种情况下,我们给函数传递的是20。

displayArray(arr, sizeof(arr));

还有一种情况比较常见:传递的元素数量比数组中实际的元素数量 少,这样可以处理数组的一部分。比如说,假设我们读入一系列年龄 并放进数组,但没有占满数组,此时如果调用sort函数来排序,我们 希望只对有效的年龄进行排序,而不是数组的所有元素。

4.5.2 用指针表示法

声明函数的数组参数不一定要用方括号表示法,也可以用指针表示 法,如下所示:

void displayArray(int* arr, int size) { 
    for (int i = 0; i < size; i++) { 
        printf("%d\n", arr[i]); 
    } 
}

在函数内部我们仍然使用数组表示法,如果有需要,也可以用指针表 示法:

void displayArray(int* arr, int size) { 
    for (int i = 0; i < size; i++) { 
        printf("%d\n", *(arr+i)); 
    } 
}

如果在声明函数时用了数组表示法,在函数体内还是可以用指针表示 法:

void displayArray(int arr[], int size) { 
    for (int i = 0; i < size; i++) { 
        printf("%d\n", *(arr+i)); 
    } 
}

4.6 使用指针的一维数组

在本节中,我们通过使用整数指针的数组来说明使用指针数组的关键 点。指针数组的例子也出现在下面几处:

  • 3.3.5节中我们使用了函数指针的数组;
  • 6.1节的“结构体的内存如何分配”中我们使用了结构体数组;
  • 5.3.4节中我们处理了argv数组。

本节的目的是说明这种方法的本质,来为接下来的几个例子打好基 础。下面的代码片段声明一个整数指针的数组,为每个元素分配内 存,然后把内存的内容初始化为元素的索引值:

int* arr[5]; 
for(int i=0; i<5; i++) { 
    arr[i] = (int*)malloc(sizeof(int)); 
    *arr[i] = i; 
}

如果把数组打印出来,得到的是数字0到4。我们用arr[i]引用指针, 用arr[i]把值赋给指针引用的位置。别被数组表示法搞糊涂了,因 为arr声明为指针数组,arr[i]返回的是一个地址,当我们用arr[i]解 引指针时,得到是这个地址的内容。

我们也可以在循环体中使用下面这种等价的指针表示法:

*(arr+i) = (int*)malloc(sizeof(int)); 
**(arr+i) = i;

这种表示法更难理解,但是理解以后能加强你的C技能。在第二个语 句中我们用了两层间接引用,掌握这种表示法将让你和初级C程序员 有本质区别。

子表达式(arr+i)表示数组的第i个元素的地址,我们需要修改这个地 址中的内容,所以用了子表达式*(arr+i)。在第一条语句中我们将已 分配的内存赋给这个位置。对(arr+i)子表达式做两次解引(如第二条 语句所示),会返回所分配内存的位置,然后我们把i赋给它。图4-9 说明了内存的分配情况。
在这里插入图片描述

图4-9:指针数组

比如说,arr[i]位于地址104,表达式(arr+i)为我们返回104, 用*(arr+1)则让我们得到其内容,在本例中,就是指针504。再 用**(arr+1)解引它就得到了504的内容,就是1。

表4-3中列出了一些示例表达式。从左到右读指针表达式且不要忽略 括号,这样会更容易理解其工作方式。

表4-3:指针数组表达式
在这里插入图片描述
前三个表达式和前面解释的差不多,最后两个则有所不同。用指针的 指针表示法能让我们知道正在处理的是指针数组,实际上我们的示例 中也用到了。如果再看一下图4-9,假设arr的每个元素指向一个长度 为1的数组,那么最后两个表达式就能说通了,我们得到的是一个有 5个元素的指针数组,这些指针指向一系列有1个元素的数组。

表达式arr[3][0]引用arr的第4个元素,然后是这个元素所指向的数组 的第1个元素。表达式arr[3][1]有错误,因为第4个元素所指向的数组 只有一个元素。

4.7 指针和多维数组

可以将多维数组的一部分看做子数组。比如说,二维数组的每一行都 可以当做一维数组。这种行为会对我们用指针处理多维数组有所影 响。

为了说明这种行为,我们创建一个二维数组并初始化,如下所示:

int matrix[2][5] = {{1,2,3,4,5},{6,7,8,9,10}};

然后打印元素的地址和值:

for(int i=0; i<2; i++) { 
    for(int j=0; j<5; j++) { 
        printf("matrix[%d][%d] Address: %p Value: %d\n", i, j, &matrix[i][j], matrix[i][j]); 
    } 
}

输出如下所示:

matrix[0][0] Address: 100 Value: 1 
matrix[0][1] Address: 104 Value: 2 
matrix[0][2] Address: 108 Value: 3 
matrix[0][3] Address: 112 Value: 4 
matrix[0][4] Address: 116 Value: 5 
matrix[1][0] Address: 120 Value: 6 
matrix[1][1] Address: 124 Value: 7 
matrix[1][2] Address: 128 Value: 8 
matrix[1][3] Address: 132 Value: 9 
matrix[1][4] Address: 136 Value: 10

数组按行–列顺序存储,也就是说,将第一行按顺序存入内存,后面 紧接着第二行。内存分配如图4-10所示。

在这里插入图片描述

图4-10:二维数组的内存分配

我们可以声明一个指针处理这个数组,如下所示:

int (*pmatrix)[5] = matrix;

(*pmatrix)表达式声明了一个数组指针,上面的整条声明语句 将pmatrix定义为一个指向二维数组的指针,该二维数组的元素类型 是整数,每列有5个元素。如果我们把括号去掉就声明了5个元素的 数组,数组元素的类型是整数指针。如果声明的列数不是5,用该指 针访问数组的结果则是不可预期的。

如果要用指针表示法访问第二个元素(就是2),下面的代码看似合 理:

printf("%p\n", matrix); 
printf("%p\n", matrix + 1);

但输出却是:

100 
120

matrix+1返回的地址不是从数组开头偏移了4,而是偏移了第一行的 长度,20字节。用matrix本身返回数组第一个元素的地址,二维数 组是数组的数组,所以我们得到是一个拥有5个元素的整数数组的地 址,它的长度是20。我们可以用下面的语句验证这一点,它会打印出20:

printf("%d\n",sizeof(matrix[0])); // 显示20

要访问数组的第二个元素,需要给数组的第一行加上1,像这 样:*(matrix[0] + 1)。表达式matrix[0]返回数组第一行第一个元素 的地址,这个地址是一个整数数组的地址,于是,给它加1实际加上 的是一个整数的长度,得到的是第二个元素。输出结果是104和2。

printf("%p %d\n", matrix[0] + 1, *(matrix[0] + 1));

我们可以用图文的形式来说明数组,如图4-11所示。
在这里插入图片描述

图4-11:二维数组图示

图4-12可以解释二维数组表示法。
在这里插入图片描述

图4-12:二维数组表示法

4.8 传递多维数组

给函数传递多维数组很容易让人迷惑,尤其是在用指针表示法的情况 下。传递多维数组时,我们要决定在函数签名1中使用数组表示法还 是指针表示法。还有一件要考虑的事情是如何传递数组的形态,这里 所说的形态是指数组的维数及每一维的大小。要想在函数内部使用数 组表示法,必须指定数组的形态,否则,编译器就无法使用下标。

1 函数签名是指函数原型声明。——译者注

要传递数组matrix,可以这么写:

void display2DArray(int arr[][5], int rows) {

或者这么写:

void display2DArray(int (*arr)[5], int rows) {

这两种写法都指明了数组的列数,这很有必要,因为编译器需要知道 每行有几个元素。如果没有传递这个信息,编译器就无法计算4.7节 讲到的arr[0][3]这样的表达式。

在第一种写法中,表达式arr[]是数组指针的一个隐式声明,而第二种 写法中的(*arr)表达式则是指针的一个显式声明。

警告 下面的声明是错误的:

void display2DArray(int *arr[5], int rows) {

尽管不会产生语法错误,但是函数会认为传入的数组拥有5个整数 指针。

这个函数的简单实现和调用方法如下:

void display2DArray(int arr[][5], int rows) { 
    for (int i = 0; i<rows; i++) { 
        for (int j = 0; j<5; j++) { 
            printf("%d", arr[i][j]); 
        }
            printf("\n"); 
    } 
}

void main() { 
    
    int matrix[2][5] = { 
        {1, 2, 3, 4, 5}, 
        {6, 7, 8, 9, 10} 
    };
    
    display2DArray(matrix, 2); 
}

在这里插入图片描述

图4-13:传递多维数组

你可能会遇到下面这样的函数,接受的参数是一个指针和行列数:

void display2DArrayUnknownSize(int *arr, int rows, int cols) { 
    for(int i=0; i<rows; i++) { 
        for(int j=0; j<cols; j++) { 
            printf("%d ", *(arr + (i*cols) + j)); 
        }
        printf("\n"); 
    } 
}

printf语句通过给arr加上前面行的元素数(i*cols)以及表示当前列 的j来计算每个元素的地址。要调用这个函数可以这么写:

display2DArrayUnknownSize(&matrix[0][0], 2, 5);

在函数内我们无法像下面这样使用数组下标:

printf("%d ", arr[i][j]);

原因是没有将指针声明为二维数组。不过,倒是可以像下面这样使用 数组表示法。我们可以用一个下标,这样写只是解释为数组内部的偏 移量,不能用两个下标是因为编译器不知道一维的长度:

printf("%d ", (arr+i)[j]);

这里传递的是&matrix[0][0]而不是matrix,尽管用matrix也能运 行,但是会产生编译警告,原因是指针类型不兼 容。&matrix[0][0]表达式是一个整数指针,而matrix则是一个整数 数组的指针。

在传递二维以上的数组时,除了第一维以外,需要指定其他维度的长 度。下面这个函数打印一个三维数组,声明中指定了数组的后二维。

void display3DArray(int (*arr)[2][4], int rows) { 
    for(int i=0; i<rows; i++) { 
        for(int j=0; j<2; j++) { 
            printf("{"); 
            for(int k=0; k<4; k++) { 
                printf("%d ", arr[i][j][k]); 
            }
        printf("}"); 
        }printf("\n"); 
    } 
}

下面说明如何调用这个函数:

int arr3d[3][2][4] = { 
    {{1, 2, 3, 4}, {5, 6, 7, 8}}, 
    {{9, 10, 11, 12}, {13, 14, 15, 16}}, 
    {{17, 18, 19, 20}, {21, 22, 23, 24}} 
};

display3DArray(arr3d,3);

输出如下所示:

{1 2 3 4 }{5 6 7 8 } 
{9 10 11 12 }{13 14 15 16 } 
{17 18 19 20 }{21 22 23 24 }

数组的内存分配如图4-14所示。

在这里插入图片描述

图4-14:三维数组

arr3d[1]表达式引用数组的第二行,是一个2行4列的二维数组的指 针。arr3d[1][0]引用数组的第二行第一列,是一个长度为5的一维数 组的指针。

4.9 动态分配二维数组

为二维数组动态分配内存涉及几个问题:

  • 数组元素是否需要连续;
  • 数组是否规则。

一个声明如下的二维数组所分配的内存是连续的:

int matrix[2][5] = {{1,2,3,4,5},{6,7,8,9,10}};

不过,当我们用malloc这样的函数创建二维数组时,在内存分配上 会有几种选择。由于我们可以将二维数组当做数组的数组,因而“内 层”的数组没有理由一定要是连续的。如果对这种数组使用下标,数 组的不连续对程序员是透明的。

注意 连续性还会影响复制内存等其他操作,内存不连续就可能 需要多次复制。

4.9.1 分配可能不连续的内存

下面的代码演示了如何创建一个内存可能不连续的二维数组。首先分 配“外层”数组,然后分别用malloc语句为每一行分配。

int rows = 2; 
int columns = 5; 

int **matrix = (int **) malloc(rows * sizeof(int *)); 

for (int i = 0; i < rows; i++) { 
    matrix[i] = (int *) malloc(columns * sizeof(int)); 
}

因为分别用了malloc,所以内存不一定是连续的,如图4-15所示。
在这里插入图片描述

图4-15:不连续分配

实际的分配情况取决于堆管理器和堆的状态,也有可能是连续的。

4.9.2 分配连续内存

我们会展示为二维数组分配连续内存的两种方法。第一种首先分 配“外层”数组,然后是各行所需的所有内存。第二种一次性分配所 有内存。

下面的代码片段演示了第一种技术,第一个malloc分配了一个整数 指针的数组,一个元素用来存储一行的指针,这就是图4-16中在地 址500处分配的内存块。第二个malloc在地址600处为所有的元素分 配内存。在for循环中,我们将第二个malloc所分配的内存的一部分 赋值给第一个数组的每个元素。

int rows = 2; 
int columns = 5; 
int **matrix = (int **) malloc(rows * sizeof(int *)); 
matrix[0] = (int *) malloc(rows *columns * sizeof(int)); 

for (int i = 1; i < rows; i++) 
    matrix[i] = matrix[0] + i *columns;

在这里插入图片描述

图4-16:用两次malloc调用分配连续内存

从技术上讲,第一个数组的内存可以和数组“体”的内存分开,为数 组“体”分配的内存是连续的。

下面是第二种技术,数组所需的所有内存是一次性分配的:

int *matrix = (int *)malloc(rows *columns * sizeof(int));

分配的情况如图4-17所示
在这里插入图片描述

图4-17:用一次malloc调用分配连续内存

后面的代码用到这个数组时不能使用下标,必须手动计算索引,如下 代码片段所示。每个元素被初始化为其索引的积:

for (int i = 0; i < rows; i++) { 
    for (int j = 0; j < columns; j++) { 
        *(matrix + (i*columns) + j) = i*j; 
    } 
}

不能使用数组下标是因为我们丢失了允许编译器使用下标所需的“形 态”信息。这个概念在4.8节讲过了。

实际项目中很少使用这种方法,但它确实说明了二维数组概念和内存 的一维本质的关系。便捷的二维数组表示法让这种映射变得透明且更 容易使用。

我们已经演示了为二维数组分配连续内存的两种方法,具体使用哪种 要看应用程序的需要。不过第二种方法是为“整个”数组分配一块内 存。

4.10 不规则数组和指针

不规则数组是每一行的列数不一样的二维数组,其原理如图4-18所 示,图中的数组有3行,每行有不同的列数。
在这里插入图片描述

图4-18:不规则数组

在了解如何创建不规则数组之前,让我们先看一下用复合字面量创建 的二维数组。复合字面量是一种C构造,前面看起来像类型转换操 作,后面跟着花括号括起来的初始化列表。下面是整数常量和整数数 组的例子,我们将其作为声明的一部分:

(const int) {100} 
(int[3]) {10, 20, 30}

下面的声明把数组声明为整数指针的数组,然后用复合字面量语句块 进行初始化,由此创建了数组arr1。

int (*(arr1[])) = { 
    (int[]) {0, 1, 2}, 
    (int[]) {3, 4, 5}, 
    (int[]) {6, 7, 8}
};

这个数组有3行3列,将数组元素用数字0到8按行–列顺序初始化。图 4-19说明了数组的内存布局。
在这里插入图片描述

图4-19:二维数组

下面的代码片段打印每个数组元素的地址和值:

for(int j=0; j<3; j++) { 
    for(int i=0; i<3; i++) { 
        printf("arr1[%d][%d] Address: %p Value: %d\n", j, i, &arr1[j][i], arr1[j][i]); 
    }
    printf("\n");
}

执行后会得到如下输出:

arr1[0][0] Address: 0x100 Value: 0 
arr1[0][1] Address: 0x104 Value: 1 
arr1[0][2] Address: 0x108 Value: 2 

arr1[1][0] Address: 0x112 Value: 3 
arr1[1][1] Address: 0x116 Value: 4 
arr1[1][2] Address: 0x120 Value: 5 

arr1[2][0] Address: 0x124 Value: 6 
arr1[2][1] Address: 0x128 Value: 7 
arr1[2][2] Address: 0x132 Value: 8

稍微修改一下声明就可以得到一个不规则数组,就是图4-18中展示 的那个。数组声明如下:

int (*(arr2[])) = { 
    (int[]) {0, 1, 2, 3},
    (int[]) {4, 5}, 
    (int[]) {6, 7, 8}
};

我们用了3个复合字面量声明不规则数组,然后从0开始按行–列顺序 初始化数组元素。下面的代码片段会打印数组来验证创建是否正确, 因为每行的列数不同,所以需要3个for循环:

int row = 0; 
for(int i=0; i<4; i++) { 
    printf("layer1[%d][%d] Address: %p Value: %d\n", row, i, &arr2[row][i], arr2[row][i]); 
}
printf("\n"); 

row = 1; 
for(int i=0; i<2; i++) { 
    printf("layer1[%d][%d] Address: %p Value: %d\n", row, i, &arr2[row][i], arr2[row][i]); 
}
printf("\n"); 

row = 2; 
for(int i=0; i<3; i++) { 
    printf("layer1[%d][%d] Address: %p Value: %d\n", row, i, &arr2[row][i], arr2[row][i]); 
}
printf("\n");

输出如下:

arr2[0][0] Address: 0x000100 Value: 0 
arr2[0][1] Address: 0x000104 Value: 1 
arr2[0][2] Address: 0x000108 Value: 2 
arr2[0][3] Address: 0x000112 Value: 3 

arr2[1][0] Address: 0x000116 Value: 4 
arr2[1][1] Address: 0x000120 Value: 5 

arr2[2][0] Address: 0x000124 Value: 6 
arr2[2][13] Address: 0x000128 Value: 7 
arr2[2][14] Address: 0x000132 Value: 8

图4-20说明了这个数组的内存布局。
在这里插入图片描述

图4-20:不规则数组的内存分配

在这些例子中,我们访问数组内容时用的是数组表示法而不是指针表 示法,这样更易读,也好理解。不过,也可以用指针表示法。

复合字面量在创建不规则数组时很有用,不过访问不规则数组的元素 比较别扭,上面的例子就用了3个for循环。如果有一个单独的数组来 维护每行的长度,那么这个例子就可以简化。你可以在C中创建不规 则数组,不过要考虑好它能起的作用是否值得花费相应的精力。

第5章 指针和字符串

字符串可以分配在内存的不同区域,通常使用指针来支持字符串操 作。指针支持动态分配字符串和将字符串作为参数传递给函数。深入 理解指针及指针与字符串结合的用法可以让程序员开发出有效而且高 效的应用程序。

字符串是很多应用程序的常见组成部分,也是一个复杂的主题。在本 章中,我们会探索声明和初始化字符串的不同方法,研究C程序中字 面量池的使用及其影响。此外,我们还会看到比较、复制和拼接字符 串等常见字符串操作。

字符串通常以字符指针的形式传递给函数和从函数返回。我们可以用 字符指针传递字符串,也可以用字符常量的指针传递字符串,后者可 以避免字符串被函数修改。本章用到的很多例子能更好地说明第3章 中提到的原理,不同之处在于本章的例子无需将长度传递给函数。

我们也可以从函数返回字符串,从而满足某个请求。可以将这个字符 串从外面传给函数并由函数修改,也可以在函数内部分配,还可以返 回静态分配的字符串。本章会逐一探讨这些方法。

我们也会研究函数指针的用法以及如何用函数指针辅助排序操作。理 解这些情况下指针如何工作是本章关注的重点。

5.1 字符串基础

字符串是以ASCII字符NUL结尾的字符序列。ASCII字符NUL表示 为\0。字符串通常存储在数组或者从堆上分配的内存中。不过,并非 所有的字符数组都是字符串,字符数组可能没有NUL字符。字符数 组也用来表示布尔值等小的整数单元,以节省内存空间。

C中有两种类型的字符串。

  • 单字节字符串
    由char数据类型组成的序列。
  • 宽字符串
    由wchar_t数据类型组成的序列。

wchar_t数据类型用来表示宽字符,要么是16位宽,要么是32位宽。 这两种字符串都以NUL结尾。可以在string.h中找到单字节字符串函 数,而在wchar.h中找到宽字符串函数。除非特别指明,本章用到的 都是单字节字符串。创建宽字符主要用来支持非拉丁字符集,对于支 持外语的应用程序很有用。

字符串的长度是字符串中除了NUL字符之外的字符数。为字符串分 配内存时,要记得为所有的字符再加上NUL字符分配足够的空间。

警告 记住,NULL和NUL不同。NULL用来表示特殊的指针,通常定义为((void*)0),而NUL是一个char,定义为\0,两者不能混 用。

字符常量是单引号引起来的字符序列。字符常量通常由一个字符组 成,也可以包含多个字符,比如转义字符。在C中,它们的类型 是int,如下所示:

printf("%d\n",sizeof(char)); 
printf("%d\n",sizeof('a'));

执行上述代码可以看到char的长度是1,而字符字面量的长度是4。 这个看似异常的现象乃语言设计者有意为之。

5.1.1 字符串声明

声明字符串的方式有三种:字面量、字符数组和字符指针。字符串字 面量是用双引号引起来的字符序列,常用来进行初始化,它们位于字 符串字面量池中,我们会在下一节讨论。

不要把字符串字面量和单引号引起来的字符搞混——后者是字符字 面量。在后面的各节我们会看到,把字符字面量当做字符串字面量用 会出问题。

下面是一个字符数组的例子,我们声明了一个header数组,最多可 以持有31个字符。因为字符串需要以NUL结尾,所以如果我们声明 一个数组拥有32个字符,那么只能用31个元素来保存实际字符串的文本。字符串在内存中的位置取决于声明的位置,我们会在5.1.3节 中探究这个问题。

char header[32];

字符指针如下所示,由于没有初始化,也就没有引用字符串,当前还 没有指定字符串的长度和位置。

char *header;

5.1.2 字符串字面量池

定义字面量时通常会将其分配在字面量池中,这个内存区域保存了组 成字符串的字符序列。多次用到同一个字面量时,字面量池中通常只 有一份副本。这样会减少应用程序占用的内存。通常认为字面量是不 可变的,因此只有一份副本不会有什么问题。不过,认定只有一份副 本或者字面量不可变不是一种好做法,大部分编译器有关闭字面量池 的选项,一旦关闭,字面量可能生成多个副本,每个副本拥有自己的 地址。

注意 GCC用-fwritable-strings选项来关闭字符串池。在 Microsoft Visual Studio中,/GF选项会打开字符串池。

图5-1说明了字面量池的内存分配方式。

在这里插入图片描述

图5-1:字符串字面量池

字符串字面量一般分配在只读内存中,所以是不可变的。字符串字面 量在哪里使用,或者它是全局、静态或局部的都无关紧要,从这个角 度讲,字符串字面量不存在作用域的概念。

字符串字面量不是常量的情况

在大部分编译器中,我们将字符串字面量看做常量,无法修改字符串。不过,在有些编译器中(比如GCC),字符串字面量是可修改 的。看下面这个例子:

char *tabHeader = "Sound"; 
*tabHeader = 'L'; 
printf("%s\n",tabHeader); // 打印"Lound"

这样会把字面量改成"Lound",这通常不是我们期望的结果,因此应 该避免这么做。像下面这样把变量声明为常量可以解决一部分问题。 任何修改字符串的尝试都会造成编译时错误:

const char *tabHeader = "Sound";

5.1.3 字符串初始化

初始化字符串采用的方法取决于变量是被声明为字符数组还是字符指 针,字符串所用的内存要么是数组要么是指针指向的一块内存。我们 可以用字符串字面量或者一系列字符初始化字符串,或者从别的地方 (比如说标准输入)得到字符。接下来我们会研究这些方法。

1. 初始化char数组

我们可以用初始化操作符初始化char数组。在下例中,header数组 被初始化为字符串字面量中所包含的字符:

char header[] = "Media Player";

字面量"Media Player"的长度为12个字符,表示这个字面量需要13 字节,我们就为数组分配了13字节来持有字符串。初始化操作会把 这些字符复制到数组中,以NUL结尾,如图5-2所示,这里假设 在main函数中声明数组。
在这里插入图片描述

图5-2:初始化char数组

我们也可以用strcpy函数初始化数组,5.2.2节会详细讨论strcpy。下 面的代码片段将字符串字面量复制到了数组中。

char header[13]; 
strcpy(header,"Media Player");

更笨的办法是把字符逐个赋给数组元素,如下所示:

header[0] = 'M'; 
header[1] = 'e'; 
... 
header[12] = '\0';

警告 下面的赋值是不合法的,我们不能把字符串字面量的地址 赋给数组名字。

char header2[]; 
header2 = "Media Player";

2. 初始化char指针

动态内存分配可以提供更多的灵活性,当然也可能会让内存存在得更 久。下面的声明用来说明这种技术:

char *header;

初始化这个字符串的常见方法是使用malloc和strcpy函数分配内存并 将字面量复制到字符串中,如下所示:

char *header = (char*) malloc(strlen("Media Player")+1); 
strcpy(header,"Media Player");

假设这段代码在main函数中,图5-3显示了程序栈的状态。
在这里插入图片描述

图5-3:初始化char指针

前面用到malloc函数的地方,我们对字符串字面量使用了strlen函 数,也可以如下所示明确指定长度:

char *header = (char*) malloc(13);

警告 在决定malloc函数要用到的字符串长度时,要注意以下事项。

  1. 一定要记得算上终结符NUL。
  2. 不要用sizeof操作符,而是用strlen函数来确定已有字符串 的长度。
  3. sizeof操作符会返回数组和指针的长度,而不是字 符串的长度。

如果不用字符串字面量和strcpy函数初始化字符串,我们也可以这么 做:

*(header + 0) = 'M'; 
*(header + 1) = 'e'; 
... 
*(header + 12) = '\0';

我们可以将字符串字面量的地址直接赋给字符指针,如下所示。不 过,这样不会产生字符串的副本,如图5-4所示。

char *header = "Media Player";

在这里插入图片描述

图5-4:复制字符串字面量的地址到指针中

警告 试图用字符字面量来初始化char指针不会起作用。因为字 符字面量是int类型,这其实是尝试把整数赋给字符指针。这样经常会造成应用程序在解引指针时终止:

char* prefix = '+'; // 不合法

正确的做法是像下面这样用malloc函数:

prefix = (char*)malloc(2); 
*prefix = '+'; 
*(prefix+1) = 0;

3. 从标准输入初始化字符串

也可以用标准输入等外部源初始化字符串。不过,在从标准输入读入 字符串时可能会出错,下面是个例子。这里会出问题是因为我们在使 用command变量之前没有为其分配内存:

char *command; 
printf("Enter a Command: "); 
scanf("%s",command);

要解决这个问题需要首先为指针分配内存,或者用定长数组代替指 针。不过,用户输入的数据可能比我们所能装下的要多,第4章会讨论更健壮的方法。

4. 字符串位置小结

我们可能将字符串分配在几个地方,下例解释了几种可能的变化,图 5-5说明了这些字符串在内存中的布局。

char* globalHeader = "Chapter"; 
char globalArrayHeader[] = "Chapter"; 

void displayHeader() { 
    static char* staticHeader = "Chapter"; 
    char* localHeader = "Chapter"; 
    static char staticArrayHeader[] = "Chapter"; 
    char localArrayHeader[] = "Chapter"; 
    char* heapHeader = (char*)malloc(strlen("Chapter")+1); 
    strcpy(heapHeader,"Chapter"); 
}

在这里插入图片描述

图5-5:字符串的内存分配

知道字符串存储的位置对理解程序的工作原理以及用指针访问字符串 有帮助。字符串的位置决定它能存在多久,以及程序的哪些部分可以 访问它。比如说,分配在全局内存的字符串会一直存在,也可以被多 个函数访问;静态字符串也一直存在,不过只有定义它们的函数才能 访问,分配在堆上的内存在释放之前会一直存在,也可以被多个函数 访问。理解这些东西能让你作出更好的选择。

5.2 标准字符串操作

5.2.1 比较字符串

字符串比较是应用程序不可分割的一部分,我们会深入研究如何比较 字符串,因为不正确的比较会产生误导或无效结果,理解字符串的比 较能帮助你避开不正确的操作。这种认识能让你触类旁通。

比较字符串的标准方法是用strcmp函数,原型如下:

int strcmp(const char *s1, const char *s2);

要比较的两个字符串都以指向char常量的指针的形式传递,这让我 们可以放心地使用这个函数,而不用担心传入的字符串被修改。这个 函数返回以下三种值之一。

  • 负数
    如果按字典序(字母序)s1比s2小就返回负数。

  • 0
    如果两个字符串相等就返回0。

  • 正数
    如果按字典序s1比s2大就返回正数。

正数和负数返回值对于按字母序对字符串进行排序很有用,使这个函 数判断相等性的用法如下所示。用户的输入存储在command中,然 后跟字符串字面量比较:

char command[16]; 
printf("Enter a Command: "); 
scanf("%s", command); 
if (strcmp(command, "Quit") == 0) { 
    printf("The command was Quit"); 
} else { 
    printf("The command was not Quit"); 
}

在这里插入图片描述

图5-6:strcmp示例

比较两个字符串有几种不正确的写法,第一种试图用赋值操作符作比 较,如下:

char command[16]; 
printf("Enter a Command: "); 
scanf("%s",command); 
if(command = "Quit") { 
	...
}

首先,这不是作比较,其次,这样会导致类型不兼容的语法错误,我 们不能把字符串字面量地址赋给数组名字。在本例中,我们试图把字 符串字面量的地址(也就是600)赋给command。command是数 组,不用数组下标就把一个值赋给这个变量是不可能的。

另一种方法是用相等操作符:

char command[16]; 
printf("Enter a Command: "); 
scanf("%s",command); 
if(command == "Quit") { 
	...
}

这样会得到假,因为我们比较的是command的地址(300)和字符 串字面量的地址(600)。相等操作符比较的是地址,而不是地址中 的内容,用数组名字或者字符串字面量就会返回地址。

5.2.2 复制字符串

复制字符串是常见的操作,通常用strcpy函数实现,其原型如下:

char* strcpy(char *s1, const char *s2);

本节会讲到基本的复制过程和常见的陷阱。假设要将一个已有的字符 串复制到动态分配的缓冲区中(也可以用字符数组)。

有一类常见的应用程序会读入一系列字符串,挨个存入占据最少内存的数组。要实现这一点,可以创建一个长度足以容纳用户可能输入的 最长字符串的数组,并且把字符串读入这个数组。有了读入的字符 串,我们就能分配合适的内存。基本的方法是这样的:

  1. 用一个很长的char数组读入字符串 ;
  2. 用malloc分配恰好容纳字符串的适量内存 ;
  3. 用strcpy把字符串复制到动态分配的内存中。

下面的代码说明了这种技术。names数组会持有每个读入的名字的 指针,而count变量则指定下一个可用的数组元素。name数组用来 持有读入的字符串,每个读入的名字都可以重复利用它,malloc函 数分配每个字符串所需的内存并将其赋给names中下一个可用的元 素。之后将名字复制到新分配的内存中:

char name[32]; 
char *names[30]; 
size_t count = 0; 

printf("Enter a name: "); 
scanf("%s",name); 
names[count] = (char*)malloc(strlen(name)+1); 
strcpy(names[count],name); 
count++;

我们可以在一个循环中重复这个操作,每次迭代增加count值。图5- 7说明了对于读入的名字Sam,这些处理的内存布局。

在这里插入图片描述

图5-7:复制字符串

两个指针可以引用同一个字符串。两个指针引用同一个地址称为别 名,这个话题会在第8章讲到。尽管通常情况下这不是问题,但要知 道,把一个指针赋值给另一个指针不会复制字符串,只是复制了字符串的地址。

为了说明这一点,下面声明了页眉指针的数组。我们将字符串字面量 的地址赋给了索引为12的页面,接着,把pageHeaders[12]中的指 针复制到pageHeaders[13]。现在这两个指针都指向同一个字符串字 面量。这里复制的是指针而不是字符串:

char *pageHeaders[300]; 
pageHeaders[12] = "Amorphous Compounds"; 
pageHeaders[13] = pageHeaders[12];

图5-8解释了这些赋值操作。
在这里插入图片描述

图5-8:复制指针的效果

5.2.3 拼接字符串

字符串拼接涉及两个字符串的合并。strcat函数经常用来执行这种操 作,这个函数接受两个字符串指针作为参数,然后把两者拼接起来并 返回拼接结果的指针。这个函数的原型如下:

char *strcat(char *s1, const char *s2);

此函数把第二个字符串拼接到第一个的结尾,第二个字符串是以常 量char指针的形式传递的。函数不会分配内存,这意味着第一个字 符串必须足够长,能容纳拼接后的结果,否则函数可能会越界写入, 导致不可预期的行为。函数的返回值的地址跟第一个参数的地址一 样。这在某些情况下比较方便,比如这个函数作为printf函数的参数时。

为了说明这个函数的用法,我们会组合两个错误消息字符串。第一个是前缀,第二个是具体的错误消息。如下所示,我们首先在缓冲区中 为两个字符串分配足够的内存,然后把第一个字符串复制到缓冲区, 最后将第二个字符串和缓冲区拼接:

char* error = "ERROR: "; 
char* errorMessage = "Not enough memory"; 

char* buffer = (char*)malloc(strlen(error)+strlen(errorMessage)+1); strcpy(buffer,error); 
strcat(buffer, errorMessage); 

printf("%s\n", buffer); 
printf("%s\n", error); 
printf("%s\n", errorMessage);

我们给malloc函数的参数加1是为了容纳NUL字符。假设第一个字面 量在内存中的位置就在第二个字面量前面,这段代码的输出会像下面 这样。图5-9说明了内存分配情况。

ERROR: Not enough memory 
ERROR: 
Not enough memory

在这里插入图片描述

图5-9:正确的拼接操作

如果我们没有为拼接后的字符串分配独立的内存,就可能会覆写第一 个字符串,下面这个没有用到缓冲区的例子会说明这一点。我们仍然 假设第一个字面量在内存中的位置就在第二个字面量前面:

char* error = "ERROR: "; 
char* errorMessage = "Not enough memory"; 

strcat(error, errorMessage); 
printf("%s\n", error); 
printf("%s\n", errorMessage);

这段代码的输出如下:

ERROR: Not enough memory 
ot enough memory

errorMessage字符串会左移一个字符,原因是拼接后的结果覆写 了errorMessage。字面量"Not enough memory"紧跟在第一个字 面量之后,因此覆写了第二个字面量。图5-10解释了这一点,字面量池的状态显示在左边,右边是复制操作后的状态。

在这里插入图片描述

图5-10:不正确的字符串拼接操作

如果我们像下面这样用char数组而不是用指针来存储字符串,就不 一定能工作了:

char error[] = "ERROR: "; 
char errorMessage[] = "Not enough memory";

如果用下面这个strcpy调用会得到一个语法错误,这是因为我们试图 把函数返回的指针赋给数组名字,这类操作不合法:

error = strcat(error, errorMessage);

如果像下面这样去掉赋值,就可能会有内存访问的漏洞,因为复制操 作会覆写栈帧的一部分。这里假设在函数内部声明数组,如图5-11 所示。无论源字符串是存储在字符串字面量池中还是栈帧中,都不应 该用来直接存放拼接后的结果,一定要专门为拼接结果分配内存:

strcat(error, errorMessage);

在这里插入图片描述

图5-11:覆写栈帧

拼接字符串时容易犯错的另一个地方是使用字符字面量而不是字符串 字面量。在下例中,我们将一个字符串拼接到一个路径字符串后,这 样是能如期工作的:

char* path = "C:"; 
char*currentPath = (char*) malloc(strlen(path)+2); 
currentPath = strcat(currentPath,"\\");

因为额外的字符和NUL字符需要空间,我们在malloc调用中给字符 串长度加了2。因为在字符串字面量中用了转义序列,所以这里拼接 的是一个反斜杠字符。

不过,如果使用字符字面量,如下所示,那么就会得到一个运行时错 误,原因是第二个参数被错误地解释为char类型变量的地址1:

currentPath = strcat(path,'\\');

5.3 传递字符串

传递字符串很简单,在函数调用中,用一个计算结果是char类型变 量地址的表达式即可。在参数列表中,把参数声明为char指针。有 趣的事情发生在函数内部使用字符串时。我们首先会在5.3.1节和 5.3.2节研究如何传递简单字符串,然后在5.3.3节中研究如何传递需 要初始化的字符串。把字符串作为参数传递给应用程序会在5.3.4节 中讲解。

5.3.1 传递简单字符串

取决于不同的字符串声明方式,有几种方法可以把字符串的地址传递 给函数。在本节中,我们会利用一个模拟strlen的函数说明这些技 术,该函数的实现如下代码所示。我们用括号来强制后面的自增操作 符先执行,使得指针加1。否则加1的就是string引用的字符了,这不 是我们想要的结果。

size_t stringLength(char* string) { 
    size_t length = 0; 
    while(*(string++)) { 
        length++; 
    }
    return length; 
}

注意 字符串实际上应该以char常量的指针的形式传递,5.3.2节 会讨论这一点。
让我们从下面的声明开始:

char simpleArray[] = "simple string"; 
char *simplePtr = (char*)malloc(strlen("simple string")+1); 
strcpy(simplePtr, "simple string");

要对这个指针调用此函数,只要用指针名字即可:

printf("%d\n",stringLength(simplePtr));

要使用数组调用函数,我们有三种选择,如下所示。在第一个语句 中,我们用了数组的名字,这会返回其地址。在第二个语句中,显式 使用了取地址操作符,不过这样写有冗余,没有必要,而且会产生警 告。在第三个语句中,我们对数组第一个元素用了取地址操作符,这 样可以工作,不过有点繁琐:

printf("%d\n",stringLength(simpleArray)); 
printf("%d\n",stringLength(&simpleArray)); 
printf("%d\n",stringLength(&simpleArray[0]));

图5-12说明了stringLength函数的内存分配情况。

在这里插入图片描述

图5-12:传递字符串

现在让我们把注意力转移到形参的声明方式上。在前 面stringLength的实现中,我们把参数声明为char指针,不过也可 以像下面这样用数组表示法:

size_t stringLength(char string[]) { ... }

函数体还是一样,这个变化不会对函数的调用方式及其行为造成影 响。

5.3.2 传递字符常量的指针

以字符常量指针的形式传递字符串指针是很常见也很有用的技术,这 样可以用指针传递字符串,同时也能防止传递的字符串被修改。下面 对5.3.1节中的stringLength函数更好的实现就是利用了这种声明:

size_t stringLength(const char* string) { 
    size_t length = 0; 
    while(*(string++)) { 
        length++; 
    }
    return length; 
}

如果我们试图像下面这样修改原字符串,那么就会产生一个编译时错 误消息:

size_t stringLength(const char* string) { 
    ... 
    *string = 'A'; 
    ... 
}

5.3.3 传递需要初始化的字符串

有些情况下我们想让函数返回一个由该函数初始化的字符串。假设我 们想传递一个部件的信息,比如名字和数量,然后让函数返回表示这 个信息的格式化字符串。通过把格式化处理放在函数内部,我们可以 在程序的不同部分重用这个函数。

不过,我们得决定是给函数传递一个空缓冲区让它填充并返回,还是让函数动态分配缓冲区并返回。

要传递缓冲区:

  • 必须传递缓冲区的地址和长度;
  • 调用者负责释放缓冲区;
  • 函数通常返回缓冲区的指针。

这种方法把分配和释放缓冲区的责任都交给了调用者。虽然没有必 要,返回缓冲区指针很常见,strcpy或类似函数就是这种情况。下面 的format函数说明了这种方法:

char* format(char *buffer, size_t size, const char* name, size_t quantity, size_t weight) { 
    snprintf(buffer, size, "Item: %s Quantity: %u Weight: %u", name, quantity, weight); 
    return buffer; 
}

这里用了snprintf函数来简化字符串格式化,该函数写入第一个参数 指向的缓冲区。第二个参数指定缓冲区的长度,函数不会越过缓冲区 写入。其他方面,这个函数和printf函数的行为一样。

下面说明这个函数的用法:

printf("%s\n",format(buffer,sizeof(buffer),"Axle",25,45));

输出如下:

Item: Axle Quantity: 25 Weight: 45

通过返回缓冲区的指针,我们可以将函数作为printf函数的参数。

还有一种方法是传递NULL作为缓冲区地址,这表示调用者不想提供 缓冲区,或者它不确定缓冲区应该是多大。这样的函数实现列在了下 面,在计算长度时,10 + 10子表达式表示数量和重量可能的最大宽 度,而1则是为NUL终结符留下空间:

char* format(char *buffer, size_t size, const char* name, size_t quantity, size_t weight) { 
    char *formatString = "Item: %s Quantity: %u Weight: %u"; 
    size_t formatStringLength = strlen(formatString)-6; 
    size_t nameLength = strlen(name); 
    size_t length = formatStringLength + nameLength + 10 + 10 + 1; 
    
    if(buffer == NULL) { 
        buffer = (char*)malloc(length); size = length; 
    }
    snprintf(buffer, size, formatString, name, quantity, weight); 
    return buffer; 
}

函数使用的变量取决于应用程序的需要。第二种方法的主要缺点在于调用者现在要负责释放分配的内存,调用者需要对函数的使用方法了 如指掌,否则可能很容易产生内存泄漏。

5.3.4 给应用程序传递参数

main函数通常是应用程序第一个执行的函数。对基于命令行的程序 来说,通过为其传递信息来打开某种行为的开关或控制某种行为很常 见。可以用这些参数来指定要处理的文件或是配置应用程序的输出。 比如说,Linux的ls命令会基于接收到的参数列出当前目录下的文件。

C用传统的argc和argv参数支持命令行参数。第一个参数argc,是一 个指定传递的参数数量的整数。系统至少会传递一个参数,这个参数 是可执行文件的名字。第二个参数argv,通常被看做字符串指针的 一维数组,每个指针引用一个命令行参数。

下面的main函数只是简单地列出了它的参数,每行一个。在这个版 本中,argv被声明为一个char指针的指针。

int main(int argc, char** argv) { 
    for(int i=0; i<argc; i++) { 
        printf("argv[%d] %s\n",i,argv[i]); 
    }
    ... 
}

程序可以用下面的命令执行:

process.exe -f names.txt limit=12 -verbose

输出如下:

argv[0] c:/process.exe 
argv[1] -f 
argv[2] names.txt 
argv[3] limit=12 
argv[4] -verbose

使用空格将每个命令行参数分开,这个程序的内存分配如图5-13所 示。
在这里插入图片描述

图5-13:使用argc/argv

argv的声明可以简化如下:

int main(int argc, char* argv[]) {

这跟char** argv是等价的,1.4.1节详细解释了这种表示法。

5.4 返回字符串

函数返回字符串时,它返回的实际是字符串的地址。这里应该关注的 主要问题是如何返回合法的地址,要做到这一点,可以返回以下三种 对象之一的引用:

  • 字面量;
  • 动态分配的内存;
  • 本地字符串变量。

5.4.1 返回字面量的地址

返回字面量的例子如下所示,利用一个整数码从四个处理中心选择一 个。这个函数的目的是把处理中心的名字作为字符串返回。在本例 中,它只是返回了字面量的地址:

char* returnALiteral(int code) { 
    switch(code) { 
        case 100: return "Boston Processing Center"; 
        case 200: return "Denver Processing Center"; 
        case 300: return "Atlanta Processing Center"; 
        case 400: return "San Jose Processing Center"; 
    } 
}

这段代码会工作得很好。唯一需要记住的一点是我们并非总是将字符 串字面量看做常量,5.1.2节讨论过这一点。也可以像下例这样声明 静态字面量,我们增加了subCode字段来选择不同的中心,这么做 的好处是无需在不同的地方使用同一个字面量,也就不会因为打错字 而引入错误了:

char* returnAStaticLiteral(int code, int subCode) { 
    static char* bpCenter = "Boston Processing Center"; 
    static char* dpCenter = "Denver Processing Center"; 
    static char* apCenter = "Atlanta Processing Center"; 
    static char* sjpCenter = "San Jose Processing Center"; 
    
    switch(code) { 
        case 100: return bpCenter; 
        case 135: 
            if(subCode <35) { 
                return dpCenter; 
            } else { 
                return bpCenter; 
            } 
        case 200: return dpCenter; 
        case 300: return apCenter; 
        case 400:
        return sjpCenter; 
    } 
}

针对多个不同目的返回同一个静态字符串的指针可能会有问题。考虑 下面的函数,这是在5.3.3节中开发的format函数的变体。将一个部 件的信息传递给函数,然后返回一个表示这个部件的格式化字符串:

char* staticFormat(const char* name, size_t quantity, size_t weight) { 
    static char buffer[64]; // 假设缓冲区足够大 
    sprintf(buffer, "Item: %s Quantity: %u Weight: %u", name, quantity, weight); 
    return buffer; 
}

为缓冲区分配64字节可能够,也可能不够,就本例的目的而言,我 们会忽略这个潜在的问题。这种方法的主要问题用如下代码片段说 明:

char* part1 = staticFormat("Axle",25,45); 
char* part2 = staticFormat("Piston",55,5); 
printf("%s\n",part1); 
printf("%s\n",part2);

执行后得到如下输出:

Item: Piston Quantity: 55 Weight: 5 
Item: Piston Quantity: 55 Weight: 5

staticFormat两次调用都使用同一个静态缓冲区,后一次调用会覆写 前一次调用的结果。

5.4.2 返回动态分配内存的地址

如果需要从函数返回字符串,我们可以在堆上分配字符串的内存然后 返回其地址。我们会开发一个blanks函数来说明这种技术,这个函 数会返回一个包含一系列代表“制表符”的空白的字符串,如下所 示。函数接受一个指定制表符序列长度的整数参数:

char* blanks(int number) { 
    char* spaces = (char*) malloc(number + 1); 
    int i; 
    for (i = 0; i<number; i++) { 
        spaces[i] = ' '; 
    }
    
    spaces[number] = '\0'; return spaces; 
} 
  ...

  char *tmp = blanks(5);

将NUL终结符赋给由number索引的数组的最后一个元素,图5-14说 明了本例的内存分配,它显示了blanks函数返回前后应用程序的状 态。

在这里插入图片描述

图5-14:返回动态分配的字符串

释放返回的内存是函数调用者的责任,如果不再需要内存但没有将其 释放会造成内存泄漏。下面是一个内存泄漏的例子,printf函数中使 用了字符串,但是接着它的地址就丢失了,因为我们没有保存:

printf("[%s]\n",blanks(5));

一个更安全的方法如下所示:

char *tmp = blanks(5); 
printf("[%s]\n",tmp); 
free(tmp);

返回局部字符串的地址

返回局部字符串的地址可能会有问题,如果内存被别的栈帧覆写就会 损坏,应该避免使用这种方法,这里作解释只是为了说明实际使用这 种方法的潜在问题。

我们重写前面的blanks函数,如下所示。在函数内部声明一个数 组,而不是动态分配内存,这个数组位于栈帧上。函数返回数组的地 址:

#define MAX_TAB_LENGTH 32 

char* blanks(int number) { 
    char spaces[MAX_TAB_LENGTH]; 
    int i; 
    for (i = 0; i < number && i < MAX_TAB_LENGTH; i++) { 
        spaces[i] = ' '; 
    }
    
    spaces[i] = '\0'; 
    return spaces; 
}

执行函数后会返回数组的地址,但是之后下一次函数调用会覆写这块 内存区域。解引指针后该内存地址的内容可能已经改变。图5-15说 明了程序栈的状态。

在这里插入图片描述

图5-15:返回局部字符串的地址

5.5 函数指针和字符串

我们已经在3.3节中深入讨论过函数指针了,它们是控制程序执行的 一种非常灵活的方法。在本节中,我们会通过将比较函数传递给排序 函数来说明这种能力。排序函数通过比较数组的元素来判断是否交换 数组元素,比较决定了数组是按升序还是降序(或者其他排序策略) 排列。通过传递一个函数来控制比较,排序函数会变得更灵活。传递 不同的比较函数可以让同一个排序函数以不同的方式工作。

我们使用的比较函数根据数组的元素大小写决定排序顺序。下面 的compare和compareIgnoreCase会根据大小写比较字符串。在 用strcmp函数比较字符串之前,compareIgnoreCase函数会先把字 符串转换成小写。5.2.1节中已经讨论过strcmp函数 了。stringToLower函数返回动态分配内存的指针,这意味着一旦不 需要就应该将其释放掉。

int compare(const char* s1, const char* s2) { 
    return strcmp(s1,s2); 
}

int compareIgnoreCase(const char* s1, const char* s2) { 
    char* t1 = stringToLower(s1); 
    char* t2 = stringToLower(s2); 
    int result = strcmp(t1, t2); 
    free(t1); 
    free(t2); 
    return result; 
}

stringToLower函数如下所示,它将传递进来的字符串用小写的形式 返回:

char* stringToLower(const char* string) { 
    char *tmp = (char*) malloc(strlen(string) + 1); 
    char *start = tmp; 
    
    while (*string != 0) { 
        *tmp++ = tolower(*string++); 
    }
    *tmp = 0; 
    return start; 
}

使用如下的类型定义声明我们要使用的函数指针:

typedef int (fptrOperation)(const char*, const char*);

下面的sort函数的实现基于冒泡排序算法,我们将数组地址、数组长 度以及一个控制排序的函数指针传递给它。在if语句中,调用传递进 来的函数并传递数组的两个元素,它会判断这两个元素是否需要交 换。

void sort(char *array[], int size, fptrOperation operation) { 
    int swap = 1; 
    while(swap) {
        swap = 0; 
        for(int i=0; i<size-1; i++) { 
            if(operation(array[i],array[i+1]) > 0) { 
                swap = 1; 
                char *tmp = array[i]; 
                array[i] = array[i+1]; 
                array[i+1] = tmp; 
            } 
        } 
    } 
}

打印函数会显示数组的内容:

void displayNames(char* names[], int size) { 
    for(int i=0; i<size; i++) { 
        printf("%s ",names[i]); 
    }
    printf("\n"); 
}

我们可以用两个比较函数中的任意一个作为参数调用sort函数。下面 用campare函数进行区分大小写的排序:

char* names[] = {"Bob", "Ted", "Carol", "Alice", "alice"}; 
sort(names,5,compare); 
displayNames(names,5);

输出如下:

Alice Bob Carol Ted alice

如果使用compareIgnoreCase函数,输出则是这样:

Alice alice Bob Carol Ted

这样sort函数就灵活得多了,我们可以设计并传递自己想要的任意简 单或复杂的操作来控制排序,而不需要针对不同的排序需求写不同的 排序函数

第6章 指针和结构体

我们可以使用C的结构体来表示数据结构元素,比如链表或树的节 点,指针是把这些元素联系到一起的纽带。理解指针对常见数据结构 多种功能的支持可以为创建数据结构提供便利。在本章中,我们会探 索C中结构体内存分配的基础和几种常见数据结构的实现。

结构体加强了数组等集合的实用性。要创建实体的数组(比如有多个 字段的颜色类型),如果不用结构体的话,就得为每个字段声明一个 数组,然后把每个字段的值放在每个数组的同一个索引下。不过,有 了结构体,我们可以只声明一个数组,其中的每个元素是一个结构体 的实例。

本章继续拓展前面所学的指针概念,包括结构体的数组表示法、结构 体的内存分配、结构体内存管理技术以及函数指针的用法。

我们会从结构体的内存分配开始,理解内存分配可以解释很多操作的 工作原理。接着我们会介绍减少堆管理开销的技术。

最后一节说明如何用指针创建一系列数据结构。首先是链表,链表是 其他几种数据结构的基础,最后是树数据结构,它没有用到链表。

6.1 介绍

声明C结构体的方式有多种。本节只看其中两种,因为我们主要关注 的是结构体和指针的配合使用。在第一种方法中,我们用struct关键 字声明一个结构体。在第二种方法中,我们使用类型定义。在下面的 声明中,结构体的名字前面加了下划线,这不是必需的,不过通常作 为命名约定。_person结构体包括了名字、职位和年龄三个字段。

struct _person { 
    char* firstName; 
    char* lastName; 
    char* title; 
    unsigned int age; 
};

结构体的声明经常使用typedef关键字简化之后的使用。下面说明如 何对_person结构体用typedef关键字:

typedef struct _person { 
    char* firstName; 
    char* lastName; 
    char* title; 
    unsigned int age; 
} Person;

person的实例声明如下:

Person person;

我们也可以声明一个Person指针并为它分配内存,如下所示:

Person *ptrPerson; 
ptrPerson = (Person*) malloc(sizeof(Person));

如果使用结构体的简单声明(像person那样),那么就用点表示法 来访问其字段。在下例中,我们给firstName和age字段赋了值:

Person person; 
person.firstName = (char*)malloc(strlen("Emily")+1); 
strcpy(person.firstName,"Emily"); person.age = 23;

不过,如果使用结构体指针,就需要用箭头操作符,如下所示。这个 操作符由一个横线和一个大于号组成:

Person *ptrPerson; 
ptrPerson = (Person*)malloc(sizeof(Person)); 
ptrPerson->firstName = (char*)malloc(strlen("Emily")+1); 
strcpy(ptrPerson->firstName,"Emily"); 
ptrPerson->age = 23;

我们不一定非得用箭头操作符,可以先解引指针然后用点操作符,如 下所示,我们又执行了一遍赋值操作:

Person *ptrPerson;
ptrPerson = (Person*)malloc(sizeof(Person)); 
(*ptrPerson).firstName = (char*)malloc(strlen("Emily")+1); 
strcpy((*ptrPerson).firstName,"Emily"); 
(*ptrPerson).age = 23;

这种方法有些笨拙,不过你偶尔还能看到有人使用它。

为结构体分配内存

为结构体分配内存时,分配的内存大小至少是各个字段的长度和。不 过,实际长度通常会大于这个和,因为结构体的各字段之间可能会有 填充。某些数据类型需要对齐到特定边界就会产生填充。比如说,短 整数通常对齐到能被2整除的地址上,而整数对齐到能被4整除的地 址上。

这些额外内存的分配意味着几个问题:

  • 要谨慎使用指针算术运算;
  • 结构体数组的元素之间可能存在额外的内存。

比如说,如果为上一节中出现的Person结构体的实例分配内存,会 分配16字节——每个元素4字节。下面这个版本的Person用短整数 来代替无符号整数作为age的类型。这样分配的内存大小还是一样, 因为结构体末尾填充了2字节:

typedef struct _alternatePerson { 
    char* firstName; 
    char* lastName; 
    char* title; 
    short age; 
} AlternatePerson;

在下面的代码片段中,我们声明了Person和AlternatePerson结构体 的实例,然后打印结构体的长度。它们的长度相同,都是16字节:

Person person; 
AlternatePerson otherPerson; 

printf("%d\n",sizeof(Person)); // 打印16 
printf("%d\n",sizeof(AlternatePerson)); // 打印16

如果我们创建一个AlternatePerson的数组(如下所示),那么每个 数组元素之间会有填充,如图6-1所示。阴影区域表示数组元素之间 的空隙。

AlternatePerson people[30];

在这里插入图片描述

图6-1:AlternatePerson的数组

如果我们把age字段移到结构体的两个字段中间,那么空隙就处于结 构体内部。根据访问结构体的方式,这可能会很重要。

6.2 结构体释放问题

在为结构体分配内存时,运行时系统不会自动为结构体内部的指针分 配内存。类似地,当结构体消失时,运行时系统也不会自动释放结构 体内部的指针指向的内存。

考虑如下结构体:

typedef struct _person { 
    char* firstName; 
    char* lastName; 
    char* title; uint age; 
} Person;

当我们声明这个类型的变量或者为这个类型动态分配内存时,三个指 针会包含垃圾数据。在下面的代码片段中,我们声明了Person,其 内存分配如图6-2所示,三个点表示未初始化的内存。

void processPerson() { 
    Person person; 
    ... 
}

在这里插入图片描述

图6-2:未初始化的Person结构体

在这个结构体的初始化阶段,会为每个字段赋一个值。对于指针字 段,我们会从堆上分配内存并把地址赋给每个指针:

void initializePerson(Person *person, const char* fn, const char* ln, const char* title, uint age) { 
    person->firstName = (char*) malloc(strlen(fn) + 1); 
    strcpy(person->firstName, fn); 
    person->lastName = (char*) malloc(strlen(ln) + 1); 
    strcpy(person->lastName, ln); 
    person->title = (char*) malloc(strlen(title) + 1); 
    strcpy(person->title, title); person->age = age;
}

可以如下这样使用这个函数,图6-3说明了内存分配情况:

void processPerson() { 
    Person person; 
    initializePerson(&person, "Peter", "Underwood", "Manager", 36); 
    ... 
}

int main() { 
    processPerson(); 
    ... 
}

在这里插入图片描述

图6-3:初始化的Person结构体

因为这个声明是函数的一部分,函数返回后person的内存会消失。 不过,动态分配的内存不会被释放,仍然保存在堆上。不幸的是,我 们丢失了它们的地址,因此无法将其释放,从而导致了内存泄漏。

用完这个实例后需要释放内存。下面的函数会释放之前创建实例时分 配的内存:

void deallocatePerson(Person *person) { 
    free(person->firstName); 
    free(person->lastName); 
    free(person->title); 
}

我们需要在函数结束前调用这个函数:

void processPerson() { 
    Person person; 
    initializePerson(&person, "Peter", "Underwood", "Manager", 36); 
    ... 
    deallocatePerson(&person);
}

另外,我们必需记得调用initialize和deallocate函数,但诸如C++这类面向对象的编程语言会自动为对象调用这些操作。

如果用Person指针,必须释放如下所示的person:

void processPerson() { 
    Person *ptrPerson; ptrPerson = (Person*) malloc(sizeof(Person)); 
    initializePerson(ptrPerson, "Peter", "Underwood", "Manager", 36); 
    ... 
    deallocatePerson(ptrPerson); 
    free(ptrPerson); 
}

图6-4说明了内存分配情况。
在这里插入图片描述

图6-4:指向person实例的指针

6.3 避免malloc/free开销

重复分配然后释放结构体会产生一些开销,可能导致巨大的性能瓶 颈。解决这个问题的一种办法是为分配的结构体单独维护一个表。当 用户不再需要某个结构体实例时,将其返回结构体池中。当我们需要 某个实例时,从结构体池中获取一个对象。如果池中没有可用的元 素,我们就动态分配一个实例。这种方法高效地维护一个结构体池, 能按需使用和重复使用内存。

为了说明这种方法,我们会用之前定义的Person结构体。用数组维 护结构体池,也可以用链表等更复杂的表,6.4.1节有相关说明。为 了让示例简单,我们用了指针数组,声明如下:

#define LIST_SIZE 10 
Person *list[LIST_SIZE];

使用表之前需要先初始化。下面的函数为数组每个元素赋值NULL:

void initializeList() { 
    for(int i=0; i<LIST_SIZE; i++) { 
        list[i] = NULL; 
    } 
}

我们用两个函数来添加和获取结构体。第一个是getPerson函数,如 下所示。如果存在可用的结构体,这个函数从表中获取一个。将数组 的元素跟NULL比较,返回第一个非空的元素,然后将它在list中的位 置赋值为NULL。如果没有可用的结构体,那就创建并返回一个新 的Person实例。这样就避免了每次需要结构体时都动态分配内存的 开销,我们只在池中为空时才分配内存。返回实例的初始化可以在返 回之前就做好,也可以由调用者来做,取决于应用程序的需要。

Person *getPerson() { 
    for(int i=0; i<LIST_SIZE; i++) { 
        if(list[i] != NULL) { 
            Person *ptr = list[i]; list[i] = NULL; return ptr; 
        } 
    }
    Person *person = (Person*)malloc(sizeof(Person)); 
    return person; 
}

第二个函数是returnPerson,这个函数要么将结构体返回表,要么 把结构体释放掉。我们会检查数组元素看看有没有NULL值,有的话 就将person添加到那个位置,然后返回指针。如果表满了,就 用deallocatePerson函数释放person内的指针,然后释放person, 最后返回NULL。

Person *returnPerson(Person *person) { 
    for(int i=0; i<LIST_SIZE; i++) { 
        if(list[i] == NULL) {
            list[i] = person; 
            return person; 
        } 
    }
    
    deallocatePerson(person); 
    free(person); 
    return NULL; 
}

下面的代码说明了表的初始化,以及如何将一个结构体添加到表中:

initializeList(); 
Person *ptrPerson; 
ptrPerson = getPerson(); 
initializePerson(ptrPerson,"Ralph","Fitsgerald","Mr.",35); 
displayPerson(*ptrPerson); 
returnPerson(ptrPerson);

这种方法有个问题,就是表的长度。如果表太短,那么就需要更频繁 地分配并释放内存。如果表太长而没有使用结构体,那么就会浪费大 量的内存,也不能用在其他地方。可以用更复杂的表管理策略来管理 表的长度。

  数据结构与算法 最新文章
【力扣106】 从中序与后续遍历序列构造二叉
leetcode 322 零钱兑换
哈希的应用:海量数据处理
动态规划|最短Hamilton路径
华为机试_HJ41 称砝码【中等】【menset】【
【C与数据结构】——寒假提高每日练习Day1
基础算法——堆排序
2023王道数据结构线性表--单链表课后习题部
LeetCode 之 反转链表的一部分
【题解】lintcode必刷50题<有效的括号序列
上一篇文章      下一篇文章      查看所有文章
加:2022-09-30 01:12:58  更:2022-09-30 01:13:29 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年5日历 -2024/5/19 19:21:07-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码