为了回答这个问题,我们首先要理清一些概念。什么是数组以及如何使用它?如果不是数组,问题中的代码是什么?
什么是数组?
数组的正式定义见 C 标准,ISO 9899:2011 6.2.5/20 Types。
数组类型描述了一个连续分配的非空集合
具有特定成员对象类型的对象,称为元素类型。
用简单的英语来说,数组是在相邻的内存单元中连续分配的相同类型的项目的集合。
例如,一个由 3 个整数组成的数组 int arr[3] = {1,2,3}; 将像这样在内存中分配:
+-------+-------+-------+
| | | |
| 1 | 2 | 3 |
| | | |
+-------+-------+-------+
那么多维数组的正式定义呢?实际上,它与上面引用的定义完全相同。它递归地应用。
如果我们要分配一个 2D 数组 int arr[2][3] = { {1,2,3}, {1,2,3} };,它将像这样在内存中分配:
+-------+-------+-------+-------+-------+-------+
| | | | | | |
| 1 | 2 | 3 | 1 | 2 | 3 |
| | | | | | |
+-------+-------+-------+-------+-------+-------+
我们在这个例子中实际上是一个数组数组。一个包含 2 个项目的数组,每个项目都是一个由 3 个整数组成的数组。
数组和其他类型一样
C 中的数组通常遵循与常规变量相同的类型系统。如上所示,您可以拥有一个数组数组,就像您可以拥有任何其他类型的数组一样。
您也可以在 n 维数组上应用与普通一维数组相同的指针算法。对于常规的一维数组,应用指针算法应该很简单:
int arr[3] = {1,2,3};
int* ptr = arr; // integer pointer to the first element.
for(size_t i=0; i<3; i++)
{
printf("%d ", *ptr); // print contents.
ptr++; // set pointer to point at the next element.
}
这是通过“阵列衰减”实现的。当arr 在表达式中使用时,它“衰减”为指向第一个元素的指针。
类似地,我们可以使用完全相同的指针算法来遍历数组数组,方法是使用一个数组指针:
int arr[2][3] = { {1,2,3}, {1,2,3} };
int (*ptr)[3] = arr; // int array pointer to the first element, which is an int[3] array.
for(size_t i=0; i<2; i++)
{
printf("%d %d %d\n", (*ptr)[0], (*ptr)[1], (*ptr)[2]); // print contents
ptr++; // set pointer to point at the next element
}
再次出现阵列衰减。 arr 类型为 int [2][3] 的变量衰减为指向第一个元素的指针。第一个元素是int [3],指向此类元素的指针声明为int(*)[3] - 一个数组指针。
为了处理多维数组,必须了解数组指针和数组衰减。
在更多情况下,数组的行为与常规变量一样。 sizeof 运算符对(非 VLA)数组的工作方式与对常规变量的工作方式相同。 32位系统示例:
int x; printf("%zu", sizeof(x)); 打印 4。
int arr[3] = {1,2,3}; printf("%zu", sizeof(arr)); 打印 12 (3*4=12)
int arr[2][3] = { {1,2,3}, {1,2,3} }; printf("%zu", sizeof(arr)); 打印 24 (2*3*4=24)
与任何其他类型一样,数组可以与库函数和通用 API 一起使用。由于数组满足连续分配的要求,例如我们可以使用memcpy 安全地复制它们:
int arr_a[3] = {1,2,3};
int arr_b[3];
memcpy(arr_b, arr_a, sizeof(arr_a));
连续分配也是其他类似标准库函数如memset、strcpy、bsearch 和qsort 工作的原因。它们旨在处理连续分配的数组。因此,如果您有一个多维数组,您可以使用bsearch 和qsort 对其进行高效搜索和排序,从而省去您自己实现二分查找和快速排序的麻烦,从而为每个项目重新发明轮子。
数组和其他类型之间的所有上述一致性是我们想要利用的非常好的东西,尤其是在进行泛型编程时。
如果不是数组,指针对指针是什么?
现在回到问题中的代码,它使用了不同的语法和指针。它没有什么神秘之处。它是指向类型指针的指针,不多不少。它不是一个数组。它不是二维数组。严格来说,不能用于指向数组,也不能用于指向二维数组。
然而,指向指针的指针可用于指向指针数组的第一个元素,而不是指向整个数组。这就是它在问题中的使用方式 - 作为“模拟”数组指针的一种方式。在问题中,它用于指向一个由 2 个指针组成的数组。然后用 2 个指针中的每一个来指向一个由 3 个整数组成的数组。
这被称为查找表,它是一种抽象数据类型(ADT),它不同于普通数组的低级概念。主要区别在于查找表的分配方式:
+------------+
| |
| 0x12340000 |
| |
+------------+
|
|
v
+------------+ +-------+-------+-------+
| | | | | |
| 0x22223333 |---->| 1 | 2 | 3 |
| | | | | |
+------------+ +-------+-------+-------+
| |
| 0xAAAABBBB |--+
| | |
+------------+ |
|
| +-------+-------+-------+
| | | | |
+->| 1 | 2 | 3 |
| | | |
+-------+-------+-------+
本例中的 32 位地址是虚构的。 0x12340000 框表示指针到指针。它包含指向指针数组中第一项的地址0x12340000。该数组中的每个指针依次包含一个指向整数数组中第一项的地址。
问题就从这里开始。
查找表版本的问题
查找表分散在整个堆内存中。它不是在相邻单元中连续分配的内存,因为每次调用malloc() 都会提供一个新的内存区域,不一定与其他内存区域相邻。这反过来又给我们带来了很多问题:
我们不能按预期使用指针算法。虽然我们可以使用某种形式的指针算法来索引和访问查找表中的项目,但我们不能使用数组指针来做到这一点。
我们不能使用 sizeof 运算符。用在指针指针上,它会给我们指针指针的大小。习惯于指向的第一个项目,它会给我们一个指针的大小。它们都不是数组的大小。
我们不能使用除数组类型(memcpy、memset、strcpy、bsearch、qsort 等)之外的标准库函数。所有此类函数都假定将数组作为输入,并连续分配数据。使用我们的查找表作为参数调用它们会导致未定义的行为错误,例如程序崩溃。
重复调用malloc 以分配多个段会导致堆fragmentation,进而导致RAM 内存使用率低。
由于内存分散,CPU在遍历查表时无法利用缓存。数据缓存的有效使用需要从上到下迭代的连续内存块。这意味着在设计上,查找表的访问时间比真正的多维数组要慢得多。
对于malloc() 的每次调用,管理堆的库代码必须计算哪里有可用空间。类似地,每次调用free() 时,都有必须执行的开销代码。因此,出于性能考虑,对这些函数的调用越少越好。
查找表都是坏的吗?
正如我们所见,基于指针的查找表存在很多问题。但它们并不全是坏的,它是一个和其他任何工具一样的工具。它只需要用于正确的目的。如果您正在寻找一个应该用作数组的多维数组,那么查找表显然是错误的工具。但它们可以用于其他目的。
当您需要所有尺寸分别具有完全可变的尺寸时,查找表是正确的选择。例如,在创建 C 字符串列表时,这样的容器会很方便。然后通常有理由采取上述执行速度性能损失以节省内存。
此外,查找表的优点是您可以在运行时重新分配部分表,而无需重新分配整个多维数组。如果这是需要经常做的事情,查找表在执行速度方面甚至可能胜过多维数组。例如,在实现链式哈希表时可以使用类似的查找表。
那么如何正确动态分配多维数组呢?
现代 C 语言中最简单的形式是简单地使用可变长度数组 (VLA)。 int array[x][y]; 其中x 和y 是在运行时给定值的变量,之前的数组声明。但是,VLA 具有本地范围,并且不会在整个程序期间持续存在 - 它们具有自动存储持续时间。因此,虽然 VLA 可以方便快捷地用于临时数组,但它并不是问题中查找表的通用替代品。
要真正动态地分配多维数组,使其获得分配的存储时长,我们必须使用malloc()/calloc()/realloc()。下面我举一个例子。
在现代 C 中,您将使用指向 VLA 的数组指针。即使程序中没有实际的 VLA,您也可以使用此类指针。在普通的type* 或void* 上使用它们的好处是增加了类型安全性。使用指向 VLA 的指针还允许您将数组维度作为参数传递给使用数组的函数,使其同时具有变量和类型安全性。
不幸的是,为了利用指向 VLA 的指针的好处,我们不能将该指针作为函数结果返回。因此,如果我们需要将指向数组的指针返回给调用者,则必须将其作为参数传递(原因在Dynamic memory access only works inside function 中描述)。这在 C 中是一种很好的做法,但会使代码有点难以阅读。它看起来像这样:
void arr_alloc (size_t x, size_t y, int(**aptr)[x][y])
{
*aptr = malloc( sizeof(int[x][y]) ); // allocate a true 2D array
assert(*aptr != NULL);
}
虽然这种带有指向数组指针的指针的语法可能看起来有点奇怪和令人生畏,但即使我们添加更多维度,它也不会变得比这更复杂:
void arr_alloc (size_t x, size_t y, size_t z, int(**aptr)[x][y][z])
{
*aptr = malloc( sizeof(int[x][y][z]) ); // allocate a true 3D array
assert(*aptr != NULL);
}
现在将该代码与为查找表版本增加一维的代码进行比较:
/* Bad. Don't write code like this! */
int*** arr_alloc (size_t x, size_t y, size_t z)
{
int*** ppp = malloc(sizeof(*ppp) * x);
assert(ppp != NULL);
for(size_t i=0; i<x; i++)
{
ppp[i] = malloc(sizeof(**ppp) * y);
assert(ppp[i] != NULL);
for(size_t j=0; j<y; j++)
{
ppp[i][j] = malloc(sizeof(***ppp) * z);
assert(ppp[i][j] != NULL);
}
}
return ppp;
}
现在那是一团难以理解的“三星级编程”。甚至不考虑 4 个维度...
使用真正二维数组的版本的完整代码
#include <stdlib.h>
#include <stdio.h>
#include <assert.h>
void arr_alloc (size_t x, size_t y, int(**aptr)[x][y])
{
*aptr = malloc( sizeof(int[x][y]) ); // allocate a true 2D array
assert(*aptr != NULL);
}
void arr_fill (size_t x, size_t y, int array[x][y])
{
for(size_t i=0; i<x; i++)
{
for(size_t j=0; j<y; j++)
{
array[i][j] = (int)j + 1;
}
}
}
void arr_print (size_t x, size_t y, int array[x][y])
{
for(size_t i=0; i<x; i++)
{
for(size_t j=0; j<y; j++)
{
printf("%d ", array[i][j]);
}
printf("\n");
}
}
int main (void)
{
size_t x = 2;
size_t y = 3;
int (*aptr)[x][y];
arr_alloc(x, y, &aptr);
arr_fill(x, y, *aptr);
arr_print(x, y, *aptr);
free(aptr); // free the whole 2D array
return 0;
}