第十章 数组和指针

文章目录

    • 10.1 数组
            • 注意 使用 const 声明数组
            • 注意 存储类别警告
      • 10.1.1 指定初始化器(C99)
      • 10.1.2 数组边界
    • 10.2 多维数组
    • 10.3 指针和数组
    • 10.4 函数、数组和指针
      • 10.4.1 使用指针形参
    • 10.5 指针操作
            • 解引用未初始化的指针
    • 10.6 保护数组中的数据
      • 10.6.1 对形式参数使用 const
      • 10.6.2 const 的其他内容
    • 10.7 指针和多维数组
            • C const 和 C++ const
      • 10.7.1 函数和多维数组
    • 10.8 变长数组(VLA)
            • 注意 变长数组不能改变大小
            • const 和数组大小
    • 10.9 复合字面量
    • 10.10 关键概念
    • 10.11 本章小结
    • 10.12 复习题
    • 10.13 编程练习

10.1 数组

前面介绍过,数组有数据类型相同的一系列元素组成。需要使用数组时,通过声明数组告诉编译器数组中内含多少元素和这些元素的类型。编译器根据这些信息正确地创建数组。数组声明的方式:
1)int states[50];
2)int power[3] = {1,2,3};

注意 使用 const 声明数组

有时需要把数组设置为只读,这样,程序只能从数组中检索值,不能把新值写入数组。要创建只读数组,应该用 const 声明和初始化数组。

使用数组前必须先初始化它。与普通变量类似,在使用数组元素之前,必须先给它们赋初值。编译器使用的值是内存相应位置上的现有值。

注意 存储类别警告

数组和其他变量类似,可以把数组创建成不同的存储类别(storage class)。第 12 章将介绍存储类别的相关内容,现在只需记住:本章描述的数组属于自动存储类别,意思是这些数组在函数内部声明,且声明时未使用关键字 static。到目前为止,本书所用的变量和数组都是自动存储类别。
在这里提到存储类别的原因是,不同的存储类别有不同的属性,所以不能把本章的内容推广到其他存储类别。对应一些其他存储类别的变量和数组,如果在声明时未初始化,编译器会自动把它们的值设置为 0。

10.1.1 指定初始化器(C99)

C99 增加了一个新特性:指定初始化器(designated initializer)。利用该特性可以初始化指定的数组元素:

/** 使用指定初始化器 */
#include 
#define MONTHS 12
int main()
{
    int days[MONTHS] = {31,28,[4] = 31,30,31,[1] = 29};
    int i;
    for(i = 0; i < MONTHS; i++)
        printf("%2d %d\n", i + 1, days[i]);
    return 0;
}

该程序在支持 C99 的编译器中输出如下:
1 31
2 29
3 0
4 0
5 31
6 30
7 31
8 0
9 0
10 0
11 0
12 0

以上输出揭示了指定初始化器的两个重要特性。第一,如果指定初始化器后面有更多的值,如该例中的初始化列表中的片段:[4] = 31,30,31,那么后面这些值将被用于初始化指定元素后面的元素。也就是说,在 days[4] 被初始化为 31后,days[5] 和 days[6] 将分别被初始化为 30 和 31。第二,如果再次初始化指定元素,那么最后的初始化将会取代之前的初始化。

10.1.2 数组边界

在使用数组时,要防止数组下标超出边界。也就是说,必须确保下标是有效的值。

C 语言为何会允许这种麻烦是发生?这要归功于 C 信任程序员的原则。不检查边界,C 程序可以运行更快。编译器没必要捕获所有的下标错误,因为在程序运行之前,数组的下标值可能尚未确定。因此,为安全起见,编译器必须在运行时添加额外代码检查数组的每个下标值,这会降低程序的运行速度。C 相信程序员能编写正确的代码,这样的程序运行速度更快。但并不是所有的程序员都能做到这一点,所有就出现了下标越界问题。、

10.2 多维数组

初始化二维数组是建立在初始化以为数组的基础上。例如,初始化一个二维数值:float rain[5][12]; 初始化一个 5 * 12 的二维数组。

10.3 指针和数组

第 9 章介绍过指针,指针提供一种以符合形式使用地址的方法。因为计算机的硬件指令非常依赖地址,指针在某种程度上把程序员想要传达的指令以更接近机器的方法表达。因此,使用指针的程序更有效率。尤其是,指针能有效地处理数组。

我们举一个变相使用只指针的例子:数组名是数组首元素的地址。也就是说,如果 flizny 是一个数组,下面的语句成立:flizny == &flizny[0]; // 数组名是该数组首元素的地址

flizny 和 &flizny[0] 都表示数组首元素的内存地址(& 是地址运算符)。两者都是常量,在程序的运行过程中,不会改变。但是,可以把它们赋值给指针变量,然后可以修改指针变量的值,如程序清单。注意指针加上一个数时,它的值发生了什么变化(转换说明 %p 通常以十六进制显示指针的值)。

/** 指针地址 */
#include 
#define SIZE 4
int main()
{
    short dates[SIZE];
    short * pti;
    short index;
    double bills[SIZE];
    double * ptf;
    pti = dates;
    ptf = bills;
    printf("%23s %10s\n","short","double");
    for(index = 0; index < SIZE; index++)
        printf("pointers + %d: %10p %10p\n", index, pti + index, ptf + index);
    return 0;
}

下面是该例的输出示例:
short double
pointers + 0: 0060FEEC 0060FEC8
pointers + 1: 0060FEEE 0060FED0
pointers + 2: 0060FEF0 0060FED8
pointers + 3: 0060FEF2 0060FEE0

第 2 行打印的是两个数组开始的地址,下一行打印的是指针加 1 后的地址,以此类推。注意,地址是十六进制的,因此 dd 比 dc 大 1,a1 比 a0 大 1。但是,显示的地址是怎么回事?
0060FEEC + 1 是否是 0060FEEE?
0060FEC8 + 1 是否是 0060FED0?

我们的系统中,地址按字节编址,short 类型占用 2 字节,double 类型占用 8 字节。在 C 中,指针加 1 指的是增加一个存储单元。对数组而言,这意味着把加 1 后的地址是下一个元素的地址,而不是下一个字节的地址。这是为什么必须声明指针所指向对象类型的原因之一。只知道地址不够,因为计算机要知道储存对象需要多少字节(即使指针指向的是标量变量,也要知道变量的类型,否则 *pt 就无法正确地取回地址上的值)。

现在可以更清楚地定义指向 int 的指针、指向 float 的指针,以及指向其他数据对象的指针。

  • 指针的值是它所指向对象的地址。地址的表示方式依赖于计算机内部的硬件。许多计算机都是按字节编址,意思是内存中的每个字节都按顺序编号。这里,一个较大对象的地址通常是该对象第一个字节的地址。
  • 在指针前面使用 * 运算可以得到该指针所指向对象的值。
  • 指针加 1,指针的值递增它所指向类型的大小。

下面的等式体现了 C 语言的灵活性:

dates + 2 == &date[2]; // 相同的地址
*(dates + 2) == dates[2]; // 相同的值

以上关系表明了数组和指针的关系十分密切,可以使用指针标识数组的元素和获得元素的值。从本质上看,同一个对象有两种表示法。实际上,C 语言标准在描述数组表示法时确实借助了指针。也就是说,定义 ar[n] 的意思是 *(ar + n)。可以认为 *(ar + n) 的意思是“到内存的 ar 位置,然后移动 n 个单元,检索储存在那里的值”。

顺带一提,不要混淆 *(dates + 2)*dates + 2。间接运算符的优先级高于 +,所以 *dates + 2 相当于 *(dates) + 2
*(dates + 2) // dates 第 3 个元素的值
*dates + 2 // dates 第 1 个元素的值加 2

明白了数组和指针的关系,便可在编写程序时适时使用数组表示法或指针表示法。

/** 指针地址 */
#include 
#define MONTHS 12
int main()
{
    int days[MONTHS] = {31,28,31,30,31,30,31,31,30,31,30,31};
    int index;

    for(index = 0; index < MONTHS; index++)
        printf("Month %2d has %d days.\n",index + 1, *(days + index)); // 与 days[index] 相同
    return 0;
}

运行结果:
Month 1 has 31 days.
Month 2 has 28 days.
Month 3 has 31 days.
Month 4 has 30 days.
Month 5 has 31 days.
Month 6 has 30 days.
Month 7 has 31 days.
Month 8 has 31 days.
Month 9 has 30 days.
Month 10 has 31 days.
Month 11 has 30 days.
Month 12 has 31 days.

这里,days 是数组首元素的地址,days + index 是元素 days[index] 的地址,而 *(days + index) 则是该元素的值,相当于 days[index]。for 循环依次引用数组中的每个元素,并打印各元素的内容。

这样编写的程序是否有优势?不一定。编译器编译这两种写法生成的代码相同。程序清单要注意的是,指针表示法和数组表示法时两种等效的方法。该例演示了可以用指针表示数组,反过来,也可以用数组表示指针。在使用以数组为参数的函数时要注意这点。

10.4 函数、数组和指针

因为数组名是该数组首元素的地址,作为实际参数的数组名要求形式参数是一个与之匹配的指针。只有在这种情况下,C 才会把 int ar[] 和 int *ar 解释成一样。也就是说,ar 是指向 int 的指针。由于函数原型可以省略参数名,所以下面 4 中原型都是等价的:

int sum(int *ar, int n);
int sum(int *, int);
int sum(int ar[], int n);
int sum(int [], int);

但是,在函数定义中不能省略参数名。下面两种形式的函数定义等价:

int sum(int *ar, int n)
{
    
}
int sum(int ar[], int n)
{
    
}

可以使用以上提到的任意一种函数原型和函数定义。

10.4.1 使用指针形参

函数要处理数组必须知道何时开始、何时结束。sum() 函数使用一个指针形参标识数组的开始,用一个整数形参表明待处理数组的元素个数(指针形参也表明了数组中的数据类型)。但是这并不是给函数传递必备信息的唯一方法。还有一种方法是传递两个指针,第 1 个指针指明数组的开始处,第 2 个指针指明数组的结束处。程序清单演示了这种方法,同时该程序也表明了指针形参是变量,这意味着可以用索引表明访问数组中的哪一个元素。

/** 数组元素之和 */
#include 
#define SIZE 10
int sump(int * start, int * end);
int main()
{
    int marbles[SIZE] = {20,10,5,39,4,16,19,26,3,20};
    int answer;

    answer = sump(marbles, marbles + SIZE);
    printf("The total number of marbles is %d.\n", answer);
    return 0;
}

/* 使用指针算术法 */
int sump(int * start, int * end)
{
    int total = 0;

    while(start < end)
    {
        total += *start; // 把数组元素的值加起来
        start++; // 让指针指向下一个元素
    }
    return total;
}

指针 start 开始指向 marbles 数组的首元素,所以赋值表达式 total += *start 把首元素(20)加 total。然后,表达式 start++ 递增指针变量 start,使其指向数组的下一个元素。因为 start 是指向 int 的指针,start 递增 1 相当于其值递增 int 类型的大小。

10.5 指针操作

C 提供了一些基本的指针操作,下面的程序示例中演示了 8 中不同的操作。为了显示每种操作的结果,该程序打印了指针的值(该指针指向的地址)、储存在指针指向地址上的值,以及指针自己的地址。如果编译器不支持 %p 转换说明,可以用 %u 或 %lu 代替 %p;如果编译器不支持用 %td 转换说明打印地址的差值,可以用 %d 或 %ld 来代替。除了这些操作,还可以使用关系运算符来比较指针。

/** 指针操作 */
#include 
int main()
{
    int urn[5] = {100, 200 , 300, 400, 500};
    int *ptr1, *ptr2, *ptr3;

    ptr1 = urn; // 把一个地址赋给指针
    ptr2 = &urn[2]; // 把一个地址赋给指针
    // 解引用指针,以及获取指针的地址
    printf("pointer value, dereferenced pointer, pointer address:\n");
    printf("ptr1 = %p, *ptr1 = %d, &ptr1 = %p\n",ptr1, *ptr1, &ptr1);
    // 指针加法
    ptr3 = ptr1 + 4;
    printf("\nadding an int to a pointer:\n");
    printf("ptr1 + 4 = %p, *(ptr1 + 4) = %d\n", ptr1 + 4, *(ptr1 + 4));
    ptr1++; // 递增指针
    printf("\nvalue after ptr1++:\n");
    printf("ptr1 = %p, *ptr1 = %d, &ptr1 = %p\n",ptr1, *ptr1, &ptr1);
    ptr2--; // 递减指针
    printf("\nvalues after --ptr2:\n");
    printf("ptr2 = %p, *ptr2 = %d, &ptr2 = %p\n", ptr2, *ptr2, &ptr2);
    --ptr1; // 恢复为初始值
    ++ptr2; // 恢复为初始值
    printf("\nPointers reset to original values:\n");
    printf("ptr1 = %p, ptr2 = %p\n", ptr1, ptr2);
    // 一个指针减去另一指针
    printf("\nsubtracting one pointer from another:\n");
    printf("ptr2 = %p, ptr1 = %p, ptr2 - ptr1 = %td\n", ptr2, ptr1, ptr2 - ptr1);
    // 一个指针减去一个整数
    printf("\nsubtracting an int from a pointer:\n");
    printf("ptr3 = %p, ptr3 - 2 = %p\n", ptr3, ptr3 - 2);
    return 0;
}

下面是我的系统运行该程序后的输出:
pointer value, dereferenced pointer, pointer address:
ptr1 = 0060FEE8, *ptr1 = 100, &ptr1 = 0060FEE4

adding an int to a pointer:
ptr1 + 4 = 0060FEF8, *(ptr1 + 4) = 500

value after ptr1++:
ptr1 = 0060FEEC, *ptr1 = 200, &ptr1 = 0060FEE4

values after --ptr2:
ptr2 = 0060FEEC, *ptr2 = 200, &ptr2 = 0060FEE0

Pointers reset to original values:
ptr1 = 0060FEE8, ptr2 = 0060FEF0

subtracting one pointer from another:
ptr2 = 0060FEF0, ptr1 = 0060FEE8, ptr2 - ptr1 = 2

subtracting an int from a pointer:
ptr3 = 0060FEF8, ptr3 - 2 = 0060FEF0

下面分别描述了指针变量的基本操作。

  • **赋值:**可以把地址赋给指针。注意,地址应该和指针类型兼容。也就是说,不能把 double 类型的地址赋给指向 int 的指针,至少要避免不明智的类型转换。C99/C11 已经强制不允许这样做。
  • 解引用:* 运算符给出指针指向地址上储存的值。
  • **取址:**和所有变量一个,指针变量也有自己的地址和值。对指针而言,& 运算符给出指针本身的地址。
  • **指针与整数相加:**可以使用 + 运算符把指针与整数相加,或整数与指针相加。无论哪种情况,整数都会和指针所指向类型的大小(以字节为单位)相乘,然后把结果与初始地址相加。如果相加的结果超出了初始指针指向的数组范围,计算结果则是未定义的。除非正好超过数组末尾第一个位置,C 保证该指针有效。
  • **递增指针:**递增指向数组元素的指针可以让该指针移动至数组的下一个元素。
  • **指针减去一个整数:**可以使用 - 运算符从一个指针中减去一个整数。指针必须是第 1 个运算对象,整数是第 2 个运算对象。该整数将乘以指针指向类型的大小(以字节为单位),然后用初始地址减去乘积。如果相减的结果超出了初始指针所指向数组的范围,计算结果则是未定义的。除非正好超过数组末尾第一个位置,C 保证该指针有效。
  • **递减指针:**当然,处理递增指针还可以递减指针。
  • **指针求差:**可以计算两个指针的差值。通常,求差的两个指针分别指向通一个数组的不同元素,通过计算求出两元素之间的距离。差值的单位与数组类型的单位相同。只有两个指针都指向相同的数组(或者其中一个指针指向数组后面的第 1 个地址),C 都能保证相减运算有效。如果指向两个不同数组的指针进行求差运算肯会得出一个值,或者导致运行时错误。
  • **比较:**使用关系运算符可以比较两个指针的值,前提是两个指针都指向相同类型的对象。

注意,这里的将有两种。可以用一个指针减去另一个指针得到一个整数,或者用一个指针减去一个整数得到另一个指针。

在递增或递减指针是还要注意一些问题。编译器不会检查指针是否仍指向数组元素。C 只能保证指向数组任意元素的指针和指向数组后面第 1 和位置的指针有效。但是,如果递增或递减一个指针后超出了这个范围,则是未定义的。另外,可以加音乐指向数组任意元素的指针。但是,即使指针指向数组后面一个位置是有效的。也能解引用这样的越界指针。

解引用未初始化的指针

说到注意事项,一定要牢记一点:千万不要解引用来初始化的指针。例如,考虑下面的例子:
int *pt; // 未初始化的指针
*pt = 5; // 严重的错误
为何不行?第 2 行的意思是把 5 储存在 pt 指向的位置。但是 pt 未被初始化,其值是一个随机值,所以不知道 5 将储存在何处,这可能不会出什么错误,也可能会擦写数据或代码,或者导致程序崩溃。切记:创建一个指针时,系统只分配了储存指针本身的内存,并未分配储存数据的内存。因此,在使用指针之前,必须先把已分配的地址初始化它。
基于这些有效的操作,C 程序员创建了指针数组、函数指针、指向指针的指针数组、指向函数的指针数组等。

10.6 保护数组中的数据

编写一个处理基本类型的函数时,要选择是传递 int 类型的值还是传递指向 int 的指针。通常都是直接传递数值,只有程序需要在函数中改变该数值时,才会传递指针。对于数组别无选择,必须传递指针,因为这样做效率高。如果一个函数按值传递数组,则必须分配足够的空间来储存原数组的副本,然后把原数组所有的数据拷贝至新的数组中。如果把数组的地址传递给函数,让函数直接处理原数组则效率要高。

传递地址会导致一些问题。C 通常都按值传递数据,因为这样做可以保证数据的完整性。如果函数使用的是原始数据的副本,就不会意外修改原始数据。但是,处理数组的函数通常都需要使用原始数据,因此这样的函数可以修改原数组。有时,这正是我们需要的。

10.6.1 对形式参数使用 const

使用 const 并不是要求原数组时常量,而是该函数在处理数组时将其视为常量,不可更改。这样使用 const 可以保护数组的数据不被修改,就像按值传递可以保护基本数据类型的原始值不被改变一样。一般而言,如果编写的函数需要修改数组,在声明数组形参时则不适用 const;如果编写的函数不用修改数组,那么在声明数组形参时最好使用 const。

10.6.2 const 的其他内容

虽然用 #define 指令可以创建类似功能的符号常量,但是 const 的用法更加灵活。可以创建 const 数组、const 指针和指向 const 的指针。

最后,在创建指针时还可以使用 const 两次,该指针既不能更改它所指向的地址,也不能修改指向地址上的值:
double rates[2] = {88, 99};
const double * const pc = rates;
pc = &rates[1]; // 不允许
*pc = 92.99; // 不允许

10.7 指针和多维数组

指针和多维数组有什么关系?为什么要了解它们的关系?处理多维数组的函数要用到指针。所以在使用这种函数之前,先要更深入地学习指针。至于第 1 个问题,我们通过几个示例回答。为简化讨论,我们使用较小的数组。假设有下面的声明:int zippo[4][2]; /* 内含 int 数组的数组 */

然后数组名 zippo 是该数组首元素的地址。在本例中,zippo 的首元素是一个内含两个 int 值的数组,所以 zippo 是这个内含两个 int 值的数组的地址。下面,我们从指针的属性进一步分析。

  • 因为 zippo 是数组首元素的地址,所以 zippo 的值和 &zippo[0] 的值相同。而 zippo[0] 本身是一个内含两个整数的数组,所以 zippo[0] 的值和它首元素(一个整数)的地址(即 &zippo[0][0] 的值)相同。简而言之,zippo[0] 是一个占用一个 int 大小对象的地址,而 zippo 是一个占用两个 int 大小对象的地址。由于这个整数和内含两个整数的数组都开始于同一个地址,所以 zippo 和 zippo[0] 的值相同。
  • 给指针或地址加 1,其值会增加对用类型大小的数值。在这方面,zippo 和 zippo[0] 不同,因为 zippo 指向的对象占用了两个 int 大小,而 zippo[0] 指向的对象只占用一个 int 大小。因此 zippo + 1 和 zippo[0] + 1 的值不同。
  • 解引用一个指针(在指针前使用 * 运算符)或在数值名后使用带下标的 [] 运算符,得到引用对象代表的值。但是 zippo[0] 是该数组首元素(zippo[0][0])的地址,所以 *(zippo[0]) 表示储存在 zippo[0][0] 上的值(即一个 int 类型的值。与此类似,*zippo 代表该数组首元素 (zippo[0]) 的值,但是 zippo[0] 本身是一个 int 类型值的地址。该值的地址是 &zippo[0][0] ,所以 *zippo 就是 &zippo[0][0] 。即一个 int 类型的值。简而言之,zippo 是地址的地址,必须解引用两次才能过的原始值。地址的地址或指针的指针是就是 双重间接(double indirection) 的例子。

显然,增加数组维数会增加指针的复杂度。现在,大部分初学者都开始意识到指针为什么是 C 语言中最难的部分。认真思考上述内容,看看是否能用所学的知识解释程序清单的程序。该程序显示了一些地址值和数组的内容。

/** zippo 的相关信息 */
#include 
int main()
{
    int zippo[4][2] = {{2, 4},{6, 8},{1, 3},{5, 7}};

    printf("   zippo = %p,   zippo + 1 = %p\n",zippo,zippo + 1);
    printf("zippo[0] = %p, zippo[0] + 1 = %p\n",zippo[0],zippo[0] + 1);
    printf(" *zippo = %p,   *zippo + 1 = %p\n",*zippo,*zippo + 1);
    printf("zippo[0][0] = %d\n",zippo[0][0]);
    printf("  *zippo[0] = %d\n",*zippo[0]);
    printf("   **zippo = %d\n",**zippo);
    printf("     zippo[2][1] = %d\n",zippo[2][1]);
    printf("*(*(zippo + 2) + 1) = %d\n",*(*(zippo + 2) + 1));
    return 0;
}

下面是我们的系统运行该程序后的输出:

   zippo = 0060FEE0,   zippo + 1 = 0060FEE8
zippo[0] = 0060FEE0, zippo[0] + 1 = 0060FEE4
 *zippo = 0060FEE0,   *zippo + 1 = 0060FEE4
zippo[0][0] = 2
  *zippo[0] = 2
   **zippo = 2
     zippo[2][1] = 3
*(*(zippo + 2) + 1) = 3
C const 和 C++ const

C 和 C++ 中 const 的用法很相似,但是并不完全相同。区别之一是,C++ 允许在声明数组大小时使用 const 整数,而 C 却不允许,区别之二是,C++ 的指针赋值检查更严格:
const int y;
const int * p2 = &y;
int * p1;
p1 = p2; // C++ 中不允许这样做,但是 C 可能只给出警告
C++ 不允许把 const 指针赋给非 const 指针,而 C 则允许这样做,但是如果通过 p1 更改 y,其行为是未定义的。

10.7.1 函数和多维数组

如果要编写处理二维数组的函数,首先要能正确地理解指针才能写出声明函数的形参。在函数体中,通常使用数组表示法进行相关操作。
int sum(int ar[][4], int rows); // 有效声明
int sum(int ar[3][4], int rows); // 有效声明,第但是 3 将被忽略

10.8 变长数组(VLA)

要创建一个能处理任意大小二维数组的函数,比较繁琐(必须把数组作为一维数组传递,然后让函数计算每行的开始处)。而且,这种方法不好处理 FORTRAN 的子例程,这些子例程都允许在函数调用中指定两个维度。虽然 FORTRAN 是比较老的编程语言,但是在过去的几十年里,数值计算领域的专家已经用 FORTRAN 开发出许多有用的计算库。C 正逐渐替代 FORTRAN,如果能直接转换现有的 FORTRAN 库就好了。

鉴于此,C99 新增了变长数组(variable-length array,VLA),允许使用变量表示数组的维度。如下所示:
int quarters = 4;
ing regions = 5;
double sales[regions][quarters]; // 一个变长数组(VLA)

前面提到过,变长数组有一些限制。变长数组必须是自动存储类别,这意味着无论在函数中声明还是作为函数形参声明,都不能使用 static 或 extern 存储类别说明符。而且,不能在声明中初始化它们。最终,C11 把变长数组作为一个可选特性,而不是必须强制实现的特性。

注意 变长数组不能改变大小

变长数组中的 “变” 不是指可以修改已创建数组的大小。一旦创建了变长数组,它的大小则保持不变,这里的 “变” 指的是:在创建数组时,可以使用变量指定数组的维度。

由于变长数组是 C 语言的新特性,目前完全支持这一特性的编译器不多。

const 和数组大小

是否可以在声明数组时使用 const 变量?
const int SZ = 80;
double ar[SZ]; // 是否允许?
C99/C11 标准允许在声明变长数组时使用 const 变量,所以该数组的定义必须是声明在块中的自动存储类别数组。

变长数组还允许动态内存分配,这说明可以在程序于宁时指定数组的大小。普通 C 数组都是静态内存分配,即在编译时确定数组的大小。由于数组大小是常量,所以编译器在编译时就知道了。

10.9 复合字面量

假设给带 int 类型形参的函数传递一个值,要传递 int 类型的变量,但是也可以传递 int 类型常量,如 5。在 C99 标准以前,对于带数组形参的函数,情况不同,可以传递数组,但是没有等价的数组常量。C99 新增了 复合字面量(compound literal)。字面量是除符合常量外的常量。例如,5 是 int 类型字面量,81.3 是 double 类型的字面量,‘Y’ 是 char 类型的字面量,“elephant” 是字符串字面量。发布 C99 标准的委员会认为,如果有代表数组和结构内容的符合字面量,在编程时会更方便。

对于数组,符合字面量类似数组初始化列表,前面是用括号括起来的类型名。例如,下面是一个普通的数组声明:int diva[2] = {10, 20};

下面的符合字面量创建了一个和 diva 数组相同的匿名数组,也有两个 int 类型的值:(int [2]){10, 20} // 复合字面量

注意,去掉声明中的数组名,留下的 int [2] 即使复合字面量的类型名。

初始化有数组名的数组时可以省略数组大小,复合字面量也可以省略大小,编译器会自动计算数组当前的元素个数:(int []){50, 20, 90} // 内含 3 个元素的复合字面量

因为复合字面量是匿名的,所以不能先创建然后在使用它,必须在创建的同时使用它。使用指针记录地址就是一种用法。也就是说,可以这样用:
int * pt1;
pt1 = (int [2]){10, 20};

注意,该复合字面量的字面常量与上面创建的 diva 数组的字面常量完全相同。与有数组名的数组类似,复合字面常量的类型名也代表首元素的地址,所以可以把他赋给指向 int 的指针。然后便可使用这个指针。

还可以把复合字面量作为实际参数传递给带有匹配形式参数的函数:
int sum(const int ar[], int n);
int total3;
total3 = sum((int []){4,4,4,5,5,5},6};

可以把这种用法应用于二维数组或多维数组。例如,下面的代码演示了如何创建二维 int 数组并储存其地址:
int (*pt2)[4]; // 声明一个指向二维数组的指针,该数组内含 2 个数组元素,每个元素是内含 4 个 int 类型值的数组
pt2 = (int [2][4]){{1,2,3,-9},{4,5,6,-8}};

这里,第 1 个实参是内含 6 个 int 类型值的数组,和数组名类似,这同时也是该数组首元素的地址。这种用法的好处是,把信息传入函数前不必先创建数组,这是复合字面量的典型用法。程序清单把上述例子放进一个完整的程序中。

#include 
#define COLS 4
int sum2d(const int ar[][COLS], int rows);
int sum(const int ar[], int n);
int main()
{
    int total1, total2,total3;
    int * pt1;
    int (*pt2)[COLS];

    pt1 = (int [2]){10, 20};
    pt2 = (int [2][COLS]){{1,2,3,-9},{4,5,6,-8}};

    total1 = sum(pt1,2);
    total2 = sum2d(pt2,2);
    total3 = sum((int []){4,4,4,5,5,5},6);
    printf("total1 = %d\n",total1);
    printf("total2 = %d\n",total2);
    printf("total3 = %d\n",total3);

    return 0;
}

int sum(const int ar[], int n)
{
    int i;
    int total = 0;

    for(i = 0; i < n; i++)
        total += ar[i];

    return total;
}

int sum2d(const int ar[][COLS], int rows)
{
    int r;
    int c;
    int tot = 0;

    for(r = 0; r < rows; r++)
        for(c = 0; c < COLS; c++)
            tot += ar[r][c];

    return tot;
}

要支持 C99 的编译器才能正常运行该程序示例,其输出如下:
total1 = 30
total2 = 4
total3 = 27

记住,复合字面量是提供只临时需要的值的一种手段。复合字面量具有块作用域,这意味着一旦离开定义复合字面量的块,程序将无法保证该字面量是否存在。也就是说,复合字面量的定义在最内层的花括号中。

10.10 关键概念

数组用于储存形同类型的数据。C 把数组看作是派生类型,因为数组时建立在其他类型的基础上。也就是说,无法简单地声明一个数组。在声明数组时必须说明其元素的类型,如 int 类型的数组、float 类型的数组,或其他类型的数组。所谓的其他类型也可以是数组类型,这种情况下,创建的是数组的数组(或称为二维数组)。

通常编写一个函数来处理数组,这样的特定的函数中解决特定的问题,有助于实现程序的模块化。在把数组名作为实际参数时,传递给函数的不是整个数组,而是数组的地址(因此,函数对应的形式参数时指针)。为了处理数组,函数必须知道从何处开始读取数据和要处理多少个数组元素。数组地址提供了 “地址”,“元素个数” 可以内置在函数中或作为单独的参数传递。第 2 种方法更普遍,因为这样做可以让同一个函数处理不同大小的数组。

数组和指针的关系密切,通一个操作可以用数组表示法或指针表示法。它们之间的关系允许你在处理数组的函数中使用数组表示法,即使函数的形式参数是一个指针,而不是数组。

对于传统的 C 数组,必须用常量表达式指明数组的大小,所以数组大小在编译时就已确定。C99/C11 新增了变长数组,可以用变量表示数组大小。这意味着变长数组的大小延迟到程序运行时才确定。

10.11 本章小结

数组是一组数据类型相同的元素。数组元素按顺序储存在内存中,通过整数下标(或索引)可以访问各元素。在 C 中,数组首元素的下标是 0,所以对于内含 n 个元素的数组,其最后一个元素的下标是 n - 1。作为程序员,要确保使用有效的数组下标,因为编译器和运行的程序都不会检查下标的有效性。

声明一个简单的以为数组形式如下:type name[size];

这里,type 是数组中每个元素的数据类型,name 是数组名,size 是数组元素的个数。对于传统的 C 数组,要求 size 是整型常量表达式。但是 C99/C11 允许使用整型非常量表达式。这种情况下的数组被称为变长数组。

C 把数组名解释为该数组首元素的地址。换言之,数组名与指向该数组首元素的指针等价。概括地说,数组和指针的关系十分密切。如果 ar 是一个数组,那么表达式 ar[i] 和 *(ar + i) 等价。

对于 C 语言而言,不能把整个数组作为参数传递给函数,但是可以传递数组的地址。然后函数可以使用传入的地址操控原始数组。如果函数没有修改原始数组的意图,应在声明函数的形式参数时使用关键字 const。在被调函数中可以使用数组表示法或指针表示法,无论用哪种表示法,实际上使用的都是指针变量。

指针加上一个整数或递增指针,指针的值以所指向对象的大小为单位改变。也就是说,如果 pd 指向一个数组的 8 字节 double 类型值,那么 pd 加 1 意味着其值加 8,以便它指向该数组的下一个元素。

二维数组即是数组的数组。例如,下面声明了一个二维数组:double sales[5][12];

该数组名为 sales,有 5 个元素(一维数组),每个元素都是一个内含 12 个 double 类型值的数组。第 1 个一维数组是 sales[0],第 2 个一维数组是 sales[1],以此类推,每个元素都是内含 12 个 double 类型值的数组。使用第 2 个下标可以访问这些一维数组中的特定元素。例如,sales[2][5] 是sales[2] 的第 6 个元素,而 sales[2] 是 sales 的第 3 个元素。

C 语言传递多维数组的传统方法是把数组名(即数组的地址)传递给类型匹配的指针形参。声明这样的指针形参要指定所有的数组维度,除了第 1 个维度。传递的第 1 个维度通常作为第 2 个参数。例如,为了处理前面声明的 sales 数组,函数原型和函数调用如下:
void display(double ar[][12], int rows);
display(sales, 5);

变长数组提供第 2 种语法,把数组维度作为参数传递。在这种情况下,对应函数原型和函数调用如下:
void display(int rows, int cols, double ar[rows][cols]);
display(5, 12, sales);

虽然上述讨论中使用的是 int 类型的数组和 double 类型的数组,其他类型的数组也是如此。然而,字符串有一些特殊的规则,这是由于其末尾的空字符所导致。有了这个空字符,不用传递数组的大小,函数通过检测字符串的末尾也知道在何处停止。

10.12 复习题

1、下面的程序将打印什么内容?

#include 
int main()
{
    int ref[] = {8,4,0,2};
    int *ptr;
    int index;

    for(index = 0,ptr = ref; index < 4; index++, ptr++)
        printf("%d %d\n",ref[index], *ptr);

    return 0;
}

2、在复习题 1 中,ref 有多少个元素?

3、在复习题 1 中,ref 的地址是什么?ref + 1 是什么意思?++ref 指向什么?

4、在下面的代码中,*ptr*(ptr + 2) 的值分别是什么?
a、
int *ptr;
int torf[2][2] = {12,14,16};
ptr = torf[0];

b、
int * ptr;
int fort[2][2] = {{12}.{14,16}};
ptr = fort[0];

5、在下面的代码中, **ptr**(ptr + 1) 的值分别是什么?
a、
int (*ptr)[2];
int torf[2][2] = {12,14,16};
prt = torf;

b、
int (*ptr)[2];
int fort[2][2] = {{12},{14,16}};
ptr = fort;

6、假设有下面的声明:
int grid[30][100];
a、用 1 种写法表示 grid[22][56]
b、用 2 种写法表示 grid[22][0]
c、用 3 种写法表示 grid[0][0]

7、正确声明一下各变量:
a、digits 是一个内含 10 个 int 类型值的数组
b、rates 是一个内含 6 个float 类型值的数组
c、mat 是一个内含 3 个元素的数组,每个元素都是内含 5 和整数的数组
d、psa 是一个内含 20 个元素的数组,每个元素都是指向 int 的指针
e、pstr 是一个指向数组的指针,该数组内含 20 个 char 类型的值

8、
a、声明一个内含 6 个 int 类型的数组,并初始化各元素为1、2、4、8、16、32
b、用数组表示法表示 a 声明的数组的第 3 个元素(其值为 4)
c、假设编译器支持 C99/C11 标准,声明一个内含 100 个 int 类型值的数组,并初始化最后一个元素为 -1,其他元素不考虑
d、假设编译器支持 C99/C11 标准,声明一个内含 100 个 int 类型值的数组,并初始化下标为 5、10、11、12、3 的元素为 101,其他元素不考虑

9、内含 10 个元素的数组下标范围是什么?

10、假设有下面的声明:
float rootbeer[10], things[10][5], *pf, value = 2.2;
int i = 3;
判断一下各项是否有效:
a、rootbeer[2] = value;
b、scanf("%f",&rootbeer);
c、rootbeer = value;
d、printf("%f", rootbeer);
e、things[4][4] = rootbeer[3];
f、things[5] = rootbeer;
g、pf = value;
h、pf = rootbeer;

11、声明一个 800 X 600 的 int 类型数组。

12、下面声明了 3 个数组
double trots[20];
short clops[10][30];
long shots[5][10][15];
a、分别以传统方式和以变长数组为参数的方式编写处理 trots 数组的 void 函数原型和函数调用
b、分别以传统方式和以变长数组为参数的方式编写处理 clops 数组的 void 函数原型和函数调用
c、分别以传统方式和以变长数组为参数的方式编写处理 shots 数组的 void 函数原型和函数调用

13、下面有两个函数原型:
void show(const double ar[], int n); // n 是数组元素的个数
void show2(const double ar2[][3], int n); // n 是二维数组的行数
a、编写一个函数调用,把一个内含8、3、9 和 2 的复合字面量传递给 show()
b、编写一个函数调用,把一个 2 行 3 列的复合字面量(8、3、9 作为第 1 行,5、4、1 作为第 2 行)传递给show2() 函数

10.13 编程练习

1、修改程序,用指针进行计算(仍然要声明并初始化数组)。

/** 计算每年的总降水量、年平均降水量和 5 年中每月的平均降水量 */
#include 
#define MONTHS 12 // 一年的月份数
#define YEARS 5 // 年数
int main()
{
    // 2010 ~ 2014 年的降水量数据初始化数组
    const float rain[YEARS][MONTHS] =
    {
        {4.3,4.3,4.3,3.0,2.0,1.2,0.2,0.2,0.4,2.4,3.5,6.6},
        {8.5,8.2,1.2,1.6,2.4,0.0,5.2,0.9,0.3,0.9,1.4,7.3},
        {9.1,8.5,6.7,4.3,2.1,0.8,0.2,0.2,1.1,2.3,6.1,8.4},
        {7.2,9.9,8.4,3.3,1.2,0.8,0.4,0.0,0.6,1.7,4.3,6.2},
        {7.6,5.6,3.8,2.8,3.8,0.2,0.0,0.0,0.0,1.3,2.6,5.2}
    };
    int year,month;
    float subtot,total;

    printf(" YEAR   RAINFALL (inches)\n");
    for(year = 0, total = 0; year < YEARS; year++)
    {
        // 每一年,各月的降水量总和
        for(month = 0, subtot = 0; month < MONTHS; month++)
            subtot += rain[year][month];
        printf("%5d %15.1f\n", 2010 + year, subtot);
        total += subtot; // 5 年的总降水量
    }
    printf("\nThe yearly average is %.1f inches.\n\n",total / YEARS);
    printf("MONTHLY AVERAGES:\n\n");
    printf(" Jan  Feb  Mar  Apr  May  Jun  Jul  Aug  Sep  Oct  Nov  Dec\n");
    for(month = 0; month < MONTHS; month++)
    {
        // 每个月,5 年的总降水量
        for(year = 0, subtot = 0; year < YEARS; year++)
            subtot += rain[year][month];
        printf("%4.1f ",subtot / YEARS);
    }
    printf("\n");
    return 0;
}

2、编写一个程序,初始化一个 double 类型的数组,然后把该数组的内容拷贝至 3 个其他数值中(在 main() 中声明这 4 个数组)。使用带数组表示法的函数进行第 1 份拷贝。使用带指针表示法和指针递增的函数进行第 2 分拷贝。把目标数组名、源数组名和待拷贝的元素个数作为前两个函数的参数。第 3 个函数以目标数组名、源数组名和指向源数组最后一个元素后面的元素的指针。也就是说,给定一下声明,则函数调用如下所示:
double source[5] = {1.1,2.2,3.3,4.4,5.5};
double target1[5];
double target2[5];
double target3[5];
copy_arr(target1, source, 5);
copy_ptr(target2, source, 5);
copy_ptrs(target3, source, source + 5);

3、编写一个函数,返回储存咋 int 类型数组中的最大值,并在一个简单的程序中测试该函数。

4、编写一个函数,返回储存在 double 类型数组中最大值的下标,并在一个简单的程序中测试该函数。

5、编写一个函数,返回储存在 double 类型数组中最大值和最小值的差值,并在一个简单的程序中测试该函数。

6、编写一个函数,把 double 类型数组中的数据倒序排列,并在一个简单的程序中测试该函数。

7、辨析一个程序,初始化一个 double 类型的二维数组,使用编程练习 2 中的一个拷贝函数把该数组中的二叔家拷贝至另一个二维数组中(因为二维数组是数组的数组,所以可以使用处理一维数组的拷贝函数来处理数组中的每个子数组)。

8、使用编程练习 2 中的拷贝函数,把一个内含 7 和元素的数组中第 3 ~ 第 5 个元素拷贝至内含 3 个元素的数组中。该函数本身不需要修改,值需要选择合适的实际参数(实际参数不需要时数组名和数组大小,值需要时数组元素的地址和待处理元素的个数)。

9、编写一个程序,初始化一个 double 类型的 3 X 5 二维数组,使用一个处理编程数组的函数将其拷贝至另一个二维数组中。还有编写一个以变长数组为形参的函数以显示两个数组的内容。这两个函数应该能处理任意 N X M 数组(如果编译器不支持变长数组,就使用传统 C函数处理 N X 5 的数组)。

10、编写一个函数,把两个数组中相对应的元素相加,然后把结果储存到第 3 个数组中。也就是说,如果数组 1 中包含的值是 2、4、5、8,数组 2 中包含的值是 1、0、4、6,那么该函数把 3、4、9、14 赋给第 3 个数组。函数接受 3 个数组名和一个数组大小。在一个简单的程序中测试该函数。

11、编写一个程序,声明一个 int 类型的 3 X 5 二维数组,并用合适的值初始化它。该程序打印数组中的值,然后各值翻倍(即是原值的 2 倍),并显示出各元素的新值。编写一个函数显示数组的内容,再编写一个函数把各元素的值翻倍。这两个函数都以函数名和行数作为参数。

12、重写程序,把 main() 中的主要任务都成用函数来完成。

/** 计算每年的总降水量、年平均降水量和 5 年中每月的平均降水量 */
#include 
#define MONTHS 12 // 一年的月份数
#define YEARS 5 // 年数
int main()
{
    // 2010 ~ 2014 年的降水量数据初始化数组
    const float rain[YEARS][MONTHS] =
    {
        {4.3,4.3,4.3,3.0,2.0,1.2,0.2,0.2,0.4,2.4,3.5,6.6},
        {8.5,8.2,1.2,1.6,2.4,0.0,5.2,0.9,0.3,0.9,1.4,7.3},
        {9.1,8.5,6.7,4.3,2.1,0.8,0.2,0.2,1.1,2.3,6.1,8.4},
        {7.2,9.9,8.4,3.3,1.2,0.8,0.4,0.0,0.6,1.7,4.3,6.2},
        {7.6,5.6,3.8,2.8,3.8,0.2,0.0,0.0,0.0,1.3,2.6,5.2}
    };
    int year,month;
    float subtot,total;

    printf(" YEAR   RAINFALL (inches)\n");
    for(year = 0, total = 0; year < YEARS; year++)
    {
        // 每一年,各月的降水量总和
        for(month = 0, subtot = 0; month < MONTHS; month++)
            subtot += rain[year][month];
        printf("%5d %15.1f\n", 2010 + year, subtot);
        total += subtot; // 5 年的总降水量
    }
    printf("\nThe yearly average is %.1f inches.\n\n",total / YEARS);
    printf("MONTHLY AVERAGES:\n\n");
    printf(" Jan  Feb  Mar  Apr  May  Jun  Jul  Aug  Sep  Oct  Nov  Dec\n");
    for(month = 0; month < MONTHS; month++)
    {
        // 每个月,5 年的总降水量
        for(year = 0, subtot = 0; year < YEARS; year++)
            subtot += rain[year][month];
        printf("%4.1f ",subtot / YEARS);
    }
    printf("\n");
    return 0;
}

13、编写一个程序,提示用户输入 3 组数,每组数包含 5 个 double 类型的数。该程序应完成下列任务。
a、把用户输入的数据储存在 3 X 5 的数组中
b、计算每组(5 个)数据的平均值
c、计算所有数据的平均值
d、找出这 15 个数据中的最大值
e、打印结果

每个任务都要用单独的函数来完成。完成任务 b,要编写一个计算并返回一维数组平均值的函数,利用循环调用该函数 3 次。对于处理其他任务的函数,应该把整个数组作为参数,完成任务 c 和 d 的函数应把结果返回主调函数。

14、以变长数组作为函数形参,完成编程练习 13。

你可能感兴趣的:(C,Primer,Plus)