在前面着重说明了数组和指针并不一致的绝大多数情形C语言数组与指针的不同之处
在实际应用中,数组和指针可以互换的情形要比两者不可互换的情形更为常见,数组下标表达式总是可以改写为带偏移量的指针表达式。

什么时候数组与指针相同

让我们分别考虑声明和使用这两种情况。

声明本身还可以进一步分成3种情况:

  • 外部数组(external array)的声明。
  • 数组的定义(定义是声明的一种特殊情况,它分配内存空间,并可能提供一个初始值)。
  • 函数参数的声明。

所有作为函数参数的数组名总是可以通过编译器转换为指针。
当一个数组名出现在一个表达式中时,它会被转换为一个指向该数组第一个元素的指针。
在其他所有情况下(最有趣的情况就是“在一个文件中定义为数组,在另一个文件中声明为指针”),数组的声明就是数组,指针的声明就是指针,两者不能混淆。

但在使用数组(在语句或表达式中引用)时,数组总是可以写成指针的形式,两者可以互换。
下表对这些情况做了一个总结:

数组 使用数组时
声明 extern,如extern char a[];不能改写成指针的形式
定义,如,char a[10];不能改写成指针的形式
函数的参数,如func(char a[]);你可以随自己喜欢,选择数组形式或者指针形式
在表达式中使用 如c = a[i];你可以随自己喜欢,选择数组形式或者是指针形式

然而,数组和指针在编译器处理时是不同的,在运行时的表示形式也是不一样的,并可能产生不同的代码。对编译器而言,一个数组就是一个地址,一个指针就是一个地址的地址。

什么时候数组和指针是相同的,C语言标准对此作了如下说明:

  • 规则1:表达式中的数组名(与声明不同)被编译器当作一个指向该数组第一个元素的指针。
  • 规则2:下标总是与指针的偏移量相同。
  • 规则3:在函数参数的声明中,数组名被编译器当作指向该数组第一个元素的指针。

下面几个小细节将详细描述这几个规则的实际含义。

规则1

“表达式中的数组名”就是指针
上面的规则1和规则2合在一起理解,就是对数组下标的应用总是可以写成“一个指向数组的起始地址的指针加上偏移量”。例如,假如我们声明:

1
int a[10], *p ,i = 2;

就可以通过以下任何一种方法来访问a[i]:

1
2
3
4
5
6
7
8
9
10
11
1.
p = a;
p[i];

2.
p = a;
*(p + i);

3.
p = a + i;
*p;

事实上,可以采用的方法更多。对数组的引用如a[i]在编译时总是被编译器改写*(a + i)的形式。C语言标准要求编译器必须具备这个概念性的行为。也许遵循这个规则的捷径就是记住方括号[]表示一个取下标操作符,就像加号表示一个加法运算符一样。取下标操作符取一个整数和一个指向类型T的指针,所产生的结果类型就是T,一个在表达式中的数组名于是就成了指针。

你只要记住:在表达式中,指针和数组是可以互换的,因为它们在编译器里的最终形式都是指针,并且都可以进行取下标操作。编译器自动把下标值得步长调整到数组元素的大小。如果整型数的长度是4个字节,那么a[i+1]和a[i]在内存中的距离就是4(而不是1)。对起始地址执行加法操作之前,编译器会负责计算每次增加的步长。这就是为什么指针总有类型限制,每个指针只能指向一种类型的原因所在——因为编译器需要知道对指针进行解除引用操作时应该取几个字节,以及每个下标的步长应取几个字节。

规则2

C语言把数组下标作为指针的偏移量
在人们的常规思维中,在运行时增加对C语言下标的范围检查是不切实际的。因为取下标操作只是表示将要访问该数组,但并不保证一定要访问。而且,程序员完全可以使用指针来访问数组,从而绕过下标操作符。在这种情况下,数组下标范围检测并不能检测所有对数组的访问的情况。事实上,下标范围检测被认为并不值得加入到C语言中。

