Будьте благодарны за массивы JavaScript: сравнение с языком C

Для многих из нас массивы JavaScript являются первой структурой данных, с которой мы знакомимся. И для них существует так много способов применения. Но задумывались ли вы, как они работают “под капотом”? Прочтите эту статью, чтобы разобраться в этом и, возможно, еще больше полюбить JavaScript.

Экспозиция в музее USS Midway: пример того, как развивались носители памяти

Изучение того, как массивы и простые методы типа .push() работают в языках более низкого уровня, может значительно углубить понимание JavaScript и вызвать признательность за наличие в нем массивов. А что может быть лучше, чем показать это на примере языка C, которым практически управляется наш мир?

Рассмотрим, как можно реализовать на C нечто подобное .push(). Обсудим основные различия между C и JavaScript, сравним управление памятью и область динамически распределяемой памяти с памятью стека в C, а также обратим внимание на типы массивов в C, переменные и указатели. Возможно, в конце вы по-новому оцените скромный метод массива JavaScript .push().

Общие сведения

Вот ключевые различия между JavaScript и C.

  • JavaScript считается языком высокого уровня, то есть он обеспечивает множество абстракций между нами (разработчиками) и машиной. Это отличный повод напомнить о том, что языки высокого уровня способны выполнять много работы за нас.
  • Язык C требует ручного управления памятью, а JavaScript обеспечивает автоматическое. Подробнее об этом позже.
  • Язык C должен быть скомпилирован заранее, тогда как JavaScript компилируется непосредственно перед выполнением. Такую компиляцию часто называют “оперативной”.
  • Массивы в C могут содержать один тип данных (char, int, float и т.д.), а в JavaScript массивы могут включать смешанные типы данных, такие как строки, числа и булевы значения. Впрочем, массивы в C также можно заставить хранить данные разных типов, проделав определенные операции.

Простота разработки на языках высокого уровня часто достигается за счет производительности. Язык C обычно используется для системного программирования и встраиваемых систем, где большее значение имеет скорость, в то время как JavaScript обычно применяется в браузерах и (с недавних пор) на серверах с использованием Node.

Все, что можно сделать на JS, можно сделать и на C, но с большим трудом! Основными преимуществами языков более высокого уровня являются инструменты “из коробки”, а также простота кросс-платформенного развертывания.

Посмотрим, как можно реализовать вставку элемента в массив в обоих языках.

JavaScript:

const myArr = [1,2,3,4,5];
myarr.push(6);

console.log(myArr);
//[1,2,3,4,5,6]

С:

#include <stdio.h>
#include <stdlib.h>

int main(void) {
  char sizeOfIntegerPointer = sizeof(int);
  int *myArr = malloc(sizeOfIntegerPointer * 5);

  if (myArr == NULL) {
    printf("There was an error allocating memory for the array");
    exit(EXIT_FAILURE);
  } 

  for(int i = 0; i < 5; i++) {
    myArr[i] = i + 1;
  }

  int *myArrExpanded = realloc(myArr, sizeOfIntegerPointer * 6);

  if (myArrExpanded == NULL) {
    printf("There was an error reallocating memory for the array");
    exit(EXIT_FAILURE);
  } 

  myArrExpanded[5] = 6;

  for(int i = 0; i < 6; i++) {
    printf("%d", myArrExpanded[i]);
  }

  free(myArrExpanded);

  return 0;
}

Гораздо больше кода! Похоже, JavaScript упрощает некоторые действия, но какие именно? Чтобы понять это, нужно погрузиться в несколько концепций. Начнем с того, как устроена память в программах на C и JavaScript.

Распределение памяти

Стек

Во время работы программ на языках C и JavaScript память, к которой они имеют доступ, разделена на несколько областей. Одна из этих областей известна как стек вызовов, в котором хранятся вызовы функций и переменные в их области видимости.

Можно представить стек вызовов как стопку панкейков. А чтобы было еще вкуснее, допустим, что на каждом панкейке есть сироп и шоколадные чипсы. Панкейки легко укладывать друг на друга. Куда сложнее убрать, скажем, третий из стопки. Если, конечно, не поднимать по два панкейка за раз, а другой рукой брать третий. Но тогда вы рискуете уронить их все и разлить повсюду сироп!

Именно поэтому стопки формируются по методу “последним вошел  —  первым вышел”. Так, если положить в стопку 4 панкейка, то чтобы добраться до второго, сначала нужно будет убрать четвертый и третий.

Удаление панкейков из стопки для доступа к первому

