В этом посте мы вкратце рассмотрим предложение в стандарт 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. Будущее: классы, экземпляры которых сравниваются по значению?
Вместо простых объектов или массивов мне нравится использовать классы для создания контейнеров с данными. Поэтому я надеюсь, что в будущем мы получим классы, экземпляры которых могут быть неизменяемыми и сравниваться по значению.
Было бы также здорово, если бы у нас была поддержка глубокого и неразрушающего обновления данных, содержащих объекты, созданные такими классами.
7. Признательность
- Спасибо Дэниелу Эренбергу и Робу Палмеру за рецензирование этого поста в блоге.
- Среди прочего, следующие люди ответили на мой твит и внесли свой вклад в этот пост: @asp_net, @bomret, @imchriskitchens, @jamiedixon, @mattxcurtis, @orangecms.
8. Что читать дальше
- Глава «Проблемы общего изменяемого состояния и как их избежать» в «Deep JavaScript»
v1vendi
Несмотря на то, что добавление нового функционала не может не радовать, синтаксические извращения, появляющиеся за счёт этого добавления, вызывают расстройство, страшно представлять себе, как разбирать конструкции типа
обучение языку превратится в знаменитую картинку "как нарисовать сову"
AxisPod
Да ничего сложного тут. Если и злоупортреблять просто тернарным оператором, то нечитаемая фигня получается.
timon_aeg
Это же хорошо. Еще чуть-чуть и будет как во взрослых функциональных языках.
ilbu
Со «старым» синтаксисом подобная конструкция выглядит еще более уродливой:
v1vendi
старый синтаксис заставляет её быть настолько длинной, что вынуждает разработчика таки разбить её на несколько инструкций. Я бы и с новым за такое по рукам бил, но если не экономить на строчках, то на "совсем старом" синтаксисе получится (не считая особенностей
const
)Я понимаю, что никто не запрещает писать с новыми фичами не экономя строк
И я твёрдо уверен, что каждая из использованных в примере фичей — крайне полезна, большинство из них я регулярно использую.
Но монстры, которых МОЖНО породить с этими возможностями, пугают :)
Не могу удержаться процитировать прекрасного aemkei и сравнить свою конструкцию с полностью валидным JS:
P.S: как раз на картинке используются только самые базовые функции JS и развлечения с приведением типов