C现代方法(第17章)笔记——指针的高级应用

文章目录

  • [第17章 指针的高级应用](#第17章 指针的高级应用)
    • [17.1 动态存储分配](#17.1 动态存储分配)
      • [17.1.1 内存分配函数](#17.1.1 内存分配函数)
      • [17.1.2 空指针](#17.1.2 空指针)
    • [17.2 动态分配字符串](#17.2 动态分配字符串)
      • [17.2.1 使用malloc函数为字符串分配内存](#17.2.1 使用malloc函数为字符串分配内存)
      • [17.2.2 在字符串函数中使用动态存储分配](#17.2.2 在字符串函数中使用动态存储分配)
      • [17.2.3 动态分配字符串的数组](#17.2.3 动态分配字符串的数组)
        • [17.2.3.1 程序------显示一个月的提醒列表(改进版)](#17.2.3.1 程序——显示一个月的提醒列表(改进版))
    • [17.3 动态分配数组](#17.3 动态分配数组)
      • [17.3.1 使用malloc函数为数组分配存储空间](#17.3.1 使用malloc函数为数组分配存储空间)
      • [17.3.2 calloc函数](#17.3.2 calloc函数)
      • [17.3.3 realloc函数](#17.3.3 realloc函数)
    • [17.4 释放存储空间](#17.4 释放存储空间)
      • [17.4.1 free函数](#17.4.1 free函数)
      • [17.4.2 "悬空指针"问题](#17.4.2 “悬空指针”问题)
    • [17.5 链表](#17.5 链表)
      • [17.5.1 声明结点类型](#17.5.1 声明结点类型)
      • [17.5.2 创建结点](#17.5.2 创建结点)
      • [17.5.3 ->运算符](#17.5.3 ->运算符)
      • [17.5.4 在链表的开始处插入结点](#17.5.4 在链表的开始处插入结点)
      • [17.5.5 搜索链表](#17.5.5 搜索链表)
      • [17.5.6 从链表中删除结点](#17.5.6 从链表中删除结点)
      • [17.5.7 有序链表](#17.5.7 有序链表)
        • [17.5.7.1 程序------维护零件数据库(改进版)](#17.5.7.1 程序——维护零件数据库(改进版))
    • [17.6 指向指针的指针](#17.6 指向指针的指针)
    • [17.7 指向函数的指针](#17.7 指向函数的指针)
      • [17.7.1 函数指针作为参数](#17.7.1 函数指针作为参数)
      • [17.7.2 qsort函数](#17.7.2 qsort函数)
      • [17.7.3 函数指针的其他用途](#17.7.3 函数指针的其他用途)
        • [17.7.3.1 程序------列三角函数](#17.7.3.1 程序——列三角函数)
    • [17.8 受限指针(C99)](#17.8 受限指针(C99))
    • [17.9 弹性数组成员(C99)](#17.9 弹性数组成员(C99))
    • 问与答
    • 写在最后

第17章 指针的高级应用

------人类的头脑可能更倾向于展示复杂的信息。比如视觉对移动、流转和变化景物的敏感度要高于静态画面,无论该画面有多漂亮。


前面几章描述了指针的两种重要应用。第11章说明了如何利用指向变量的指针作为函数的参数,从而允许函数修改该变量。第12章说明了如何对指向数组元素的指针进行算术运算来处理数组。本章则通过观察另外两种应用来完善指针的内容:动态存储分配指向函数的指针

通过使用动态存储分配,程序可以在执行期间获得需要的内存块。17.1节解释动态存储分配的基本概念。17.2节讨论动态分配字符串,这比通常的字符数组更加灵活。17.3节概括地介绍数组的动态存储分配。17.4节处理存储分配的问题,即不再需要内存单元时,动态地释放已分配的内存块。

因为动态分配的结构可以链接在一起形成表、树以及其他高度灵活的数据结构,所以它们在C语言编程中扮演着重要的角色。17.5节重点讲述链表,它是最基础的链式数据结构。这一节中引出的问题("指向指针的指针"的概念)对引出17.6节非常重要。

17.7节介绍指向函数的指针,这是非常有用的内容。C语言中一些功能最强大的库函数期望把指向函数的指针作为参数。这里将考察其中一个函数qsort,它可以对任意数组进行排序。

最后两节讨论从C99开始新出现的与指针相关的特性:受限指针(17.8节)弹性数组成员(17.9节)。这些特性主要面向高级C程序员,初学者可以跳过。


17.1 动态存储分配

C语言的数据结构通常是固定大小的。例如,一旦程序完成编译,数组元素的数量就固定了。[从C99开始,变长数组(8.3节)的长度在运行时确定,但在数组的生命周期内仍然是固定长度的。]因为在编写程序时强制选择了大小,所以固定大小的数据结构可能会有问题。也就是说,在不修改程序并且再次编译程序的情况下无法改变数据结构的大小。

请思考16.3节中允许用户向数据库中添加零件的inventory程序。数据库存储在长度为100的数组中。为了扩大数据库的容量,可以增加数组的大小并且重新编译程序。但是,无论如何增大数组,始终有可能填满数组。幸运的是,还有别的办法。C语言支持动态存储分配,即在程序执行期间分配内存单元的能力。利用动态存储分配,可以设计出能根据需要扩大(和缩小)的数据结构

虽然动态存储分配适用于所有类型的数据,但主要用于字符串、数组和结构 。动态分配的结构是特别有趣的,因为可以把它们链接形成其他数据结构


17.1.1 内存分配函数

为了动态地分配存储空间,需要调用三种内存分配函数的一种,这些函数都是声明在<stdlib.h>头(26.2节)中的:

  • malloc函数------分配内存块,但是不对内存块进行初始化。
  • calloc函数------分配内存块,并且对内存块进行清零。
  • realloc函数------调整先前分配的内存块大小。

在这三种函数中,malloc函数是最常用的一种。因为malloc函数不需要对分配的内存块进行清零,所以它比calloc函数更高效。

当为申请内存块而调用内存分配函数时,因为函数无法知道计划存储在内存块中的数据是什么类型的,所以它不能返回int类型、char类型等普通类型的指针。因此,函数会返回void *类型的值。void *类型的值是"通用"指针,它本质上只是内存地址


17.1.2 空指针

当调用内存分配函数时,总存在这样的可能性:找不到满足我们需要的足够大的内存块。如果真的发生了这类问题,函数会返回空指针(null pointer)空指针是"不指向任何地方的指针",这是一个区别于所有有效指针的特殊值。在把函数的返回值存储到指针变量中后,需要判断该指针变量是否为空指针。

请注意 !!程序员的责任是测试任意内存分配函数的返回值,并且在返回空指针时采取适当的动作。试图通过空指针访问内存的效果是未定义的,程序可能会崩溃或者出现不可预测的行为。

空指针用名为NULL的宏来表示,所以可以用下列方式测试malloc函数的返回值:

c 复制代码
p = malloc(10000); 
if (p == NULL) { 
/* allocation failed; take appropriate action */ 
} 

一些程序员把malloc函数的调用和NULL的测试组合在一起:

c 复制代码
if ((p = malloc(10000)) == NULL) { 
/* allocation failed; take appropriate action */ 
} 

名为NULL的宏在6个头<locale.h>、<stddef.h>、<stdio.h>、<stdlib.h>、<string.h><time.h>中都有定义。(从C99开始引入的头<wchar.h>也定义了NULL。)只要把这些头中的一个包含在程序中,编译器就可以识别出NULL。当然,使用任意内存分配函数的程序都会包含<stdlib.h>,这使NULL必然有效。

C语言中,指针测试真假的方法和数的测试一样。所有非空指针都为真,而只有空指针为假。因此,语句

c 复制代码
if (p == NULL) ... 
//上面的语句可以写成
if (!p) ...

if(p != NULL)...
//上面的语句等价于
if (p) ... 

17.2 动态分配字符串

动态内存分配对字符串操作非常有用。字符串存储在字符数组中,而且可能很难预测这些数组需要的长度。通过动态分配字符串,可以推迟到程序运行时才做决定。

17.2.1 使用malloc函数为字符串分配内存

malloc函数具有如下原型:

c 复制代码
void *malloc(size_t size); 

malloc函数分配size字节的内存块,并且返回指向该内存块的指针 。注意,size的类型是size_t(7.6节),这是在C语言库中定义的无符号整数类型。除非正在分配一个非常巨大的内存块,否则可以只把size当成普通整数。

malloc函数为字符串分配内存是很容易的,因为C语言保证char类型值恰好需要1字节的内存(换句话说,sizeof(char)的值为1)。为给n个字符的字符串分配内存空间,可以写成

c 复制代码
p = malloc(n + 1); 

这里的pchar *类型变量。(实际参数是n+1而不是n,这就给空字符留出了空间。)在执行赋值操作时会把malloc函数返回的通用指针转换为char*类型,而不需要强制类型转换。(通常的情况下,可以把void*类型值赋给任何指针类型的变量,反之亦然。)然而,一些程序员喜欢对malloc函数的返回值进行强制类型转换:

c 复制代码
p = (char *) malloc(n + 1); 

请注意 !!当使用malloc函数为字符串分配内存空间时,不要忘记包含空字符的空间。

因为使用malloc函数分配的内存不需要清零或者以任何方式进行初始化,所以p指向带有n+1个字符的未初始化的数组,对上述数组进行初始化的一种方法是调用strcpy函数:

c 复制代码
strcpy(p, "abc"); 
//数组中的前4个字符分别为a、b、c和\0

17.2.2 在字符串函数中使用动态存储分配

动态存储分配使编写返回指向"新"字符串的指针的函数成为可能,所谓新字符串是指在调用此函数之前字符串并不存在。如果编写的函数把两个字符串连接起来而不改变其中任何一个字符串,请思考一下这样做会遇到什么问题。C标准库没有包含此类函数(因为strcat函数改变了作为参数传递过来的一个字符串,所以此函数并不是我们所要的函数),但是可以很容易地自行写出这样的函数。

自行编写的函数将测量用来连接的两个字符串的长度,然后调用malloc函数为结果分配适当大小的内存空间。接下来函数会把第一个字符串复制到新的内存空间中,并且调用strcat函数来拼接第二个字符串。

c 复制代码
char *concat(const char *s1, const char *s2) 
{ 
    char *result; 
    result = malloc(strlen(s1) + strlen(s2) + 1); 
    if (result == NULL) { 
        printf("Error: malloc failed in concat\n"); 
        exit(EXIT_FAILURE); 
    } 
    strcpy(result, s1); 
    strcat(result, s2); 
    return result; 
} 

如果malloc函数返回空指针,那么concat函数显示出错消息并且终止程序。这并不是正确的处理措施,一些程序需要从内存分配失败后恢复并且继续运行。

下面是concat函数可能的调用方式:

c 复制代码
p = concat("abc", "def"); 

这个调用之后,p将指向字符串"abcdef",此字符串是存储在动态分配的数组中的。数组(包括结尾的空字符)一共有7个字符长。

请注意 !!像concat这样动态分配存储空间的函数必须小心使用。当不再需要concat函数返回的字符串时,需要调用free函数(17.4节)来释放它占用的空间。如果不这样做,程序最终会用光内存空间。


17.2.3 动态分配字符串的数组

13.7节解决了在数组中存储字符串的问题。我们发现把字符串存储为二维字符数组中的行可能会浪费空间,所以试图建立一个指向字面串的指针的数组。如果数组元素是指向动态分配的字符串的指针,那么13.7节的方法是有效的。为了说明这一点,先来重新编写13.5节的程序remind.c,此程序显示一个月的日常提醒列表。


17.2.3.1 程序------显示一个月的提醒列表(改进版)

原始程序remind.c把提醒字符串存储在二维字符数组中,且数组的每行包含一个字符串。程序读入一天和相关的提醒后,会搜索数组并使用strcmp函数进行比较,从而确定这一天所处的位置。然后,程序使用函数strcpy把该位置下面的全部字符串向下移动一个位置。最后,程序把这一天复制到数组中,并且调用strcat函数来添加这一天的提醒。

新程序(remind2.c)中,数组是一维的,且数组的元素是指向动态分配的字符串的指针。在此程序中换成动态分配的字符串主要有2个好处。

  • 第一,与原先那种用固定数量的字符来存储提醒的方式相比,可以为要存储的提醒分配确切字符数量的空间,从而可以更有效地利用空间。
  • 第二,不需要为了给新提醒分配空间而调用函数strcpy来移动已有的字符串,只需要移动指向字符串的指针即可。

下面是新程序,把二维数组换成指针数组显得异常容易:只需要改变程序的8行内容即可。

c 复制代码
/*
remind2.c
--Prints a one-month reminder list (dynamic string version)
*/

#include <stdio.h> 
#include <stdlib.h>  //修改1
#include <string.h> 

#define MAX_REMIND 50 /* maximum number of reminders */ 
#define MSG_LEN 60 /* max length of remider message */ 

int read_line(char str[], int n); 

int main(void) 
{ 
    char *reminders[MAX_REMIND];  //修改2
    char day_str[3], msg_str[MSG_LEN+1]; 
    int day, i, j, num_remind = 0; 

    for (;;) { 
        if (num_remind == MAX_REMIND) { 
            printf("-- No space left --\n"); 
            break; 
        } 
        printf("Enter day and reminder: "); 
        scanf("%2d", &day); 
        if (day == 0) 
            break; 
        sprintf(day_str, "%2d", day); 
        read_line(msg_str, MSG_LEN); 

        for (i = 0; i < num_remind; i++) 
            if (strcmp(day_str, reminders[i]) < 0) 
                break; 
        for (j = num_remind; j > i; j--) 
            reminders[j] = reminders[j-1];   //修改3

        reminders[i] = malloc(2 + strlen(msg_str) + 1);  //修改4
        if (reminders[i] == NULL) {  //修改5
            printf("-- No space left --\n");  //修改6
            break;  //修改7
        }  //修改8

        strcpy(reminders[i], day_str); 
        strcat(reminders[i], msg_str); 
        num_remind++; 
    } 

    printf("\nDay Reminder\n"); 
    for (i = 0; i < num_remind; i++) 
        printf(" %s\n", reminders[i]); 
    
    return 0; 
} 

int read_line(char str[], int n) 
{ 
    int ch, i = 0;
    
    while ((ch = getchar()) != '\n') 
        if (i < n) 
            str[i++] = ch; 
    str[i] = '\0'; 
    return i; 
}

17.3 动态分配数组

动态分配数组会获得和动态分配字符串相同的好处(不用惊讶,因为字符串就是数组)。编写程序时常常很难为数组估计合适的大小。较方便的做法是等到程序运行时再来确定数组的实际大小。C语言解决了这个问题,方法是允许在程序执行期间为数组分配空间,然后通过指向数组第一个元素的指针访问数组 。数组和指针之间的紧密关系已经在第12章中讨论过了,这一关系使得动态分配的数组用起来就像普通数组一样简单。

虽然malloc函数可以为数组分配内存空间,但有时会用calloc函数代替malloc,因为calloc函数会对分配的内存进行初始化。realloc函数允许根据需要对数组进行"扩展"或"缩减"。


17.3.1 使用malloc函数为数组分配存储空间

可以使用malloc函数为数组分配存储空间,这种方法和用它为字符串分配空间非常相像。主要区别就是任意数组的元素不需要像字符串那样是1字节的长度。这样的结果是,我们需要使用sizeof运算符(7.6节)来计算出每个元素所需要的空间数量。

假设正在编写的程序需要n个整数构成的数组,这里的n可以在程序执行期间计算出来。首先需要声明指针变量:

c 复制代码
int *a;

//一旦n的值已知,就让程序调用malloc函数为数组分配存储空间:
a = malloc(n * sizeof(int)); 

请注意 !!计算数组所需要的空间数量时始终要使用sizeof运算符。如果不能分配足够的内存空间,会产生严重的后果。思考下面的语句,此语句试图为n个整数的数组分配空间:

c 复制代码
a = malloc(n * 2); 

如果int类型值大于2字节(在大多数计算机上是如此),那么malloc函数将无法分配足够大的内存块。以后访问数组元素时,程序可能会崩溃或者行为异常。

一旦a指向动态分配的内存块,就可以忽略a是指针的事实,可以把它用作数组的名字。这都要感谢C语言中数组和指针的紧密关系。例如,可以使用下列循环对a指向的数组进行初始化:

c 复制代码
for (i = 0; i < n; i++) 
    a[i] = 0; 
//当然,用指针算术运算代替取下标操作来访问数组元素也是可行的。

17.3.2 calloc函数

虽然可以用malloc函数为数组分配内存,但是C语言还提供了另外一种选择(即calloc函数),此函数有时会更好用一些。calloc函数在<stdlib.h>中具有如下所示的原型:

c 复制代码
void *calloc(size_t nmemb, size_t size); 

calloc函数为nmemb个元素的数组分配内存空间,其中每个元素的长度都是size字节。如果要求的空间无效,那么此函数返回空指针。在分配了内存之后,calloc函数会通过把所有位设置为0的方式进行初始化。例如,下列calloc函数调用为n个整数的数组分配存储空间,并且保证所有整数初始均为零:

c 复制代码
a = calloc(n, sizeof(int)); 

因为calloc函数会清除分配的内存,而malloc函数不会,所以可能有时需要使用calloc函数为不同于数组的对象分配空间。通过调用以1作为第一个实际参数的calloc函数,可以为任何类型的数据项分配空间:

c 复制代码
struct point { int x, y; } *p; 
p = calloc(1, sizeof(struct point)); 
//在执行此语句之后,p将指向一个结构,且此结构的成员x和y都会被设为零。

17.3.3 realloc函数

为数组分配完内存后,可能会发现数组过大或过小。realloc函数可以调整数组的大小使它更适合需要。下列realloc函数的原型出现在<stdlib.h>中:

c 复制代码
void *realloc(void *ptr, size_t size); 

当调用realloc函数时,ptr必须指向先前通过malloccallocrealloc的调用获得的内存块。size表示内存块的新尺寸,新尺寸可能会大于或小于原有尺寸。虽然realloc函数不要求ptr指向正在用作数组的内存,但实际上通常是这样的。

请注意 !!要确定传递给realloc函数的指针来自先前malloccallocrealloc的调用。如果不是这样的指针,程序可能会行为异常。

C标准列出了几条关于realloc函数的规则:

  • 当扩展内存块时,realloc函数不会对添加进内存块的字节进行初始化。
  • 如果realloc函数不能按要求扩大内存块,那么它会返回空指针,并且在原有的内存块中的数据不会发生改变。
  • 如果realloc函数被调用时以空指针作为第一个实际参数,那么它的行为就将像malloc函数一样。
  • 如果realloc函数被调用时以0作为第二个实际参数,那么它会释放内存块。

C标准没有确切地指明realloc函数的工作原理。尽管如此,我们仍然希望它非常有效。在要求减少内存块大小时,realloc函数应该"在原先的内存块上"直接进行缩减,而不需要移动存储在内存块中的数据。同理,扩大内存块时也不应该对其进行移动。如果无法扩大内存块(因为内存块后边的字节已经用于其他目的),realloc函数会在别处分配新的内存块,然后把旧块中的内容复制到新块中。

请注意 !!一旦realloc函数返回,请一定要对指向内存块的所有指针进行更新,因为realloc函数可能会使内存块移动到其他地方。


17.4 释放存储空间

malloc函数和其他内存分配函数所获得的内存块都来自一个叫作堆(heap)的存储池。过于频繁地调用这些函数(或者让这些函数申请大内存块)可能会耗尽堆,这会导致函数返回空指针。

更糟的是,程序可能分配了内存块,然后又丢失了对这些块的记录,因而浪费了空间。请思考下面的例子:

c 复制代码
p = malloc(...); 
q = malloc(...); 
p = q; 

在执行完前两条语句后,p指向了一个内存块,而q指向了另一个内存块,在把q赋值给p之后,两个指针现在都指向了第二个内存块,因为没有指针指向第一个内存块,所以再也不能使用此内存块了。

对程序而言,不可再访问到的内存块被称作垃圾(garbage) 。留有垃圾的程序存在内存泄漏(memroy leak)现象。一些语言提供垃圾收集器(garbage collector)用于垃圾的自动定位和回收,但是C语言不提供。相反,每个C程序负责回收各自的垃圾,方法是调用free函数来释放不需要的内存。


17.4.1 free函数

free函数在<stdlib.h>中有下列原型:

c 复制代码
void free(void *ptr);

使用free函数很容易,只需要简单地把指向不再需要的内存块的指针传递给free函数就可以了:

c 复制代码
p = malloc(...); 
q = malloc(...); 
free(p); 
p = q; 

调用free函数会释放p所指向的内存块。然后此内存块可以被后续的malloc函数或其他内存分配函数的调用重新使用。

请注意 !!free函数的实际参数必须是先前由内存分配函数返回的指针。 (参数也可以是空指针,此时free调用不起作用。)如果参数是指向其他对象(比如变量或数组元素)的指针,可能会导致未定义的行为。


17.4.2 "悬空指针"问题

虽然free函数允许收回不再需要的内存,但是使用此函数会导致一个新的问题:悬空指针(dangling pointer)。调用free(p)函数会释放p指向的内存块,但是不会改变p本身。如果忘记了p不再指向有效内存块,混乱可能随即而来

c 复制代码
char *p = malloc(4); 
... 
free(p); 
... 
strcpy(p, "abc");     /*** WRONG ***/ 

修改p指向的内存是严重的错误,因为程序不再对此内存有任何控制权了。

请注意 !!试图访问或修改释放的内存块会导致未定义的行为。试图修改释放的内存块可能会引起程序崩溃等损失惨重的后果。

悬空指针是很难发现的,因为几个指针可能指向相同的内存块。在释放内存块后,全部的指针都悬空了。


17.5 链表

动态存储分配对建立表、树、图和其他链式数据结构是特别有用的 。本节将介绍链表,而对其他链式数据结构的讨论超出了本书的范畴。为了获取更多的信息,可以参考Robert Sedgewick的《算法:C语言实现(第1~4部分)基础知识、数据结构、排序及搜索(原书第3版)》这样的书。

链表(Linked List)是由一连串的结构(称为结点)组成的,其中每个结点都包含指向链中下一个结点的指针,链表中的最后一个结点包含一个空指针。

在前面几章中,我们在需要存储数据项的集合时总是使用数组,而现在链表为我们提供了另外一种选择。链表比数组更灵活,我们可以很容易地在链表中插入和删除结点,也就是说允许链表根据需要扩大和缩小 。另一方面,我们也失去了数组的"随机访问"能力。我们可以用相同的时间访问数组内的任何元素,而访问链表中的结点用时不同。如果结点距离链表的开始处很近,那么访问到它会很快;反之,若结点靠近链表结尾处,访问到它就很慢。

本节会描述在C语言中建立链表的方法,还将说明如何对链表执行几个常见的操作,即在链表开始处插入结点搜索结点删除结点


17.5.1 声明结点类型

为了建立链表,首先需要一个表示表中单个结点的结构。简单起见,先假设结点只包含一个整数(即结点的数据)和指向表中下一个结点的指针。下面是结点结构的描述:

c 复制代码
struct node { 
    int value;       //data stored in the node    
    struct node *next;    //pointer to the next node 
};

注意 !!成员next具有struct node *类型,这就意味着它能存储一个指向node结构的指针。顺便说一下,node这个名字没有任何特殊含义,只是一个普通的结构标记。

关于node结构,有一点需要特别提一下。正如16.2节说明的那样,通常可以选择使用标记或者用typedef来定义一种特殊的结构类型的名字。但是,在结构有一个指向相同结构类型的指针成员时(就像node中那样),要求使用结构标记。没有node标记,就没有办法声明next的类型。

现在已经声明了node结构,还需要记录表开始的位置。换句话说,需要有一个始终指向表中第一个结点的变量。这里把此变量命名为first

c 复制代码
struct node *first = NULL; 
//把first初始化为NULL表明链表初始为空。

17.5.2 创建结点

在构建链表时,需要逐个创建结点,并且把生成的每个结点加入链表中。创建结点包括3个步骤(本节将集中介绍前两个步骤):

  1. 为结点分配内存单元;
  2. 把数据存储到结点中;
  3. 把结点插入链表中。

为了创建结点,需要一个变量临时指向该结点(直到该结点插入链表中为止)。设此变量为new_node

c 复制代码
struct node *new_node; 

我们用malloc函数为新结点分配内存空间,并且把返回值保存在new_node中:

c 复制代码
new_node = malloc(sizeof(struct node)); 

new_node指向了一个内存块,且此内存块正好能放下一个node结构。

请注意 !!传给sizeof的是待分配的类型的名字,而不是指向此类型的指针的名字:

c 复制代码
new_node = malloc(sizeof(new_node));   /*** WRONG ***/ 

上面的代码仍然能通过编译,但是malloc函数将只为指向node结构的指针分配足够的内存单元。当程序试图把数据存储到new_node可能指向的结点中时,可能会引起崩溃。

接下来将把数据存储到新结点的成员value中:

c 复制代码
(*new_node).value = 10;

为了访问结点的成员value,可以采用间接寻址运算符*(引用new_node指向的结构),然后用选择运算符.(选择此结构内的一个成员)。在*new_node两边的圆括号是强制要求的,因为运算符.的优先级高于运算符*


17.5.3 ->运算符

在介绍往链表中插入新结点之前,先来讨论一种有用的捷径。利用指针访问结构中的成员是很普遍的,因此C语言专门提供了一种运算符。此运算符称为右箭头选择(right arrow selection),它由一个减号跟着一个>组成。利用运算符->可以编写语句:

c 复制代码
new_node->value = 10;
//上述语句等价于
(*new_node).value = 10; 

运算符->运算符*运算符.的组合,它先对new_node间接寻址以定位所指向的结构,然后再选择结构的成员value

由于运算符->产生左值(4.2节),所以可以在任何允许普通变量的地方使用它 。刚才已经看到一个new_node->value出现在赋值运算左侧的例子,在scanf调用中也很常见:

c 复制代码
scanf("%d", &new_node->value); 

注意 !!尽管new_node是一个指针,运算符&仍然是需要的。如果没有运算符&,就会把new_node->value的值传递给scanf函数,而这个值是int类型。


17.5.4 在链表的开始处插入结点

链表的好处之一就是可以在表中的任何位置添加结点:在开始处、结尾处或者中间的任何位置。然而,链表的开始处是最容易插入结点的地方,所以这里集中讨论这种情况。

如果new_node正指向要插入的结点,并且first正指向链表中的首结点,那么为了把结点插入链表将需要两条语句。首先,修改结点的成员next,使其指向先前在链表开始处的结点:

c 复制代码
new_node->next = first; 

接下来,使first指向新结点:

c 复制代码
first = new_node; 

如果在插入结点时链表为空,那么这些语句是否还能起作用呢?幸运的是,可以。为了确信这是真的,一起来跟踪一下在空链表中插入两个结点的过程。首先插入含有数10的结点,然后插入含有数20的结点。

c 复制代码
first = NULL;
new_node = malloc(sizeof(struct node));
new_node->value = 10;
new_node->next = first;
first = new_node;
new_node = malloc(sizeof(struct node));
new_node->value = 20;
new_node->next = first;
first = new_node;

往链表中插入结点是经常用到的操作,所以希望为此编写一个函数。把此函数命名为add_to_list。此函数有两个形式参数:list(指向旧链表中首结点的指针)和n(需要存储在新结点中的整数):

c 复制代码
struct node *add_to_list(struct node *list, int n) 
{ 
    struct node *new_node; 
    
    new_node = malloc(sizeof(struct node)); 
    if (new_node == NULL) { 
        printf("Error: malloc failed in add_to list\n"); 
        exit(EXIT_FAILURE); 
    } 
    new_node->value = n; 
    new_node->next = list; 
    return new_node; 
}

注意 !!add_to_list函数不会修改指针list,而是返回指向新产生的结点的指针(现在位于链表的开始处)。当调用add_to_list函数时,需要把它的返回值存储到first中:

c 复制代码
first = add_to_list(first, 10); 
first = add_to_list(first, 20); 

上述语句为first指向的链表增加了含有1020的结点。用add_to_list函数直接更新first,而不是为first返回新的值,这样做是个技巧。17.6节将回到这个问题。

下列函数用add_to_list来创建一个含有用户输入的数的链表:

c 复制代码
struct node *read_numbers(void) 
{ 
    struct node *first = NULL; 
    int n; 
    
    printf("Enter a series of integers (0 to terminate): "); 
    for (;;) { 
        scanf("%d", &n); 
        if (n == 0) 
            return first; 
        first = add_to_list(first, n); 
    } 
}
//链表内的数会发生顺序倒置,因为first始终指向包含最后输入的数的结点。

17.5.5 搜索链表

一旦创建了链表,可能就需要为某个特殊的数据段而搜索链表。虽然while循环可以用于搜索链表,但是for语句常常是首选。我们习惯于在编写含有计数操作的循环时使用for语句,但是for语句的灵活性使它也适合其他工作,包括对链表的操作。下面是一种访问链表中结点的习惯方法,使用了指针变量p来跟踪"当前"结点:

c 复制代码
//惯用法
for (p = first; p != NULL; p = p->next) 
    ...

赋值表达式p = p->next使指针p从一个结点移动到下一个结点。当编写遍历链表的循环时,在C语言中总是采用这种形式的赋值表达式。

现在编写名为search_list的函数,此函数为找到整数n而搜索链表(形式参数list指向它)。如果找到n,那么search_list函数将返回指向含有n的结点的指针;否则,它会返回空指针。下面的第一版search_list函数依赖于"链表搜索"惯用法:

c 复制代码
struct node *search_list(struct node *list, int n) 
{ 
    struct node *p; 
    
    for (p = list; p != NULL; p = p->next) 
        if (p->value == n) 
            return p; 
    return NULL; 
} 

当然,还有许多其他方法可以编写search_list函数。其中一种替换方式是除去变量p,而用list自身来跟踪当前结点:

c 复制代码
struct node *search_list(struct node *list, int n) 
{ 
    for (; list != NULL; list = list->next) 
        if (list->value == n) 
            return list; 
    return NULL; 
}

因为list是原始链表指针的副本,所以在函数内改变它不会有任何损失 。另一种替换方法是把判定list->value == n和判定list != NULL合并起来:

c 复制代码
struct node *search_list(struct node *list, int n) 
{ 
    for (; list != NULL && list->value != n; list = list->next) 
        ; 
    return list; 
}

因为到达链表末尾处时listNULL,所以即使找不到n,返回list也是正确的。如果使用while语句,那么search_list函数的这一版本可能会更加清楚:

c 复制代码
struct node *search_list(struct node *list, int n) 
{ 
    while (list != NULL && list->value != n) 
        list = list->next; 
    return list; 
} 

17.5.6 从链表中删除结点

把数据存储到链表中有一个很大的好处,那就是可以轻松地删除不需要的结点。就像创建结点一样,删除结点也包含3个步骤:

  1. 定位要删除的结点;
  2. 改变前一个结点,从而使它"绕过"删除结点;
  3. 调用free函数收回删除结点占用的内存空间。

第(1)步并不像看起来那么容易。如果按照显而易见的方式搜索链表,那么将在指针指向要删除的结点时终止搜索。但是,这样做就不能执行第(2)步了,因为第(2)步要求改变前一个结点。

针对这个问题有各种不同的解决办法。这里将使用"追踪指针"方法:在第(1)步搜索链表时,将保留一个指向前一个结点的指针(prev),还有指向当前结点的指针(cur)。如果list指向待搜索的链表,并且n是要删除的整数,那么下列循环就可以实现第(1)步

c 复制代码
for (cur = list, prev = NULL; 
    cur != NULL && cur->value != n; 
    prev = cur, cur = cur->next) 
    ;

这里我们看到了C语言中for语句的威力。这是个很奇异的示例,它采用了空循环体 并应用逗号运算符,却能够执行搜索n所需的全部操作。当循环终止时,cur指向要删除的结点,而prev指向前一个结点(如果有的话)。

为了看清楚这个循环的工作过程,现在假设list指向依次含有30402010的链表。

  • 假设n20,那么目标就是删除此链表中的第3个结点。在执行完cur = list, prev = NULL后,cur指向了链表中的第1个结点;
  • 因为cur正指向一个结点,且此结点不含有20,所以判定表达式cur != NULL && cur->value != n为真。在执行完prev = cur, cur = cur->next后,我们发现指针prev跟踪在指针cur的后边;
  • 判定表达式cur != NULL && cur->value != n再次为真,所以再次执行prev = cur, cur = cur->next
  • 因为cur此时指向了含有20的结点,所以条件表达式cur != NULL && cur->value != n为假,从而循环终止。

接下来,将根据第(2)步的要求执行绕过操作。语句

c 复制代码
prev->next = cur->next;

使前一个结点中的指针指向了当前结点后面的结点;

现在准备完成第(3)步,即释放当前结点占用的内存:

c 复制代码
free(cur);

下面的函数delete_from_list所使用的策略就是刚刚概述的操作。在给定链表和整数n时,delete_from_list函数就会删除含有n的第一个结点。如果没有含有n的结点,那么函数什么也不做。无论上述哪种情况,函数都返回指向链表的指针:

c 复制代码
struct node *delete_from_list(struct node *list, int n) 
{ 
    struct node *cur, *prev; 
    
    for (cur = list, prev = NULL; 
        cur != NULL && cur->value != n; 
        prev = cur,  cur = cur->next) 
        ; 
    
    if (cur == NULL) 
        return list;         /* n was not found */ 

    if (prev == NULL) 
        list = list->next;   /* n is in the first node */ 
    else 
        prev->next = cur->next; /* n is in some other node */ 
    
    free (cur); 
    return list; 
}

删除链表中的首结点是一种特殊情况 。判定表达式prev == NULL会检查这种情况,这需要一种不同的绕过步骤。


17.5.7 有序链表

如果链表的结点是有序的(按结点中的数据排序),则称该链表为有序链表。往有序列表中插入结点会更困难一些(因为不再始终把结点放置在链表的开始处),但是搜索会更快(在到达期望结点应该出现的位置后,就可以停止查找了)。下面的程序表明,插入结点的难度增加了,但搜索也更快了。

17.5.7.1 程序------维护零件数据库(改进版)

下面重做16.3节的零件数据库程序,这次把数据库存储在链表中。用链表代替数组主要有两个好处:

  • 不需要事先限制数据库的大小,数据库可以扩大到没有更多内存空间存储零件为止;
  • 可以很容易地按零件编号对数据库排序,当往数据库中添加新零件时,只要把它插入链表中的适当位置就可以了。在原来的程序中,数据库是无序的。

在新程序中,part结构将包含一个额外的成员(指向链表中下一个结点的指针),而且变量inventory是指向链表首结点的指针:

c 复制代码
struct part { 
    int number; 
    char name[NAME_LEN+1]; 
    int on_hand; 
    struct part *next; 
};

struct part *inventory = NULL;     /* points to first part */ 

新程序中的大多数函数非常类似于它们在原始程序中的版本。然而,find_part函数和insert函数变得更加复杂了,这是因为把结点保留在按零件编号排序的链表inventory中。

在原来的程序中,函数find_part返回数组inventory的索引。而在新程序中,find_part函数返回指针,此指针指向的结点含有需要的零件编号。如果没有找到该零件编号,find_part函数会返回空指针。因为链表inventory是根据零件编号排序的,所以新版本的find_part函数可以通过在结点的零件编号大于或等于需要的零件编号时停止搜索来节省时间。find_part函数的搜索循环形式如下:

c 复制代码
for (p = inventory; 
    p != NULL && number > p->number; 
    p = p->next) 
    ;

p变为NULL时(说明没有找到零件编号)或者当number > p->number为假时(说明找到的零件编号小于或等于已经存储在结点中的数),循环终止。在后一种情况下,我们仍然不知道需要的数是否真的在链表中,所以还需要另一次判断:

c 复制代码
if (p != NULL && number == p->number) 
    return p; 

原始版本的insert函数把新零件存储在下一个有效的数组元素中;新版本的函数需要确定新零件在链表中所处的位置,并且把它插入那个位置。insert函数还要检查零件编号是否已经出现在链表中了。通过使用与find_part函数中类似的循环,insert函数可以同时完成这两项任务:

c 复制代码
for (cur = inventory, prev = NULL; 
    cur != NULL && new_node->number > cur->number; 
    prev = cur, cur = cur->next) 
    ; 

此循环依赖于两个指针:指向当前结点的指针cur和指向前一个结点的指针prev。一旦终止循环,insert函数将检查cur是否不为NULL,以及new_node->number是否等于cur->number。如果条件成立,那么零件的编号已经在链表中了。否则,insert函数将把新结点插入到prevcur指向的结点之间,所使用的策略与删除结点所采用的类似。(即使新零件的编号大于链表中的任何编号,此策略仍然有效。这种情况下,cur将为NULL,而prev将指向链表中的最后一个结点。)


下面是新程序。和原始程序一样,此版本需要16.3节描述的read_line函数。假设realine.h含有此函数的原型。

c 复制代码
/*
inverntory2.c
--Maintains a parts database (linked list version) 
*/

#include <stdio.h> 
#include <stdlib.h> 
#include "readline.h" 

#define NAME_LEN 25 

struct part { 
    int number; 
    char name[NAME_LEN+1]; 
    int on_hand; 
    struct part *next; 
}; 

struct part *inventory = NULL;   /* points to first part */ 

struct part *find_part(int number); 
void insert(void); 
void search(void); 
void update(void); 
void print(void); 

/**********************************************************  
 * main: Prompts the user to enter an operation code,     *  
 *       then calls a function to perform the requested   *  
 *       action. Repeats until the user enters the        *  
 *       command 'q'. Prints an error message if the user *  
 *       enters an illegal code.                          *  
 **********************************************************/ 
int main(void) 
{ 
    char code; 
    
    for (;;) { 
        printf("Enter operation code: "); 
        scanf(" %c", &code); 
        while (getchar() != '\n')   /* skips to end of line */ 
            ; 
        switch (code) { 
            case 'i': insert();  
                        break; 
            case 's': search();  
                        break; 
            case 'u': update();  
                        break; 
            case 'p': print();   
                        break; 
            case 'q': return 0; 
            default:  printf("Illegal code\n"); 
        } 
        printf("\n"); 
    } 
} 

/**********************************************************  
 * find_part: Looks up a part number in the inventory     *  
 *            list. Returns a pointer to the node         *  
 *            containing the part number; if the part     *  
 *            number is not found, returns NULL.          *  
 **********************************************************/ 
struct part *find_part(int number) 
{ 
    struct part *p; 
    
    for (p = inventory; 
        p != NULL && number > p->number; 
        p = p->next) 
        ; 
    if (p != NULL && number == p->number) 
        return p; 
    return NULL; 
} 

/**********************************************************  
 * insert: Prompts the user for information about a new   *  
 *         part and then inserts the part into the        * 
 *         inventory list; the list remains sorted by     * 
 *         part number. Prints an error message and       *  
 *         returns prematurely if the part already exists *  
 *         or space could not be allocated for the part.  *  
 **********************************************************/ 
void insert(void) 
{ 
    struct part *cur, *prev, *new_node; 
    
    new_node = malloc(sizeof(struct part)); 
    if (new_node == NULL) { 
        printf("Database is full; can't add more parts.\n"); 
        return; 
    } 
    
    printf("Enter part number: "); 
    scanf("%d", &new_node->number); 
    
    for (cur = inventory, prev = NULL; 
        cur != NULL && new_node->number > cur->number; 
        prev = cur, cur = cur->next) 
        ; 
    if (cur != NULL && new_node->number == cur->number) { 
        printf("Part already exists.\n"); 
        free(new_node); 
        return; 
    } 
    
    printf("Enter part name: "); 
    read_line(new_node->name, NAME_LEN); 
    printf("Enter quantity on hand: "); 
    scanf("%d", &new_node->on_hand); 
    new_node->next = cur; 
    if (prev == NULL) 
        inventory = new_node; 
    else 
        prev->next = new_node; 
} 

/**********************************************************  
 * search: Prompts the user to enter a part number, then  *  
 *         looks up the part in the database. If the part *  
 *         exists, prints the name and quantity on hand;  *  
 *         if not, prints an error message.               *  
 **********************************************************/ 
void search(void) 
{ 
    int number; 
    struct part *p; 
    
    printf("Enter part number: "); 
    scanf("%d", &number); 
    p = find_part(number); 
    if (p != NULL) { 
        printf("Part name: %s\n", p->name); 
        printf("Quantity on hand: %d\n", p->on_hand); 
    } else 
        printf("Part not found.\n"); 
} 

/**********************************************************  
 * update: Prompts the user to enter a part number.       *  
 *         Prints an error message if the part doesn't    *  
 *         exist; otherwise, prompts the user to enter    *  
 *         change in quantity on hand and updates the     *  
 *         database.                                      *  
 **********************************************************/ 
void update(void) 
{ 
    int number, change; 
    struct part *p; 
    
    printf("Enter part number: "); 
    scanf("%d", &number); 
    p = find_part(number); 
    if (p != NULL) { 
        printf("Enter change in quantity on hand: "); 
        scanf("%d", &change); 
        p->on_hand += change; 
    } else 
        printf("Part not found.\n"); 
} 

/**********************************************************  
 * print: Prints a listing of all parts in the database,  *  
 *        showing the part number, part name, and         *  
 *        quantity on hand. Part numbers will appear in   *  
 *        ascending order.                                *  
 **********************************************************/ 
void print(void) 
{ 
    struct part *p; 
    printf("Part Number   Part Name                  " 
            "Quantity on Hand\n"); 
    for (p = inventory; p != NULL; p = p->next) 
        printf("%7d       %-25s%11d\n", p->number, p->name, p->on_hand); 
} 

注意insert函数中free的用法 !!insert函数在检查零件是否已经存在之前就为零件分配内存空间。如果已存在,那么函数insert释放内存以避免内存泄漏。


17.6 指向指针的指针

13.7节中我们已经遇到过指向指针的指针。在那一节中,使用了元素类型为char *的数组,指向数组元素的指针的类型为char **。"指向指针的指针"这一概念也频繁出现在链式数据结构中。特别是,当函数的实际参数是指针变量时,有时候会希望函数能通过让指针指向别处来改变此变量。这就需要用到指向指针的指针

请思考一下17.5节中的函数add_to_list,此函数用来在链表的开始处插入结点。当调用函数add_to_list时,我们会传递给它指向原始链表首结点的指针,然后函数会返回指向新链表首结点的指针:

c 复制代码
struct node *add_to_list(struct node *list, int n) 
{ 
    struct node *new_node; 
    
    new_node = malloc(sizeof(struct node)); 
    if (new_node == NULL)  { 
        printf("Error: malloc failed in add_to_list\n"); 
        exit(EXIT_FAILURE); 
    } 
    new_node->value = n; 
    new_node->next = list; 
    return new_node; 
} 

假设修改了函数,使它不再返回new_node,而是把new_node赋值给list。换句话说,把return语句从函数add_to_list中移走,同时用下列语句进行替换:

c 复制代码
list = new_node;

可惜这个想法无法实现。假设按照下列方式调用函数add_to_list

c 复制代码
add_to_list(first, 10);

在调用点上会把first复制给list。(像所有其他参数一样,指针也是值传递的 。)函数内的最后一行改变了list的值,使它指向了新的结点。但是,此赋值操作对first没有影响

让函数add_to_list修改first是可能的,但是这就要求给函数add_to_list传递一个指向first的指针。下面是此函数的正确形式:

c 复制代码
void add_to_list(struct node **list, int n) 
{ 
    struct node *new_node; 
    
    new_node = malloc(sizeof(struct node)); 
    
    if (new_node == NULL) { 
        printf("Error: malloc failed in add_to_list\n"); 
        exit(EXIT_FAILURE); 
    } 
    new_node->value = n; 
    new_node->next = *list; 
    *list = new_node; 
} 

当调用新版本的函数add_to_list时,第一个实际参数将是first的地址:

c 复制代码
add_to_list(&first, 10); 

既然给list赋予了first的地址,那么可以使用*list作为first的别名。特别是把new_node赋值给*list会修改first的内容。


17.7 指向函数的指针

到目前为止,已经使用指针指向过各种类型的数据,包括变量、数组元素以及动态分配的内存块。但是C语言没有要求指针只能指向数据,它还允许指针指向函数 。指向函数的指针(函数指针)不像人们所想象的那样奇怪。毕竟函数占用内存单元,所以每个函数都有地址,就像每个变量都有地址一样

17.7.1 函数指针作为参数

可以以使用数据指针相同的方式使用函数指针 。在C语言中把函数指针作为参数进行传递是十分普遍的。假设我们要编写一个名为integrate的函数来求函数fa点和b点之间的积分。我们希望函数integrate尽可能具有一般性,因此把f作为实际参数传入。为了在C语言中达到这种效果,我们把f声明为指向函数的指针。假设希望对具有double型形式参数并且返回double型结果的函数求积分,函数integrate的原型如下所示:

c 复制代码
double integrate(double (*f)(double), double a, double b); 

*f两边的圆括号说明f是个指向函数的指针,而不是返回值为指针的函数。把f当作函数声明也是合法的:

c 复制代码
double integrate(double f(double), double a, double b); 
//从编译器的角度来看,这种原型和前一种形式是完全一样的。

在调用函数integrate时,将把一个函数名作为第一个实际参数。例如,下列调用将计算sin函数(23.3节)从0π/2的积分:

c 复制代码
result = integrate(sin, 0.0, PI / 2); 

注意 !!sin的后边没有圆括号。当函数名后边没跟着圆括号时,C语言编译器会产生指向函数的指针,而不会产生函数调用的代码 。此例中不是在调用函数sin,而是给函数integrate传递了一个指向函数sin的指针。如果这样看上去很混乱的话,可以想想C语言处理数组的过程。如果a是数组的名字,那么a[i]就表示数组的一个元素,而a本身则作为指向数组的指针。类似地,如果f是函数,那么C语言把f(x)当作函数的调用来处理,而f本身则被视为指向函数的指针。

integrate函数体内,可以调用f所指向的函数:

c 复制代码
y = (*f)(x);

*f表示f所指向的函数,x是函数调用的实际参数。因此,在函数integrate(sin, 0.0, PI/2)执行期间,*f的每次调用实际上都是sin函数的调用。作为(*f)(x)的一种替换选择,C语言允许用f(x)来调用f所指向的函数。虽然f(x)看上去更自然一些,但是这里将坚持用(*f)(x),以提醒读者f是指向函数的指针而不是函数名


17.7.2 qsort函数

指向函数的指针看似对日常编程没有什么用处,但是从事实来看这是没有远见的。实际上,C函数库中一些功能最强大的函数要求把函数指针作为参数。其中之一就是函数qsort,此函数的原型可以在<stdlib.h>中找到。函数qsort是给任意数组排序的通用函数。

因为数组的元素可能是任何类型的,甚至是结构或联合,所以必须告诉函数qsort如何确定两个数组元素哪一个"更小"。通过编写比较函数可以为函数qsort提供这些信息。当给定两个指向数组元素的指针pq时,比较函数必须返回一个整数。如果*p"小于"*q,那么返回的数为负数;如果*p"等于"*q,那么返回的数为零;如果*p"大于"*q,那么返回的数为正数。这里把"小于""等于"和"大于"放在双引号中是因为需要由我们来确定如何比较*p*q

函数qsort具有下列原型:

c 复制代码
void qsort(void *base, size_t nmemb, size_t size, 
           int (*compar) (const void *, const void *));  

base必须指向数组中的第一个元素。(如果只是对数组的一段区域进行排序,那么要使base指向这段区域的第一个元素。)在一般情况下,base就是数组的名字。nmemb是要排序的元素数量(不一定是数组中元素的数量)。size是每个数组元素的大小,用字节来衡量。compar是指向比较函数的指针。当调用函数qsort时,它会对数组进行升序排列,并且在任何需要比较数组元素的时候调用比较函数。

为了对16.3节inventory数组进行排序,可以采用函数qsort的下列调用方式:

c 复制代码
qsort(inventory, num_parts, sizeof(struct part), compare_parts); 

请注意 !!第二个实际参数是num_parts而不是MAX_PARTS。我们不希望对整个inventory数组进行排序,只是对当前存储的区域进行排序。最后一个实际参数compare_parts是比较两个part结构的函数。

编写compare_parts函数并不像想象中的那么容易。函数qsort要求它的形式参数类型为void *,但我们不能通过void *型的指针访问part结构的成员,因为我们需要指向结构part的指针。为了解决这个问题,要用compare_parts把形式参数pq赋值给struct part *型的变量,从而把它们转换为希望的类型。现在compare_parts可以使用新指针访问到pq指向的结构的成员了。假设希望按零件编号的升序对inventory数组排序,下面是函数compare_parts可能的形式:

c 复制代码
int compare_parts(const void *p, const void *q) 
{ 
    const struct part *p1 = p; 
    const struct part *q1 = q; 
    
    if (p1->number < q1->number) 
        return --1; 
    else if (p1->number == q1->number) 
        return 0; 
    else 
        return 1; 
}

p1q1的声明中含有单词const,以免编译器生成警告消息。由于pqconst指针(表明它们指向的对象不能修改),它们只应赋值给声明为const的指针变量。此版本的compare_parts函数虽然可以使用,但是大多数C程序员愿意编写更加简明的函数。首先,注意到能用强制类型转换表达式替换p1q1

c 复制代码
int compare_parts(const void *p, const void *q) 
{ 
    if (((struct part *) p)->number <  
        ((struct part *) q)->number) 
        return -1; 
    else if (((struct part *) p)->number ==  
            ((struct part *) q)->number) 
        return 0; 
    else 
        return 1; 
}

表达式((struct part *)p)两边的圆括号是必需的。如果没有这些圆括号,那么编译器会试图把p->number强制转换成struct part*类型。

通过移除if语句可以把函数compare_parts变得更短:

c 复制代码
int compare_parts(const void *p, const void *q) 
{ 
  return ((struct part *) p)->number --  
         ((struct part *) q)->number; 
} 

如果p的零件编号小于q的零件编号,那么用p的零件编号减去q的零件编号会产生负值。如果两个零件编号相同,则减法结果为零。如果p的零件编号较大,则减法结果为正数。(注意,整数相减是有风险的,因为有可能导致溢出。我们这里假设零件编号是正整数,从而避免了风险。)

为了用零件的名字代替零件编号对数组inventory进行排序 ,可以使用函数compare_parts的下列写法:

c 复制代码
int compare_parts(const void *p, const void *q) 
{ 
    return strcmp(((struct part *) p)->name,  
                ((struct part *) q)->name); 
} 

函数compare_parts需要做的事就是调用函数strcmp,此函数会方便地返回负值、零值或正值的结果。


17.7.3 函数指针的其他用途

我们已经强调了函数指针用作其他函数的实际参数是非常有用的,但函数指针的作用不仅限于此。C语言把指向函数的指针当作指向数据的指针对待。我们可以把函数指针存储在变量中,或者用作数组的元素,再或者用作结构或联合的成员,甚至可以编写返回函数指针的函数

下面的例子中变量存储的就是指向函数的指针:

c 复制代码
void (*pf)(int);

pf可以指向任何带有int型形式参数并且返回void型值的函数。如果f是这样的一个函数,那么可以用下列方式让pf指向f

c 复制代码
pf = f;

注意 !!在f的前面没有取地址符号(&)。一旦pf指向函数f,可以用下面这种写法调用f

c 复制代码
(*pf)(i);

//也可以用下面这种写法调用:
pf(i);

元素是函数指针的数组应用相当广泛。例如,假设我们编写的程序需要向用户显示可选择的命令菜单。我们可以编写函数实现这些命令,然后把指向这些函数的指针存储在数组中:

c 复制代码
//函数指针数组
void (*file_cmd[])(void) = {new_cmd, 
                            open_cmd,  
                            close_cmd, 
                            close_all_cmd, 
                            save_cmd, 
                            save_as_cmd, 
                            save_all_cmd, 
                            print_cmd, 
                            exit_cmd 
                           };

如果用户选择命令n,且n是范围在0~8的数,那么可以对数组file_cmd取下标,并调用相应的函数:

c 复制代码
(*file_cmd[n])();   /* or file_cmd[n](); */ 

当然,通过使用switch语句可以获得类似的效果。然而,使用函数指针数组可以有更大的灵活性,因为数组元素可以在程序运行时发生改变。


17.7.3.1 程序------列三角函数

下列函数用来显示含有cos函数、sin函数和tan函数[这三个函数都在<math.h>(23.3节)中]值的表格。程序围绕名为tabulate的函数构建。当给此函数传递函数指针f时,此函数会显示出函数f的值。

c 复制代码
/*
tabulate.c
--Tabulates values of trigonometric functions 
*/
#include <math.h> 
#include <stdio.h> 

void tabulate(double (*f)(double), double first, 
              double last, double incr); 

int main(void) 
{ 
    double final, increment, initial; 
    
    printf("Enter initial value: "); 
    scanf("%lf", &initial); 
    printf("Enter final value: "); 
    scanf("%lf", &final); 
    
    printf("Enter increment: "); 
    scanf("%lf", &increment); 
    
    printf("\n      x        cos(x)"   
            "\n   -------    -------\n"); 
    tabulate(cos, initial, final, increment); 
    
    printf("\n      x        sin(x)"   
            "\n   -------    -------\n"); 
    tabulate(sin, initial, final, increment); 
    
    printf("\n      x        tan(x)"   
            "\n   -------    -------\n"); 
    tabulate(tan, initial, final, increment); 
    
    return 0; 
} 

void tabulate(double (*f)(double), double first, 
              double last, double incr) 
{ 
    double x; 
    int i, num_intervals; 
    
    num_intervals = ceil((last - first) / incr); 
    for (i = 0; i <= num_intervals; i++) { 
        x = first + i * incr; 
        printf("%10.5f %10.5f\n", x, (*f)(x)); 
    } 
}

函数tabulate使用了函数ceil,此函数也属于<math.h>。当给定double型的实际参数x时,函数ceil会返回大于或等于x最小整数

下面是运行tabulate.c程序的可能结果:

bash 复制代码
Enter initial value:  0 
Enter final value:  .5 
Enter increment:  .1 

       x         cos(x) 
    -------     ------- 
    0.00000     1.00000 
    0.10000     0.99500 
    0.20000     0.98007 
    0.30000     0.95534 
    0.40000     0.92106 
    0.50000     0.87758 

       x         sin(x) 
    -------     ------- 
    0.00000     0.00000 
    0.10000     0.09983 
    0.20000     0.19867 
    0.30000     0.29552 
    0.40000     0.38942 
    0.50000     0.47943 

       x          tan(x) 
    -------     ------- 
    0.00000     0.00000 
    0.10000     0.10033 
    0.20000     0.20271 
    0.30000     0.30934 
    0.40000     0.42279 
    0.50000     0.54630 

17.8 受限指针(C99)

这一节和下一节将讨论从C99开始引入的与指针相关的两种特性。对这两种特性感兴趣的主要是高级C程序员,大多数读者可以跳过这两节。

C99开始,关键字restrict可以出现在指针的声明中:

c 复制代码
int * restrict p;

restrict声明的指针叫作受限指针(restricted pointer)这样做的目的是,如果指针p指向的对象在之后需要修改,那么该对象不会允许通过除指针p之外的任何方式访问(其他访问对象的方式包括让另一个指针指向同一个对象,或者让指针p指向命名变量) 。如果一个对象有多种访问方式,通常把这些方式互称为别名

下面先来看一个不适合 使用受限指针的例子。假设pq的声明如下:

c 复制代码
int * restrict p; 
int * restrict q;

现在假设p指向动态分配的内存块:

c 复制代码
p = malloc(sizeof(int)); 

(如果把变量或者数组元素的地址赋给p,也会出现类似的情况。)通常情况下可以将p复制给q,然后通过q对整数进行修改:

c 复制代码
q = p; 
*q = 0;     /* causes undefined behavior */ 

但由于p是受限指针,语句*q = 0的执行效果是未定义的。通过将pq指针指向同一个对象,可以使*p*q互为别名。

如果把受限指针p声明为局部变量而没有用extern存储类型(18.2节),那么restrict在声明p的程序块开始执行时仅对p起作用。(注意,函数体是程序块。)restrict可以用于指针类型的函数参数,这种情况下restrict仅在函数执行时起作用。但是,如果将restrict应用于文件作用域的指针变量,则在整个程序的执行过程中起作用。

使用restrict的规则是非常复杂的,详见C99及之后的标准。由受限指针创建别名也是合法的。例如,受限指针p可以被合法地复制到另一个受限指针变量q中,前提是p是一个函数的局部变量,而q则定义在一个嵌套于该函数体的程序块内。

为了说明restrict的使用方法,让我们首先看一下memcpymemmove两个函数,它们都属于<string.h>(23.6节)memcpy在标准中的原型如下:

c 复制代码
void *memcpy(void * restrict s1, const void * restrict s2,  
            size_t n); 

memcpystrcpy类似,只不过它是从一个对象向另一个对象复制字节(strcpy是从一个字符串向另一个字符串复制字符)。s2指向待复制的数据,s1指向复制数据存放的目的地,n是待复制的字节数。s1s2都使用restrict,说明复制的源和目的地不应互相重叠(但不能确保不重叠)。

与之相反,restrict并不出现在memmove的原型中:

c 复制代码
void *memmove(void *s1, const void *s2, size_t n); 

memmove所做的事情与memcpy相同:从一个地方把字节复制到另一个地方。不同之处是memmove可以保证当源和目的地相重叠时依然执行复制的过程。例如,可以用memmove把数组中的元素偏移一个位置:

c 复制代码
int a[100]; 
... 
memmove(&a[0], &a[1], 99 * sizeof(int)); 

C99之前没有文档对memmovememcpy的不同之处进行说明。两个函数的原型几乎一致:

c 复制代码
void *memcpy(void *s1, const void *s2, size_t n); 
void *memmove(void *s1, const void *s2, size_t n); 

C99开始的版本中,memcpy的原型中使用了restrict,这样程序员就知道s1s2指向的目标不能相互重叠,否则就不能保证函数执行。

尽管在函数原型中使用restrict有利于文档说明,但这还不是其存在的主要原因。restrict提供给编译器的信息可以使之产生更有效的代码,这个过程称为优化(optimization)。(register存储类型提供了同样的功能。)但是,并不是所有的编译器都会尝试程序优化,而且进行优化的编译器通常也允许程序员禁用优化。一旦禁用优化,标准可以保证restrict不会对遵循标准的程序产生任何影响:如果从这样的程序中删除所有的restrict,程序行为应该完全一样。

大多数程序员不会使用restrict,除非他们要微调程序以达到可能的最佳性能。尽管如此,了解restrict的用法还是有用的,因为许多标准库函数原型中用到了restrict


17.9 弹性数组成员(C99)

有时我们需要定义一个结构,其中包括未知大小的数组。例如,我们可能需要使用一种与众不同的方式来存储字符串。通常,一个字符串是一个以空字符标志结束的字符数组,但是用其他方式存储字符串是有好处的。一种选择是将字符串的长度与字符存于一起(没有空字符)。长度和字符可以存储在如下的结构中:

c 复制代码
struct vstring { 
    int len;  
    char chars[N]; 
};

这里N是一个表示字符串最大长度的宏。但是,我们不希望使用这样的定长数组,因为这样会迫使我们限制字符串的长度,而且会浪费内存(大多数字符串并不需要N个字符)。

C程序员解决这个问题的传统方案 是声明chars的长度为1,然后动态地分配每一个字符串:

c 复制代码
struct vstring { 
    int len;  
    char chars[1]; 
}; 
... 
struct vstring *str = malloc(sizeof(struct vstring) + n - 1); 
str->len = n;  

这里使用了一种"欺骗"的方法,分配比该结构声明时应具有的内存(这个例子中是n-1个字符)更多的内存,然后使用这些内存来存储chars数组额外的元素。这种方法在过去的这些年中非常流行,称为"struct hack"

struct hack不仅限于字符数组,它有很多用途。现在这种方法已很流行,被许多的编译器支持,有的编译器(包括GCC)甚至允许chars数组的长度为零,这就使得这一技巧更明显了。但是C89标准并不能保证struct hack工作,也不允许数组长度为0

正是因为认识到struct hack技术是非常有用的,从C99开始提供了弹性数组成员(flexible array member)来达到同样的目的。当结构的最后一个成员是数组时,其长度可以省略:

c 复制代码
struct vstring{
    int len; 
    char chars[];    /* flexible array member -- since C99 */ 
};  

//chars数组的长度在为vstring结构分配内存时确定,通常调用malloc:
struct vstring *str = malloc(sizeof(struct vstring) + n); 
str->len = n;

在这个例子中,str指向一个vstring结构,其中char数组占有n个字符。sizeof操作在计算结构大小时忽略了chars的大小(弹性数组成员的不同寻常之处在于,它在结构内并不占空间)。

包含弹性数组成员的结构需要遵循一些专门的规则。弹性数组成员必须出现在结构的最后,而且结构必须至少还有一个其他成员。复制包含弹性数组成员的结构时,其他成员都会被复制但不复制弹性数组本身。

具有弹性数组成员的结构是不完整类型(incomplete type)。不完整类型缺少用于确定所需内存大小的信息。本章末尾的问与答部分以及19.3节会进一步讨论不完整类型,它们有许多限制。特别是不完整类型(包括含有弹性数组成员的结构)不能作为其他结构的成员和数组的元素,但是数组可以包含指向具有弹性数组成员的结构的指针。


问与答

问1NULL宏表示什么?

答:NULL实际表示0 。当在要求指针的地方使用0时,C语言编译器会把它当作空指针而不是整数0。提供宏NULL只是为了避免混淆。赋值表达式

c 复制代码
p = 0;

既可以是给数值型变量赋值为0,也可以是给指针变量赋值为空指针。而我们无法简单地说明到底是哪一种。相反,赋值表达式

c 复制代码
p = NULL;

可以让我们明白p是指针。

问2 :在伴随编译器的头文件中,NULL定义如下:

C 复制代码
#define NULL (void *) 0

这样把0强制转换为void *型有什么好处吗?

答:这种技巧在C标准中是合法的 。它可以帮助编译器检查到空指针的不正确使用。例如,假设试图把NULL赋值给整型变量:

c 复制代码
i = NULL;

如果NULL定义为0,那么这个赋值绝对是合法的。但是,如果把NULL定义为(void *)0,那么编译器将提示我们把指针赋值给了整型变量。

NULL定义为(void *)0还有一个更重要的好处。假设调用带有可变长度实际参数列表(26.1节)的函数,并且用NULL作为其中一个实际参数。如果NULL定义为0,那么编译器会错误地把整数值零传递给函数。(在普通函数调用中,因为编译器从函数的原型可以知道它所期望的是指针,所以NULL可以正常工作。然而,当函数具有可变长度实际参数列表时,编译器不会获得这类信息。)如果NULL定义为(void *)0,那么编译器将传递空指针。

更混乱的是,一些头文件把NULL定义为0L0long型版本)。就像把NULL定义为0一样,这种定义是C语言早期时代的延续,那时的指针和整数彼此兼容。但是,就大多数目的而言,NULL究竟如何定义并不重要,把它当作空指针的名字就可以了。

问3 :既然0用来表示空指针,那么我猜想空指针就是每个位都为零的地址,对吗?

答:不一定 。每个C语言编译器都被允许用不同的方式来表示空指针,而且不是所有编译器都使用零地址的。例如,一些编译器为空指针使用不存在的内存地址,这样硬件就能检查出试图通过空指针访问内存的方式。

我们不关心如何在计算机内存储空指针,那是编译器专家关注的细节。重要的是,当0作为指针使用时,编译器会把它转换为适当的内部形式。

问4 :把NULL用作空字符,这是否可以接受?

答:绝对不行NULL是用来表示空指针而不是空字符的宏。把 NULL用作空字符对一些编译器适用,但不是全部都可以的(因为一些编译器把NULL定义为(void *)0)。在任何情况下,把NULL用作非指针的内容都会导致大量的混乱,如果希望给空字符一个名字,可以定义下面的宏:

c 复制代码
#define NUL '\0'

问5 :程序终止时得到这样一条消息"Null pointer assignment"。这是什么意思呢?

答:早期基于DOSC编译器生成的程序会产生这一消息。它说明程序使用坏指针(并不一定是空指针)把数据存储到内存中了 。可惜的是此消息直到程序终止才显示出来,所以没有线索可以表明是哪条语句导致了错误。消息"Null pointer assignment"可能是因为在scanf函数中丢失&导致的:

c 复制代码
scanf("%d", i); /* should have been scanf("%d", &i); */ 

另一种可能是含有指针的赋值操作对指针未进行初始化或设为空:

c 复制代码
*p = i;  /* p is uninitialized or null */

问6 :程序如何知道发生了"空指针赋值"

答:此消息依赖于这样一个事实:数据在小型或中型存储模型中是存储在单个段中的,且此段的地址起始为0。编译器会在数据段的开始处留出"空洞",即初始化为0但是未被程序使用的一小块内存。当程序终止时,它会查看"空洞"中的数据是否非零。如果是,那么一定是通过坏指针改变的。

问7 :对malloc或者其他内存分配函数的返回值进行强制类型转换有什么好处吗?

答:一般没什么好处 。对这些函数返回的void *型指针进行强制类型转换是没必要的,因为void *型的指针会在赋值操作时自动转换为任何指针类型。对返回值进行强制类型转换的习惯来自经典C。在经典C中,内存分配函数返回char *型的值,用强制类型转换是必要的。面向C++编译器的程序可以从强制类型转换中受益,但除此之外似乎没有其他理由这么做了。

C89中,不执行强制类型转换实际上是有点好处的。假设我们忘了在程序中包含<stdlib.h>头,调用malloc时编译器会假定其返回类型为int(任何C函数的默认返回类型)。如果我们不对malloc的返回值进行强制类型转换,C89编译器会产生错误(至少是警告),因为我们试图把整型值赋给指针变量。另外,如果我们把返回值强制类型转换为指针,程序可能会通过编译,但是不太可能正确地运行。在C99中,这一好处没有了。忘记包含<stdlib.h>头会导致调用malloc函数时出错,因为C99要求函数在调用之前必须声明。

问8 :函数calloc把内存块中的位初始化为0,这是否意味着内存块中的全部数据项都变为0了?

答:通常是,但不总是 。把整数设置成零位会始终使整数为0。把浮点数设置成零位通常会使数为0,但这是不能保证的,要依赖于浮点数的存储方式。对指针来说也是类似的,所有位都为0的指针并不一定是空指针

问9:我已经知道了结构标记机制是如何允许结构包含指向自身的指针的。但是,如果两个结构都含有指向对方的指针成员,会怎么样呢?

答:下面是处理这种情况的方法:

c 复制代码
struct s1;    /* incomplete declaration of s1 */ 

struct s2 { 
    ...  
    struct s1 *p; 
    ...  
}; 

struct s1 { 
    ...  
    struct s2 *q; 
    ...  
};

s1的第一处声明创建了一个不完整的结构类型(不完整类型19.3节),因为我们没有指明s1的成员。s1的第二处声明通过描述结构的成员"完善"了该类型。虽然使用上有一些限制,但不完整的结构类型声明在C语言中是允许的。使用方法之一是创建一个指向这一类型的指针(上面声明p的时候就是这么做的)。

问10 :用错误的参数调用malloc函数(导致分配的内存过大或过小)似乎是一个常见的错误。malloc有没有更安全的用法?

答: 。在调用malloc为单个对象分配内存时,一些程序员使用下面的惯用法:

c 复制代码
p = malloc(sizeof(*p));

因为sizeof(*p)p所指向的对象的大小,所以这一语句可以确保所分配到的内存大小是正确的。乍一看,这种惯用法很傻:p似乎没有初始化,从而*p的值没有定义。sizeof并不对*p求值,而仅仅计算其大小,所以即便p未初始化或者包含空指针,该惯用法也没有问题

为了给n个元素的数组分配空间,可以对上述惯用法做一点小小的改动:

c 复制代码
p = malloc(n * sizeof(*p)); 

问11 :为什么不把函数qsort简单命名为sort呢?

答:函数qsort的名字来源于1962C. A. R. Hoare提出的快速排序算法(9.6节讨论过)。不过,尽管许多qsort函数的版本采用了快速排序算法,C标准并不要求函数qsort使用快速排序算法。

问12 :就像下例所示那样,把函数qsort的第一个参数强制转换为void*类型,不是必需的吧?

c 复制代码
qsort((void *) inventory, num_parts, sizeof(struct part), 
   compare_parts); 

答:不是必需的 。任何类型的指针都可以自动转换为void *类型的。

问13 :我打算使用函数qsort对整数数组进行排序,但是在编写比较函数时遇到了问题。编写的秘诀是什么?

答:下面是可以使用的版本:

c 复制代码
int compare_ints(const void *p, const void *q) 
{ 
    return *(int *)p - *(int *)q; 
}

很奇怪吗?表达式(int *)pp强制转换为int*类型,所以*(int*)p将是p所指向的整数。不过需要提醒一下,整数相减可能会导致溢出。如果待排序的整数完全是任意给定的,那么使用if语句来比较*(int *)p*(int *)q更安全。

问14 :我需要对字符串数组进行排序,所以计划只使用函数strcmp作为比较函数。然而,当把它传递给函数qsort时,编译器给出了一条警告消息。我试图通过把函数strcmp嵌入到比较函数中的方法来解决这个问题:

c 复制代码
int compare_strings(const void *p, const void *q) 
{ 
   return strcmp(p, q); 
} 

现在程序通过了编译,但是函数qsort好像没有对数组进行排序。我做错什么了吗?

答:首先,不能把strcmp本身传递给函数qsort,因为qsort函数要求比较函数带有两个const void *型的形式参数。因为错误地把pq假设为字符串(char *型指针),所以函数compare_strings无法工作。事实上,pq指向的数组元素含有char*型指针。为了修正函数compare_strings,需要把pq强制转换为char **型的,然后用*运算符减少一层间接寻址操作:

c 复制代码
int compare_strings(const void *p, const void *q) 
{ 
    return strcmp(*(char **)p, *(char **)q); 
} 

写在最后

本文是博主阅读《C语言程序设计:现代方法(第2版·修订版)》时所作笔记,日后会持续更新后续章节笔记。欢迎各位大佬阅读学习,如有疑问请及时联系指正,希望对各位有所帮助,Thank you very much!

相关推荐
凤枭香几秒前
Python OpenCV 傅里叶变换
开发语言·图像处理·python·opencv
ULTRA??4 分钟前
C加加中的结构化绑定(解包,折叠展开)
开发语言·c++
听忆.10 分钟前
手机屏幕上进行OCR识别方案
笔记
远望清一色20 分钟前
基于MATLAB的实现垃圾分类Matlab源码
开发语言·matlab
confiself30 分钟前
大模型系列——LLAMA-O1 复刻代码解读
java·开发语言
XiaoLeisj41 分钟前
【JavaEE初阶 — 多线程】Thread类的方法&线程生命周期
java·开发语言·java-ee
杜杜的man1 小时前
【go从零单排】go中的结构体struct和method
开发语言·后端·golang
幼儿园老大*1 小时前
走进 Go 语言基础语法
开发语言·后端·学习·golang·go
半桶水专家1 小时前
go语言中package详解
开发语言·golang·xcode
llllinuuu1 小时前
Go语言结构体、方法与接口
开发语言·后端·golang