Когда в программе вызываются функции, локальные переменные внутри функции и адрес возврата вызывающей функции, известные как стековый фрейм, помещаются в стек вызовов. После завершения работы функции стековый фрейм удаляется из стека, и все переменные, которые были объявлены в этой функции, удаляются.

Вот детальный пример диаграммы стека вызовов в действии в JavaScript.

Как правило, объем памяти, выделенный для стека вызовов и каждого стекового фрейма, вычисляется при компиляции программы и не может быть изменен во время выполнения. Тем не менее можно выделить больше памяти в стеке во время выполнения программы с помощью функции alloca в C, но обычно этого делать не рекомендуется.

Основная идея заключается в следующем: если вам нужно, чтобы данные хранились между вызовами функций, и/или объем памяти, необходимый во время выполнения программы, не известен заранее, необходимо использовать другую, более постоянную область памяти.

Динамическая память

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

Переменные, размещаемые в динамической памяти, не уничтожаются в конце вызова функции, как это происходит в стеке. Эти переменные хранятся до тех пор, пока мы их не освободим. В языках низкого уровня мы вручную освобождаем эти объекты, когда заканчиваем их использовать. Но в языках более высокого уровня, таких как JavaScript, есть встроенный “санитар”, называемый сборщиком мусора, который автоматически выбрасывает эти переменные в мусорную корзину, когда программа заканчивает их использовать.

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

Примечание: существуют и другие области памяти программы на языке C. Они перечислены здесь.

Теперь рассмотрим некоторые из различных типов массивов в C.

Массивы

Не будем много говорить о различных типах массивов в JavaScript, потому что они  —  мастера на все руки! Они динамичны, то есть могут уменьшаться, увеличиваться и быть смешанных типов. Они “всеядны” в мире массивов, что справедливо для многих языков высокого уровня. Простые массивы в C  —  это совсем другой зверь.

Вот пример простого массива целых чисел на языке C. Посмотрите на верхнюю строку. Эти числа означают местоположение элемента в памяти. Вы можете использовать эти места в памяти с помощью указателей, о которых будет рассказано ниже.

Массивы фиксированной длины в C

Массивы фиксированной длины определяются во время компиляции. Их размеры не могут изменяться во время выполнения и должны быть известны до компиляции программы. Так, int arr[5] = {1,2,3,4,5} будет оставаться целочисленным массивом из 5 элементов в течение всего времени работы программы.

Массивы переменной длины в C

Массивы переменной длины были введены в C99 (выпущенном в 1999 году, через 27 лет после создания языка C). Их размеры могут быть изменены во время выполнения программы. Например:

int array_len;

printf("Enter desired length of array: ");

// Оператор ‘&’ в языке C означает “адрес массива”. Мы подробнее коснемся этого ниже
scanf("%d", &array_len);

int arr[array_len];

Этот код на языке C принимает ввод от пользователя и создает размер массива на основе его ввода. Длина массива переменной длины не обязательно должна быть известна до выполнения программы, но после инициализации его размер не может быть изменен.

Пользователь может ввести число, чтобы определить длину такого массива в C, но после этого размер не может меняться. Это немного ближе к тому, что нам нужно реализовать, но все равно не позволит добавить элемент в массив.

Динамические массивы в C

Как вы уже догадались, размеры динамических массивов можно изменять во время работы программы. Это возможно, потому что память, которую они используют, выделяется в области динамической памяти. А это значит, что при необходимости можно запросить больше памяти у ОС во время выполнения программы. Но важно отметить, что запрос памяти у ОС является затратной операцией, поскольку для ее выделения во время выполнения программы необходимо проделать различные шаги. Таким образом, у обоих типов массивов есть свои компромиссы и варианты использования.

Теперь вернемся к начальному примеру кода на языке C:

#include <stdio.h>
#include <stdlib.h>

int main(void) {
  // Запрос памяти у ОС
  // Привет, ОС! Прими запрос на память, в 5 раз превышающую размер целого числа 
  char sizeOfIntegerPointer = sizeof(int);
  int *myArr = malloc(sizeOfIntegerPointer * 5);

 // Убедитесь, что распределение памяти прошло успешно
  if (myArr == NULL) {
    printf("There was an error allocating memory for the array");
    exit(EXIT_FAILURE);
  }

 // Инициализируйте значения массива
  for(int i = 0; i < 5; i++) {
    myArr[i] = i + 1;
  }

 // Переместите значения массива в новую, расширенную область памяти с помощью realloc
  int *myArrExpanded = realloc(myArr, sizeOfIntegerPointer * 6);

  if (myArrExpanded == NULL) {
    printf("There was an error reallocating memory for the array");
    exit(EXIT_FAILURE);
  } 

  myArrExpanded[5] = 6;

  // Выведите значения 
  for(int i = 0; i < 6; i++) {
    printf("%d", myArrExpanded[i]);
  }

  // Освободите память 
  free(myArrExpanded);

  return 0;
}

