无论如何,VLA有什么意义?

Sha*_*baz 12 c variable-length-array

我理解变量长度数组是什么以及它们是如何实现的.这个问题是关于他们存在的原因.

我们知道VLA只允许在功能块(或原型)中使用,并且它们基本上不能在堆栈上的任何地方(假设正常实现):C11,6.7.6.2-2:

如果标识符被声明为具有可变修改类型,则它应该是普通标识符(如6.2.3中所定义),没有链接,并且具有块范围或函数原型范围.如果标识符被声明为具有静态或线程存储持续时间的对象,则它不应具有可变长度数组类型.

我们举一个小例子:

void f(int n)
{
    int array[n];
    /* etc */
}
Run Code Online (Sandbox Code Playgroud)

有两种情况需要注意:

  • n <= 0:f必须防范这一点,否则行为是不明确的:C11,6.7.6.2-5(强调我的):

    如果size是一个不是整数常量表达式的表达式:如果它出现在函数原型范围的声明中,则将其视为替换为*; 否则, 每次评估它时,其值应大于零.可变长度数组类型的每个实例的大小在其生命周期中不会改变.如果size表达式是运算sizeof符操作数的一部分,并且更改size表达式的值不会影响运算符的结果,则无法指定是否计算size表达式.

  • n > stack_space_left / element_size:没有标准的方法可以找到剩余多少堆栈空间(因为只要涉及标准就没有堆栈这样的东西).所以这个测试是不可能的.只有明智的解决方案才是拥有预定义的最大可能大小n,例如N,确保不会发生堆栈溢出.

换句话说,程序员必须确保0 < n <= N一些N选择.但是,程序应该n == N仍然可以工作,所以人们也可以声明数组的大小N是常量而不是可变长度n.

我知道引入了VLA以替换alloca(在本回答中也提到过),但实际上它们是相同的(在堆栈上分配可变大小的内存).

所以问题是为什么allocaVLA存在并且因此VLA存在以及它们为什么不被弃用?在我看来,使用VLA的唯一安全方法是使用有限大小,在这种情况下,采用具有最大大小的正常阵列始终是可行的解决方案.

AnT*_*AnT 18

对于不完全清楚,我,几乎每C99 VLA的话题讨论弹出时间的原因,人们开始在堆栈中主要谈论声明运行时大小的数组作为本地对象(即创建它们的可能性" ").这是相当令人惊讶和误导的,因为VLA功能的这一方面 - 支持本地阵列声明 - 恰好是VLA提供的辅助辅助功能.它在VLA的作用方面并没有起到任何重要作用.大多数时候,当地VLA声明及其伴随的潜在陷阱的问题被VLA评论家逼到了前台,他们将其作为一个"稻草人",旨在破坏讨论并将其陷入勉强相关的细节之中.

C语言中VLA支持的本质首先是语言类型概念的革命性质的延伸.它涉及引入诸如可变修改类型的这种基本上新类型.实际上,与VLA相关的每个重要实现细节实际上都附加到其类型,而不是VLA对象本身.正是在语言中引入了可变修改类型,构成了众所周知的VLA蛋糕的大部分,而在本地内存中声明这类类型的对象的能力只不过是对这个蛋糕的微不足道和无关紧要的锦上添花.

考虑一下:每次在一个代码中声明这样的东西

/* Block scope */
int n = 10;
...
typedef int A[n];
...
n = 5; /* <- Does not affect `A` */
Run Code Online (Sandbox Code Playgroud)

可变修改类型的大小相关特征A(例如,值n)在控制经过上述typedef-declaration的确切时刻完成.任何n进一步下行的价值变化,在此声明下方A都不会影响规模A.停一下,想想它意味着什么.这意味着该实现应该与A隐藏的内部变量相关联,该内部变量将存储数组类型的大小.这个隐藏的内部变量从n控件传递声明时初始化A.

