Недавно мне внезапно понадобились матрицы (обычные двумерные массивы, кто не в курсе) в JavaScript. Под катом хочу рассказать как я чуть не наступил на собственные грабли и как все-таки создавать матрицы (если они внезапно понадобились) на ванильном JavaScript.
Казалось бы, что может быть проще, чем создание двумерного массива? Окей, давайте еще раз вместе пройдем этот путь с самого начала. Поставим сначала себе задачу - создать матрицу размера 5x8 ( 5 строк, 8 столбцов). Упростим себе задачу. Как создать обычный массив в JS? Предположим, что у нас есть только стандарт ECMAScript 5, и мы пока ничего не знаем о современных ECMAScript 6-10. Стандарт ECMAScript 5 предлагает нам несколько способов создания одномерного массива.
Сразу замечу, что массив в JS, это "вырожденный" объект (если быть точнее, ассоциативный массив), у которого в качестве ключей выступают индексы. Это нам пригодится чуть позже.
Итак, самый простой способ создания одномерного массива это задание с помощью квадратных скобок.
var array1 = [];
В результате у нас получится пустой массив длины 0. Также внутри квадратных скобок через запятую можно указать набор значений. В этом случае, получим уже непустой одномерный массив с длиной, равной количеству элементов.
Кроме этого, стандарт допускает создание массива с помощью конструктора.
var array2 = new Array();
Этот вариант полностью равносилен первому варианту. Здесь также создается пустой массив.
Конструктор класса Array принимает аргументы и поведение конструктора зависит от типа и количества аргументов. Например, предположим нам нужен массив из пяти элементов. Тогда его можно создать с помощью команды new Array(5). Хорошо, а как создать массив из трех конкретных элементов? Легко,
new Array(1, "two", 3.3).
Что это значит? В зависимости от количества аргументов, интерпретатор сам решает какой массив нужно создать. Если аргумент один (и он типа number), то создается массив с длиной, которая будет передана в качестве аргумента. Если аргумента два и больше - то будет создан массив из этих элементов. Здесь есть потенциальная проблема. Например, нельзя создать массив из одного элемента типа number. Более того, если вдруг вы решите создать массив из элемента со значением к примеру 5.1, то вообще получите RangeError. Так как тип значения 5.1 Number и количество аргументов - один, интерпретатор языка сначала попытается вычислить длину потенциального массива из аргумента и столкнется с проблемой что округленное значение не совпадает со значением в аргументе и выдаст ошибку (подробнее можно почитать здесь).
Отмечу кстати, что на самом деле по стандарту не обязательно писать new Array. Достаточно вызвать функцию Array. При вызове этой функции будет создан объект Array, а значения аргументов функции будут переданы в конструктор.
Давайте теперь вернемся к основной задаче. Так как создать матрицу размера 5x8? Давайте подробнее остановимся на конструкторе объекта Array с одним аргументом. Предположим, что вы указали валидное значение для длины. Что происходит согласно стандарту? На самом деле, нет никакой магии, просто создается объект типа Array, у которого, помимо всего прочего, меняется свойство длины length на значение переданное в конструкторе. И все? Тут мозг программиста, сталкивающегося с императивными языками программирования без динамической типизации должен задать несколько вопросов. Э, а там выделение памяти и все такое? Добро пожаловать в мир динамической типизации! На данном этапе ничего неизвестно о типе/типах данных, которые будут записаны в массив, вообще ничего. Мы только знаем длину массива, и то - это пока неточно. Поэтому нет ничего удивительного, что элементов нет, и по факту, у нас просто есть объект с определенным свойством length. Можно предположить, что, вполне возможно, как-то движки типа V8 от Хрома или другие могут оптимизировать работу с памятью, когда у нас указана длина массива, но в стандарте про это ничего не сказано.
Отлично, с массивом разобрались. У нас есть теперь массив без элементов, явной длины. Что мы можем с ним сделать? Ну, например, мы можем заполнить его значениями. До ECMAScript 6, это можно было сделать только с помощью цикла, например, for. Т.е. итерируемся по i от 0 до length-1 и для каждого i значению a[i] присваиваем некоторое значение value. Т.е. решение нашей основной задачи сводилось бы, например, к следующему:
var matrix = Array(5);
for (var i = 0; i < array.length; i++) {
matrix[i] = Array(8);
}
Вроде задачу решили, но как-то длинно и совсем не декларативно. Здесь можно вспомнить, например, про методы map или forEach объекта Array. Но вот незадача. Если еще раз взглянуть на стандарт, то можно увидеть, что все будет хорошо до момента проверки [[HasProperty]] с индексом (тут вспоминаем, что массив это объект хоть и вырожденный). Такого проперти у нашего объекта-массива естественно нет, так как массив пустой. Хоть длина у него задана, а объектов-то на самом деле внутри него еще никаких нет. Т.е. цикл отработает вхолостую, что не есть хорошо. В итоге наш массив останется без изменений. Аналогично для forEach.
Так что, для того чтобы заполнить массив декларативно, пришлось ждать стандарта ES6, где подвезли много всего, в том числе и специальный метод fill (Array.prototype.fill(value [ , start [ , end ] ] )), который позволяет заполнить массив значением value. Метод fill не делает проверку [[HasProperty]], а просто вызывает метод Set, который устанавливает очередное значение массива значением value. И вот здесь легко можно наступить на грабли. Где подвох? Метод fill замечательно работает с примитивными типами, но когда мы вдруг решим заполнить массив каким-нибудь объектом (массивом, например) нас ждет полное разочарование. Давайте рассмотрим пример:
let matrix = Array(5).fill(Array(8));
Что здесь происходит? На самом деле мы создаем массив длины 5, состоящий из ссылок на один и тот-же! массив длины 8. Т.е. теперь меняя значение в одном массиве мы автоматом меняем в остальных. Это не то что мы хотели, верно?
Хорошо, можно попробовать сделать так:
let matrix = Array(5).fill().map(()=> []);
Уже лучше, но массив приходится проходить дважды. Сначала мы его заполняем undefined, затем мапим на массив. Что-то как-то не очень.
Можно вспомнить, что у нас наступил ES6, и попробовать использовать оператор spread:
let matrix = [...Array(5)].map(() => Array(8));
Уже вроде лучше, но все-таки мне больше по душе вот этот вариант:
let matrix = Array.from(Array(5), () => Array(8));
Что здесь происходит? Мы создаем массив из массива длины 5 и передаем мапу в качестве колбэка.
На мой взгляд, это самый короткий, удобный и понятный способ создания матрицы заданного размера. Не наступайте на грабли!