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

时间:2014-03-20 10:40:47

标签: c variable-length-array

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

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

  

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

我们举一个小例子:

void f(int n)
{
    int array[n];
    /* etc */
}

有两种情况需要注意:

  • n <= 0f必须防止这种情况,否则行为未定义:C11,6.7.6.2-5(强调我的):

      

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

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

换句话说,程序员必须确保0 < n <= N选择N。但是,该程序应该适用于n == N,因此也可以使用常量N而不是可变长度n来声明数组。

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

所以问题是为什么alloca因此存在VLA,为什么他们不被弃用?在我看来,使用VLA的唯一安全方法是使用有限大小,在这种情况下,采用具有最大大小的正常数组始终是一个可行的解决方案。

5 个答案:

答案 0 :(得分: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;
}

答案 1 :(得分:11)

出于我不完全清楚的原因,几乎每次在讨论中出现C99 VLA主题时,人们就开始谈论将运行时大小的数组声明为本地对象的可能性(即,创建“在堆栈上”)。这是相当令人惊讶和令人误解的,因为VLA功能的这一方面-支持本地数组声明-恰好是VLA提供的一种辅助辅助功能。在VLA可以做什么方面,它实际上没有发挥任何重要作用。大多数时候,VLA批评家将本地VLA声明及其伴随的潜在陷阱问题摆在了前台,他们将其用作“稻草人”,意在使讨论脱轨,并在几乎不相关的细节之间进行讨论。 >

用C语言支持VLA的本质首先是语言 type 概念的革命性定性扩展。它涉及引入诸如可变修改类型这样的根本上新型的类型。实际上,与VLA相关的每个重要实现细节实际上都附加到其 type 上,而不是附加到VLA对象本身上。正是在语言中引入了可变修改类型,从而构成了众所周知的VLA蛋糕,而在本地内存中声明此类对象的能力仅是微不足道的蛋糕上没有糖霜。

考虑一下:每次有人在自己的代码中声明这样的内容

/* Block scope */
int n = 10;
...
typedef int A[n];
...
n = 5; /* <- Does not affect `A` */
变量修改类型A

与尺寸有关的特性(例如n的值)在控件经过上述typedef声明的确切时刻确定。在此声明n下,A的值所做的任何更改都不会影响A的大小。停下来思考一下这意味着什么。这意味着该实现应与A的隐藏内部变量关联,该内部变量将存储数组类型的大小。当控件经过n的声明时,将在运行时从A初始化此隐藏的内部变量。

这为上面的typedef声明提供了一个非常有趣且不寻常的属性,这是我们之前从未见过的:此typedef声明生成可执行代码(!)。而且,它不仅生成可执行代码,而且生成至关重要的可执行代码。如果我们以某种方式忘记初始化与此类typedef声明相关联的内部变量,我们将最终得到“中断” /未初始化的typedef别名。内部代码的重要性是该语言对此类可变修改声明施加一些异常限制的原因:该语言禁止将控制权从其范围之外传递给其范围

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

typedef int A[n];

skip:;

再次请注意,以上代码未定义任何VLA阵列。它只是为可变修改类型声明了一个看起来纯真的别名。但是,跳过此类typedef声明是非法的。 (尽管在其他情况下,我们已经熟悉了C ++中与跳转相关的限制)。

生成代码的typedef和需要运行时初始化的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); 
}

以上代码中的两个函数调用都是完全有效的,并且它们的行为完全由该语言定义,尽管事实是我们通过了一个VLA,其中“经典”数组是可以预期的,反之亦然。当然,编译器无法在此类调用中控制类型兼容性(因为至少一种涉及的类型是运行时大小的)。但是,如果需要,编译器(或用户)具有在调试版本的代码中执行运行时检查所需的一切。

(注意:通常,数组类型的参数总是隐式地调整到指针类型的参数中。这完全适用于VLA参数声明,就像适用于“经典”数组参数声明一样。这意味着在上面的示例参数a中实际上具有类型int (*)[m][k]。此类型不受n值的影响。我有意向数组添加了一些额外的维数,以保持其对运行的依赖性时间值。)

VLA和“经典”数组作为函数参数之间的兼容性也得到了支持,因为编译器不必将可变修改的参数与任何有关其大小的其他隐藏信息一起使用。取而代之的是,语言语法迫使用户在公开场合传递这些额外的信息。在上面的示例中,用户被迫首先将参数nmk包括到功能参数列表中。如果不先声明nmk,则用户将无法声明a(另请参见上面关于n的说明)。用户将这些参数明确传递给函数,这些参数将带来有关a实际大小的信息。

再举一个例子,利用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);
}

该代码旨在引起您对以下事实的注意:该代码大量使用了可变修改类型的宝贵属性。没有VLA,不可能优雅地实现。这是为什么在C中迫切需要这些属性来替换以前使用的丑陋hacks的主要原因。但是,与此同时,在上述程序中,甚至没有在本地内存中创建单个VLA,这意味着这种流行的VLA批评向量根本不适用于此代码。

基本上,上面的最后两个示例是对VLA支持的重点的简要说明。

答案 2 :(得分:5)

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

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


我想澄清的一点是 alloca和VLA 之间存在一些显着差异。 This post指出了不同之处:

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

答案 3 :(得分:3)

你的论点似乎是因为必须绑定检查VLA的大小,为什么不分配最大大小并完成运行时分配。

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

通过相同的参数,当我们可以静态地分配可能需要的最大大小时,我们不需要在运行时malloc数组。最后,堆耗尽仅略微优于堆栈溢出。

答案 4 :(得分:0)

堆栈分配(VLA 分配)非常快,只需要快速修改堆栈指针(通常是单个 CPU 指令)。无需昂贵的堆分配/解除分配。

但是,为什么不直接使用固定大小的数组呢?

假设您正在编写一个高性能代码,并且您需要一个可变大小的缓冲区,比如 8 到 512 个元素。您可以只声明一个 512 个元素的数组,但如果大多数时候您只需要 8 个元素,那么由于影响堆栈内存中的缓存位置,过度分配会影响性能。现在想象一下这个函数必须被调用数百万次。

另一个例子,假设你的函数(带有本地 VLA)是递归的,你事先知道在任何时候所有递归分配的 VLA 的总大小是有限的(即数组具有可变大小,但所有大小的总和是有界)。在这种情况下,如果您使用最大可能大小作为固定的本地数组大小,您可能会分配比其他所需更多的内存,从而使您的代码变慢(由于缓存未命中),甚至导致堆栈溢出。