正确分配多维数组

Posted

技术标签:

【中文标题】正确分配多维数组【英文标题】:Correctly allocating multi-dimensional arrays 【发布时间】:2018-02-08 04:07:56 【问题描述】:

这个问题的目的是提供一个关于如何正确地在 C 中动态分配多维数组的参考。即使在一些 C 编程书籍中,这个话题也经常被误解和解释得不好。因此,即使是经验丰富的 C 程序员也很难做到正确。


我的编程老师/书籍/教程告诉我动态分配多维数组的正确方法是使用指针到指针。

但是,SO 上的几位高级用户现在告诉我,这是错误和不好的做法。他们说指针到指针不是数组,我实际上并没有分配数组,而且我的代码不必要地慢。

这就是我被教导分配多维数组的方式:

#include <stdlib.h>
#include <stdio.h>
#include <assert.h>

int** arr_alloc (size_t x, size_t y)

  int** pp = malloc(sizeof(*pp) * x);
  assert(pp != NULL);
  for(size_t i=0; i<x; i++)
  
    pp[i] = malloc(sizeof(**pp) * y);
    assert(pp[i] != NULL);
  

  return pp;


int** arr_fill (int** pp, size_t x, size_t y)

  for(size_t i=0; i<x; i++)
  
    for(size_t j=0; j<y; j++)
    
      pp[i][j] = (int)j + 1;
    
  

  return pp;


void arr_print (int** pp, size_t x, size_t y)

  for(size_t i=0; i<x; i++)
  
    for(size_t j=0; j<y; j++)
    
      printf("%d ", pp[i][j]);
    
    printf("\n");
  


void arr_free (int** pp, size_t x, size_t y)

  (void) y;

  for(size_t i=0; i<x; i++)
  
    free(pp[i]);
    pp[i] = NULL;
  
  free(pp);
  pp = NULL;



int main (void)

  size_t x = 2;
  size_t y = 3;
  int** pp;

  pp = arr_alloc(x, y);
  pp = arr_fill(pp, x, y);
  arr_print(pp, x, y);
  arr_free(pp, x, y);

  return 0;

输出

1 2 3
1 2 3

这段代码运行良好!怎么会错呢?

【问题讨论】:

【参考方案1】:

为了回答这个问题,我们首先要理清一些概念。什么是数组以及如何使用它?如果不是数组,问题中的代码是什么?


什么是数组?

数组的正式定义见 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)); 打印 4int 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));

连续分配也是其他类似标准库函数如memsetstrcpybsearchqsort 工作的原因。它们旨在处理连续分配的数组。因此,如果您有一个多维数组,您可以使用bsearchqsort 对其进行高效搜索和排序,从而省去您自己实现二分查找和快速排序的麻烦,从而为每个项目重新发明***。

数组和其他类型之间的所有上述一致性是我们想要利用的非常好的东西,尤其是在进行泛型编程时。


如果不是数组,指针对指针是什么?

现在回到问题中的代码,它使用了不同的语法和指针。它没有什么神秘之处。它是指向类型指针的指针,不多不少。它不是一个数组。它不是二维数组。严格来说,不能用于指向数组,也不能用于指向二维数组。

然而,指向指针的指针可用于指向指针数组的第一个元素,而不是指向整个数组。这就是它在问题中的使用方式 - 作为“模拟”数组指针的一种方式。在问题中,它用于指向一个由 2 个指针组成的数组。然后用 2 个指针中的每一个来指向一个由 3 个整数组成的数组。

这被称为查找表,它是一种抽象数据类型(ADT),它不同于普通数组的低级概念。主要区别在于查找表的分配方式:

+------------+
|            |
| 0x12340000 |
|            |
+------------+
      |
      |
      v
+------------+     +-------+-------+-------+
|            |     |       |       |       |
| 0x22223333 |---->|   1   |   2   |   3   |
|            |     |       |       |       |
+------------+     +-------+-------+-------+
|            | 
| 0xAAAABBBB |--+
|            |  | 
+------------+  |  
                |
                |  +-------+-------+-------+
                |  |       |       |       |
                +->|   1   |   2   |   3   |
                   |       |       |       |
                   +-------+-------+-------+

本例中的 32 位地址是虚构的。 0x12340000 框表示指针到指针。它包含指向指针数组中第一项的地址0x12340000。该数组中的每个指针依次包含一个指向整数数组中第一项的地址。

问题就从这里开始。


查找表版本的问题

查找表分散在整个堆内存中。它不是在相邻单元中连续分配的内存,因为每次调用malloc() 都会提供一个新的内存区域,不一定与其他内存区域相邻。这反过来又给我们带来了很多问题:

我们不能按预期使用指针算法。虽然我们可以使用某种形式的指针算法来索引和访问查找表中的项目,但我们不能使用数组指针来做到这一点。

我们不能使用 sizeof 运算符。用在指针指针上,它会给我们指针指针的大小。习惯于指向的第一个项目,它会给我们一个指针的大小。它们都不是数组的大小。

我们不能使用除数组类型(memcpymemsetstrcpybsearchqsort 等)之外的标准库函数。所有此类函数都假定将数组作为输入,并连续分配数据。使用我们的查找表作为参数调用它们会导致未定义的行为错误,例如程序崩溃。