还有一种说法是,在编写数组算法时,使用指针比使用数组更有效率。这个颇为人们接受的说法在通常情况下是错误的。使用现代的产品质量优化的编译器,一维数组和指针引用所产生的代码并不具有显著的差别。不管怎样,数组下标是定义在指针的基础上的,所以优化器常常可以把它转换为更有效率的指针表达形式,并生成相同的机器指令。用指针迭代一个一维数组常常也并不比直接使用下标迭代一个一维数组来的更快。不论是指针还是数组,在连续的内存地址上移动时,编译器都必须计算每次前进的步长。计算的方法是偏移量乘以每个数组元素占用的字节数,计算结果就是偏移数组起始地址的实际字节数。步长因子常常是2的乘方(如int是4个字节,double是8个字节等),这样编译器在计算时就可以使用快速的左移位运算,而不是相对缓慢的加法运算。一个二进制数左移3位相当于它乘以8,如果数组中的元素大小不是2的乘方(如数组的元素类型是一个结构体),那就不能使用这个技巧了。

然而,迭代一个int数组是人们最容易想到的。如果一个经过良好优化的编译器进行代码分析,并把基本变量放在高速的寄存器中来确认循环是否继续,那么最终在循环中访问指针和数组所产生的代码很可能是相同的。

在处理一维数组时,指针并不见得比数组更快。C语言把数组下标改写成指针偏移量的根本原因是指针和偏移量是底层硬件所使用的基本模型。

规则3

“作为函数参数的数组名”等同于指针
规则3也需要进行解释。首先回顾一下形参、实参的术语:

术语 定义 例子
形参(parameter) 它是一个变量,在函数定义或函数声明的原型中定义。又称为“形式参数(formal parameter)” int power(int base,int n); base 和 n都是形参
实参(argument) 在实际调用一个函数时所传递给函数的值。又称为“实际参数(actual parameter)” i = power(10, j); 10和j都是实参。在同一个函数的多次调用时,实参可以不同。

标准规定作为“类型的数组”的形参的声明应该调整为“类型的指针”。在函数形参定义这个特殊情况下,编译器必须把数组形式改写成指向数组第一个元素的指针形式。编译器只向函数传递数组的地址,而不是整个数组的拷贝。不过,现在让我们重点观察一下数组,隐形转换意味着三种形式是完全等同的。因此,在my_function()的调用上,无论实参是数组还是真的指针都是合法的。

1
2
3
my_function(int *turnip) {...}
my_function(int turnip[]) {...}
my_function(int turnip[200]) {...}

为什么会发生混淆

当人们学习编程时,一开始总是把所有的代码都放到一个函数里。随着水平的进步,他们把代码分别放到几个函数中。在水平继续提高后,他们最终学会了如何用几个文件来构造一个程序。在这个过程中,他们可以看到大量的作为函数参数的数组和指针,在这种情况下,两者是可以完全互换的,如下所示:

1
2
3
4
5
char my_array[10];
char *my_ptr;
...
i = strlen(my_array);
j = strlen(my_ptr);

程序员们还可以看到许多类似下面的语句:

1
printf("%s %s", my_ptr, my_array);

它清楚的展示了数组和指针的可互换性。人们很容易忽视这只是发生在一种特定的上下文环境中,也就是它们作为一个函数调用的参数使用。更糟的是,你可以如下编写:

1
printf("array at location %x holds string %s", a, a);

在同一条语句中,既把数组名作为一个地址(指针),又把它作一个字符数组。这条语句之所以可行是因为printf是一个函数,所以数组实际上是作为指针来传递的。我们也习惯了在main函数的参数中看到char **argv或char *argv[]这样的形式,它们也是可以互换的。同样,这个之所以成立是因为argv是一个函数的参数。

为什么C语言把数组形参当作指针

把作为形参的数组和指针等同起来是出于效率原因的考虑。在C语言中,所有非数组形式的数据实参均以传值形式(对实参作一份拷贝并传递给调用的函数,函数不能修改作为实参的实际变量的值,而只能修改传递给它的那份拷贝)调用。然而,如果要拷贝整个数组,无论在时间上还是在内存空间上的开销都可能是非常大的。而且在绝大部分情况下,你其实并不需要整个数组的拷贝,你只想告诉函数在那个时刻对哪个特定的数组感兴趣。要达到这个目的,可以考虑的方法是在形参上增加一个存储说明符(storage specifier),表示它是传值调用还是传址调用。如果采用“所有的数组在作为参数传递时都转为指向数组起始地址的指针,而其他的参数均采用传值调用”的约定,就可以简化编译器。类似的,函数的返回值绝不能是一个函数、数组,而只能是指向数组或函数的指针。

