[Перевод] Первый взгляд на записи и кортежи в JavaScript
В этом посте мы вкратце рассмотрим предложение в стандарт ECMAScript «Record & Tuple» от Робина Рикарда и Рика Баттона. Это предложение добавляет два вида составных примитивных значений в JavaScript:
- записи (records) — неизменяемая и сравниваемая по значению версия простых объектов;
- кортежи (tuples) — неизменяемая и сравниваемая по значению версия массивов.
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. Будущее: классы, экземпляры которых сравниваются по значению?
Вместо простых объектов или массивов мне нравится использовать классы для создания контейнеров с данными. Поэтому я надеюсь, что в будущем мы получим классы, экземпляры которых могут быть неизменяемыми и сравниваться по значению.
Было бы также здорово, если бы у нас была поддержка глубокого и неразрушающего обновления данных, содержащих объекты, созданные такими классами.