这为上面的typedef声明提供了一个相当有趣和不寻常的属性:它生成可执行代码(!).而且,它不仅生成可执行代码,还生成至关重要的可执行代码.出于这个原因,该语言对这种可变修改的声明施加了一些不寻常的限制:该语言禁止将控制权从其范围之外传递到其范围内

/* Block scope */
int n = 10;
goto skip; /* Error: invalid goto */

typedef int A[n];

skip:;
Run Code Online (Sandbox Code Playgroud)

再次注意,上面的代码没有定义任何VLA数组.它只是为一个可变修改类型声明了一个看似无辜的别名.然而,跳过这种typedef声明是违法的.(我们已经熟悉C++中与跳转相关的限制,尽管在其他情况下).

代码生成与"经典"语言typedef的重大不同typedef.(它也恰好构成了在C++中采用VLA的一个重要障碍.)

当一个人声明一个实际的VLA对象时,除了分配实际的数组内存之外,编译器还会创建一个或多个隐藏的内部变量,这些变量保存有问题的数组的大小.人们必须明白,这些隐藏变量不是与数组本身相关联,而是与其可变修改类型相关联.

这种方法的一个重要且显着的结果如下:与VLA相关联的关于数组大小的附加信息不直接构建在VLA的对象表示中.它实际上存储在数组之外,作为"sidecar"数据.这意味着(可能是多维的)VLA的对象表示与具有相同维度和相同大小的普通经典编译时大小的数组的对象表示完全兼容.例如

void foo(unsigned n, unsigned m, unsigned k, int a[n][m][k]) {}
void bar(int a[5][5][5]) {}

int main(void)
{
  unsigned n = 5;
  int vla_a[n][n][n];
  bar(a);

  int classic_a[5][6][7];
  foo(5, 6, 7, classic_a); 
}
Run Code Online (Sandbox Code Playgroud)

在上面的代码中这两个函数调用是完全有效的,他们的行为是由语言,尽管我们通过VLA,其中"经典"阵列的预期,反之亦然事实完全定义.当然,编译器无法控制此类调用中的类型兼容性(因为至少有一个涉及的类型是运行时大小的).但是,如果需要,编译器(或用户)拥有在调试版代码中执行运行时检查所需的一切.

(注意:通常,阵列型的参数总是隐含地调整.到指针类型的参数这适用于VLA参数声明,正是因为它适用于"经典"阵列参数声明这意味着在上述例子中的参数.typedef实际上具有键入a.这个类型不受值的影响int (*)[m][k].我故意在数组中添加了一些额外的维度,以保持对运行时值的依赖.)

作为函数参数的VLA和"经典"数组之间的兼容性也得到以下事实的支持:编译器不必伴随可变修改的参数以及关于其大小的任何其他隐藏信息.相反,语言语法强制用户在打开时传递此额外信息.在上面的例子中,用户被迫首先包含参数n,nm进入功能参数列表.如果没有声明k,n以及m第一,用户不会已经能够声明k(见有关上述注a).这些参数,由用户明确传递给函数,将带来有关实际大小的信息n.

再举一个例子,通过利用VLA支持,我们可以编写以下代码

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

void init(unsigned n, unsigned m, int a[n][m])
{
  for (unsigned i = 0; i < n; ++i)
    for (unsigned j = 0; j < m; ++j)
      a[i][j] = rand() % 100;
}

void display(unsigned n, unsigned m, int a[n][m])
{
  for (unsigned i = 0; i < n; ++i)
    for (unsigned j = 0; j < m; ++j)
      printf("%2d%s", a[i][j], j + 1 < m ? " " : "\n");
  printf("\n");
}

int main(void) 
{
  int a1[5][5] = { 42 }; 
  display(5, 5, a1);
  init(5, 5, a1);
  display(5, 5, a1);

  unsigned n = rand() % 10 + 5, m = rand() % 10 + 5;
  int (*a2)[n][m] = malloc(sizeof *a2);
  init(n, m, *a2);
  display(n, m, *a2);
  free(a2);
}
Run Code Online (Sandbox Code Playgroud)

