Использование литерала объекта, как простого средства для хранения пар ключ-значение давно стало обычным делом в 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)


  1. Bibainet
    04.06.2015 15:48
    +3

    Хороший способ. Огорчает только то что такие объекты при кодировании в JSON и обратно перстают быть «голыми».


    1. Finom
      04.06.2015 19:15
      +4

      Можно делать типа так:

      var object = Object.assign( Object.create( null ), JSON.parse( json ) );
      
      Только пробегаться по всему дереву объекта.


  1. shock_one
    04.06.2015 16:42
    -5

    Use polyfill, Luke.


  1. YChebotaev
    05.06.2015 05:27
    +1

    Стало интересно, насколько такие объекты быстрее, или медленнее обычных. Написал тест на jsperf-е.

    На моем компьютере разница совсем незначительная.


  1. mifki
    05.06.2015 07:39
    +2

    Если брать {} и использовать его только как словарь, в нем же и так не будет ничего лишнего для for… in, в чем проблема?


    1. k12th
      05.06.2015 08:02
      +6

      В том, что нельзя одновременно хранить toString и hasOwnProperty. Это ведь такой частый кейс!


      1. Lure_of_Chaos
        05.06.2015 10:37
        -1

        А как раз самый частый кейс, что свойство может оказаться методом, который for… in спокойно себе проитерирует, хотя нам нужны только данные


        1. k12th
          05.06.2015 11:00

          Конечно, если в словарь положить функцию, то она там будет.

          Тут, скорее, проблема в криворуком стороннем коде может быть. Вот тут да, лучше подстелить соломки.


          1. Lure_of_Chaos
            05.06.2015 11:06
            -1

            А какой другой код (положит)подложит туда toString и hasOwnProperty?


            1. k12th
              05.06.2015 12:23

              А откуда свойство окажется методом?:)

              Ну вообще prototype.js и прочие sugar.js (и не только) любят пошалить в прототипах встроенных объектов. Из-за них, в основном, все эти страшилки про «не используйте for..in».


              1. Lure_of_Chaos
                05.06.2015 12:25
                -1

                > А откуда свойство окажется методом?:)
                потому что функция — полноправный объект, а контроль типов у нас по определению отсутствует

                > «не используйте for..in».
                потому, что мы итерируем по ключам вместо значений, это идет вразрез со всеми языками.


                1. faiwer
                  05.06.2015 12:32

                  Поэтому, кажется, придумали for-of. Осталось дождаться повсеместной поддержки. Честно говоря реализация в iojs не обрадовала =(


                  1. k12th
                    05.06.2015 12:46

                    for-of не только эту проблему решает. А что там в iojs поломано на эту тему?


                    1. faiwer
                      05.06.2015 12:48

                      Из того что я запомнил: у меня не взлетело:

                      for (var [key, value] of phoneBookMap)
                      

                      А жаль. Во многих местах пришлось отказаться от for-of из-за этого


                      1. k12th
                        05.06.2015 12:53

                        Мда, половина смысла теряется.
                        В babel работает, если что: github.com/hogart/alchemy/blob/fa4d02205fb4127d9e4d6a0524fb762752cead46/src/lib/alchemy.js#L16


                        1. faiwer
                          05.06.2015 12:58

                          А в babel оно ключи передаёт только в случае использования Map? Или если обычный объект прогонять, то тоже?


                      1. faiwer
                        05.06.2015 12:56

                        Полез смотреть где я вообще эти [] углядел. Увидел я их в статье на frontender.info. Но не обратил внимания на то, что там речь шла о Map. Впрочем это не отменяет того факта, что iojs ругался на синтаксис.

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


                        1. k12th
                          05.06.2015 13:05

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

                          В моем примере по ссылке используется Array#entries, который итерирует по парам индекс-значение.


  1. ua9msn
    05.06.2015 08:31
    -1

    Есть еще одна засада — объект невозможно отсортировать, в то время как массив — пожалуйста.
    Поэтому, увы, как бы нам не хотелось, это не полноценная замена ассоциативным массивам.


    1. k12th
      05.06.2015 09:04

      В качестве частного решения можно предложить такое:

      Object.keys(obj).sort().forEach // ну или .map, смотря что нужно сделать
      

      Но, если честно, не помню, чтобы мне был нужен хэш, ключи в котором располагаются в определенном порядке. Приведите кейс, если не трудно?


      1. faiwer
        05.06.2015 12:38
        -1

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

        Не уверен, что сие описание сгодится как нормальный case, но меня на тот момент, такое поведение удивило :)


        1. k12th
          05.06.2015 12:45

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

          Вообще да, иногда хочется и быстрый доступ по id и итерацию по порядку — ну так, для красоты. Но обычно можно либо обойтись, либо воспользоваться какими-то обертками (Backbone.Collection, например).


      1. ua9msn
        05.06.2015 14:22

        упс, не туда


      1. ua9msn
        05.06.2015 14:23

        > Object.keys(obj).sort().forEach
        Тут вы получаете сортированные ключи, но не массив объектов.
        В большинстве случаев сортировка должна производиться по содержимому. У меня такого вагон и маленькая тележка,
        например

        {
            'foo': {
                value: 3,
                name: 'Vasya'
            },
            'bar': {
                value: 2,
                name: 'Petya'
            }
        }
        

        Вы в любом случае не отсортируете этот объект по полю value, в момент доступа он будет не отсортирован, вам будет нужен дополнительный массив для хранения порядка элементов. А конкретный бизнес пример — ну представьте что у вас есть таблица и строки в ней с ID. Это означает либо дополнительный массив для порядка, либо отсутствие доступа к строке по ID (если запихать эти объекты в обычный массив)


        1. k12th
          05.06.2015 14:25
          +2

          Object.keys(obj).sort(function (key1, key2) { return obj[key1].value - obj[key2].value })
          


          1. ua9msn
            05.06.2015 15:59

            Ну правильно, это и есть способ получить дополнительный массив порядка объектов.
            Но сам объект отсортированным не будет. Что бы вывести строки в этом порядке нужно итерировать этот массив, прямое обращение к объекту через for..in вернет все не отсортированным


        1. Bronx
          21.06.2015 21:16

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


    1. Aingis
      05.06.2015 11:35

      Вообще-то возможно как минимум в некоторых реализациях. В V8 так точно можно. Хотя это не определено стандартом, конечно.


  1. torbasow
    05.06.2015 09:22

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


    Да ведь они уже везде реализованы, и недавно я с большим удовольствием и пользой использовал нативную Map (правда, в проекте только для Fx29+).


    1. IonDen Автор
      05.06.2015 11:18

      Вы сами ответили на свой вопрос) Понятно что спека готова и во многих бараузерах уже есть, но еще рановато.


  1. kulakowka
    05.06.2015 12:14
    +1

    Используйте Babel.js для поддержки ES6 синтаксиса.


  1. ckr
    05.06.2015 21:26
    +2

    Что примечательно, запустил один и тот же код по созданию млрд обычных объектов и млрд голых. Память оба процесса расходуют одинаково. А вот процессорное время голые съели заметно больше, чем обычные.
    Обычные объекты создались за 43 с хвостиком секунды процессорного времени.
    Голые съели больше 4х минут.
    Скриншот прилагается. Node v0.10.26


    1. IonDen Автор
      06.06.2015 00:05
      +2

      Круто, а можете так же сравнить:

      var normal = Object.create(Object.prototype);
      var naked = Object.create(null);
      

      Может это Object.create не оптимизирован.


      1. ckr
        08.06.2015 01:04
        +1

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

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

        Также, по заявкам трудящихся на комментарий ниже, к утру потестирую вышеперечисленное на node v0.12.4.


    1. StreetStrider
      07.06.2015 13:54

      Интересно бы ещё посмотреть ситуацию на 12-ой.


      1. ckr
        08.06.2015 01:27
        +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. ckr
    08.06.2015 02:03
    +1

    Отличная статья!
    Идея (по крайней мере, для меня) новая, и, разумеется, имеет полное право на существование.

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

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

    Выше, в комментариях, обсуждалась технологическая проблема, в следствии которой приходилось держать отдельный массив для быстрого доступа к данным по сложному ключу или для сортировки этих данных. В теории СУБД такие массивы называются индексами. В использовании массивов такого назначения нет ничего плохого.
    Если не нравится то, что приходится использовать несколько разных массивов с разными именами, один из самых производительных способов — создать свой класс, наследуемый от Array или Collection. Ну, и реализовать работу всех индексов в рамках этого класса.


  1. bBars
    08.06.2015 14:34
    +2

    function AssocArray() {}
    AssocArray.prototype = null;
    

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

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