В JavaScript есть два основных типа значений. Первый тип — это примитивы, а второй — объекты (в том числе функции). Примитивы — это простые типы, например числа (integer, float, infiniti, NaN), булевские значения, строки, undefined
, и null
(даже несмотря на то, что typeof null === 'object'
, null
тоже относится к примитивам).
Примитивные значения являются immutable (т.е. неизменяемыми). Конечно, переменная может быть переназначена, даже если её значение является примитивом. Например, в этом коде let x = 1; x++;
, вы переназначаете переменную x
, но при этом изменение примитивного значения 1
не происходит.
Например, в языке C и некоторых других есть понятие pass-by-reference и pass-by-value (передача аргументов в функцию по ссылке или по значению). В 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. Они используются везде. Объекты часто используются в качестве коллекций пар ключ/значение. Но это накладывает определённые ограничения. До появления символов, ключи объектов могли быть только строками. Если попытаться использовать «нестроковое» значение в качестве ключа для объекта, оно будет приведено к строке. Можете увидеть это на примере:
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
была создана отчасти, чтобы позволить хранить пары ключ/значение в случае, когда ключ не является строкой.
Теперь, когда мы вспомнили, что такое примитивное значение, давайте поговорим о символах. Symbol — это примитив, который нельзя создать повторно. В каком-то смысле он подобен объекту, потому что создание нескольких экземпляров приводит к значениям, которые не будут точно равны. Но всё же, символ — это примитив, потому что он не может быть изменён. Пример использования:
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()
. Это сделано в целях обратной совместимости. В «старом» коде не предусмотрены символы, поэтому этот результат не возвращается из устаревшего метода Object.keys()
.
На первый взгляд кажется, что символы можно использовать для создания приватных свойств объекта. В других языках для этого есть скрытые свойства в классах. Отсутствие такой возможности всегда считалось недостатком 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
Примечание: в настоящее время разработчики решают проблему, связанную с добавлением приватных свойств в классы JavaScript. Эта фича называется Private Fields, и, хотя она не решит всех проблем, зато поможет работать с объектами, которые являются экземплярами класса. Private Fields поддерживаются в Chrome 74.
Символы не предоставляют полной приватности свойств, но они полезны в ситуациях, когда разным библиотекам необходимо добавить свойства к объектам без риска возникновения конфликтов имён.
Рассмотрим ситуацию, когда метаданные из двух разных библиотек нужно присоединить к объекту. Допустим, требуется установить идентификатор для объекта. Если просто задать ключ из двух букв строчного типа (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(); // random approach
function lib1tag(obj) {
obj[library1property] = 42;
}
const library2property = 'LIB2-NAMESPACE-id'; // namespaced approach
function lib2tag(obj) {
obj[library2property] = 369;
}
Звучит разумно. Такой подход очень похож на то, что происходит при использовании символов. Пока две библиотеки используют разные имена свойств, проблем не возникнет.
Здесь внимательный читатель заметил, что эти два подхода не были полностью эквивалентны. В именах свойств, где эти имена уникальны, всё ещё есть недостаток: их ключи очень легко обнаружить, особенно если код выполняется для итерации ключей, либо для сериализации объектов. Рассмотрим следующий пример:
const library2property = 'LIB2-NAMESPACE-id'; // namespaced
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 не содержал бы его значение на выходе. Почему? Потому что спецификация JSON не изменилась. JSON в качестве ключей допускает только строки, и JavaScript не представит свойства символов в конечной полезной нагрузке JSON.
Исправить проблему попадания имён свойств в вывод JSON можно с помощью Object.defineProperty()
:
const library2property = uuid(); // namespaced approach
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)); // {}
На данный момент мы почти воссоздали возможности символов. Скрытые строковые свойства и символы скрыты от сериализаторов. Свойства можно извлечь с помощью метода Reflect.ownKeys()
, и поэтому они не являются приватными. Если предположить, что для формирования имён ключей используются некие случайные значения или пространства имён библиотек, то это означает, что мы решили проблему конфликтов имён.
Но, есть ещё одна маленькая разница. Поскольку строки неизменяемы, а символы гарантированно уникальны, всегда есть возможность того, что кто-то, перебрав все возможные сочетания символов в строке, вызовет конфликт имён. Математически это показывает, что символы дают преимущество перед строками.
В Node.js при проверке объекта (например, с помощью console.log()
), если обнаружен метод объекта с именем inspect
, то функция вызывается и выходные данные используются в качестве зарегистрированного представления объекта. Не все могут учесть такое поведение, поэтому метод с именем inspect
часто конфликтует с объектами, созданными пользователями. Теперь для этого существует символ require('util').inspect.custom
. Метод inspect
является устаревшим в Node.js v10 и полностью игнорируется в v11. Теперь случайно изменить поведение 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 есть функция, которая нарушает приватность прокси объектов. В самом JavaScript этой функции нет, и она не используется в других средах, например в веб-браузере. Она позволяет получить доступ к базовому объекту при наличии доступа к прокси. Следующий пример показывает, как использовать эту функцию, чтобы обойти приватность свойства:
const [originalObject] = process
.binding('util')
.getProxyDetails(proxy);
const allKeys = Reflect.ownKeys(originalObject);
console.log(allKeys[3]); // Symbol(fav book)
Теперь нужно либо изменить глобальный объект Reflect
, либо привязку процесса util
, чтобы предотвратить их использование в конкретном экземпляре Node.js. Но это не простая задача и совсем другая история.
Перевод статьи Thomas Hunter II: JavaScript Symbols: But Why?
Комментарии