Какой смысл VLA в любом случае?

Я понимаю, какие массивы переменной длины и как они реализованы. Этот вопрос касается того, почему они существуют.

Мы знаем, что VLA разрешены только внутри функциональных блоков (или прототипов) и что они в принципе не могут быть нигде, кроме стека (при условии нормальной реализации): C11, 6.7.6.2-2:

Если идентификатор объявлен как имеющий измененный тип, он должен быть обычным идентификатор (как определено в 6.2.3), не имеют привязки и имеют либо масштаб или функцию блока прототип. Если идентификатор объявлен как объект со статическим или потоковым хранилищем длительность, он не должен иметь тип массива переменной длины.

Возьмем небольшой пример:

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

есть два случая, о которых нужно позаботиться:

  • n <= 0: f должен защититься от этого, в противном случае поведение undefined: C11, 6.7.6.2-5 (основное внимание):

    Если размер является выражением, которое не является целочисленным постоянным выражением: если оно встречается в объявлении в области прототипа функции, обрабатывается так, как если бы он был заменен на *; в противном случае, каждый раз, когда он оценивается, он должен иметь значение больше нуля. Размер каждого экземпляра типа массива переменной длины не изменяется в течение его срока службы. Где размер выражение является частью операнда оператора sizeof и изменяет значение выражение размера не повлияет на результат оператора, не определено, независимо от того, выражение размера оценивается.

  • n > stack_space_left / element_size: Нет стандартного способа определения того, сколько осталось пространства стека (поскольку нет такой вещи, как стек, если речь идет о стандарте). Таким образом, этот тест невозможно. Единственное разумное решение состоит в том, чтобы иметь предопределенный максимально возможный размер для n, скажем n, чтобы убедиться, что переполнение стека не происходит.

Иными словами, программист должен убедиться, что 0 < n <= N для некоторого n выбора. Тем не менее, программа должна работать для n == N в любом случае, поэтому можно также объявить массив с постоянным размером n, а не переменной длиной n.

Мне известно, что VLAs были введены для замены alloca (как упоминалось в этом ответе), но в действительности они - одно и то же (присвойте переменный размер память в стеке).

Итак, вопрос в том, почему существует alloca и, следовательно, существует VLA и почему они не устарели? Единственный безопасный способ использования VLA кажется мне ограниченным, и в этом случае принятие нормального массива с максимальным размером всегда является жизнеспособным решением.

Ответ 1

По причинам, которые мне не совсем понятны, почти каждый раз, когда в обсуждении появляется тема C99 VLA, люди начинают говорить в основном о возможности объявления массивов времени выполнения как локальных объектов (т.е. их создания) в стеке. "). Это довольно удивительно и вводит в заблуждение, поскольку этот аспект функциональности VLA - поддержка объявлений локальных массивов - оказывается довольно вспомогательной, вторичной возможностью, предоставляемой VLA. Это на самом деле не играет существенной роли в том, что может сделать VLA. Большую часть времени вопрос о локальных декларациях VLA и связанных с ними потенциальных подводных камнях выдвигается критиками VLA, которые используют его в качестве "соломенного человека", намеревающегося сорвать дискуссию и увязнуть в ней между едва уместными деталями.

Суть поддержки VLA в C - это, прежде всего, революционное качественное расширение языковой концепции типа. Он предполагает введение таких принципиально новых типов типов, как изменяемые типы. Практически каждая важная деталь реализации, связанная с VLA, фактически связана с ее типом, а не с объектом 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 имеет в "классическом" языке. (Это также создает значительные препятствия на пути принятия VLA в C++.)

Когда объявляется фактический объект 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 и "классическими" массивами в качестве параметров функции также поддерживается тем фактом, что компилятору не нужно сопровождать изменяемый параметр с какой-либо дополнительной скрытой информацией о его размере. Вместо этого синтаксис языка заставляет пользователя передавать эту дополнительную информацию в открытую. В приведенном выше примере пользователь был вынужден сначала включить параметры n, m и k в список параметров функции. Без предварительного указания n, m и k пользователь не смог бы объявить 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, чтобы заменить уродливые хаки, которые ранее использовались вместо них. Тем не менее, в приведенной выше программе в локальной памяти даже не создается ни одного VLA, что означает, что этот популярный вектор критики VLA вообще не применим к этому коду.

По сути, два последних примера, приведенные выше, являются краткой иллюстрацией смысла поддержки VLA.

Ответ 2

Глядя на комментарии и ответы, мне кажется, что 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;
}

Ответ 3

Несмотря на все упомянутые вами вопросы о VLA, лучшая часть VLA заключается в том, что компилятор автоматически обрабатывает управление хранением и сложность индексных вычислений массивов, границы которых не являются константами времени компиляции.
Если вам требуется локальное распределение динамической памяти, единственным вариантом является VLA.

Я думаю, что это может быть причиной того, что VLA принимается на C99 (необязательно на C11).


Одна вещь, которую я хочу прояснить, есть некоторые замечательные различия между alloca и VLA. Этот пост указывает на различия:

  • Возврат памяти alloca() действителен до тех пор, пока текущая функция сохраняется. Срок службы памяти, занимаемой VLA, действителен до тех пор, пока идентификатор VLA остается в области видимости.
  • Вы можете alloca() сохранить память в цикле, например, и использовать память вне цикла, VLA исчезнет, ​​потому что идентификатор выходит из области действия, когда цикл завершается.

Ответ 4

Кажется, что ваш аргумент состоит в том, что, поскольку нужно связать проверку размера VLA, почему бы просто не выделить максимальный размер и не выполнить распределение времени выполнения.

Этот аргумент игнорирует тот факт, что память является ограниченным ресурсом в системе, разделяемым между многими процессами. Память, распределенная в одном процессе, недоступна для любого другого (или, возможно, есть, но за счет замены на диск).

По тому же аргументу нам не нужно было бы malloc массива во время выполнения, когда мы могли бы статически распределять максимальный размер, который может понадобиться. В конце концов, изнурение кучи лишь немного предпочтительнее.