Я оставил комментарии к каждой части кода, которую считал важной, и написал дополнительные пояснения ниже.

Запрос памяти у ОС

Вызов функции malloc (выделение памяти), вероятно, самый нервирующий фрагмент этого кода, но на самом деле все не так уж плохо! malloc принимает один параметр: сколько памяти в байтах нужно запросить у ОС.

Разные типы данных занимают разные размеры (например, 4 байта для целого числа, 1 байт для символьного типа и т.д.). Поэтому можно использовать функцию sizeof, чтобы определить, сколько байт нужно для конкретного типа данных, и умножить это на длину желаемого массива. Если нужен массив из 5 элементов, достаточно умножить 5 на размер целого числа, чтобы получить место для массива из 5 целых чисел!

Вы можете подумать, что результат malloc возвращает массив. Это кажется правдоподобным, учитывая использование в последующих циклах for! Но на самом деле возвращается указатель. Указатель  —  это просто переменная, которая хранит область памяти.

Рассмотрим более простой пример:

Здесь представлен массив из 5 целых чисел. Каждый элемент имеет определенный адрес в памяти. Условно говоря, это его домашний адрес. На изображении видно, что значение arr[0] равно 1, поскольку именно это значение мы присвоили первому элементу. Адрес, по которому живет 1, хранится в myPointer.

Таким образом, в исходном примере результат malloc вернул бы указатель на первый элемент массива.

Зачем использовать указатели? И зачем повторно использовать звездочку, если она уже означает умножение? Хорошие вопросы! К сожалению, у меня нет ответа на последний, но есть ряд причин для использования указателей.

Например, в JavaScript при передаче объектов функциям мы передаем так называемую ссылку на этот объект. В языке C нет такого понятия, как ссылка. Здесь единственный способ передать конкретный массив целых чисел конкретной функции и изменить его без создания копии  —  использовать указатели.

Проверка успешности процесса распределения

Когда мы запрашиваем память у ОС, есть вероятность, что у ОС окажется недостаточно памяти. Поэтому всегда нужно проверять, что запрос прошел успешно, что мы и делаем с помощью оператора if. Если распределение не удалось, мы выходим из программы с кодом ошибки.

Перемещение значений массива в более крупную область памяти

Функция realloc перераспределяет память, ранее выделенную с помощью malloc, на новый размер, указанный во втором параметре. Она скопирует все данные в новую, расширенную область памяти. Так мы можем получить динамические массивы и в более широком смысле динамически распределяемую память.

Присвоение и освобождение памяти

Затем мы можем безопасно присвоить значение шестому элементу массива и, наконец, освободить перераспределенную память, чтобы предотвратить утечку памяти, поскольку мы больше не используем ее.

Напомню, что память стека вызовов освобождается после завершения работы функции, но с областью динамической памяти дело обстоит иначе. В C объекты в динамической памяти могут и будут оставаться там до тех пор, пока вы вручную не высвободите их с помощью free.

Сравнение с JavaScript

Расширив представление о том, как изменяется размер массивов в C, посмотрим еще раз на код JS:

const myArr = [1,2,3,4,5];
myarr.push(6);

console.log(myArr); 
//[1,2,3,4,5,6]

Только в первой строке мы запрашиваем память у ОС, причем без фиксированного размера. Нет необходимости вручную распределять память и проверять, было ли распределение успешным.

Как только мы поместим 6 в массив, нам не нужно беспокоиться о перераспределении памяти, которая была распределена ранее. В JavaScript переменные объявляются в стеке и в области динамической памяти, но вам, как программисту, не нужно об этом беспокоиться  —  язык делает это за вас.

Только представьте, насколько утомительным может оказаться этот процесс в C, если повторять его снова и снова в сложном приложении.

Заключение

Мы лишь поверхностно ознакомились с каждой из затронутых тем. И все же надеюсь, что эти знания позволят вам лучше понять языки высокого уровня в целом. Теперь, используя .push(), вы сможете оценить тот объем работы, который JS делает за вас.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Josh Melo: Be Grateful for JavaScript Arrays: A Comparison with C

Предыдущая статьяАнализ работы Guess.js в приложении Angular 
Следующая статья5 тегов HTML, о которых вы могли не знать