关于 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 constant1
, 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 在栈上开了一块运行时确定大小的内存,但是众所周知,并没有一个标准的方式去检验栈内存是否够用。这一点和堆内存不同,像 malloc
、calloc
这样的函数会在内存不足时返回空指针,用户代码可以得知这一信息,然后可以作适当的调整,程序仍然能正常运行;但我们没法检验栈内存是否够用,而一旦遭遇了栈溢出,程序基本上就会直接崩溃,没有回旋的余地。典型的可能造成栈溢出的行为就是递归的深度过大,所以在实际应用中对于递归函数的使用必须慎之又慎。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++ 代码充满了 decltype
、auto
和模板类型推导,还有 SFINAE 和其它模板元编程技术,到了 C++20 还有 concept
等等。举个例子:
template <typename T>
void fun(T &a, T &b) { /* ... */ }
int a[n], b[m];
fun(a, b);
现在 a
和 b
的类型分别是 int [n]
和 int [m]
,它们都需要到运行时才能确定。但是如果运行时发现 n != m
,那 a
和 b
就具有不同的类型,模板类型参数 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/