有些人喜欢把它理解成除数组和函数之外的所有的C语言参数在缺省情况下都是传值调用,数组和函数则是传址调用。数据也可以使用传址调用,只要在它的前面加上取地址操作符(&),这样传递给函数的是实参的地址而不是实参的拷贝。事实上,取地址操作符的主要用途就是实现传址调用。“传址调用”这个说法从严格意义上说并不十分准确,因为编译器的机制非常清楚——在被调用的函数中,你只拥有一个指向变量的指针而不是变量本身。如果你取实参的地址或对它进行拷贝,就能体会到两者的差别。

数组形参是如何被引用的

C语言允许程序员把形参声明为数组(程序员打算传递给函数的东西)或者指针(函数实际所接收到的东西)。编译器知道何时形参是作为数组声明的,但事实上在函数内部,编译器始终把它当作一个指向数组第i个元素(元素长度未知)的指针。这样,编译器可以产生正确的代码,并不需要对数组和指针这两种情况作仔细区分。

不管程序员实际所写的是哪种形式,函数并不自动知道指针所指的数组共有多少个元素,所以必须要有个约定,如数组以NULL结尾或者另有一个附加的参数表示数组的范围。
在下列定义中:

1
2
3
4
5
6
7
func(int *turnip) {...}
func(int turnip[]) {...}
func(int turnip[200]) {...}

int my_int; //数据定义
int *my_int_ptr;
int my_int_array[10];

你可以合法的使用下列任何一个实参来调用上面任何一个原型函数。它们常常用于不同的目的:

调用时的实参 类型 通常目的
func(&my_int); 一个整型数的地址 一个int参数的传址调用
func(my_int_ptr); 指向整型数的指针 传递一个指针
func(my_int_array); 整型数组 传递一个数组
func(&my_int_array[i]); 一个整型数组某个元素的地址 传递数组的一部分

相反,如果处于func()函数内部,就没有一种容易的方法分辨这些不同的实参,因此也无法知道调用该函数是出于何种目的。所有属于函数实参的数组在编译时被编译器改写为指针。因此,在函数内部对数组参数的任何引用都将产生一个对指针的引用。

因此,很有意思的是,没有办法把数组本身传递给一个函数,因为它总是被自动转换为指向数组的指针。当然,在函数内部使用指针,所能进行的对数组的操作几乎跟传递原原本本的数组没有差别。只不过想用sizeof(实参)来获得数组的长度,所得到的结果不正确而已。

这样,在声明这样一个函数时,你就有了选择的余地。可以把形参定义成数组,也可以定义成指针。不论你选择什么,编译器都会注意到该对象是一个函数参数的特殊情况,它会产生代码对该指针进行解除引用操作。

如果你想让代码看上去清楚明白,就必须遵循一定的规则!我们倾向于始终把参数定义为指针,因为这是编译器内部所使用的形式。但从另一方面看,有些人觉得int table[]比int *\table更能表达程序员的意图。table[]这种记法清楚的表明了table内有好几个元素,提示函数会对它们都进行处理。

注意,有一样操作只能在指针里进行而无法在数组中进行,那就是修改它的值。数组名是不可修改的左值,它的值是不能改变的。

数组和指针可交换性的总结

  • 1.用a[i]这样的形式对数组进行访问总是被编译器”改写”或解释为像*(a+i)这样的指针访问。
  • 2.指针始终就是指针。它绝不可以改写成数组。你可以用下标形式访问指针,一般都是指针作为函数参数时,而且你知道实际传递给函数的是一个数组。
  • 3.在特定的上下文中,也就是它作为函数的参数(也只有这种情况),一个数组的声明可以看作一个指针。作为函数参数的数组(就是在一个函数调用中)始终会被编译器修改成为指向数组第一个元素的指针。
  • 4.因此,当把一个数组定义为函数的参数时,可以选择把它定义为数组,也可以定义指针。不管选择哪种方法,在函数内部事实上获得的都是一个指针。
  • 5.在其他所有情况中,定义与声明必须匹配。如果定义了一个数组,在其他文件对它进行声明时也必须把它声明为数组,指针也是如此。

C语言的多维数组

数组的数组

