Defined or Undefined? Нюансы создания массивов в 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), а что с ячейками? Не считая частного случая, когда переданный аргумент не является числом, и, создаётся массив с единственной ячейкой «0» с соответствующим значением (пункт 7), про них ни слова… То есть длина == 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 пункт, создание тех самых ячеек.

    А теперь, что делает дальше Array.prototype.map()?

    22.1.3.15 Array.prototype.map ( callbackfn [ , thisArg ] )
    
        1. Let O be ToObject(this value). 
        2. ReturnIfAbrupt(O). 
        3. Let len be ToLength(Get(O, "length")). 
        4. ReturnIfAbrupt(len). 
        5. If IsCallable(callbackfn) is false, throw a TypeError exception. 
        6. If thisArg was supplied, let T be thisArg; else let T be undefined. 
        7. Let A be ArraySpeciesCreate(O, len). 
        8. ReturnIfAbrupt(A). 
        9. Let k be 0. 
        10. Repeat, while k < len 
            1. Let Pk be ToString(k). 
            2. Let kPresent be HasProperty(O, Pk). 
            3. ReturnIfAbrupt(kPresent). 
            4. If kPresent is true, then 
                1. Let kValue be Get(O, Pk). 
                2. ReturnIfAbrupt(kValue). 
                3. Let mappedValue be Call(callbackfn, T, «kValue, k, O»). 
                4. ReturnIfAbrupt(mappedValue). 
                5. Let status be CreateDataPropertyOrThrow (A, Pk, mappedValue). 
                6. ReturnIfAbrupt(status). 
            5. Increase k by 1. 
        11. Return A.

    Пункт 7 — создаётся копия исходного массива, в пункте 10 происходит len итераций по его элементам, и, в частности, в пункте 10.2 идёт проверка — есть ли конкретная ячейка в исходном массиве, чтобы затем, в случае успеха, выполнить маппинг (10.4) и создать соответствующую ячейку в копии — 10.4.5. Так как 10.2 даёт false по каждому из 5 проходов, ни одна ячейка в возвращаемой копии массива также не будет создана.

    Итак, как работает конструктор массивов и метод Array.prototype.map() мы разобрались, но задача осталась по прежнему не решенной, ибо матрица не построена. На помощь нам придет 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
    Поделиться публикацией

    Комментарии 28

      0
      Прост для полноты положу тут, как красиво создавать заполненные (неразрежённые) массивы без грязных хаков с апплаями:
      const matrix = new Array(5).fill(
        new Array(5).fill()
      )
        0

        По спеке вроде не надо нью у Array конструктора — поведение одинаковое — что без, что с.
        Еще проще:


        [...Array(5)].fill([...Array(5)])
          +4
          Ваш код — тоже некорректный:
            +2

            Ага, согласен, ну тогда:


            [...Array(5)].map(() => [...Array(5)])
              +2
              Этот код аналогичен, тому, что в статье.
              Array(...Array(5)).map(() => Array(...Array(5)));

              Просто вы использовали способ создания массива, через литерал, а не функцию.
              Но в целом, согласен — про литералы я сам в посте не упомянул и ваше дополнение вполне уместно.
        +4
        Прост для полноты положу тут, как красиво создавать заполненные (неразрежённые) массивы без грязных хаков с апплаями:
        const matrix = new Array(5).fill(
          new Array(5).fill()
        )


        Ваш код — некорректный:

          +2
          Да, согласен, так получится заполнять только примитивными значениями массивы, зря я замахнулся на матрицы, пусть останется только одномерный случай и без нью :). Для матриц придётся маппить (что уже не так компактно, но всё ещё прозрачнее хаков с эпплаями, и fill красивее, имхо, чем вариант со спредом у комментатора выше :))
          const matrix = Array(5).fill().map(
            () => Array(5).fill()
          )
            +1

            так тоже будет ожидаемый результат:


            const matrix = Array.from(Array(5), () => Array(5).fill())
              0
              Спасибо всем, кто дополнительно привел свои варианты и тем кто нашел в части из них ошибки — ваши комментарии прекрасно дополняют материал.
          0
          Прост для полноты положу тут, как красиво создавать заполненные (неразрежённые) массивы без грязных хаков с апплаями:

          const matrix = new Array(5).fill(
            new Array(5).fill()
          )

          Про то, что этот способ не вполне рабочий уже было сказано выше и вы в последующем комментарии уже сами свой вариант обновили, поэтому, хотелось бы остановиться на «хаках с апплаями».

          На мой взгляд приведенный в посте способ с apply() самый простой для применения в средах исполнения до ECMAScript 6. Кроме того, описание работы конструктора Array и метода Function.prototype.apply() (касательно разбивки объекта подобного массиву), даёт лучшее понимание, того как работают остальные, в том числе, новые, методы со схожим функционалом в части работы с подобными объектами.
          +3
          Мне кажется, в любом учебнике по JS написано, что
          const arr = new Array(5)
          создаёт массив без элементов, но с заданной длиной.
          Это будет эквивалентно такому коду:
          const arr = [];
          arr.length = 5;
            +1
            Да, согласен, написано. И, правильно, что вы заострили своё внимание на этом моменте — это лишнее напоминание о том, что для начала надо читать инструкцию по применению.
            Однако, прочитав определение в учебнике, мы не всегда сразу можем увидеть из этого все возможные следствия, и, если, к тому же, мы не знаем в точности их внутреннюю составляющую, самостоятельно не обращаясь к спецификациям. И, этот пост создавался, в том числе, для того, чтобы подчеркнуть важность понимания внутренних механизмов языка на котором мы пишем.
            0
            Не проще использовать специально сделанный для этого Array.from? Второй аргумент — как раз функция маппинга.
            let arr = Array.from(Array(5), () => new Array(5).fill());

            А так, про дырки вопрос известный. Для лучшей производительности лучше заполнить массивы данными того типа, что будут использоваться. Хотя бы нулями.
              +1
              diomas выше писал:
              так тоже будет ожидаемый результат:

              const matrix = Array.from(Array(5), () => Array(5).fill())

              Ваш код аналогичен.

              Кроме того, касательно Array.from() — я уже писал в комментарии выше — этот метод работает только в ECMAScript 6 и до сих пор не имеет поддержки в некоторых браузерах (Array.from()), метод же Function.prototype.apply() единственный, который может использоваться везде или практически везде, но прежде всего целью статьи было, показать через внутренний принцип работы некоторых функций то как JavaScript работает с массивами и подобными им объектами.
              Оператор spread, в частности, был, приведен в статье в качестве примера, в том числе, для того, чтобы показать, что не только Function.prototype.apply(), может проводить подобные фокусы. Использование других методов вроде from() и fill() конечно вполне себе законно и расширяет возможности, и где-то красоту, поэтому я, естественно согласен, что их можно и нужно использовать в соответствующих ситуациях.

              На счет производительности думаю вы правы, но мне кажется этот вопрос выходит за рамки данной статьи. Но в качестве «справочно» сойдёт :)
                +1
                ECMAScript 6 — это ECMAScript 2015, то есть ему уже 4 года. Да и полифилится Array.from() легко. На чистом ES5 сейчас пишут только откровенные ретрограды.
                  +1
                  Array.from()
                  Настольные
                  Возможность Chrome Firefox (Gecko) Internet Explorer Opera Safari
                  Базовая поддержка 45 32 (32) Нет Нет 9.0

                  Мобильные
                  Возможность Android Chrome для Android Firefox Mobile (Gecko) IE Mobile Opera Mobile Safari Mobile
                  Базовая поддержка Нет Нет 32.0 (32) Нет Нет Нет

                  И, я не призываю писать всё время на ES5, повторюсь: цель статьи была не в том, чтобы обязательно вывести в ней какой ультимативный способ для создания массивов/матриц, а для разъяснения принципов работы некоторых внутренних инструментов JavaScript. И ничего против использования Array.from(), Array.prototype.fill(), оператора spread и прочего, я не имею.

                    +1
                    Не смотрите на русский MDN — там во многом старая информация.

                    Array.from() browser compatibility

                    Для фич из новых версий ES куда удобнее ECMAScript Compatability Table (показывает только принципиально наличие, не гарантируя полностью правильную работу).
                      0
                      Спасибо, здесь вы правы, учту.
              0
              проблема этого кода
              let arr = new Array(5).map(function() { return new Array(5); });

              не в методе создания массива, а в методе `map`
              первый же абзац говорит что `map` не вызывает callback если значение в массиве не было инициализированно. Но про этом если присвоить элементу массива `undefined` это считается проинициализированным.

              var ar = new Array(5);ar[2] = void 0; ar.map((v,i) => console.log(v,i)) // undefined 2

              в итоге немного изменив код получим желаемый результат

              
              new Array(5).fill(void 0).map(() => new Array(5));
              
                0
                Получится
                /*
                […]
                0: Array(5) [ <5 empty slots> ]
                1: Array(5) [ <5 empty slots> ]
                2: Array(5) [ <5 empty slots> ]
                3: Array(5) [ <5 empty slots> ]
                4: Array(5) [ <5 empty slots> ]
                */

                а надо
                /*
                […]
                0: Array(5) [ undefined, undefined, undefined, … ]
                1: Array(5) [ undefined, undefined, undefined, … ]
                2: Array(5) [ undefined, undefined, undefined, … ]
                3: Array(5) [ undefined, undefined, undefined, … ]
                4: Array(5) [ undefined, undefined, undefined, … ]
                */

                дополнив то, что вы написали, этого можно достичь так:
                new Array(5).fill(void 0).map(() => new Array(5).fill(void 0));
                или, еще проще:
                new Array(5).fill().map(() => new Array(5).fill());

                впрочем, чуть выше napa3um уже привел подобный пример.
                  +1

                  Но всё же тащить void 0 в продакшн лучше не стоит :)

                    –1

                    А зачем изначально инициализировать массив undefined через fill, и уже только потом использовать map что бы создать новые массивы?
                    Мне кажется код станет лучше если написать:


                    new Array(5).fill(new Array(5).fill());
                      0
                      Выше, уже ответили почему подобный код не корректен.
                      А в целом, рекомендую внимательней ознакомится как со статьёй, так и с комментариями к ней, в частности, в которых уже подробно разобрали случаи с Array.prototype.fill().
                        0

                        Да, спасибо, не подумал, действительно не правильно будет работать.

                    +1

                    Зачем в 2019 писать void 0?

                    0
                    Статья обновлена: помимо несущественных правок и изменения оформления, добавлено немного информации про работу метода Array.prototype.map(). Если кому интересно, можете ознакомиться.
                    Спасибо.

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

                    Самое читаемое