habrahabr

Defined or Undefined? Нюансы создания массивов в JavaScript

  • суббота, 10 августа 2019 г. в 00:20:04
https://habr.com/ru/post/463041/
  • JavaScript
  • Программирование
  • Алгоритмы
  • ООП


image

Пару месяцев назад я наткнулся на интересный вопрос на stackoverflow, там, если вкратце, человек хотел создать пустую матрицу 5х5, и, используя один способ у него получилось, а используя другой — нет. В развернувшейся дискуссии на этот счёт были приведены интересные мысли.

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

Прим.: я также ответил и под той дискуссией, под ником AndreyGS – там я ответил довольно кратко, здесь же я постараюсь до конца охватить проблему.

В общем так, перед нами стоит задача создать массив. Как же мы это будем делать? Как ни странно, есть разные варианты, в зависимости от того, что мы хотим получить.

Нам известно, что функции в JavaScript имеют два внутренних метода Call и Construct. Если мы используем ключевое слово new, то используется метод Construct, который создаёт новый экземпляр объекта, присваивает ему ссылку this , и, затем, выполняет тело функции. Не все функции имеют данный метод, но нам это сейчас не так уж и важно.

При создании же массивов есть одна особенность: не важно, используем мы Array(…) или new Array(…) — спецификация ECMAScript не делает различий для них и, кроме того, считает их эквивалентными.

22.1.1 The Array Constructor
The Array constructor is the %Array% intrinsic object and the initial value of the Array property of the global object. When called as a constructor it creates and initializes a new exotic Array object. When Array is called as a function rather than as a constructor, it also creates and initializes a new Array object. Thus the function call Array(…) is equivalent to the object creation expression new Array(…) with the same arguments.


Поэтому, и я не буду мудорствовать лукаво, и, в примерах буду использовать только конструкцию new Array(…), дабы не сбивать никого с толку.

Начнём.

Создаём массив:

let arr = new Array(5);

Что же у нас получилось?

console.log(arr); // Array(5) [ <5 empty slots> ]
console.log(arr[0]); // undefined
console.log(Object.getOwnPropertyDescriptor(arr,"0")); // undefined

Хм… ну, в принципе, так ведь и должно быть — мы задали длину и получили пять пустых ячеек, со значением undefined, с которыми можно работать дальше, верно? Правда, есть тут пара моментов, которые меня смущают. Давайте проверим.

let arr = new Array(5).map(function() { return new Array(5); });

console.log(arr); // Array(5) [ <5 empty slots> ]
console.log(arr[0]); // undefined
console.log(Object.getOwnPropertyDescriptor(arr,"0")); // undefined
console.log(arr[0][0]); // TypeError: arr[0] is undefined

Как же так, ведь мы должны были получить матрицу, и в каждой ячейке, соответственно, должен быть массив из 5 элементов…

Обратимся опять же к документации ECMAScript и посмотрим, что в ней написано касательно метода создания массивов с одним аргументом:

22.1.1.2 Array (len)
This description applies if and only if the Array constructor is called with exactly one argument.

1. Let numberOfArgs be the number of arguments passed to this function call.
2. Assert: numberOfArgs = 1.
3. If NewTarget is undefined, let newTarget be the active function object, else let newTarget be NewTarget.
4. Let proto be GetPrototypeFromConstructor(newTarget, "%ArrayPrototype%").
5. ReturnIfAbrupt(proto).
6. Let array be ArrayCreate(0, proto).
7. If Type(len) is not Number, then
1. Let defineStatus be CreateDataProperty(array, "0", len).
2. Assert: defineStatus is true.
3. Let intLen be 1.
8. Else,
1. Let intLen be ToUint32(len).
2. If intLen ≠ len, throw a RangeError exception.
9. Let setStatus be Set(array, "length", intLen, true).
10. Assert: setStatus is not an abrupt completion.
11. Return array.


И, что мы видим, оказывается объект создан, свойство length создано в процедуре ArrayCreate(6 пункт), значение в свойстве length проставлено (пункт 9), а что с ячейками? Про них ни слова… То есть длина == 5 есть, а пяти ячеек нет. Да, компилятор путает нас, когда мы пытаемся обратиться к отдельной ячейке, он выдаёт, что её значение undefined, тогда как её фактически нет.

Вот, для сравнения метод создания массивов с несколькими аргументами отправленными в конструктор:

22.1.1.3 Array (...items )
This description applies if and only if the Array constructor is called with at least two arguments.
When the Array function is called the following steps are taken:

1. Let numberOfArgs be the number of arguments passed to this function call.
2. Assert: numberOfArgs ≥ 2.
3. If NewTarget is undefined, let newTarget be the active function object, else let newTarget be NewTarget.
4. Let proto be GetPrototypeFromConstructor(newTarget, "%ArrayPrototype%").
5. ReturnIfAbrupt(proto).
6. Let array be ArrayCreate(numberOfArgs, proto).
7. ReturnIfAbrupt(array).
8. Let k be 0.
9. Let items be a zero-origined List containing the argument items in order.
10. Repeat, while k < numberOfArgs
1. Let Pk be ToString(k).
2. Let itemK be items[k].
3. Let defineStatus be CreateDataProperty(array, Pk, itemK).
4. Assert: defineStatus is true.
5. Increase k by 1.
11. Assert: the value of array’s length property is numberOfArgs.
12. Return array.


Здесь, пожалуйста — 10 пункт, создание тех самых ячеек.

Итак, как работает конструктор массивов мы разобрались, но задача осталась по прежнему не решенной, ибо матрица не построена. На помощь нам придет Function.prototype.apply()!
Давайте сразу проверим её в действии:

let arr = Array.apply(null, new Array(5));

