Настоящие ассоциативные массивы в JavaScript

Автор оригинала: Ryan Morr
  • Перевод
  • Tutorial
Использование литерала объекта, как простого средства для хранения пар ключ-значение давно стало обычным делом в JavaScript. Тем не менее, литерал объекта всё же не является настоящим ассоциативным массивом и по этому, в некоторых ситуациях, его использование может привести к неожиданным результатам. Пока JS не предоставляет нативную реализацию ассоциативных массивов (не во всех браузерах, по крайней мере), существует отличная альтернатива объектам, с нужной функциональностью и без подводных камней.

Проблема с объектами


Проблема заключается в цепочке прототипов. Любой новый объект наследует свойства и методы от Object.prototype, которые могут помешать нам однозначно определить существование ключа. Возьмем для примера метод toString, проверка наличия ключа с таким же именем, с помощью оператора in приведет к ложноположительному результату:

var map = {};
'toString' in map; // true

Это происходит потому что оператор in, не найдя свойство в экземпляре объекта, смотрит дальше по цепочке прототипов в поисках унаследованных значений. В нашем случае это метод toString. Чтобы решить эту проблему существует метод hasOwnProperty , который был задуман специально для того, чтобы проверить наличие свойств только в текущем объекте:

var map = {};
map.hasOwnProperty('toString'); // false

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

var map = {};
map.hasOwnProperty = 'foo';
map.hasOwnProperty('hasOwnProperty'); // TypeError

Быстренько чиним и эту проблему. Для этого воспользуемся другим, нетронутым объект и вызовем его метод hasOwnProperty в контексте нашего объекта:

var map = {};
map.hasOwnProperty = 'foo';
{}.hasOwnProperty.call(map, 'hasOwnproperty'); // true

Вот, этот способ уже работает без проблем, но всё же он накладывает некоторые ограничения, на то как мы будем его использовать. Например, каждый раз, когда вы захотите перечислить свойства своего объекта с помощью for ... in, вам придется отфильтровывать всё унаследованное барахло:

var map = {};
var has = {}.hasOwnProperty;

for(var key in map){
    if(has.call(map, key)){
        // do something
    }
}

Через какое-то время этот способ вас ужасающе утомит. Слава богу есть вариант получше.

Голые объекты


Секрет создания чистого ассоциативного массива в избавлении от прототипа и всего того багажа, что он тащит с собой. Чтобы это осуществить, воспользуемся методом Object.create, представленного в ES5. Уникальность этого метода в том, что вы можете явно определить прототип нового объекта. Например создадим обычный объект чуть более наглядно:

var obj = {};
// то же самое:
var obj = Object.create(Object.prototype);

Помимо того, что вы можете выбрать любой прототип, метод также дает вам возможность не выбирать прототип вовсе, просто передав null вместо него:

var map = Object.create(null);

map instanceof Object; // false
Object.prototype.isPrototypeOf(map); // false
Object.getPrototypeOf(map); // null

Эти голые объекты (или словари) идеально подходят для создания ассоциативных массивов, так как отсутствие [[Prototype]] убирает риск наткнуться на конфликт имён. И даже лучше! После того, как мы лишили объект всех унаследованных методов и свойств, любые попытки использовать его не по прямому назначению (хранилище), будут приводить к ошибкам:

var map = Object.create(null);
map + ""; // TypeError: Cannot convert object to primitive value

Нет ни примитивного значения, ни строкового представления. Голые объекты предназначены лишь для работы в качестве хранилища пар ключ-значение и точка.

Имейте в виду, что метода hasOwnProperty тоже больше нет, да он и не нужен, так как оператор in теперь прекрасно работает без каких-либо проверок.

var map = Object.create(null);
'toString' in map; // false

Более того, те утомительные циклы for ... in теперь стали гораздо проще. Наконец-то мы можем без опаски писать их так, как они и должны выглядеть:

var map = Object.create(null);

for(var key in map){
    // do something
}

Несмотря на внесенные изменения, мы можем по-прежнему делать с объектами всё что нужно, как то использовать точечную нотацию или квадратные скобочки, превращать их в строку или использовать объект как контекст для любого метода из Object.prototype:

var map = Object.create(null);

Object.defineProperties(map, {
    'foo': {
        value: 1,
        enumerable: true
    },
    'bar': {
        value: 2,
        enumerable: false
    }
});

map.foo; // 1
map['bar']; // 2

JSON.stringify(map); // {"foo":1}

{}.hasOwnProperty.call(map, 'foo'); // true
{}.propertyIsEnumerable.call(map, 'bar'); // false

Даже различные способы проверки типов по прежнему будут работать:

var map = Object.create(null);

typeof map; // object
{}.toString.call(map); // [object Object]
{}.valueOf.call(map); // Object {}

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

Заключение


