Несколько слов в защиту VLA
Введение
Исходный вариант этого опуса я написал еще в 2019 году на другом ресурсе. Он планировался как вялый ответ на поток совершенно незаслуженной критики, направленной на такое свойство языка, как Variable Length Array (VLA). Поток обладал свойствами типичной эхо-камеры и пытаться противостоять ему было бесполезно. Все выглядело так, как будто VLA доживают свои последние деньки. Однако относительно недавно я с удивлением узнал, что выходящий вскорости стандарт C23 встал на ту же самую точку зрения, которой придерживаюсь в этом вопросе и я: в C23 поддержка VLA снова становится обязательной, а опциональной остается лишь возможность объявления локальных VLA.
Я, разумеется, ни в коем случае не претендую на то, что мои разглагольствования на эту тему сыграли какая-то роль в принятии этого эпохального решения. Состояние дел в С23 я упоминаю лишь потому, что оно воодушевило меня опубликовать здесь несколько причесанную версию этого текста, даже если теперь VLA не нуждаются в чьей-либо защите.
Что же такое VLA
Так повелось, что когда заходит речь о таком свойстве языка С, как Variable Length Array, на передний план быстро выходят рассуждения и споры об опасностях, связанных с созданием массивов заранее неизвестного размера (т.е. run-time размера) в автоматической памяти, т.е., как ещё принято говорить, в стеке. Зачастую выпячивание этой возможности и кроющихся за ней подводных камней используется критиками VLA как ложная цель, водимая ради увода обсуждения от сути вопроса. Это вызывает недоумение, ибо по моему мнению возможность локального объявления таких массивов — это совершенно побочное и второстепенное свойство VLA, не играющее большой роли в их функциональности.
Ключевой особенностью VLA является совсем не возможность создания таких массивов в стеке, а введенное для поддержки VLA мощное расширение системы типизации языка: появление в языке С такой фундаментально новой концептуальной группы типов, как variably modified types. Для целей данного изложения я переведу этот термин как «вариабельные типы». Как будет проиллюстрировано ниже, практически все наиболее важные свойства и внутренние реализационные детали VLA привязаны именно к его типу, а не к самому объекту как таковому. Именно введение в язык вариабельных типов является пресловутым айсбергом VLA, в то время как возможность создавать объекты таких типов в автоматической памяти — это не более чем незначительная (и необязательная) верхушка этого айсберга.
Рассмотрим следующий пример. Пусть у нас в в программе следующим образом объявляется typedef-псевдоним A
int n = 10;
typedef char A[n];
/* `А` соответствует типу `char [10]` */
Спецификация языка требует, чтобы характеристики этого вариабельного типа, то есть количество элементов массива, описываемого типом A
, фиксировалось в тот момент, когда управление проходит по данному typedef-объявлению. Слово «фиксировалось» в данном случае означает то, что изменение значения переменной n
после объявления псевдонима A
уже не должно влиять на характеристики типа A
. Например, оператор sizeof
, будучи примененным к A
или к объекту типа A
обязан вернуть именно 10.
/* Продолжая вышеприведенный код */
n = 42;
/* Тем не менее здесь `A` по-прежнему соответствует типу `char [10]` */
Но каким образом реализация может соблюсти эти требования? Понятно, что для этого в общем случае придется тем или иным образом сохранять значение, которая переменная n
имела в тот момент, когда управление проходило по typedef-объявлению A
. Это означает, что с вариабельным типом A
будет ассоциирована скрытая внутренняя переменная, описывающая размер A
. Эта скрытая переменная инициализируется в момент прохода управления по typedef-объявлению. В дальнейшем размер типа A
будет вычисляться именно на основе этой внутренней переменной, т.е. что там далее будет происходить с n
уже не будет играть никакой роли.
Это сразу же наделяет данное typedef-объявление доселе невиданном свойством — оно порождает выполнимый код, т.е. код, который в данном примере будет сохранять значение n
в некоей скрытой внутренней переменной. Обратите внимание: typedef-объявление, которое порождает выполнимый код! До появления VLA такого не было ни в С, ни в С++.
Более того, этот код — это не просто выполнимый код, это код, выполнение которого критически необходимо для правильной функциональности вариабельного типа. Если мы каким-то образом сумеем попасть в область видимости псевдонима A
в обход его объявления, то этой самой скрытой внутренней переменной не будет назначено никакого осмысленного значения и вариабельный тип A
будет вести себя некорректно. По этой причине в языке C появляется сопутствующее (и тоже доселе невиданное) ограничение: язык С запрещает передачу управления извне области видимости сущности вариабельного типа внутрь этой области видимости. В качестве «сущности» может выступать как объявление псевдонима для вариабельного типа, так и объявление объекта вариабельного типа
int n = 10;
goto skip; /* ОШИБКА: недопустимая передача управления */
typedef char A[n];
skip:;
Подчеркну еще раз, что в вышеприведенном примере нет объявления VLA-массива, а присутствует лишь объявление typedef-псевдонима для вариабельного типа. Однако передача управления через такое typedef-объявление не допускается.
Многие из вас сразу же заметят в этом ограничении явную аналогию с очень похожим ограничением языка С++, где не допускается передача управления через объявление переменной с явным инициализатором или с нетривиальным конструктором. В данном случае можно сказать, что typedef-объявления для вариабельных типов в языке С обладают «нетривиальными конструкторами» и передача управления в обход вызовов таких «конструкторов» запрещена в языке С в соответствии с той же самой логикой.
Когда/если дело доходит до определения фактического локального VLA-массива, происходит выделение памяти для элементов самого массива. Так как это VLА, выделение памяти будет выполняться каким-то run-time механизмом, наподобие alloca
, а сам массив физически будет представлен указателем на эту память. (Разумеется, тут есть простор для оптимизаций: нет необходимости использовать полноценный указатель там, где достаточно лишь смещения в кадре стека, но речь не об этом.) Это, как несложно видеть, тоже является «нетривиальным конструктором», в обход которого тоже ни в коем случае нельзя передавать управление.
Например, вот такой код
int n = 10, m = 20;
typedef int A[n][m];
A a;
a[5][6] = 42;
будет транслирован компилятором в нечто подобное следующему
int n = 10, m = 20;
size_t _internal_A1 = n, _internal_A2 = m;
int *a = alloca(_internal_A1 * _internal_A2 * sizeof(int));
a[5 * _internal_A2 + 6] = 42;
(Конечно же, в дополнение к этому компилятору необходимо сгенерировать код для освобождения выделенной памяти по завершении охватывающего блока, ибо VLA подчиняются тем же правилам времени жизни, что и обычные автоматические переменные).
Однако и в таком случае следует понимать, что эти скрытые переменные, хранящие размеры массива a
в данном примере, ассоциированы не столько с самим объектом a
, сколько с его вариабельным типом int [n][m]
. И если в коде объявлено несколько VLA-массивов одинакового типа, они в принципе могут пользоваться одними и теми же скрытыми переменными для хранения своих размеров
int a[n][m], b[n][m], c[n][m];
/* Не нужно заводить 6 скрытых переменных, ибо достаточно двух */
Такой подход к реализации вариабельных типов обладает тем замечательным свойством, что дополнительная информация о размерах, ассоциированная с VLA-массивом, не является встроенной в объектное представление самого массива, а хранится «где-то рядом», совершенно отдельным, независимым образом. Это приводит к тому, что объектное представление VLA с любым количеством измерений является полностью совместимым с объектным представлением классического «фиксированного» массива с тем же количеством измерений и теми же размерами. Например
unsigned n = 5;
int a[n][n + 1][n + 2]; /* VLA */
int (*p)[5][6][7]; /* Указатель на "классический" массив */
p = &a; /* Присваивание корректно */
(p)[1][2][3] = 42; / Поведение определено: a[1][2][3] получает значение 42 */
Или, на примере часто возникающей необходимости передачи массива в функцию
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(vla_a);
int classic_a[5][6][7];
foo(5, 6, 7, classic_a);
}
Вызовы функций в вышеприведенной программе являются совершенно корректными и их поведение полностью определено языком, несмотря на то, что мы передаем VLA туда, где как будто ожидается классический «фиксированный» массив, и наоборот, передаем классический массив туда, где как будто ожидается VLA. Понятно, что компилятор в такой ситуации не имеет возможности полностью контролировать правильность вызовов, т.е. не имеет возможности проверять совпадение фактических размеров массивов-параметров и массивов-аргументов (хотя, при желании, возможность сгенерировать проверочный код в отладочном режиме есть и у него, и у пользователя).
(Примечание: да, я знаю, что в С не бывает параметров типа «массив». Параметры типа «массив», независимо от того, являются ли они VLA или нет, всегда неявно трансформируются в параметры типа «указатель», что означает, что в вышеприведенном примере параметр a
на самом деле имеет тип int (*)[m][k]
и значение n
на его тип не влияет. Я специально добавил побольше измерений массивам, чтобы не потерять их вариабельные свойства.)
Совместимость также поддерживается тем, что при передаче VLA в функции синтаксис языка заставляет автора кода волей-неволей передавать информацию и о размерах массива тоже. В вышеприведенном примере автор кода был вынужден первым делом перечислить в списке параметров функции foo
параметры n
, m
и k
, ибо без них он бы не смог объявить параметр a
. Именно эти явно передаваемые пользователем параметры и «принесут» в функцию информацию о фактическом размере массива a
.
Пользуясь вышеописанными свойствами VLA мы можем написать, например, следующую программу
#include
#include
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, в ней не создается ни одного VLA в локальной памяти. То есть это популярное направление критики VLA здесь совершенно не применимо.
Как я уже заметил выше, стандарт C23 планирует поддержать такое использование VLA. Поддержка вариабельных типов в C23 вновь становится обязательной, в то время как поддержка возможности объявления самих автоматических VLA-массивов остаётся опциональной.
Несколько сопутствующих замечаний о VLA
Благодаря наличию в языке VLA появляется возможность воздвигать загадочные конструкции, практическая ценность которых сомнительна и поведение которых не всегда очевидно. Например, допустимы такие варианты объявления функций
int n = 100; void foo(int a[n++]) {} void bar(int m, int a[++n][++m]) {} void baz(int a[printf("Hello World\n")]) {}
Выражения, использованные в объявлениях VLA-параметров в определениях функций, будут честно вычисляться (вместе со своими побочными эффектами), при каждом вызове функции. Обратите внимание, что несмотря на тот факт, что параметры типа «массив» будут трансформированы в параметры типа «указатель», это не отменяет требования вычисления выражения, использовавшегося для задания размера массива в исходном объявлении. В данном примере каждый вызов функции
baz
будет сопровождаться выводом строки"Hello World\n"
.Объявления VLA-массивов не допускают указания инициализаторов, что также предотвращает использование VLA в составных литералах
int n = 10; int a[n] = { 0 }; /* ОШИБКА: нельзя указывать инициализатор */
Предположу, что причина такого ограничения заключается в том, явно указанные инициализаторы для VLA могут нарушать одно из старинных правил языка, поступиться которым комитет по стандартизации пока не готов.
No initializer shall attempt to provide a value for an object not contained within the entity being initialized.
Да и привело бы это к тому, что во время выполнения инициализации пришлось бы предпринимать run-time усилия для «отсечения» тех явно указанных инициализаторов, которые оказались за пределами массива. Это потенциально могло бы приводить к относительно громоздкому инициализационному коду.
Тем не менее C23 добавляет в язык поддержку пустого инициализатора
{}
, который будет применим в том числе и к VLA. Понятно, что такой инициализатор не создает упомянутых проблем.
Отступление: VLA в C++
В популистских контекстах зачастую можно услышать утверждения о том, что в некоторых компиляторах (читай: GCC) вариабельные типы и VLA поддерживаются и в С++ коде. На самом деле тот факт, что некоторые компиляторы в некоем расширенном С++ режиме разрешают указывать неконстантные выражения в качестве размеров автоматических массивов, совсем не свидетельствует о поддержке именно С-подобных VLA на территории С++ кода. С и С++ — существенно разные языки, и поддержка С-подобных VLA в С++ затруднительна.
Простые эксперименты показывают, что поведение нынешних псевдо-VLA в GCC C++ фундаментально отличается от стандартного поведения VLA в языке С. Например вот такая программа
#include
int main(void)
{
int n = 10;
typedef char A[n];
n = 20;
A a;
printf("%zu %zu\n", sizeof(A), sizeof a);
}
выведет 10 10
в режиме С (как и должно быть), но выведет 20 20
при компиляции в режиме GNU C++. При ближайшем рассмотрении можно заметить, что GNU C++ не производит сохранения размера массива в поcторонней переменной, а вместо этого продолжает извлекать его из переменной n
даже после того, как ее значение поменялось. Видать идея «typedef, порождающего выполнимый код» плохо согласуется с фундаментальным идеями языка С++.
Однако стоит нам чуть усложнить выражение, описывающее размер массива, как GNU C++ примет решение все таки завести дополнительную переменную
typedef char A[n++];
Если мы подправим таким образом объявление A
в вышеприведенной программе, то все таки получим на печать желанные 10 10
. Точных критериев, которыми руководствуется GNU C++ при принятии решения о заведении скрытой переменной, я не выяснял, но по-видимому наличие побочных эффектов и/или вызовов функций в выражении, задающем размер массива, приводит к её заведению.
При этом, однако, GNU C++ не беспокоится о том, чтобы предотвращать передачу управления через такое typedef-объявление. Если скрытая переменная таки была заведена, но передача управления произошла, эта скрытая переменная остается неинициализированной. В результате чего sizeof
такого типа будет возвращать «мусорное» значение.
Причины такого странного метания GNU C++ между решениями «заводить переменную» и «не заводить переменную» мне не ясны. Вариабельные типы в C++ являются расширением языка, то есть формально C++ код их использующий автоматически является нестандартным/некорректным. В таких условиях у GCC есть полная свобода действий в реализации поддержки VLA в C++ и введения любых сопутствующих ограничений. Почему GNU C++ не копирует С-подобную реализацию поддержки VLA — непонятно.