Первый взгляд на записи и кортежи в JavaScript

Автор оригинала: Dr. Axel Rauschmayer
  • Перевод

В этом посте мы вкратце рассмотрим предложение в стандарт ECMAScript «Record & Tuple» от Робина Рикарда и Рика Баттона. Это предложение добавляет два вида составных примитивных значений в JavaScript:


  • записи (records) — неизменяемая и сравниваемая по значению версия простых объектов;
  • кортежи (tuples) — неизменяемая и сравниваемая по значению версия массивов.

image


1. Сравнение по значению


Сейчас JavaScript сравнивает по значению (то есть, просматривая содержимое) только примитивные типы данных, например, строки:


> 'abc' === 'abc'
true

Объекты же сравниваются по внутренним ссылкам (поэтому объект равен только самому себе).


> {x: 1, y: 4} === {x: 1, y: 4}
false
> ['a', 'b'] === ['a', 'b']
false

Предложение «Record & Tuple» от Робина Рикарда и Рика Баттона позволяет создавать составные значения, которые поддерживают сравнение по значению.


Например, добавив к литералу объекта знак решётки (#), мы создадим запись — составное значение, которое сравнивается по значению и является неизменяемым:


> #{x: 1, y: 4} === #{x: 1, y: 4}
true

Если мы добавим знак # к литералу массива, мы создадим кортеж — массив, который сравнивается по значению и является неизменяемым:


> #['a', 'b'] === #['a', 'b']
true

Составные значения, которые сравниваются по значению, называются составными примитивными значениями или составными примитивами.


1.1. Записи и кортежи — примитивы


Мы можем увидеть, что записи и кортежи являются примитивами, при использовании typeof:


> typeof #{x: 1, y: 4}
'record'
> typeof #['a', 'b']
'tuple'

1.2. Ограничения на содержимое записей и кортежей


Записи:


  • ключи должны быть строками;
  • значения должны быть примитивами (включая записи и кортежи).

Кортежи:


  • элементы должны быть примитивами (включая записи и кортежи).

1.3. Преобразование объектов в записи и кортежи


> Record({x: 1, y: 4}) 
#{x: 1, y: 4}
> Tuple.from(['a', 'b'])
#['a', 'b']

Примечание: эти преобразования — поверхностные (shallow). Если какой-либо элемент (в том числе вложенный) не является примитивным, Record() и Tuple.from() бросят исключение.


1.4. Преобразование записей и кортежей в объекты


> Object(#{x: 1, y: 4})
{x: 1, y: 4}
> Array.from(#['a', 'b'])
['a', 'b']

Примечание: эти преобразования — поверхностные (shallow).


1.5. Работа с записями


const record = #{x: 1, y: 4};

// доступ к свойствам
assert.equal(record.y, 4);

// деструктуризация
const {x} = record;
assert.equal(x, 1);

// использование spread-синтаксиса
assert.ok(#{...record, x: 3, z: 9} === #{x: 3, y: 4, z: 9});

1.6. Работа с кортежами


const tuple = #['a', 'b'];

// доступ к элементам
assert.equal(tuple[1], 'b');

// деструктуризация (кортежи — итерируемы)
const [a] = tuple;
assert.equal(a, 'a');

// использование spread-синтаксиса
assert.ok(#[...tuple, 'c'] === #['a', 'b', 'c']);

// обновление элементов
assert.ok(tuple.with(0, 'x') === #['x', 'b']);

1.7. Почему значения, сравниваемые по значению, в JavaScript — неизменяемые?


Некоторые структуры данных, такие как хеш-таблицы (hash maps) и деревья поиска (search trees), имеют слоты, в которых ключи располагаются в соответствии с их значениями. Если значение ключа изменяется, его обычно нужно поместить в другой слот. Вот почему в JavaScript значения, которые могут использоваться как ключи, либо:


  • сравниваются по значению и неизменяемы (примитивы);
  • сравниваются по внутренним идентификаторам и потенциально изменяемыми (объекты).

1.8. Преимущества составных примитивов


Составные примитивы могут быть полезны в следующих случаях:


  • Глубокое сравнение объектов, например, с помощью встроенного оператора ===.
  • Простой шаринг значений: если мы отправляем куда-то объект и хотим, чтобы он остался неизменным, нам нужно предварительно сделать его глубокую копию. При неизменяемых значениях это делать не нужно.
  • Неразрушающие обновления данных: мы можем безопасно реиспользовать части составного значения, когда создаём их копии (потому что любая часть составного примитива также является неизменяемой).
  • Новые возможности для объектов Map и Set, ведь два составных примитива с одинаковым содержимым будут считаться строго равными, в том числе, и при использовании в качестве ключей в Map и элементов в Set.

В следующих разделах мы рассмотрим эти преимущества.


2. Примеры: делаем объекты Set и Map более полезными


2.1. Удаление дубликатов с помощью объектов Set


С составными примитивами мы можем исключить дубликаты, несмотря на то, что они не являются атомарными:


> [...new Set([#[3,4], #[3,4], #[5,-1], #[5,-1]])]
[#[3,4], #[5,-1]]

Этот трюк не сработает с массивами:


> [...new Set([[3,4], [3,4], [5,-1], [5,-1]])]
[[3,4], [3,4], [5,-1], [5,-1]]

2.2. Сравнение ключей в объектах Map


Так как объекты сравниваются по внутреннему идентификатору, довольно редко имеет смысл использовать их в качестве ключей объекта Map (если мы не говорим о WeakMap).


const m = new Map();
m.set({x: 1, y: 4}, 1);
m.set({x: 1, y: 4}, 2);
assert.equal(m.size, 2);

Другое дело, когда мы используем составные примитивы: объект Map в строке A будет использовать записи с адресами в качестве ключа.


const persons = [
  #{
    name: 'Eddie',
    address: #{
      street: '1313 Mockingbird Lane',
      city: 'Mockingbird Heights',
    },
  },
  #{
    name: 'Dawn',
    address: #{
      street: '1630 Revello Drive',
      city: 'Sunnydale',
    },
  },
  #{
    name: 'Herman',
    address: #{
      street: '1313 Mockingbird Lane',
      city: 'Mockingbird Heights',
    },
  },
  #{
    name: 'Joyce',
    address: #{
      street: '1630 Revello Drive',
      city: 'Sunnydale',
    },
  },
];

const addressToNames = new Map(); // (A)
for (const person of persons) {
  if (!addressToNames.has(person.address)) {
    addressToNames.set(person.address, new Set());
  }
  addressToNames.get(person.address).add(person.name);
}

assert.deepEqual(
  // Преобразуем Map в массив пар ключ-значение,
  // чтобы затем сравнить через assert.deepEqual().
  [...addressToNames],
  [
    [
      #{
        street: '1313 Mockingbird Lane',
        city: 'Mockingbird Heights',
      },
      new Set(['Eddie', 'Herman']),
    ],
    [
      #{
        street: '1630 Revello Drive',
        city: 'Sunnydale',
      },
      new Set(['Dawn', 'Joyce']),
    ],
  ]);

