Символьные примитивы — это одно из новшеств стандарта ES6, которое принесло в JavaScript некоторые ценные возможности. Символы, представленные типом данных Symbol, особенно полезны при использовании их в качестве идентификаторов свойств объектов. В связи с таким сценарием их применения напрашивается вопрос о том, что такого они могут, чего не могут строки.
В материале, перевод которого мы сегодня публикуем, речь пойдёт о типе данных Symbol в JavaScript. Начнём мы с обзора некоторых возможностей JavaScript, в которых нужно ориентироваться для того, чтобы разобраться с символами.
Предварительные сведения
В JavaScript, по сути, существует два вида значений. Первый вид — примитивные значения, второй — объектные (в их число входят и функции). Примитивные значения включают в себя простые типы данных наподобие чисел (сюда входит всё — от целых чисел, до чисел с плавающей точкой, значений Infinity
и NaN
), логических значений, строк, значений undefined
и null
. Обратите внимание на то, что, хотя при проверке вида typeof null === 'object'
получается true
, null
— это примитивное значение.
Примитивные значения иммутабельны. Их нельзя изменять. Конечно, в переменную, хранящую примитивное значение, можно записать что-то новое. Например, здесь выполняется запись нового значения в переменную x
:
let x = 1;
x++;
Но при этом не происходит изменения (мутации) примитивного числового значения 1
.
В некоторых языках, например — в C, есть концепции передачи аргументов функций по ссылке и по значению. В JavaScript тоже есть нечто подобное. То, как именно организуется работа с данными, зависит от их типа. Если в функцию передают примитивное значение, представленное некоей переменной, а потом изменяют его в этой функции, значение, хранящееся в исходной переменной, при этом не меняется. Однако если в функцию передать объектное значение, представленное переменной, и модифицировать его, то изменится и то, что хранится в этой переменной.
Рассмотрим следующий пример:
function primitiveMutator(val) {
val = val + 1;
}
let x = 1;
primitiveMutator(x);
console.log(x); // 1
function objectMutator(val) {
val.prop = val.prop + 1;
}
let obj = { prop: 1 };
objectMutator(obj);
console.log(obj.prop); // 2
Примитивные значения (за исключением таинственного NaN
, которое не равно самому себе) всегда оказываются равными другим примитивным значениям, выглядящим так же, как они сами. Например:
const first = "abc" + "def";
const second = "ab" + "cd" + "ef";
console.log(first === second); // true
Однако конструирование объектных значений, внешне выглядящих одинаково, не приведёт к тому, что получатся сущности, при сравнении которых будет выявлено их равенство друг другу. Проверить это можно так:
const obj1 = { name: "Intrinsic" };
const obj2 = { name: "Intrinsic" };
console.log(obj1 === obj2); // false
// При этом их свойства .name являются примитивными значениями:
console.log(obj1.name === obj2.name); // true
Объекты играют фундаментальную роль в JavaScript. Они применяются буквально повсюду. Например, часто их используют в виде коллекций вида ключ/значение. Но до появления типа данных Symbol
в качестве ключей объектов можно было применять лишь строки. В этом крылось серьёзное ограничение использования объектов в виде коллекций. При попытке назначения нестрокового значения в виде ключа объекта это значение приводилось к строке. Убедиться в этом можно так:
const obj = {};
obj.foo = 'foo';
obj['bar'] = 'bar';
obj[2] = 2;
obj[{}] = 'someobj';
console.log(obj);
// { '2': 2, foo: 'foo', bar: 'bar',
'[object Object]': 'someobj' }
Кстати, хотя это немного уводит нас от темы символов, хочется отметить, что структура данных Map
была создана для того чтобы позволить использовать хранилища данных формата ключ/значение в ситуациях, когда ключ не является строкой.
Что такое символ?
Теперь, когда мы выяснили особенности примитивных значений в JavaScript, мы наконец готовы к тому, чтобы приступить к разговору о символах. Символ — это уникальное примитивное значение. Если подходить к символам с этой позиции, то можно заметить, что символы в этом плане похожи на объекты, так как создание нескольких экземпляров символов приведёт к созданию разных значений. Но символы, кроме того, являются иммутабельными примитивными значениями. Вот пример работы с символами:
const s1 = Symbol();
const s2 = Symbol();
console.log(s1 === s2); // false
При создании экземпляра символа можно воспользоваться необязательным первым строковым аргументом. Этот аргумент представляет собой описание символа, которое предназначено для использования при отладке. На сам символ это значение не влияет.
const s1 = Symbol('debug');
const str = 'debug';
const s2 = Symbol('xxyy');
console.log(s1 === str); // false
console.log(s1 === s2); // false
console.log(s1); // Symbol(debug)
Символы как ключи свойств объектов
Символы можно использовать в качестве ключей свойств объектов. Это очень важно. Вот пример использования их в таком качестве:
const obj = {};
const sym = Symbol();
obj[sym] = 'foo';
obj.bar = 'bar';
console.log(obj); // { bar: 'bar' }
console.log(sym in obj); // true
console.log(obj[sym]); // foo
console.log(Object.keys(obj)); // ['bar']
Обратите внимание на то, что ключи, заданные символами, не возвращаются при вызове метода Object.keys()
. Код, написанный до появления в JS символов, ничего о них не знает, в результате сведения о ключах объектов, представленных символами, не должны возвращаться древним методом Object.keys()
.
На первый взгляд может показаться, что вышеописанные особенности символов позволяют использовать их для создания приватных свойств JS-объектов. Во многих других языках программирования можно создавать скрытые свойства объектов с использованием классов. Отсутствие этой возможности уже давно считается одним из недостатков JavaScript.
К сожалению, код, который работает с объектами, может беспрепятственно обращаться к их строковым ключам. Код может обращаться и к ключам, заданным символами, причём, даже в том случае, если у кода, из которого работают с объектом, нет доступа к соответствующему символу. Например, с помощью метода Reflect.ownKeys()
можно получить список всех ключей объекта, и тех, что являются строками, и тех, что являются символами:
function tryToAddPrivate(o) {
o[Symbol('Pseudo Private')] = 42;
}
const obj = { prop: 'hello' };
tryToAddPrivate(obj);
console.log(Reflect.ownKeys(obj));
// [ 'prop', Symbol(Pseudo Private) ]
console.log(obj[Reflect.ownKeys(obj)[1]]); // 42
Обратите внимание на то, что в настоящее время ведётся работа над тем, чтобы оснастить классы возможностью использования приватных свойств. Эта возможность называется Private Fields (приватные поля). Она, правда, не затрагивает абсолютно все объекты, относясь лишь к тем из них, которые созданы на основе предварительно подготовленных классов. Поддержка приватных полей уже имеется в браузере Chrome версии 72 и старше.
Предотвращение коллизий имён свойств объектов
Символы, конечно, не добавляют в JavaScript возможностей по созданию приватных свойств объектов, но они являются ценным новшеством языка по другим причинам. А именно, они полезны в ситуациях, когда неким библиотекам нужно добавлять свойства в объекты, описанные за их пределами, и при этом не опасаться коллизии имён свойств объектов.
Рассмотрим пример, в котором две различные библиотеки хотят добавить к объекту метаданные. Возможно, и той и другой библиотеке нужно оснастить объект некими идентификаторами. Если для имени подобного свойства просто использовать нечто вроде строки id
, состоящей из двух букв, можно столкнуться с ситуацией, когда одна библиотека перезапишет свойство, заданное другой.
function lib1tag(obj) {
obj.id = 42;
}
function lib2tag(obj) {
obj.id = 369;
}
Если же воспользоваться в нашем примере символами, то каждая библиотека может сгенерировать, при инициализации, нужные ей символы. Затем эти символы могут быть использованы для назначения свойств объектам и для доступа к этим свойствам.
const library1property = Symbol('lib1');
function lib1tag(obj) {
obj[library1property] = 42;
}
const library2property = Symbol('lib2');
function lib2tag(obj) {
obj[library2property] = 369;
}
Именно глядя на подобный сценарий можно ощутить пользу от появления символов в JavaScript.
Однако тут может возникнуть вопрос, касающийся использования библиотеками, для имён свойств объектов, случайных строк или строк, со сложной структурой, включающих в себя, например, название библиотеки. Подобные строки могут образовывать нечто вроде пространств имён для идентификаторов, используемых библиотеками. Например, это может выглядеть так:
const library1property = uuid(); // вызов функции для получения случайного значения
function lib1tag(obj) {
obj[library1property] = 42;
}
const library2property = 'LIB2-NAMESPACE-id'; // использование пространства имён библиотеки
function lib2tag(obj) {
obj[library2property] = 369;
}
В общем-то, можно поступить и так. Подобные подходы, на самом деле, очень похожи на то, что происходит при использовании символов. И если, используя случайные идентификаторы или пространства имён, пара библиотек не сгенерирует, по воле случая, одинаковые имена свойств, то проблем с именами не будет.
Проницательный читатель сказал бы сейчас, что два рассматриваемых подхода к именованию свойств объектов не являются полностью эквивалентными. У имён свойств, которые формируются случайным образом или с использованием пространств имён, есть недостаток: соответствующие ключи очень легко обнаружить, особенно если в коде выполняется перебор ключей объектов или их сериализация. Рассмотрим следующий пример:
const library2property = 'LIB2-NAMESPACE-id'; // используется пространство имён
function lib2tag(obj) {
obj[library2property] = 369;
}
const user = {
name: 'Thomas Hunter II',
age: 32
};
lib2tag(user);
JSON.stringify(user);
// '{"name":"Thomas Hunter II","age":32,"LIB2-NAMESPACE-id":369}'
Если бы в этой ситуации для имени ключа использовался бы символ, тогда JSON-представление объекта не содержало бы значения символа. Почему это так? Дело в том, что то, что в JavaScript появился новый тип данных, ещё не означает того, что изменения внесены и в спецификацию JSON. JSON поддерживает, в качестве ключей свойств объектов, только строки. При сериализации объекта не делается попыток представить символы в каком-то особом виде.
Рассматриваемую проблему попадания имён свойств в JSON-представление объектов можно решить благодаря использованию Object.defineProperty()
:
const library2property = uuid(); // случайное значение
function lib2tag(obj) {
Object.defineProperty(obj, library2property, {
enumerable: false,
value: 369
});
}
const user = {
name: 'Thomas Hunter II',
age: 32
};
lib2tag(user);
// '{"name":"Thomas Hunter II",
"age":32,"f468c902-26ed-4b2e-81d6-5775ae7eec5d":369}'
console.log(JSON.stringify(user));
console.log(user[library2property]); // 369
Строковые ключи, «скрытые» благодаря установке их дескриптора enumerable
в значение false
, ведут себя практически так же, как и ключи, представленные символами. И те и другие не выводятся при вызове Object.keys()
, и те и другие можно обнаружить, воспользовавшись Reflect.ownKeys()
. Вот как это выглядит:
const obj = {};
obj[Symbol()] = 1;
Object.defineProperty(obj, 'foo', {
enumberable: false,
value: 2
});
console.log(Object.keys(obj)); // []
console.log(Reflect.ownKeys(obj)); // [ 'foo', Symbol() ]
console.log(JSON.stringify(obj)); // {}
Тут, надо сказать, мы почти воссоздали возможности символов, пользуясь другими средствами JS. В частности, и ключи, представленные символами, и скрытые ключи не попадают в JSON-представление объекта. И те и другие можно узнать, обратившись к методу Reflect.ownKeys()
. В результате и те и другие нельзя назвать по-настоящему приватными. Если предположить, что для формирования имён ключей используются некие случайные значения или пространства имён библиотек, то это означает, что от риска возникновения коллизии имён мы избавились.
Однако между использованием имён-символов и имён, созданных с использованием других механизмов, есть одно маленькое различие. Так как строки иммутабельны, а символы гарантированно уникальны, всегда есть возможность того, что кто-то, перебрав все возможные сочетания символов в строке, вызовет коллизию имён. С математической точки зрения это означает, что символы действительно дают нам ценную возможность, которая отсутствует у строк.
В Node.js, при исследовании объектов (например, с использованием console.log()
), если обнаруживается метод объекта, имеющий имя inspect
, то, для получения строкового представления объекта и последующего его вывода на экран, используется именно этот метод. Несложно понять, что абсолютно все не могут этого учитывать, поэтому подобное поведение системы может привести к вызову метода объекта inspect
, который предназначен для решения задач, не относящихся к формированию строкового представления объекта. Эта возможность признана устаревшей в Node.js 10, в 11 версии методы с подобным именем просто игнорируются. Теперь для реализации этой возможности предусмотрен символ require('util').inspect.custom
. А это значит, что никто уже никогда не сможет непреднамеренно нарушить работу системы, создав метод объекта с именем inspect
.
Имитация приватных свойств
Вот интересный подход, который можно использовать для имитации приватных свойств объектов. Этот подход предусматривает применение ещё одной современной возможности JavaScript — прокси-объектов. Такие объекты служат обёртками для других объектов, которые позволяют программисту вмешиваться в действия, выполняемые с этими объектами.
Прокси-объекты предлагают много способов для перехвата действий, выполняемых над объектами. Нас интересует возможность управления операциями чтения ключей объекта. В подробности о прокси-объектах мы тут углубляться не будем. Если вам они интересны — взгляните на эту публикацию.
Мы можем использовать прокси для того чтобы управлять тем, какие свойства объекта видны извне. В данном случае мы хотим создать прокси, который скрывает два известных нам свойства. Одно имеет строковое имя _favColor
, а второе представлено символом, записанным в переменную favBook
:
let proxy;
{
const favBook = Symbol('fav book');
const obj = {
name: 'Thomas Hunter II',
age: 32,
_favColor: 'blue',
[favBook]: 'Metro 2033',
[Symbol('visible')]: 'foo'
};
const handler = {
ownKeys: (target) => {
const reportedKeys = [];
const actualKeys = Reflect.ownKeys(target);
for (const key of actualKeys) {
if (key === favBook || key === '_favColor') {
continue;
}
reportedKeys.push(key);
}
return reportedKeys;
}
};
proxy = new Proxy(obj, handler);
}
console.log(Object.keys(proxy)); // [ 'name', 'age' ]
console.log(Reflect.ownKeys(proxy)); // [ 'name', 'age', Symbol(visible) ]
console.log(Object.getOwnPropertyNames(proxy)); // [ 'name', 'age' ]
console.log(Object.getOwnPropertySymbols(proxy)); // [Symbol(visible)]
console.log(proxy._favColor); // 'blue
Справиться со свойством, имя которого представлено строкой _favColor
, несложно: достаточно почитать исходный код. Динамические ключи (наподобие uuid-ключей, которые мы видели выше), можно подобрать брутфорсом. Но без ссылки на символ получить доступ к значению Metro 2033
из объекта proxy
нельзя.
Тут надо отметить, что в Node.js есть одна особенность, нарушающая приватность прокси-объектов. Эта особенность не существует в самом языке, поэтому она не актуальна для других сред выполнения JS, таких, как браузер. Дело в том, что эта особенность позволяет получать доступ к объекту, скрытому за прокси-объектом, при наличии доступа к прокси-объекту. Вот пример, демонстрирующий возможность обхода механизмов, показанных в предыдущем фрагменте кода:
const [originalObject] = process
.binding('util')
.getProxyDetails(proxy);
const allKeys = Reflect.ownKeys(originalObject);
console.log(allKeys[3]); // Symbol(fav book)
Теперь, чтобы предотвратить использование этой особенности в конкретном экземпляре Node.js, нужно либо модифицировать глобальный объект Reflect
, либо привязку процесса util
. Однако это — та ещё задача. Если вам это интересно — взгляните на эту публикацию, посвящённую защите API, основанных на JavaScript.
Итоги
В этом материале мы поговорили о типе данных Symbol
, о том, какие возможности он даёт JavaScript-разработчикам, и о том, какие существующие механизмы языка можно использовать для имитации этих возможностей.
Уважаемые читатели! Пользуетесь ли вы символами в своих JavaScript-проектах?
Автор: ru_vds