| 目录 |

6 数组

“数组下标该从 0 开始还是从 1 开始?我提出的折中方案 0.5 被否决了,而且我认为他们没怎么认真考虑。”

——斯坦·凯利-布特尔,计算机科学家

幸运的是,C 有数组。我知道它被视为一门底层语言53,但它至少内置了数组这一概念。而且由于很多语言都从 C 的语法中汲取过灵感,你大概已经熟悉用 [ 和 ] 来声明和使用数组了。

但 C 仅仅勉强算是有数组!后面我们会发现,数组在 C 里只是语法糖——底层其实全是指针之类的东西。吓坏了吧!不过眼下,我们就先把它们当数组用。呼。

6.1 简单示例

我们直接来写一个示例:

#include <stdio.h>

int main(void)
{
    int i;
    float f[4];  // 声明一个含 4 个 float 的数组

    f[0] = 3.14159;  // 索引当然从 0 开始。
    f[1] = 1.41421;
    f[2] = 1.61803;
    f[3] = 2.71828;

    // 把它们全部打印出来:

    for (i = 0; i < 4; i++) {
        printf("%f\n", f[i]);
    }
}

声明数组时必须给出大小,而且这个大小必须是固定的54。

在上面的例子中,我们创建了一个含 4 个 float 的数组。声明中方括号里的数字告诉了我们这一点。

在随后的几行里,我们访问数组中的值,读取或设置它们,用的仍是方括号。

希望这看起来和你已经掌握的语言差不多!

6.2 获取数组的长度

不能——大概不能。C 并不记录这个信息55,你得另用一个变量来管理它。

我说“不能”,其实是指某些情况下你可以。有一个技巧,可以在数组声明所在的作用域内取得数组的元素个数。但一般来说,如果把数组传给函数,这招就不好使了56。

来看看这个技巧。基本思路是用数组的 sizeof 除以每个元素的大小,就得到长度。例如,如果 int 是 4 字节,而数组长 32 字节,那么里面肯定装得下 \(\frac{32}{4}\) 也就是 \(8\) 个 int。

int x[12];  // 12 个 int

printf("%zu\n", sizeof x);     // 总共 48 字节
printf("%zu\n", sizeof(int));  // 每个 int 4 字节

printf("%zu\n", sizeof x / sizeof(int));  // 48/4 = 12 个 int!

如果是 char 数组,那么数组的 sizeof 就是元素个数,因为 sizeof(char) 被定义为 1。其他类型则必须除以每个元素的大小。

但这个技巧只在数组定义所在的作用域内有效。把数组传给函数就不管用了——哪怕你在函数签名里把它写得“很大”:

void foo(int x[12])
{
    printf("%zu\n", sizeof x);     // 8?!48 去哪儿了?
    printf("%zu\n", sizeof(int));  // 每个 int 4 字节

    printf("%zu\n", sizeof x / sizeof(int));  // 8/4 = 2 个 int??错了。
}

这是因为向函数“传递”数组时,你传的只是指向首元素的指针,而 sizeof 量的正是这个指针。详见下文向函数传递一维数组一节。

用 sizeof 和数组还能做一件事:不必声明数组,就能得到固定元素个数数组的大小。这就像用 sizeof(int) 得到 int 的大小一样。

例如,想知道 48 个 double 的数组需要多少字节,可以这样写:

sizeof(double [48]);

6.3 数组初始化器

可以事先用常量初始化数组:

#include <stdio.h>

int main(void)
{
    int i;
    int a[5] = {22, 37, 3490, 18, 95};  // 用这些值初始化

    for (i = 0; i < 5; i++) {
        printf("%d\n", a[i]);
    }
}

初始化器里的项数绝不能超过数组能容纳的数量,否则编译器会不高兴:

foo.c: In function ‘main’:
foo.c:6:39: warning: excess elements in array initializer
    6 |     int a[5] = {22, 37, 3490, 18, 95, 999};
      |                                       ^~~
foo.c:6:39: note: (near initialization for ‘a’)

但(有意思的是!)初始化器里的项数可以少于数组能容纳的数量,剩下的元素会自动初始化为零。对所有类型的数组初始化器而言这都是普遍规律:只要写了初始化器,凡是没有显式赋值的元素都会被设为零。