此代码旨在引起您注意以下事实:此代码大量使用可变修改类型的有价值属性.没有VLA,就不可能优雅地实施.这就是为什么在C中迫切需要这些属性来替换之前使用过的丑陋黑客的主要原因.然而,在同一时间,甚至不是一个单一的VLA是在上述程序的本地内存中创建的,这意味着这个流行的VLA批评矢量并不适用于这个代码在所有.

基本上,上面最后两个例子简要说明了VLA支持的重点.

  • 谢谢你。关于 VLA 类型的观点很有启发性。这些示例还有助于提醒编写“通用矩阵数学函数”的问题,并意识到 VLA 就是答案。 (4认同)
  • 鉴于VLA _types_具有明显的实用性,令人沮丧的是C11使它们成为可选的。 (2认同)

Sha*_*baz 15

看看评论和答案,在我看来,当您知道通常您的输入不是太大(类似于知道您的递归可能不是太深)时,VLA很有用,但您实际上没有上限,你通常会忽略可能的堆栈溢出(类似于通过递归忽略它们)希望它们不会发生.

它实际上也可能不是一个问题,例如,如果你有无限的堆栈大小.

也就是说,这是他们的另一个用途,我发现它实际上并没有在堆栈上分配内存,但更容易使用动态多维数组.我将通过一个简单的例子来证明:

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

int main(void)
{
    size_t n, m;

    scanf("%zu %zu", &n, &m);

    int (*array)[n][m] = malloc(sizeof *array);

    for (size_t i = 0; i < n; ++i)
        for (size_t j = 0; j < m; ++j)
            (*array)[i][j] = i + j;

    free(array);
    return 0;
}
Run Code Online (Sandbox Code Playgroud)


har*_*mic 7

您的论点似乎是,既然必须检查 VLA 的大小,为什么不只分配最大大小并使用运行时分配来完成。

该论点忽略了这样一个事实,即内存是系统中的一种有限资源,在许多进程之间共享。在一个进程中浪费地分配的内存对任何其他进程都不可用(或者可能是,但以交换到磁盘为代价)。

根据相同的论点,当我们可以静态分配可能需要的最大大小时,我们不需要在运行时 malloc 数组。最后,堆耗尽只比堆栈溢出稍微好一点。

  • 这个论点在任何现代操作系统上都不适用。您分配虚拟内存,而不是 RAM。过度分配不会产生任何费用。 (3认同)
  • 其次,如果你用完了堆内存,“malloc”会很好地返回 NULL,你可以对其采取行动。您可以很好地告诉用户,例如该操作是不可能的,或者优雅地保存一些状态,归还资源并返回到先前的良好状态。堆栈溢出会让你死掉,这很糟糕。 (2认同)

hac*_*cks 6

尽管你提到了关于VLA的所有要点,但VLA最好的部分是编译器自动处理存储管理以及边界不是编译时常量的数组的索引计算的复杂性.
如果您想要本地动态内存分配,那么唯一的选择是VLA.

我认为这可能是C99采用VLA的原因(C11上可选).


我要澄清的一点是,alloca与VLA 之间存在一些显着差异.这篇文章指出了不同之处:

  • alloca()只要当前函数持续存在,内存返回就是有效的.只要VLA的标识符保留在范围内,VLA占用的内存的生存期就是有效的.
  • alloca()例如,你可以在循环中记忆并使用循环外的内存,因为当循环终止时标识符超出范围,VLA就会消失.

  • @Shahbaz如果你有一个递归算法,只能在给定缓冲区的一部分上运行,并且由于某些原因必须复制它.然后所需的堆栈空间将是`datalength*recursiondepth`,而对于VLA,它可以(并且将)更少,允许更深的递归. (8认同)
  • 实际上,不是"在C99及以后采用VLA",而是在C99中采用了VLA的历史,而在C11中则是可选的. (3认同)