关于 VLA,它有问题,但也不是一无是处

关于 VLA,它有问题,但也不是一无是处

这个学期又是 CS100 (C/C++ 编程) 助教了,昨天在准备下周习题课内容的时候认认真真地把标准里的函数、数组、声明、初始化的规则全读了一遍,可以说有不少意外收获…

其实我当年是先学的 C++,从来没有严格地学过 C,也一直在用 C++ 的方式去写 C。上个学期操作系统 project 应该算是我第一次用纯 C 写一份不那么简单的代码。事实上真正的 C 和 C++ 里的那种 C 还是不太一样的,并且这两门语言也在朝着不同的方向发展。

那么就说一说 C99 引入的一个颇受争议的特性:Variable-Length Array (VLA),即运行期确定大小的数组。

VLA 简介

我们都知道,开数组的时候,数组的大小必须是一个编译期确定的值(即“常量表达式”)。相比 C++,C 在这方面限制更多,例如用常量表达式初始化的 const 变量在 C++ 中是常量表达式,但在 C 中不是。所以像下面这样的代码

const int maxn = 100;
int a[maxn];

在 C++ 中是合法的,但在 C 中不是——至少不是普通的数组。某些编译器会非常聪明地看出这个 maxn 其实是编译期确定的值,然后将它按照普通数组的方式处理,而不是处理成一个 VLA,这算是一种编译器扩展。VLA,顾名思义,就是长度为变量的数组,它自 C99 引入 C 语言标准,也就是说在 C99 里,下面这个许多新手会犯的“错误”是符合标准的:

int main(void) {
  int n;
  scanf("%d", &n);
  int a[n];
  // ...
}

到了 C11,语言标准又将 VLA 列为了 optional feature,编译器可以选择支持或不支持,用户可以通过宏 __STDC_NO_VLA__ 来判断编译器是否支持。而到了 C23,标准的说法是

If the compiler defines the macro constant __STDC_NO_VLA__ to integer constant 1, then VLA objects with automatic storage duration are not supported.
The support for VM types and VLAs with allocated storage durations is mandated.

C23 的标准具体是什么意思,我们后面再说。值得一提的是,VLA 从来没有被加入 C++ 标准,它曾经差点进入 C++14,但最后还是(和它的兄弟 std::dynarray 一起)被踢了出去。

关于内存

VLA 在栈上开了一块运行时确定大小的内存,但是众所周知,并没有一个标准的方式去检验栈内存是否够用。这一点和堆内存不同,像 malloccalloc 这样的函数会在内存不足时返回空指针,用户代码可以得知这一信息,然后可以作适当的调整,程序仍然能正常运行;但我们没法检验栈内存是否够用,而一旦遭遇了栈溢出,程序基本上就会直接崩溃,没有回旋的余地。典型的可能造成栈溢出的行为就是递归的深度过大,所以在实际应用中对于递归函数的使用必须慎之又慎。VLA 自然也有这个问题:如果你开了一个 int a[n];,其中 n 最大可能取到的值是 N,你必须保证你的程序在 n == N 时也能够正常运行,不会栈溢出,那既然这样为何不直接开 int a[N];呢?

这个说法有一定的道理,但是它想得有点简单了。不妨考虑一个这样的例子:我希望开一个数组 int a[n];,这个 n 最大可能取到 1000,但在 99% 的情况下它都不会超过 10。如果你直接用一个 int a[1000]; 来代替,这将是对于内存的极大的浪费。再比如我想同时开 n 个数组,每个数组的长度的上确界都是 n,但这 n 个数组的长度总和恰好也等于 n。假设 n 可能取到的最大值是 N,如果我将这 n 个数组都开成 int a[N];,总的空间消耗将达到 O ( N 2 ) O\left(N^2\right) O(N2),这显然是不能接受的。你当然可以通过共用一个长度为 N 的数组的方式来解决这个问题,但显然不如直接开 VLA 来得优雅、简便。