int a[5] = {22, 37, 3490};

// 等同于:

int a[5] = {22, 37, 3490, 0, 0};

想把整个数组设为零时,初始化器里常见这样的简写:

int a[100] = {0};

意思是:“把第一个元素设为零,然后自动把其余元素也设为零。”

还可以在初始化器里为某个值指定下标,从而设置特定的数组元素!这样做时,C 会乐呵呵地继续为你初始化后续的值,直到初始化器用完,其余部分则用 0 填满。

做法是把下标写在方括号里,后面跟一个 =,然后写值。

下面是一个构造数组的例子:

int a[10] = {0, 11, 22, [5]=55, 66, 77};

因为我们把下标 5 指定为 55 的起点,数组里最终的数据是:

0 11 22 0 0 55 66 77 0 0

里面也可以放简单的常量表达式。

#define COUNT 5

int a[COUNT] = {[COUNT-3]=3, 2, 1};

得到的结果是:

0 0 3 2 1

最后,只要省略数组大小,C 还能根据初始化器算出数组的尺寸:

int a[3] = {22, 37, 3490};

// 等同于:

int a[] = {22, 37, 3490};  // 把大小省掉了!

6.4 越界!

C 不会阻止你越界访问数组,甚至可能连警告都没有。

我们把上面的例子拿来,继续往数组末尾之外打印。它只有 5 个元素,但我们试着打印 10 个,看看会怎样:

#include <stdio.h>

int main(void)
{
    int i;
    int a[5] = {22, 37, 3490, 18, 95};

    for (i = 0; i < 10; i++) {  // 坏消息:打印的元素太多了!
        printf("%d\n", a[i]);
    }
}

在我的电脑上运行,输出是:

22
37
3490
18
95
32765
1847052032
1780534144
-56487472
21890

哎呀!那是什么?原来,打印到数组末尾之外会导致 C 开发者所说的未定义行为。这个怪物我们以后再细说,眼下它的意思是:“你干了坏事,程序运行期间什么都可能发生。”

所谓“什么都可能发生”,通常是指读到零、读到垃圾数字,或者程序崩溃。但严格说,C 规范规定在这种情况下,编译器可以生成任何行为的代码57。

简而言之:不要做任何会导致未定义行为的事。永远不要58。

6.5 多维数组

数组想加多少维就可以加多少维。

int a[10];
int b[2][7];
int c[4][5][6];

它们在内存中以行主序59存储。也就是说,对二维数组而言,列出的第一个下标表示行,第二个表示列。

多维数组也可以用嵌套的初始化器:

#include <stdio.h>

int main(void)
{
    int row, col;

    int a[2][5] = {      // 初始化一个二维数组
        {0, 1, 2, 3, 4},
        {5, 6, 7, 8, 9}
    };

    for (row = 0; row < 2; row++) {
        for (col = 0; col < 5; col++) {
            printf("(%d,%d) = %d\n", row, col, a[row][col]);
        }
    }
}

输出为:

(0,0) = 0
(0,1) = 1
(0,2) = 2
(0,3) = 3
(0,4) = 4
(1,0) = 5
(1,1) = 6
(1,2) = 7
(1,3) = 8
(1,4) = 9

也可以用显式下标来初始化:

// 生成一个 3×3 的单位矩阵

int a[3][3] = {[0][0]=1, [1][1]=1, [2][2]=1};

这样构造出的二维数组如下:

1 0 0
0 1 0
0 0 1

6.6 数组与指针

[随口一提] 那么……我上面好像提过,数组在底层其实是指针?现在我们该浅浅地探讨一下,免得后面完全摸不着头脑。稍后我们会考察数组与指针之间真正的关系,但眼下我只想看看如何把数组传给函数。

6.6.1 获取指向数组的指针

告诉你一个秘密。一般来说,C 程序员说到指向数组的指针时,指的是指向数组首元素的指针60。

那我们就来获取一个指向数组首元素的指针。

#include <stdio.h>

int main(void)
{
    int a[5] = {11, 22, 33, 44, 55};
    int *p;

    p = &a[0];  // p 指向该数组
                // 嗯,其实是指向第一个元素

    printf("%d\n", *p);  // 打印出 “11”
}

这在 C 里太常见了,所以语言提供了一种简写:

p = &a[0];  // p 指向该数组

// 等同于:

p = a;      // p 指向该数组,但样子好看多了!

单独使用数组名,就等于取得指向数组首元素的指针!接下来的例子里我们会大量用到这一点。

但等一下——p 不就是一个 int* 吗?而 *p 得到 11,和 a[0] 一样?没错——正是如此。你已经开始窥见 C 中数组与指针的关系了。(我们会在指针(二)一章的数组/指针等价一节里更深入地讨论这一点。)

6.6.2 向函数传递一维数组

来看一个一维数组的例子。我要写几个函数,把数组传进去让它们做不同的事。

准备好见识一些令人瞠目的函数签名吧!

#include <stdio.h>

// 作为指向第一个元素的指针传入
void times2(int *a, int len)
{
    for (int i = 0; i < len; i++)
        printf("%d\n", a[i] * 2);
}

// 一样,只是改用数组写法
void times3(int a[], int len)
{
    for (int i = 0; i < len; i++)
        printf("%d\n", a[i] * 3);
}

// 一样,只是改用带大小的数组写法
void times4(int a[5], int len)
{
    for (int i = 0; i < len; i++)
        printf("%d\n", a[i] * 4);
}

int main(void)
{
    int x[5] = {11, 22, 33, 44, 55};

    times2(x, 5);
    times3(x, 5);
    times4(x, 5);
}

这些把数组列为函数形参的写法全都等价。

void times2(int *a, int len)
void times3(int a[], int len)
void times4(int a[5], int len)

在 C 老手的用法中,第一种远比其他的常见。

事实上,在后一种写法里,编译器根本不在乎你填的数字是多少(只要大于零61)。它完全不强制任何东西。

话虽如此,当向函数传递多维数组时,函数声明中数组的大小其实确实有影响,不过这个我们回头再说。

6.6.3 在函数中修改数组

我们说过,数组不过是伪装起来的指针。这意味着,当你把数组传给函数时,你传的大概是指向该数组第一个元素的指针。

但既然函数拿到了指向这些数据的指针,它就能操纵这些数据!所以函数对数组所做的修改,在调用方那里是看得见的。

下面这个例子把指向数组的指针传给函数,函数修改该数组中的值,而这些修改在调用方那里可见。

#include <stdio.h>

void double_array(int *a, int len)
{
    // 把每个元素乘以 2
    //
    // 这会让 main() 中 x 的值翻倍,因为 x 和 a 都指向
    // 内存中的同一个数组!

    for (int i = 0; i < len; i++)
        a[i] *= 2;
}

int main(void)
{
    int x[5] = {1, 2, 3, 4, 5};

    double_array(x, 5);

    for (int i = 0; i < 5; i++)
        printf("%d\n", x[i]);  // 2、4、6、8、10!
}

尽管我们传入的数组对应的是类型为 int* 的形参 a,但看看我们是如何用 a[i] 这种数组写法来访问它的!什么鬼。这完全允许。

稍后我们讨论数组与指针的等价性时,就会明白这件事为什么合理得多。目前,只要知道函数对数组所做的修改在调用方那里可见,就足够了。

6.6.4 向函数传递多维数组

说到多维数组,情况就有点不一样了。C 需要知道除第一维之外的所有维度,这样它才有足够的信息,知道该到内存的哪个位置去查找某个值。

下面这个例子把所有维度都明确写了出来:

#include <stdio.h>

void print_2D_array(int a[2][3])
{
    for (int row = 0; row < 2; row++) {
        for (int col = 0; col < 3; col++)
            printf("%d ", a[row][col]);
        printf("\n");
    }
}

int main(void)
{
    int x[2][3] = {
        {1, 2, 3},
        {4, 5, 6}
    };

    print_2D_array(x);
}

但在这种情况下,下面这两种写法62是等价的:

void print_2D_array(int a[2][3])
void print_2D_array(int a[][3])

编译器其实只需要第二维,才能算出第一维每增加 1 时该在内存中跳过多远。一般来说,它需要知道除第一维之外的所有维度。

另外要记住,编译器在编译期只做最少的边界检查(如果你运气好的话),而 C 在运行期完全不检查边界。没有安全带!别因为越界访问数组元素而翻车!


| 目录 |