Ах, массивы JavaScript. Для многих из нас это первая структура данных, с которой мы знакомимся, и не зря! Есть так много способов, которыми мы можем использовать наши маленькие (а иногда и массивные) спископодобные структуры. Но вы когда-нибудь лежали в постели и просыпались от любопытства, как они работают под капотом? Нет, вы, наверное, нормальный человек! Но что касается остальных из вас, оставайтесь с нами, и у вас тоже может развиться более сильная любовь к JavaScript.

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

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

Фон

Вот некоторые ключевые различия между 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, потому что массивы 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: ");

// The ‘&’ operator in C means the “address of”. We’ll touch on this more below
scanf("%d", &array_len);
int arr[array_len];

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

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

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

Давайте теперь вернемся к примеру кода C с самого начала:

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

int main(void) {
  // Request memory from the OS
  // Hello OS! I'm requesting memory for 5 times the size of an integer
  char sizeOfIntegerPointer = sizeof(int);
  int *myArr = malloc(sizeOfIntegerPointer * 5);


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

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

  // Move the array values to a new, expanded memory location through 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;

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

  // Free up the memory
  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, если повторять его снова и снова в сложном приложении. Так что слава Богу за JavaScript!

Заключение

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