有人声称C语言没有多维数组,这是不对的。ANSI C标准在第6.5.4.2节以及第69号脚注上表示:

当几个[]修饰符连续出现时(方括号里面是数组的范围),就是定义一个多维数组。

那些人的意思是C语言没有像其他语言一样的多维数组,所有其他语言都把这称为数组的数组。C语言的方法多少有点独特:定义和引用多维数组唯一的办法就是使用数组的数组。尽管C语言把数组的数组当作是多维数组,但不能把几个下标范围如[i][j][k]这样的下标形式合并成风格如[i,j,k]这样的。如果你清楚的明白自己在做什么,也介意产生不合规范的程序,可以把[i][j][k]这样的下标值计算为相应的偏移量,然后只用单一的下标[z]来引用数组。当然这不是一种值得推荐的做法。同样糟糕的是,像[i,j,k]这样的下标形式(由逗号分离)是C语言合法的表达形式,只是它并非同时引用这几个下标(它实际上所引用的下标值是k,也就是逗号表达式的值)。C语言支持其他语言一般称作数组的数组的东西,但却称它为多维数组,这样就模糊了两者的边界,使许多人对两者混淆不清。

在C语言中,可以像下面这样声明一个10X20的多维字符数组:

1
char carrot[10][20];

或者声明一种看上去更像数组的数组形式:

1
2
typedef char vegetable[20];
vegetable carrot[10];

不论哪种情况,访问单个字符都是通过carrot[i][j]的形式,编译器在编译时会把它解析为*(*(carrot+i)+j)的形式。

当提到C语言中的数组时,就把它看作是一种向量(vector),也就是某种对象的一维数组,数组的元素可以是另一个数组。

内存中数组是如何布局的

在C语言的多维数组中,最右边的下标是最先变化的,这个约定被称为“行主序”。由于“行/列主序”这个术语只适用于恰好是二维的多维数组,所以更确切的术语是“最右的下标先变化“。
C语言中多维数组最大的用途是存储多个字符串。有人指出”最右边的下标先变化”在这方面具有优势(每个字符串中相邻的字符在内存中也相邻存储)。

如何对数组进行初始化

在最简单的情况下,一维数组可以通过把初始值都放在一堆花括号内来完成初始化。如果在数组的定义里未标明它的长度,C语言约定按照初始化值得个数来确定数组得长度。

1
2
float a[5] = {0.0, 1.0, 2.72, 3.14, 25.625};
float b[] = {0.0, 1.0, 2.72, 3.14, 25.625};

只能够在数组声明时对它进行整体得初始化。之所以存在这个限制,并没过硬的理由。
多维数组可以通过嵌套的花括号进行初始化,可以在最后一个初始化值的后面加一个逗号,也可以省略它。同时,也可以省略最左边下标的长度(也只能是最左边的下标),编译器会根据初始化值的个数推断出它的长度。

如果数组的长度比所提供的初始化值的个数要多,剩余的几个元素会自动设置为0。如果元素的类型是指针,那么它们被初始化为NULL;如果元素类型是float,那么它们被初始化为0.0。

下面是一种初始化二维字符串数组的方法:

1
2
3
4
5
6
7
char vegetables[][9] = {
"beet",
"barley",
"basil",
"broccoli",
"beans"
};

一种有用的方法是建立指针数组。字符串常量可以作数组初始化值,编译器会正确的把各个字符存储于数组中的地址。因此:

1
2
3
4
5
6
7
char *vegetables[] = {
"carrot",
"celery",
"corn",
"cilantro",
"crispy fried potatos"
}; //没问题

注意它的初始化部分与字符数组的数组初始化部分是一样的。只有字符串常量才可以初始化指针数组。指针数组不能由非字符串的类型直接初始化:

1
2
3
4
5
int *weights[] = {
{1,2,3,4,5},
{6,7},
{8,9,10}
}; //无法成功编译

如果想用这种方法对数组进行初始化,可以创建几个单独的数组,然后用这些数组名来初始化原先的数组:

1
2
3
4
5
6
7
8
9
int row_1[] = {1,2,3,4,5,-1}; // -1作为行结束标志
int row_2[] = {6,7,-1};
int row_3[] = {8,9,10,-1};

int *weights[] = {
row_1,
row_2,
row_3
};