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

от автора

В этом посте мы вкратце рассмотрим предложение в стандарт 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. Что читать дальше

ссылка на оригинал статьи https://habr.com/ru/post/504092/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *