Note

数组(array)是同类型元素的有序集合,指针(pointer)是存储地址的变量。本章揭示两者的”亲密关系”:数组名就是首元素地址,ar[i] 本质是 *(ar+i) 的伪装。掌握指针与数组的配合,是写出高效 C 程序的关键一步。

10.1 数组

数组(array) 由一系列相同数据类型的元素组成,声明告诉编译器元素个数与类型:

float candy[365];    /* array of 365 floats */
char code[12];       /* array of 12 chars */
int states[50];      /* array of 50 ints */

访问元素用下标(subscript,索引 index),编号从 0 开始:candy[0] 是第一个元素,candy[364] 是最后一个。

10.2 数组初始化

用花括号括起、逗号分隔的值列表即可初始化:

int powers[8] = {1,2,4,6,8,16,32,64}; /* ANSI only */

清单 10.1(day_mon1.c)用它打印每月天数:

#define MONTHS 12
int main(void)
{
    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 %d has %2d days.\n", index +1, days[index]);
 
    return 0;
}

用符号常量表示数组大小是推荐做法:将来改尺寸只需改一处。只读不写的数组应加 const:

const int days[MONTHS] = {31,28,31,30,31,30,31,31,30,31,30,31};

不初始化会怎样? 清单 10.2(no_data.c)打印未初始化的数组,得到的全是垃圾值(如 4204937)——编译器直接使用内存位置上碰巧已有的内容。部分初始化(清单 10.3,int some_data[SIZE] = {1492, 1066};)则让后两个元素自动为 0:列表不够时剩余元素补 0,一个不初始化才是垃圾值,列表超长则是编译错误

Warning

未初始化的自动存储类数组,元素值是不确定的垃圾值,读取它们等于读随机数据。本章讨论的是函数内声明且未加 static 的自动数组;其他存储类未初始化时元素会被置 0(第 12 章)。

让编译器数元素:省略大小,编译器按列表项数定长(清单 10.4):

const int days[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31};
 
for (index = 0; index < sizeof days / sizeof days[0]; index++)

sizeof days 是整个数组的字节数,除以单个元素的字节数即元素个数。指定初始化器(C99)则在列表中用方括号索引指定初始化哪个元素(清单 10.5):

int arr[6] = { [5] = 212};         // 只初始化 arr[5]
int days[MONTHS] = {31,28, [4] = 31,30,31, [1] = 29};

两条规则:指定项后的值继续初始化后续元素(days[5]=30、days[6]=31);同一元素多次初始化时最后一次生效。未初始化的元素一律为 0。

给数组赋值:声明之后只能逐元素赋值(通常用循环)。C 不允许整体赋值,花括号列表只在初始化时可用:

yaks = oxen;               /* not allowed */
yaks[SIZE] = oxen[SIZE];   /* invalid */

10.3 数组边界(Array Bounds)

下标必须落在 0 到 n-1 之间——编译器不检查越界。清单 10.6(bounds.c)故意对 4 元素数组使用 -1 到 6 的下标,某编译器输出显示 value1 从 44 变成 -1、value2 从 88 变成 9:这套实现把 value1、value2 恰好放在数组紧前、紧后,arr[-1] 和 arr[4] 便”踩”到了它们。越界的后果是未定义的(undefined):可能正常、可能怪异、可能中止。

Warning

C 信任程序员:不做边界检查换来更快的程序,风险全由你承担。两个习惯:牢记下标从 0 开始、最大到 n-1;在声明和循环里统一使用符号常量(for (i = 0; i < SIZE; i++))。

10.4 指定数组大小

C99 之前,方括号里必须是大于 0 的整型常量表达式(sizeof 表达式算常量,const 值不算——与 C++ 不同):

float a1[5];                 // yes
float a3[sizeof(int) + 1];   // yes(sizeof 表达式算常量)
float a4[-4];                // no,  a5[0]: size must be > 0
float a6[2.5];               // no,  size must be an integer
float a8[n];                 // not allowed before C99

C99 起允许最后这种写法,即变长数组(VLA,variable-length array)——为数值计算和移植 FORTRAN 库而生,VLA 不能在声明时初始化,后文详述。

10.5 多维数组(Multidimensional Arrays)

存 5 年的月降雨量,最好用”数组的数组”:

float rain[5][12]; // 5 个元素,每个元素是含 12 个 float 的数组

从内向外读声明:rain 是含 5 个元素的数组,每个元素类型是 float[12]。rain[0]、rain[1]……各是 12 个 float 的数组,rain[0][0] 才是单个 float。也可看作 5 行 12 列的表格:第二个下标沿行移动(逐月),第一个下标沿列移动(逐年)。内部存储是按行顺序排布的。

/图:二维数组示意图

清单 10.7(rain.c)是二维数组的经典应用——求各年总量、年均值和各月均值:

/* rain.c -- finds yearly totals, yearly average, and monthly average for several years of rainfall data */
#include <stdio.h>
#define MONTHS 12    // number of months in a year
#define YEARS    5    // number of years of data
int main(void)
{
    // initializing rainfall data for 2000 - 2004
    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 each year, sum rainfall for each month
    for (month = 0, subtot = 0; month < MONTHS; month++)
        subtot += rain[year][month];
    printf("%5d %15.1f\n", 2000 + year, subtot);
    total += subtot; // total for all years
    }
    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 ");
    printf(" Nov  Dec\n");
    for (month = 0; month < MONTHS; month++)
    { // for each month, sum rainfall over years
    for (year = 0, subtot = 0; year < YEARS; year++)
        subtot += rain[year][month];
    printf("%4.1f ", subtot/YEARS);
    }
    printf("\n");
 
    return 0;
}

嵌套循环是二维数组的天然搭档:一个循环管第一个下标,另一个管第二个;交换内外循环的位置,就从”按年统计”变成”按月统计”。二维初始化:五个内层花括号列表再套一层外层花括号,每个内层列表对应一行。某行列表太短则该行剩余元素补 0;太长是错误,不会挤到下一行。可省略内层花括号(数据按行顺序填充),但保留内层括号可读性更好。

/图:二维数组两种初始化方法的对比

三维照此类推:int box[10][20][30]; 像 10 张 20×30 表格叠放,处理时用三层嵌套循环。

10.6 指针与数组(Pointers and Arrays)

数组记法是指针记法的伪装:数组名就是首元素的地址flizny == &flizny[0]。两者都是常量,但可赋给指针变量。对指针加 1 会怎样?清单 10.8(pnt_add.c)分别对 short 和 double 数组的指针做加法,输出显示:

  • short 指针 +1,地址增加 2(short 占 2 字节)
  • double 指针 +1,地址增加 8(double 占 8 字节)

给指针加 1,C 加的是一个存储单元(元素)的大小,不是 1 个字节——这正是声明指针必须指明指向类型的原因。

/图:数组与指针加法——加 1 跨过一个元素而非一个字节

数组与指针的核心等式:

dates + 2 == &dates[2]        /* 同一个地址 */
*(dates + 2) == dates[2]      /* 同一个值 */

C 标准就是把 ar[n] 定义为 (ar + n) 的。注意区分 *(dates+2)(第 3 个元素的值)与 *dates+2(首元素的值加 2)—— 的优先级高于 +。因此清单 10.9 用 *(days + index) 代替 days[index],输出与清单 10.1 完全相同。

10.7 函数、数组和指针