Если говорить о простых хранилищах пар ключ-значение, то голые объекты справятся с этой задачей однозначно лучше обычных объектов, избавив разработчика от всего лишнего. Для более функциональных структур данных придется подождать ES6 (ES2015), который предоставит нам нативные ассоциативные массивы в виде объектов Map, Set и других. А пока этот радужный момент не настал, голые объекты — лучший выбор.

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

    +3
    Хороший способ. Огорчает только то что такие объекты при кодировании в JSON и обратно перстают быть «голыми».
      +4
      Можно делать типа так:
      var object = Object.assign( Object.create( null ), JSON.parse( json ) );
      
      Только пробегаться по всему дереву объекта.
      –5
      Use polyfill, Luke.
        +1
        Стало интересно, насколько такие объекты быстрее, или медленнее обычных. Написал тест на jsperf-е.

        На моем компьютере разница совсем незначительная.
        • НЛО прилетело и опубликовало эту надпись здесь
            +6
            В том, что нельзя одновременно хранить toString и hasOwnProperty. Это ведь такой частый кейс!
              –1
              А как раз самый частый кейс, что свойство может оказаться методом, который for… in спокойно себе проитерирует, хотя нам нужны только данные
                0
                Конечно, если в словарь положить функцию, то она там будет.

                Тут, скорее, проблема в криворуком стороннем коде может быть. Вот тут да, лучше подстелить соломки.
                  –1
                  А какой другой код (положит)подложит туда toString и hasOwnProperty?
                    0
                    А откуда свойство окажется методом?:)

                    Ну вообще prototype.js и прочие sugar.js (и не только) любят пошалить в прототипах встроенных объектов. Из-за них, в основном, все эти страшилки про «не используйте for..in».
                      –1
                      > А откуда свойство окажется методом?:)
                      потому что функция — полноправный объект, а контроль типов у нас по определению отсутствует

                      > «не используйте for..in».
                      потому, что мы итерируем по ключам вместо значений, это идет вразрез со всеми языками.
                        0
                        Поэтому, кажется, придумали for-of. Осталось дождаться повсеместной поддержки. Честно говоря реализация в iojs не обрадовала =(
                          0
                          for-of не только эту проблему решает. А что там в iojs поломано на эту тему?
                            0
                            Из того что я запомнил: у меня не взлетело:
                            for (var [key, value] of phoneBookMap)
                            

                            А жаль. Во многих местах пришлось отказаться от for-of из-за этого
                              0
                              Мда, половина смысла теряется.
                              В babel работает, если что: github.com/hogart/alchemy/blob/fa4d02205fb4127d9e4d6a0524fb762752cead46/src/lib/alchemy.js#L16
                                0
                                А в babel оно ключи передаёт только в случае использования Map? Или если обычный объект прогонять, то тоже?
                                0
                                Полез смотреть где я вообще эти [] углядел. Увидел я их в статье на frontender.info. Но не обратил внимания на то, что там речь шла о Map. Впрочем это не отменяет того факта, что iojs ругался на синтаксис.

                                Тогда моя претензия больше к самой конструкции, хочется иметь сразу и ключ. Но похоже это претит самому принципу доступа через итераторы. Во всяком случае здесь я не нашёл решений.
                                  0
                                  Нет, должно работать так, как вы и хотели: www.2ality.com/2015/02/es6-iteration.html, пример в пункте 2.3. Все зависит от того, что именно лежит в итерируемом под ключом Symbol.iterator. Ну и деконструкция должна тоже работать, я не помню, поддерживается она в iojs или нет.

                                  В моем примере по ссылке используется Array#entries, который итерирует по парам индекс-значение.
              –1
              Есть еще одна засада — объект невозможно отсортировать, в то время как массив — пожалуйста.
              Поэтому, увы, как бы нам не хотелось, это не полноценная замена ассоциативным массивам.
                0
                В качестве частного решения можно предложить такое:
                Object.keys(obj).sort().forEach // ну или .map, смотря что нужно сделать
                

                Но, если честно, не помню, чтобы мне был нужен хэш, ключи в котором располагаются в определенном порядке. Приведите кейс, если не трудно?
                  –1
                  Я натыкался на эти грабли в 2011 году (когда только осваивал javascript). Использовал hash вместо массива, в качестве ключа были id-ки. В качестве значений нечто вроде: {id: int, position: int, ...}. Данные приходили из PHP, в котором сохранялся изначальный порядок сортировки (по position). А браузеры вели себя по разному. В итоге переделал на массивы. Хотя казалось, что будет удобнее иметь прямой доступ по id.

                  Не уверен, что сие описание сгодится как нормальный case, но меня на тот момент, такое поведение удивило :)
                    0
                    Насколько мне известно, такое поведение (определенный порядок ключей в словаре) только в PHP есть. Новички в Python, например, любят написать свой OrderedDict.

                    Вообще да, иногда хочется и быстрый доступ по id и итерацию по порядку — ну так, для красоты. Но обычно можно либо обойтись, либо воспользоваться какими-то обертками (Backbone.Collection, например).
                    0
                    упс, не туда
                      0
                      > Object.keys(obj).sort().forEach
                      Тут вы получаете сортированные ключи, но не массив объектов.
                      В большинстве случаев сортировка должна производиться по содержимому. У меня такого вагон и маленькая тележка,
                      например
                      {
                          'foo': {
                              value: 3,
                              name: 'Vasya'
                          },
                          'bar': {
                              value: 2,
                              name: 'Petya'
                          }
                      }
                      

                      Вы в любом случае не отсортируете этот объект по полю value, в момент доступа он будет не отсортирован, вам будет нужен дополнительный массив для хранения порядка элементов. А конкретный бизнес пример — ну представьте что у вас есть таблица и строки в ней с ID. Это означает либо дополнительный массив для порядка, либо отсутствие доступа к строке по ID (если запихать эти объекты в обычный массив)
                        +2
                        Object.keys(obj).sort(function (key1, key2) { return obj[key1].value - obj[key2].value })
                        
                          0
                          Ну правильно, это и есть способ получить дополнительный массив порядка объектов.
                          Но сам объект отсортированным не будет. Что бы вывести строки в этом порядке нужно итерировать этот массив, прямое обращение к объекту через for..in вернет все не отсортированным
                          0
                          Отдельные индексы универсальнее и гибче — можно задавать разный порядок, можно кэшировать и мгновенно переключать сортировку, можно комбинировать сортировку с фильтрацией и т.п.
                      • НЛО прилетело и опубликовало эту надпись здесь
                        0
                        Для более функциональных структур данных придется подождать ES6 (ES2015), который предоставит нам нативные ассоциативные массивы в виде объектов Map, Set и других. А пока этот радужный момент не настал


                        Да ведь они уже везде реализованы, и недавно я с большим удовольствием и пользой использовал нативную Map (правда, в проекте только для Fx29+).
                          0
                          Вы сами ответили на свой вопрос) Понятно что спека готова и во многих бараузерах уже есть, но еще рановато.
                        • НЛО прилетело и опубликовало эту надпись здесь
                            +2
                            Что примечательно, запустил один и тот же код по созданию млрд обычных объектов и млрд голых. Память оба процесса расходуют одинаково. А вот процессорное время голые съели заметно больше, чем обычные.
                            Обычные объекты создались за 43 с хвостиком секунды процессорного времени.
                            Голые съели больше 4х минут.
                            Скриншот прилагается. Node v0.10.26
                              +2
                              Круто, а можете так же сравнить:
                              var normal = Object.create(Object.prototype);
                              var naked = Object.create(null);
                              

                              Может это Object.create не оптимизирован.
                                +1
                                Да, Вы правы по поводу Object.create().
                                Поротестировал много разных вариантов прототипов.
                                Результаты приблизительно одинаковы везде — 4 минуты ± 15 секунд.
                                Тест показывал неоптимизированность именно Object.create().
                                Сами тесты проводил самыми стандартными средствами: readline-интерфейс node и top с фильтром результатов по pid.

                                Есть еще один хороший сервис тестирования, но через браузер: jsperf.com/naked-vs-simple-objects
                                Тут надо быть вимательным, результаты отличаются в разных браузерах на разных ОС.

                                Также, по заявкам трудящихся на комментарий ниже, к утру потестирую вышеперечисленное на node v0.12.4.
                                0
                                Интересно бы ещё посмотреть ситуацию на 12-ой.
                                  +3
                                  Итак, скрин делать не буду. Оставлю результаты текстом.
                                  Node v0.12.4

                                  for(var i = 0, l = 1000 * 1000 * 1000; i < l; i++) { var obj = {} }                                 //  =>  0:07.29
                                  for(var i = 0, l = 1000 * 1000 * 1000; i < l; i++) { var obj = new Object() }                       //  =>  0:34.58
                                  for(var i = 0, l = 1000 * 1000 * 1000; i < l; i++) { var obj = Object.create(Object.prototype); }   //  =>  1:11.67
                                  for(var i = 0, l = 1000 * 1000 * 1000; i < l; i++) { var obj = Object.create(null) }                //  =>  1:50.54
                                  
                                +1
                                Отличная статья!
                                Идея (по крайней мере, для меня) новая, и, разумеется, имеет полное право на существование.

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

                                От себя хотел выделить правило: Никогда не используй название свойств объектов JS для хранения даннных. Свойства объектов используем только для структурирования данных.
                                Хранение данных в названиях свойств объектов равносильно хранению данных в названиях столбцов в таблицах реляционных СУБД.

                                Выше, в комментариях, обсуждалась технологическая проблема, в следствии которой приходилось держать отдельный массив для быстрого доступа к данным по сложному ключу или для сортировки этих данных. В теории СУБД такие массивы называются индексами. В использовании массивов такого назначения нет ничего плохого.
                                Если не нравится то, что приходится использовать несколько разных массивов с разными именами, один из самых производительных способов — создать свой класс, наследуемый от Array или Collection. Ну, и реализовать работу всех индексов в рамках этого класса.
                                  +2
                                  function AssocArray() {}
                                  AssocArray.prototype = null;
                                  

                                  Смысл, ведь, тот же?
                                  А тест производительности что-то врет, видимо.

                                  На моей машине в Chrome 43.0.2357.81 m 1M объектов {} создается за ≈1.5 сек, в то время как new AssocArray занимает ≈1.7 сек, а Object.create(null) — ≈2.1 сек.

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

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