重复调用malloc 以分配多个段会导致堆fragmentation,进而导致RAM 内存使用率低。

由于内存分散,CPU在遍历查表时无法利用缓存。数据缓存的有效使用需要从上到下迭代的连续内存块。这意味着在设计上,查找表的访问时间比真正的多维数组要慢得多。

对于malloc() 的每次调用,管理堆的库代码必须计算哪里有可用空间。类似地,每次调用free() 时,都有必须执行的开销代码。因此,出于性能考虑,对这些函数的调用越少越好。


查找表都是坏的吗?

正如我们所见,基于指针的查找表存在很多问题。但它们并不全是坏的,它是一个和其他任何工具一样的工具。它只需要用于正确的目的。如果您正在寻找一个应该用作数组的多维数组,那么查找表显然是错误的工具。但它们可以用于其他目的。

当您需要所有尺寸分别具有完全可变的尺寸时,查找表是正确的选择。例如,在创建 C 字符串列表时,这样的容器会很方便。然后通常有理由采取上述执行速度性能损失以节省内存。

此外,查找表的优点是您可以在运行时重新分配部分表,而无需重新分配整个多维数组。如果这是需要经常做的事情,查找表在执行速度方面甚至可能胜过多维数组。例如,在实现链式哈希表时可以使用类似的查找表。


那么如何正确动态分配多维数组呢?

现代 C 语言中最简单的形式是简单地使用可变长度数组 (VLA)。 int array[x][y]; 其中xy 是在运行时给定值的变量,之前的数组声明。但是,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;

【讨论】:

写得好,需要答案。但有一件事困扰着我:为什么要提到bsearch/qsort?这些旨在在单一维度上运行。如果您使用它们对 p2p 数组的第一维上的指针进行排序,它的工作原理与二维数组上的行排序一样,假设用户定义了适当的比较函数并提供了有效的参数。 @RestlessC0bra 1) 正确,尽管“行”和“列”的定义在应用程序中。 C 标准只要求给定类型的x 连续变量有y 连续段。 2) 正确。 3) 实际上——指向 VLA 的指针不一定必须指向具有自动存储持续时间的对象,甚至不一定指向 VLA。 type (*name)[n] 形式的指针,其中n 是运行时值,可以设置为指向任何相同类型和大小的数组,无论分配在何处。 替代*aptr = malloc( sizeof(int[x][y]) );,使用*aptr = malloc(sizeof **aptr); 匹配惯用正确的pointer = malloc(sizeof *pointer); 您说“找到了数组的正式定义...”,但随后您引用了数组类型的正式定义。事实上,该标准并没有在任何地方正式定义 array 重复调用malloc 分配多个段会导致堆碎片化,进而导致 RAM 内存使用不佳 动态分配 N 维几乎是微不足道的“数组”只有 N+1 次调用 malloc(),虽然不是微不足道的,但可以通过一次调用分配一个。【参考方案2】:

C 没有多维数组(作为 原始 数据类型)。但是你可以有数组(或其他聚合)数组和指针数组。

一种可能的方法是用abstract data type 推理(可能使用flexible array members,这是一种实现技巧,您可以使用其他方法),例如this answer。

我们不能推荐任何抽象数据类型,因为这取决于你的作业文本,而我们没有。您需要设计您的抽象数据类型(在一张纸上),然后再实现它。

一旦您(在纸上或板上)列出了 ADT 所需的所有操作,实施它们就很简单了。

这段代码运行良好!怎么会错呢?

那句话是不一致的(错误的w.r.t.什么规范?)...

我建议使用所有警告和调试信息进行编译(例如 with gcc -Wall -Wextra -g 和 GCC),以改进您的代码,直到您没有收到任何警告,使用调试器 gdb(了解正在发生的事情在你的程序中)和其他工具,如valgrind。

【讨论】:

如何回答“这种动态分配二维数组/数组数组的方法有什么问题”的问题? 这是一个非常常见的行业事实上的标准术语,意思是array of arrays。不过,问题确实 not 包含数组数组,这就是这里的重点。如果你想整理这篇文章,那么至少添加一些有意义的东西。完全不清楚灵活的数组成员在这里如何成为一个有用的解决方案,或者它们的好处是什么。 “C 没有多维数组”类似于说 C 没有负数。检查语法;没有负常数。您只能使用正常量并应用一元 - 运算符。当然 C 确实有负数,而且它确实有多维数组。两者都是简单地从原语构建的,而不是原语本身。 C 没有多维数组?我认为你在这方面有点太过分了。每6.5.2.1 Array subscripting, paragraph 3 of the C 11 standard(我的粗体字):“连续的下标运算符指定一个多维数组对象的元素。如果E是一个n维数组(n >= 2)尺寸为 ixjx . . . xk,然后 E(用作左值以外的其他值)被转换为指向 (n - 1) 维数组 ..." 如果 C 标准可以使用术语“多维数组对象”... (cont) 说多维数组不是原始对象与说 structunion 不是原始对象一样有用。

以上是关于正确分配多维数组的主要内容,如果未能解决你的问题,请参考以下文章

“动态分配的内存模拟多维数组”的正确术语?

如何在 C++ 中正确使用动态分配的多维数组 [重复]

如何分配动态静态多维数组

多维数组动态内存分配背后的算法是啥?

我的Java——数组——多维数组

如何为多维数组动态分配内存