求数组和的函数怎么写?数组名是首元素地址,形参应是指向 int 的指针;光有地址不知道元素个数,把大小作为第二个参数传入:```c int sum(int * ar, int n) // 清单 10.10 的 sum() { int i; int total = 0; for (i = 0; i < n; i++) // use n elements total += ar[i]; // ar[i] the same as *(ar + i) return total; }


**声明数组形参**:只有在函数原型或函数头中(且仅限此场合),`int ar[]` 与 `int * ar` 同义——ar 是指向 int 的指针。以下四个原型完全等价:

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

清单 10.10(sum_arr1.c)暴露了一个关键事实:sizeof marbles 是 40 字节(10 个 4 字节 int),而函数内 sizeof ar 只有 4 字节——ar 是首元素的指针,不是数组本身。这也解释了 C 为什么不按值传数组:拷贝太浪费,传地址又快又省,代价是函数能改动原数据。

另一种写法:传首尾两个指针(清单 10.11,sum_arr2.c):

/* use pointer arithmetic */
int sump(int * start, int * end)
{
    int total = 0;
 
    while (start < end)
    {
    total += *start; /* add value to total */
    start++; /* advance pointer to next element */
    }
 
    return total;
}

调用为 sump(marbles, marbles + SIZE):end 指向末元素之后的位置(one-past-the-end)。C 保证该位置是合法指针(但不保证可解引用),while (start < end) 处理完末元素即停。若 end 指向末元素本身,调用就得写成 marbles + SIZE - 1,难看又易错。指针参数是变量、可自增,这是它比数组名(常量)灵活之处。

total += *start++; 一行浓缩了优先级规则:* 与 ++ 优先级相同、从右向左结合,++ 作用于指针而非值;后缀形式保证先取值加进 total、指针再前进。对比:*++start 先移动再取值;(*start)++ 则取值后把加 1、指针不动(清单 10.12 order.c 验证:只有 (*p3)++ 改了数组元素的值,另两个只是移动指针)。ar[i] 与 *(ar+i) 对数组名和指针变量都有效,但 ar++ 只对指针变量合法;指针记法可能生成更高效代码,但正确性和清晰度优先,优化交给编译器。

10.8 指针运算(Pointer Operations)

清单 10.13(ptr_ops.c)演示了指针的八种基本操作,核心片段:

int urn[5] = {100, 200, 300, 400, 500};
int *ptr1, *ptr2, *ptr3;
 
ptr1 = urn;        // 赋值:数组首地址
ptr2 = &urn[2];    // 赋值:第三个元素地址
ptr3 = ptr1 + 4;   // 指针 + 整数
ptr1++;            // 递增:移到下一元素
ptr2--;            // 递减:移回上一元素
ptr2 - ptr1;       // 求差:相距 2 个 int(不是 2 字节)
ptr3 - 2;          // 指针 - 整数
ptr1 < ptr2;       // 比较:同类型指针可用关系运算符

归纳:

  1. 赋值:地址须与指针类型兼容,不能把 double 的地址赋给 int *(C99 强制)。
  2. 解引用:* 取出指向位置的值。
  3. 取指针自身的地址:指针也是变量,&ptr1 可得它自己的地址。
  4. 加/减整数:整数乘以所指类型字节数再加到地址上;结果越出原数组则未定义——唯一例外是”末元素之后”的位置。
  5. 递增/递减:移动到下/上一个元素;指针自己的地址不变。
  6. 求差:两个指向同一数组的指针相减,得到相距的元素个数;跨数组相减无意义。
  7. 比较:同类型指针可用 ==、< 等。

/图:递增一个 int 指针——地址前进 4 字节,指向下一元素

数组名(urn)是常量不能自增,指针不能相加、相乘——ptr1++ptr2 = ptr1 + 2ptr2 = urn + 1 合法,urn++ptr2 + ptr1urn * ptr1 非法。

Warning

计算机不跟踪指针是否仍指向数组元素。指向元素或”末元素之后”是安全的,越过这条线则行为未定义;指向”末元素之后”的指针合法,但不保证可以解引用。

解引用未初始化的指针是灾难

int * pt;   // an uninitialized pointer
*pt = 5;    // a terrible error

pt 的值是随机的,5 会被写到不可预知的位置——可能无害、可能覆盖数据或代码、可能崩溃。创建指针只分配了存放指针本身的内存,并未为它指向的数据分配内存。用指针前必须先让它指向已分配的位置(如已有变量的地址),或先用 malloc() 分配(第 12 章)。

Warning

铁律:绝不对未初始化的指针解引用。double * pd; *pd = 2.4; 同样危险。

10.9 保护数组内容(Protecting Array Contents)

处理基本类型可选传值或传指针;数组只能传指针(为效率),函数因此获得了修改原数据的能力。有时这正是想要的:

void add_to(double ar[], int n, double val)
{
    int i;
    for (i = 0; i < n; i++)
    ar[i] += val;
}

add_to(prices, 100, 2.50); 让 prices 每个元素加 2.5。但对只读函数来说,一个笔误(如 total += ar[i]++;)就可能毁掉原数据。对策:在原型和函数头中给形参加 const,即 int sum(const int ar[], int n);——此后 ar[i]++ 触发编译错误。注意:const 形参并不要求实参真是 const 数组,它只是约束这个函数别改数据,相当于给数组提供了按值传递式的保护。清单 10.14(arf.c)中 show_array() 只读数组故用 const,mult_array() 要改数组故不用——两者都是 void,mult_array() 靠指针而非 return 把新值送回 dip。

Success

原则一句话:函数要改数组,形参别加 const;函数不改数组,形参务必加 const。既防笔误破坏数据,也让函数能同时接受普通数组和 const 数组作实参。

关于 const 的更多内容

const 的位置不同,含义不同:

double rates[5] = {88.99, 100.12, 59.45, 183.11, 340.5};
const double * pd = rates;  // 指向 const 的指针
*pd = 29.89;      // 不允许:不能用 pd 改值
pd[2] = 222.22;   // 不允许
rates[0] = 99.99; // 允许:rates 本身不是 const
pd++;             // 允许:可以改指向

赋值规则:const 或非 const 数据的地址都可赋给”指向 const 的指针”;但 const 数据的地址不能赋给普通指针——否则能借指针篡改 const 数据。推论:show_array()(const 形参)两种数组都能收;mult_array()(非 const 形参)不能收 const 数组。const 放在 * 之后(double * const pc = rates;)则锁死指向、仍可改值;两个 const 都上(const double * const pc)则指向与值都锁死。

10.10 指针与多维数组

int zippo[4][2]; 为例(4 个元素,每个是含 2 个 int 的数组):

  • zippo 是首元素的地址,而首元素是”两 int 数组”,所以 zippo 指向一个数组型对象;zippo[0] 则指向单个 int(&zippo[0][0])。两者数值相同、类型不同。
  • zippo + 1 地址加 8(2 个 int),zippo[0] + 1 地址只加 4(1 个 int)。
  • zippo 是”地址的地址”——双重间接(double indirection),要解引用两次才得到 int 值:**zippo == zippo[0][0]。

清单 10.15(zippo1.c)打印验证上述关系(zippo、zippo[0]、*zippo 地址相同但步长不同;zippo[2][1] 与 ((zippo+2)+1) 相等)。逐步拆解等价表达式:

zippo            → 首个"两 int 元素"的地址
zippo+2          → 第三个"两 int 元素"的地址
*(zippo+2)       → 第三个元素(两 int 数组),即其首 int 的地址
*(zippo+2)+1     → 该数组中第二个 int 的地址
*(*(zippo+2)+1)  → 第三行第二个 int 的值,即 zippo[2][1]

/图:数组的数组——地址、内容与指针的关系

指向多维数组的指针:pointer-to-int 不行——那兼容 zippo[0],而 zippo 指向”两 int 数组”。正确写法是 int (* pz)[2];。括号必不可少:[] 优先级高于 *int * pax[2]; 声明的是”两个 int 指针的数组”,加括号才是”指向两 int 数组的一个指针”。清单 10.16(zippo2.c)证明指针 pz 可完全像数组名一样使用:

zippo[m][n] == *(*(zippo + m) + n)
pz[m][n]    == *(*(pz + m) + n)

指针兼容性:指针间赋值比数值类型严格——x = n;(int→double)可隐式转换,pd = p1;(int*→double*)直接编译错误。规律:非法赋值都是两指针指向的类型不一致。以 int ar1[2][3]; int ar2[3][2]; int (*pa)[3]; int **p2; 为例:pt = ar1;(pt 是 int *)无效,pa = ar1; 有效,pa = ar2; 无效。const 的两层间接也不安全:pp2 = &p1;(const int ** 接 int **)被禁止——否则可经 *pp2 = &n;*p1 = 10; 一路改掉真正的 const 数据。单层间接下”非 const 赋给 const”(p2 = p1;)则合法。

10.11 函数与多维数组

二维数组形参应声明为”指向行的指针”。以 int junk[3][4] 为例,junk 指向”含 4 个 int 的数组”,形参写法:

void somefunction( int (* pt)[4] );  // pt 是指针
void somefunction( int pt[][4] );    // 形参专用等价写法

清单 10.17(array2d.c)的三个函数分别按行求和、按列求和、求总和,原型展示了三种等价语法:

void sum_rows(int ar[][COLS], int rows);
void sum_cols(int[][COLS], int); // ok to omit names
int sum2d(int (*ar)[COLS], int rows); // another syntax

列数内建于函数,行数经参数传入——同一函数可用于任何”4 列”数组。但 int sum2(int ar[][], int rows);错误声明:编译器要把 ar[1] 转换为 ar+1,必须知道 ar 指向的对象多大,空括号无从算起。第一对括号可写数字但会被忽略(方便 typedef 用户):

typedef int arr4[4];              // arr4 即"含 4 个 int 的数组"
typedef arr4 arr3x4[3];           // arr3x4 即"含 3 个 arr4 的数组"
int sum2(arr3x4 ar, int rows);    // 与下面两种等价
int sum2(int ar[3][4], int rows); // 合法,3 被忽略
int sum2(int ar[][4], int rows);  // 标准形式

一般规律:N 维数组形参必须给出除最左一维外的所有维度——最左括号标识指针,其余括号描述所指对象,如 int sum4d(int ar[][12][20][30], int rows); 等价于 int sum4d(int (*ar)[12][20][30], int rows);

10.12 变长数组(VLAs)

经典 C 的痛点:行数能通过参数传入,列数却写死在函数里,想算 6×5 数组就得再写一个函数。C99 的**变长数组(VLA)**允许用变量作维度:

int quarters = 4;
int regions = 5;
double sales[regions][quarters]; // a VLA

限制:必须是自动存储类(函数内或作形参);不能在声明时初始化。注意 VLA 的”变长”指创建时尺寸可由变量指定,不是创建后还能改大小——VLA 一旦创建尺寸就固定;C99 刚发布时各家编译器支持并不齐全。

带 VLA 参数的函数(清单 10.18,vararr2d.c 的核心):

int sum2d(int rows, int cols, int ar[rows][cols]); // ar a VLA
 
int sum2d(int rows, int cols, int ar[rows][cols])
{
    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;
}

rows、cols 必须在 ar 之前声明(ar 的声明用到了它们),顺序颠倒就错;原型省略参数名时用星号代替维度:int sum2d(int, int, int ar[*][*]);。这个 sum2d() 通吃传统数组和 VLA、任意行列组合。注意:参数表中的 VLA 声明并不创建数组——ar 仍是指针,函数操作的仍是原数组;函数体内 int temp[n][m]; 才是真正的 VLA。VLA 还意味着动态内存分配(运行期定大小),传统数组则是编译期定死的静态分配,第 12 章展开。

Success

VLA 形参让”一个函数处理任意尺寸的二维数组”从别扭的技巧变成一行声明,这是 C99 为接替 FORTRAN 数值计算库而设计的关键特性。

10.13 复合字面量(Compound Literals)

给 int 形参可传常量 5;C99 之前数组却没有对应的”数组常量”。复合字面量(compound literal) 补上了:类型名 + 括号包裹的初始化列表。

int diva[2] = {10, 20};      // 普通数组声明
(int [2]){10, 20}            // 复合字面量:匿名的两 int 数组
(int []){50, 20, 90}         // 省略大小,编译器数出 3 个

复合字面量匿名,必须创建时即用。两种典型用法(清单 10.19,flc.c):

int * pt1;
int (*pt2)[COLS];
pt1 = (int [2]) {10, 20};      // 用指针记住位置,*pt1 是 10
pt2 = (int [2][COLS]) { {1,2,3,-9}, {4,5,6,-8} };
total1 = sum(pt1, 2);          // 30
total2 = sum2d(pt2, 2);        // 4
total3 = sum((int []) {4,4,4,5,5,5}, 6);  // 27
 
复合字面量像数组名一样代表首元素地址,能赋给匹配类型的指针、直接传给匹配的函数——**不建数组就把一批值传给函数**,这是它最典型的用途。
 
## 要点回顾
 
- 数组下标从 0 到 n-1;越界与否全靠自律,编译器不检查。部分初始化补 0、全不初始化是垃圾值、列表超长是错误;`sizeof days / sizeof days[0]` 自动求元素个数。
- 数组名 = 首元素地址,`ar[i] ≡ *(ar + i)`;指针 +1 跨过一个元素的字节数;数组传参传地址,形参 `int ar[]` 与 `int * ar` 在函数头中同义。
- 不改数组的函数,形参加 const——保护数据还兼容 const 实参;const 位置不同,锁"值"还是锁"指向"不同。
- 二维数组形参必须写明除第一维外的所有维度;`int (*pz)[2]` 是指向"两 int 数组"的指针,别与 `int *pax[2]`(指针数组)混淆。
- C99 新特性:指定初始化器 `[5] = 212`、VLA 让维度可用变量、复合字面量让匿名数组直接当实参。
 
> [!question]- 自测题
> 
> 1. `int ref[] = {8, 4, 0, 2};` 配合 `for (index = 0, ptr = ref; index < 4; index++, ptr++) printf("%d %d\n", ref[index], *ptr);` 打印什么?ref 有几个元素?
> 2. ref 是什么的地址?ref + 1 呢?
> 3. `float a5[0];` 为什么非法?`float a8[n];`(n 是 int 变量)在 C99 前后各是什么情况?
> 4. 写出下列变量的声明:a) digits,含 10int 的数组;b) mat,含 3"含 5 个 int 的数组"的数组;c) psa,含 20char 指针的数组;d) pstr,指向"含 20 个 char 的数组"的指针。
> 5. 为什么 `int sum2(int ar[][], int rows);` 是错误的声明?该怎么改?
>
> **答案解析**
> 1. 每行打印两个相同的数:8 84 40 02 2。ref 由初始化列表自动定长,共 4 个元素。
> 2. ref 是首元素 ref[0](类型 int)的地址;ref + 1ref[1] 的地址(地址值增加一个 int 的字节数)。
> 3. 数组大小必须大于 00 元素数组非法;a8[n] 在 C99 前非法,C99 起合法,属于变长数组 VLA(不能在声明时初始化)。
> 4. a) `int digits[10];`;b) `int mat[3][5];`;c) `char * psa[20];`;d) `char (*pstr)[20];`(c 与 d 的区别:括号决定 * 先结合还是 [] 先结合)。
> 5. 编译器处理 ar[r][c] 时要按 ar + r 定位第 r 行,但第二维为空意味着它不知道每行含多少个 int,无法计算偏移。应补上列数,如 `int sum2(int ar[][4], int rows);` 或 `int sum2(int (*ar)[4], int rows);`。