console.log(arr); // Array(5) [ undefined, undefined, undefined, undefined, undefined ]
console.log(arr[0]); // undefined
console.log(Object.getOwnPropertyDescriptor(arr,"0")); // Object { value: undefined, writable: true, enumerable: true, configurable: true }

Ура, здесь отчетливо наблюдаются все пять ячеек, а также у первой, тестовой, ячейки под номером “0” появился дескриптор.

В данном случае программа работала следующим образом:

  1. Мы вызвали метод Function.prototype.apply() и передали ему контекст null, а в качестве массива new Array(5).
  2. new Array(5) создал массив без ячеек, но с длиной 5.
  3. Function.prototype.apply() использовала внутренний метод разбития массива на отдельные аргументы, в результате чего, передала конструктору Array пять аргументов со значениями undefined.
  4. Array получив 5 аргументов со значениями undefined, добавил их в соответствующие ячейки.

Всё вроде понятно, кроме того, что же это за внутренний метод у Function.prototype.apply(), который из ничего делает 5 аргументов — предлагаю опять взглянуть на документацию ECMAScript:

19.2.3.1 Function.prototype.apply

1. If IsCallable(func) is false, throw a TypeError exception.
2. If argArray is null or undefined, then Return Call(func, thisArg).
3. Let argList be CreateListFromArrayLike(argArray).

7.3.17 CreateListFromArrayLike (obj [, elementTypes] )

1. ReturnIfAbrupt(obj).
2. If elementTypes was not passed, let elementTypes be (Undefined, Null, Boolean, String, Symbol, Number, Object).
3. If Type(obj) is not Object, throw a TypeError exception.
4. Let len be ToLength(Get(obj, "length")).
5. ReturnIfAbrupt(len).
6. Let list be an empty List.
7. Let index be 0.
8. Repeat while index < len
a. Let indexName be ToString(index).
b. Let next be Get(obj, indexName).
c. ReturnIfAbrupt(next).
d. If Type(next) is not an element of elementTypes, throw a TypeError exception.
e. Append next as the last element of list.
f. Set index to index + 1.
9. Return list.


Смотрим самые интересные пункты:

19.2.3.1 — пункт 3: создание списка аргументов из объекта подобного массиву (как мы помним у таких объектов должно быть свойство длины).

7.3.17 — непосредственно сам метод создания списка. В нём идёт проверка на то, объект это или нет, и, если да, запрос значения поля length (пункт 4). Затем создается индекс, равный “0” (пункт 7). Создаётся цикл с инкрементацией индекса до значения взятого из поля length (пункт 8). В этом цикле идёт обращение к значениям ячеек переданного массива с соответствующими индексами (пункт 8a и 8b). А как мы помним, при обращении к значению отдельной ячейки массива в котором фактически нет ячеек всё равно выдаёт значение — undefined. Полученное значение добавляется в конец списка аргументов (пункт 8e).

Ну, а теперь, когда, всё встало на свои места, можно спокойно построить уже ту самую пустую матрицу.

let arr = Array.apply(null, new Array(5)).map(function(){ return Array.apply(null,new Array(5)); });

console.log(arr); // Array(5) [ (5) […], (5) […], (5) […], (5) […], (5) […] ]
console.log(arr[0]); // Array(5) [ undefined, undefined, undefined, undefined, undefined ]
console.log(Object.getOwnPropertyDescriptor(arr,"0")); // Object { value: (5) […], writable: true, enumerable: true, configurable: true }
console.log(arr[0][0]); // undefined
console.log(Object.getOwnPropertyDescriptor(arr[0],"0")); // Object { value: undefined, writable: true, enumerable: true, configurable: true }

Теперь, как можно заметить, всё сходится и довольно просто выглядит: мы, известным нам уже способом, создаём простой пустой Array.apply(null, new Array(5)) массив а затем передаём его методу map, который создаёт по такому же массиву в каждой из ячеек.

Кроме того, можно сделать ещё проще. В ECMAScript6 появился оператор spread , и, что характерно, он также специфически работает с массивами. Поэтому, мы можем просто вбить:

let arr = new Array(...new Array(5)).map(() => new Array(...new Array(5)));

или уж совсем упростим, хоть я ранее и обещал new не трогать…

let arr = Array(...Array(5)).map(() => Array(...Array(5)));
прим.: здесь мы также использовали стрелочные функции, так как раз мы всё равно имеем дело со spread оператором, который появился в той же спецификации, что и они.

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

Кроме того, мы, естественно, можем построить свои функции, которые подобным Function.prototype.apply() перебором, будут создавать для нас нормальные массивы с пустыми ячейками, однако же понимание внутренних принципов работы JavaScript и, соответственно с этим, правильное и адекватное использование встроенных функций, является базисом, освоить который приоритетно. Ну, и, конечно, так просто быстрее и удобнее.

И, напоследок, возвращаясь к тому самому вопросу на stackoverflow – там, я напомню, человек ошибочно посчитал, что полученный им метод привёл к правильному ответу, и, что он получил матрицу 5х5, однако — там закралась маленькая ошибка.

Он вбил:

Array.apply(null, new Array(5)).map(function(){
return new Array(5);
});


Как думаете, какой здесь будет на самом деле результат?

Ответ
console.log(arr); // Array(5) [ (5) […], (5) […], (5) […], (5) […], (5) […] ]
console.log(arr[0]); // Array(5) [ <5 empty slots> ]
console.log(Object.getOwnPropertyDescriptor(arr,«0»)); // Object { value: (5) […], writable: true, enumerable: true, configurable: true }
console.log(arr[0][0]); // undefined
console.log(Object.getOwnPropertyDescriptor(arr[0],«0»)); // undefined

неправда ли, это не совсем то, что он хотел получить…

Ссылки:

→ ECMAScript 2015 Language Specification
What is Array.apply actually doing