当然,栈溢出的风险仍然是不可忽略的问题。事实上 VLA 引入语言标准的目的之一就是为了取代一个风险更大的函数:alloca,它可以直接在栈上开指定大小的内存,在函数返回时自动释放。但是栈内存的分配和释放相比堆内存都更高效,也更简单,所以如果对效率有极致的追求的话,在确保安全的情况下用 VLA 代替一部分 malloc/free 确实会有一定的帮助。毕竟如果你对效率不以为意,那为什么不左转去 Python 之家呢?

关于类型

VLA 的另一个重要的意义,就在于它改变了 C 的静态类型系统。VLA 的长度是运行时确定的,而数组的长度也是类型的一部分,因此 VLA 也就具有一个运行时确定的类型,即 variably-modified type。既然这样,语言的一些其它相关的规则也会发生改变,例如 sizeof 无法保证在编译时得出结果,typedef int arr[n]; 这样的类型别名声明也会生成一些代码。这些对于 C 来说其实问题不大,但如果引入 C++ 就不行了,因为虽然 C 和 C++ 都是静态类型语言,但 C 并不像 C++ 那样如此依赖静态类型系统—— C++ 代码充满了 decltypeauto 和模板类型推导,还有 SFINAE 和其它模板元编程技术,到了 C++20 还有 concept 等等。举个例子:

template <typename T>
void fun(T &a, T &b) { /* ... */ }
int a[n], b[m];
fun(a, b);

现在 ab 的类型分别是 int [n]int [m],它们都需要到运行时才能确定。但是如果运行时发现 n != m,那 ab 就具有不同的类型,模板类型参数 T 的推导就会失败,fun(a, b) 这个调用甚至不该编译!如果在 C++ 中引入了 VLA,这些和类型推导有关的规则都需要重新考虑,这可不是一件容易的事。况且,现代 C++ 的发展方向之一就是将尽可能多的工作从运行时移到编译时,而 VLA 恰与此相反。

但是如果单看 C,这种 variably-modified type 并不是坏事。比方说我们想在堆上开一个 n × m n\times m n×m 的矩阵,常见的写法是

int **a = malloc(sizeof(int *) * n);
for (int i = 0; i != n; ++i)
  a[i] = malloc(sizeof(int) * m);
// a[i][j]
for (int i = 0; i != n; ++i)
  free(a[i]);
free(a);

或者开一块长度为 n m nm nm 的内存,然后用 i * m + j 来访问第 i i i 行第 j j j 列的元素:

int *a = malloc(sizeof(int) * n * m);
// a[i * m + j]
free(a);

但是有了 variably-modified type,我们就可以直接写成

int (*a)[n][m] = malloc(sizeof(*a));
// (*a)[i][j]
free(a);

确实简便了许多。注意,这种使用 VLA 的方式并没有在栈上分配任何的内存,也就没有栈溢出的风险,所以 C23 要求编译器必须支持这种写法,这也就是标准所说的

The support for VM types and VLAs with allocated storage durations is mandated.

总结

VLA 在内存和类型上都有它的特殊之处。它和 C 语言的许多特性一样,用好了能简化代码、提升效率,但用错了就会导致灾难。我们 CS100 的作业是不允许使用 VLA 的,因为我们相信对于大多数初学者来说,VLA 最大的好处就是能让他们写

int n;
scanf("%d", &n);
int a[n];

而并不了解这背后的内存管理和可能的危害。这种“能跑就行”的编程方式不是我们所鼓励的,而且它也很有可能因为栈内存不足而不能跑。


参考资料:

https://en.cppreference.com/w/c/language/array

Linux manpage of alloca

https://stackoverflow.com/questions/12407754/what-technical-disadvantages-do-c99-style-vlas-have/

https://stackoverflow.com/questions/22530363/whats-the-point-of-vla-anyway

https://stackoverflow.com/questions/1887097/why-arent-variable-length-arrays-part-of-the-c-standard/

  • 11
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值