Несколько слов в защиту VLA

2b2d51537004d91eb868c640988d1a61

Введение

Исходный вариант этого опуса я написал еще в 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 — непонятно.

© Habrahabr.ru