3. Примеры: преимущества глубокого равенства


3.1. Обработка объектов со значениями, содержащими составные свойства


В следующем примере мы используем метод Array.filter() (строка B), чтобы извлечь все записи, адрес которых равен адресу на строке A.


const persons = [
  #{
    name: 'Eddie',
    address: #{
      street: '1313 Mockingbird Lane',
      city: 'Mockingbird Heights',
    },
  },
  #{
    name: 'Dawn',
    address: #{
      street: '1630 Revello Drive',
      city: 'Sunnydale',
    },
  },
  #{
    name: 'Herman',
    address: #{
      street: '1313 Mockingbird Lane',
      city: 'Mockingbird Heights',
    },
  },
  #{
    name: 'Joyce',
    address: #{
      street: '1630 Revello Drive',
      city: 'Sunnydale',
    },
  },
];

const address = #{ // (A)
  street: '1630 Revello Drive',
  city: 'Sunnydale',
};
assert.deepEqual(
  persons.filter(p => p.address === address), // (B)
  [
    #{
      name: 'Dawn',
      address: #{
        street: '1630 Revello Drive',
        city: 'Sunnydale',
      },
    },
    #{
      name: 'Joyce',
      address: #{
        street: '1630 Revello Drive',
        city: 'Sunnydale',
      },
    },
  ]);

3.2. Изменялся ли объект?


Всякий раз, когда мы работаем с кэшированными данными (например, previousData в примере ниже), встроенное глубокое равенство позволяет нам эффективно проверять, изменилось ли что-нибудь.


let previousData;
function displayData(data) {
  if (data === previousData) return;
  // ···
}

displayData(#['Hello', 'world']); // выполнит код функции
displayData(#['Hello', 'world']); // остановится на return

3.3. Тестирование


Большинство сред тестирования поддерживают глубокое сравнение для проверки, дает ли вычисление ожидаемый результат. Например, встроенный модуль Node.js assert имеет функцию deepEqual(). С составными примитивами у нас есть альтернатива такой функциональности:


function invert(color) {
  return #{
    red: 255 - color.red,
    green: 255 - color.green,
    blue: 255 - color.blue,
  };
}
assert.ok(invert(#{red: 255, green: 153, blue: 51}) === #{red: 0, green: 102, blue: 204});

Примечание: учитывая, что встроенные проверки на равенство делают больше, чем просто сравнивают значения, вероятнее всего они будут поддерживать составные примитивы и будут для них более эффективны (в отличие от используемых ранее проверок глубокого равенства).


4. Плюсы и минусы нового синтаксиса


Некоторыми недостатками нового синтаксиса является то, что символ # уже используется в другом месте (для приватных полей) и то, что символы, не относящиеся к буквам и цифрам всегда немного загадочны. Это можно наблюдать на следующем примере:


const della = #{
  name: 'Della',
  children: #[
    #{
      name: 'Huey',
    },
    #{
      name: 'Dewey',
    },
    #{
      name: 'Louie',
    },
  ],
};

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


Вместо специального литерального синтаксиса мы могли бы использовать фабричные функции:


const della = Record({
  name: 'Della',
  children: Tuple([
    Record({
      name: 'Huey',
    }),
    Record({
      name: 'Dewey',
    }),
    Record({
      name: 'Louie',
    }),
  ]),
});

Этот синтаксис мог бы быть улучшен, если бы JavaScript поддерживал Tagged Collection-литералы (предложение Кэт Марчан, которое она отозвала):


const della = Record!{
  name: 'Della',
  children: Tuple![
    Record!{
      name: 'Huey',
    },
    Record!{
      name: 'Dewey',
    },
    Record!{
      name: 'Louie',
    },
  ],
};

Увы, даже если мы используем укороченные имена, результат все еще визуально загроможден:


const R = Record;
const T = Tuple;

const della = R!{
  name: 'Della',
  children: T![
    R!{
      name: 'Huey',
    },
    R!{
      name: 'Dewey',
    },
    R!{
      name: 'Louie',
    },
  ],
};

5. JSON и записи и кортежи


  • JSON.stringify() обрабатывает записи как объекты и кортежи как массивы (рекурсивно).
  • JSON.parseImmutable() работает как JSON.parse(), но всегда возвращает записи вместо объектов и кортежи вместо массивов (рекурсивно).

6. Будущее: классы, экземпляры которых сравниваются по значению?


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


Было бы также здорово, если бы у нас была поддержка глубокого и неразрушающего обновления данных, содержащих объекты, созданные такими классами.


7. Признательность



8. Что читать дальше


AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

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

    +3

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


    let {a} = obj.optionalMethod?.(...param) ? x : y ?? #{a: 1}

    обучение языку превратится в знаменитую картинку "как нарисовать сову"

      +3
      Да ничего сложного тут. Если и злоупортреблять просто тернарным оператором, то нечитаемая фигня получается.
        0
        Это же хорошо. Еще чуть-чуть и будет как во взрослых функциональных языках.
          +1
          Со «старым» синтаксисом подобная конструкция выглядит еще более уродливой:
          const { a } = obj.optionalMethod && obj.optionalMethod(...param) ? x : y !== undefined ? y : { a: 1 }
          
            +3

            старый синтаксис заставляет её быть настолько длинной, что вынуждает разработчика таки разбить её на несколько инструкций. Я бы и с новым за такое по рукам бил, но если не экономить на строчках, то на "совсем старом" синтаксисе получится (не считая особенностей const)


            if (obj.optionalMethod && obj.optionalMethod.apply(this, param)) {
              var a = x.a
            } else if (y !== undefined) {
              var a = y.a
            } else {
              var a = 1
            }

            Я понимаю, что никто не запрещает писать с новыми фичами не экономя строк
            И я твёрдо уверен, что каждая из использованных в примере фичей — крайне полезна, большинство из них я регулярно использую.
            Но монстры, которых МОЖНО породить с этими возможностями, пугают :)
            Не могу удержаться процитировать прекрасного aemkei и сравнить свою конструкцию с полностью валидным JS: image


            P.S: как раз на картинке используются только самые базовые функции JS и развлечения с приведением типов

          +2

          А ведь эти штуки очень даже неплохо подошли бы для Virtual DOM, сразу есть сравнение, они иммутабельны.

            –3
            1.2. Ограничения на содержимое записей и кортежей

            Записи:
            • значения должны быть примитивами (включая записи и кортежи).


            Не получится.
              0

              Почему? Можно ведь засунуть все из VNode, кроме child'ов и event'ов в запись, и с помощью этого просто и красиво сравнивать все, кроме child'ов и event'ов в границах одного VNode.

                0

                Только если сделать объект-обёртку, в котором будут запись с иммутабельными данными и объект с потомками и ивентами. Как я понял из статьи, вложить в запись объект или массив нельзя. А толку от задумки с такой обёрткой я лично не вижу, вопрос со сравнением никуда не денется.


                Плюс, представим ситуацию, что у нас список из 10 одинаковых VNode, и нам нужно что-то сделать с одной конкретной. Если сейчас отсутствие ключа у узла влияет только на производительность при повторном рендере, то с записями это будет куча головной боли, потому что одинаковые узлы язык будет считать за одно и то же.

                  +2
                  Как я понял из статьи, вложить в запись объект или массив нельзя.

                  Зато можно вложить в запись кортеж или другую запись.


                  Плюс, представим ситуацию, что у нас список из 10 одинаковых VNode, и нам нужно что-то сделать с одной конкретной.

                  Ключи никуда не денутся.


                  Записи и кортежи дают удобные объекты, у которых сравнение работает быстрее, они гарантированно иммутабльны.

                    0
                    Записи и кортежи дают удобные объекты, у которых сравнение работает быстрее, они гарантированно иммутабльны.

                    Так ведь получается, что если потомок иммутабелен, то должен быть иммутабелен его родитель, тем самым будет иммутабельным всё дерево. Если потомок-запись нужно обновить, то нужно обновить и родителя, ведь потомок-запись внутри него уже другая, так снизу до самого верха, и тем самым на каждый чих будет создаваться новое DOM-дерево. Великоватая плата за более быстрое сравнение.

                    Ключи никуда не денутся.

                    С учётом того, что сейчас ключи – желательный, но не обязательный момент, которым иногда любят пренебрегать из-за незнания/ненастроенного линтера/лени, всё это посыпется у кучи проектов, никакой обратной совместимости.
                      0
                      Так ведь получается, что если потомок иммутабелен, то должен быть иммутабелен его родитель, тем самым будет иммутабельным всё дерево.

                      Вовсе не обязательно. Почему бы не использовать записи только для всех полей VNode, кроме событий, родителей и детей?

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

                        А вот для пропсов кстати это может оказаться очень полезным, как ниже писали. Не знаю, как в реакте, а во Vue для того, чтобы изменение было реактивным, нужно создавать новый объект. Вот там бы точно такое пригодилось и в пропсах, и в computed, и в стейтах Vuex.
                          0

                          Кстати, во Vue иммутабельные типы врятли зайдут. Второй Vue работает через множество defineGetter'ов, третий через Proxy. У Vue все мутабельно (ну кроме пропсов, но и там придется конверировать все это дело). В React'е это может вполне таки зайти: в реакте большинство вещей иммутабльны: пропсы, данные компонентов (если использовать функциональные компоненты и хуки). В прибавку, Redux, самый популярный стейт менеджмент для React использует иммутабельный стор, который на каждое изменение создается новый (shallow copy).

              –2
              С учётом того, что во фреймворках и вообще по хорошему тону элементы перезаписываются локально, а не полностью обновляется весь DOM, сомневаюсь в этом.
                0

                А в чем проблема то? Посмотрите мой комментарий выше.

              0
              Может кто-нибудь подсказать живые примеры, где иммутабельные объекты и массивы могут пригодиться? На ум приходят только какие-нибудь константы и конфигурации, которые изначально прописаны в коде. В большинстве задач всё равно используются составные типы, в которых что-то меняется, и тут кортежи и записи не дают никакого выигрыша.
                +5

                "FP"-подобный код. Скажем redux store. Всякие #[...#items, newItem] должны быть гораздо быстрее, чем [...items, newItem] (O(n) vs O(log n)).

                  +4
                  Это полезно, например, в React, когда в качестве prop передается не примитив. Тогда понять, изменился ли prop можно быстрым prevProp !== prop, вместо какого-нибудь deepEqual.
                    –1

                    Эту штуку нельзя применить в реакте. Потому что в пропсах почти никогда не передаётся только примитив. Во всяком случае во всём коде который я писал, всегда передавались объекты, какие-нибудь map'ы или функции, но чтобы были только строки или только числа — вот такого не встречал почти никогда.

                      +3
                      всегда передавались объекты, какие-нибудь map'ы или функции

                      Вместо object-ов, массивов и map-ов просто #{} и #[] (их вкладывать можно). А вот с функциями облом, это да

                        0

                        И опять пойдём по пути "заражения"? Как уже было с async/await. Конечно возможность крутая, но для этого нужно переписать очень и очень много библиотек, при этом сильно уменьшив область их применения. А переделывать каждый раз обычных объект в такой вот словарик — кажется дорогим удовольствием. Самое главное, что если нам нужно постоянно для каждого компонента вычищать колбеки и у пропсов переделывать их тип, то легче уже будет делать это всё в самом shouldComponentUpdate.

                          0

                          Я думаю для props в React это в целом не применимо. Тут скорее речь про pure FP. Какие-нибудь immutable stores аля redux

                            –1

                            Просто коренной комментарий был про использование в React)


                            На счёт immutable сторов я не очень понял в чём разница с frozen объектами, кроме того, что тут deepFreeze. На уровне интерпретатора замороженные объекты явно имеют какое-то специальное свойство и никто не запрещает v8 или spidermonkey применять оптимизации сравнения для двух frozen объектов.


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

                              0
                              я не очень понял в чём разница с frozen объектами, кроме того, что тут deepFreeze

                              BigO


                              На уровне интерпретатора замороженные объекты явно имеют какое-то специальное свойство и никто не запрещает v8 или spidermonkey применять оптимизации сравнения для двух frozen объектов.

                              Интересная мысль. Мне кажется таких оптимизаций не будет. Т.к. вначале всё равно придётся сформировать весь объект пошагово а потом его трансформировать в персистентный… Да и очень уж это будет похоже на хак.

                  0
                  В итоге придет к тому что просто изменятся функции для сравнения по значению. Чтобы не заводить этих новых объектов на случай необходимости изменить свойства, ну или хотя бы сравнить по указателю. Так что лучше бы просто ввели указатели на значение, можно и рекурсивные. И адекватную обработку сравнений с ними. Хотя конечно сейчас все это синтаксический сахар, который можно добавить на свой проект не добавляя новый символ в весь язык.
                    +2
                    Я изучал JS после питона и в процессе много страдал по поводу того, что есть слишком много того, что в питоне — одна строчка, а в js — пачка кода (lodash и ко не считаем). В последнее время JS очень активно набирает функционал в стандартную библиотеку. И писать на нём становится всё приятнее и приятнее.
                      –6
                      да хватит уже тащить в JS все что под руку попадется! Горшочек не вари!
                        +1

                        Идея прикольная, вот только её не примут. Самая главная конструкция с этими типами === не имеет никакого специального синтаксиса. Значит транспилировать её не получится. Именно по той же самой причине транспилируемости у нас приватные поля делаются через #, а не свойствами самого поля. Именно поэтому у нас для всего придумывают новый синтаксис такой, чтобы он был чётко отличим от старого...


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


                        const a = #[1, 2, 3]
                            , b = #[2, 3, 4]
                        
                        assert(a === b)  # должен быть false

                        Вот в этом примере явно видно, что на этапе компиляции нельзя понять что будет храниться в a и b, а соответственно нельзя со 100% уверенностью применить обычный оператор === или именно полифил для этой штуки c поэлементным сравнением.


                        Если же каждый оператор === заменять полифилом, то получится слишком много кода.

                          0

                          Я полагаю, что просто никто всерьёз это полифилить и не будет.

                            +2

                            Babel понемногу двигается в этом направлении. Реализована поддержка синтаксиса (аж в двух вариантах причём): https://github.com/babel/babel/pull/10865/files

                              +1

                              Хм. А как они планируют их полифилить? Просто оставят обычными объектами и массивами + Object.freeze? Или обернут какими-нибудь persistent data structure обёртками?

                                +1

                                Пока не встречал к сожалению объяснений, если встречу, отпишу.

                                    +2
                                    typeof will return an incorrect value when provided a Record or Tuple. This is because the polyfill implements the proposal via interning frozen objects

                                    В общем я выше угадал :) А вот реализация Tuple. Никаких persistent data structure. Во всяком случае на данный момент. Возможно они в планах.

                            +2
                            Будущее: классы, экземпляры которых сравниваются по значению?

                            Тут бы отлично подошли кортежи/записи + символы. Например, ввести символ Symbol.equal, который вызывается при сравнения ===. Тогда можно было бы определять этот символ в экземпляре и там возвращать кортеж/запись


                            Как бы могло выглядеть


                            class Test {
                              [Symbol.equal]() {
                                return Record({
                                  a: this.a,
                                  b: this.b,
                                  c: this.c
                                });
                              }
                            
                              constructor(a, b, c) {
                                this.a = a;
                                this.b = b;
                                this.c = c;
                              }
                            }
                            
                            const test1 = new Test(1, 2 ,3);
                            const test2 = new Test(1, 2, 3));
                            console.log(test1 === test2);
                              0

                              Звучит как отличный новый пропозал!

                                0

                                Есть предложение на перегрузку операторов, только хз в каком оно состоянии.

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

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