JavaScript Symbols: навіщо?

10 хв. читання

Symbols — новий примітивний тип даних у JavaScript. Серед його переваг найбільш корисною є можливість визначення властивостей об'єктів. Але що в Symbols такого особливого, що відрізняє цей тип від звичайних рядків?

Перш ніж поринемо у вивчення symbol, пригадаємо декілька фіч JavaScript.

Передмова

В JavaScript існує два типи значень: примітиви та об'єкти (що також влючають функції). Примітиви являють собою прості типи даних, на зразок чисел (усе від цілочислових до значень с плаваючою комою, а також Infinity та NaN), boolean, рядки, undefined та null (помітьте: хоч виконується typeof null === 'object', null — досі примітивний тип).

Примітивні значення незмінні. Звичайно ж, змінну примітивного типу даних можна переприсвоїти. Наприклад, коли ви пишете let x = 1; x++;, ви переприсвоюєте змінну x. Але ви не змінили примітивне числове значення 1.

В деяких мовах програмування (наприклад, С) існує концепція передачі змінної по значенню та за посиланням. 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 properties примітиви:
console.log(obj1.name === obj2.name); // true

Об'єкти відіграють важливу роль у JavaScript — вони усюди. Часто об'єкти використовують як колекції пар ключ/значення. Однак, у такого підходу є значне обмеження: доки не існувало типу symbols, ключі об'єктів могли бути лише рядками. Якщо ми намагалися створити ключ не рядок, будь-яке значення перетворювалось на рядок. Розглянемо приклад:

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?

Тепер, коли ми знаємо, що таке примітивне значення, ми, нарешті, готові дати визначення для Symbol. Symbol — примітив, який неможливо повторно відтворити. Подібно до об'єктів, різні екземпляри symbol не будуть суворо рівними. Але symbol поєднує також ознаки примітивів, тому що є незмінюваним. Розглянемо приклад використання:

const s1 = Symbol();
const s2 = Symbol();
console.log(s1 === s2); // false

При створенні екземпляра символу існує необов'язковий перший аргумент, в який можна передати рядок. Це значення корисне для полегшення в налагодженні коду, а іншого впливу на symbol не має.

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)

Symbols як властивості об'єктів

Поширений спосіб використання Symbols — ключі в об'єктах. Наприклад:

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() не повертає ключі типу Symbols. Усе з метою зворотної сумісності. Старий код не знає про існування Symbols, тому виклик Object.keys() з symbols повинен повертати той самий результат.

Одразу можна помітити, що Symbols ідеально підходять для створення приватних полів в об'єктах. Більшість мов програмування мають приховані властивості в їхніх класах, а відсутність такого механізму у JavaScript був значним недоліком.

На жаль, код, який взаємодіє з цим об'єктом, все ще може отримати доступ до властивостей, ключі яких типу Symbols. Це навіть можливо в ситуаціях, коли область коду, де викликається метод, ще не має доступу до самого symbol. Як приклад, метод Reflect.ownKeys() може надати список усіх ключів об'єкту: як рядків, так і symbols:

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.

Запобігання колізій назв властивостей

Symbols не дають безпосередньої можливості використовувати приватні поля у JavaScript, але їхня основна користь не в цьому. Symbols допомагають уникнути колізій, коли різноманітні бібліотеки намагаються додати властивості до об'єктів.

Уявіть ситуацію, коли дві різні бібліотеки хочуть додати певні метадані до об'єкта. Можливо, вони хочуть встановити ідентифікатор. Просто використовувати рядок id як ключ не надійно, тому що інші бібліотеки можуть застосувати той самий підхід.

function lib1tag(obj) {
  obj.id = 42;
}
function lib2tag(obj) {
  obj.id = 369;
}

З symbols, кожна бібліотека може генерувати потрібний symbol при створенні екземпляру:

const library1property = Symbol('lib1');
function lib1tag(obj) {
  obj[library1property] = 42;
}
const library2property = Symbol('lib2');
function lib2tag(obj) {
  obj[library2property] = 369;
}

Такий підхід дійсно має переваги.

Ви можете поцікавитись чому кожна бібліотека не може просто генерувати випадковий рядок або використовувати спеціальний рядок з простору імен при створенні екземпляра?

const library1property = uuid(); // підхід з випадковим значенням
function lib1tag(obj) {
  obj[library1property] = 42;
}
const library2property = 'LIB2-NAMESPACE-id'; // підхід з простором імен
function lib2tag(obj) {
  obj[library2property] = 369;

І ви матимете рацію. Це дуже нагадує підхід з використанням symbols. Якщо дві бібліотеки не захочуть використати однакову назву для властивості, то не буде ризику перекриття.

Тут уважний читач помітить, що описані підходи не ідентичні. Властивості з унікальними іменами все ще мають недолік: їх ключі легко знайти, особливо, якщо виконати ітерацію ключами або здійснити серіалізацію об'єктів. Розглянемо наступний приклад:

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}'

Якщо б назва властивості була типу symbol, ми не побачили б це значення у JSON. Чому так? Якщо в JavaScript з'явились symbols, це ще не означає, що специфікація JSON змінилась. JSON дозволяє зберігати лише рядки як ключі, тому JavaScript не намагатиметься відобразити властивості з symbol у 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 поводяться дуже подібно до ключів-symbols. В обох випадках вони приховані від 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(), тому вони насправді не приватні. Припустивши, що ми використали якийсь простір імен / випадкове значення для рядкових назв властивостей, ми позбавились ризику випадкової колізії внаслідок використання декількох бібліотек.

Але досі є невелика відмінність. Оскільки рядки незмінні, а symbols завжди гарантують унікальність, досі існує вірогідність, що хтось згенерує таку комбінацію рядків, яка призведе до колізії. Тобто symbols мають очевидну перевагу, яку ми не можемо отримати від рядків.

Якщо у Node.js при перевірці об'єкта (наприклад, використовуючи console.log()) буде виявлено метод inspect, він викликається і його результат використовується для логування вмісту об'єкта. Звичайно, така поведінка не очікується від метода inspect, створеного розробником. Тут ми знову маємо справу з колізією. На допомогу приходять symbols: require('util').inspect.custom.

Зверніть увагу: метод inspect застарілий у Node.js v10 та повністю ігнорується у in v11.

Імітація приватних властивостей

Розглянемо цікавий підхід для імітації приватних властивостей об'єкта. Для цього нам знадобиться ще одна фіча JavaScript: проксі. Проксі по суті огортає об'єкт та дозволяє нам втручатися у різні взаємодії з цим об'єктом.

Проксі пропонують безліч способів для перехоплення дій над об'єктами. Нас цікавить що відбуватиметься при спробі прочитати ключі об'єкта. У статті не будемо детально розглядати проксі, тому якщо хочете дізнатися більше, відвідайте посилання.

Ми можемо використовувати проксі, щоб приховати доступні властивості об'єкта. У нашому прикладі ми збираємося створити проксі, що приховуватиме дві властивості: _favColor — рядок, а favBook — symbol:

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) можна також легко знайти. А ось без прямого посилання на symbol, ніхто не отримає доступ до значення Metro 2033 з об'єкта proxy.

Node.js Caveat: у Node.js є фіча, що порушує приватність проксі. Вона не стосується самого JavaScript та не застосовується, наприклад, у браузері. З нею ми можемо отримати доступ до основного об'єкта, використавши проксі. Переконаємось у коді:

const [originalObject] = process
  .binding('util')
  .getProxyDetails(proxy);
const allKeys = Reflect.ownKeys(originalObject);
console.log(allKeys[3]); // Symbol(fav book)

Тепер нам треба буде або змінити глобальний об'єкт Reflect, або змінити .binding('util'), щоб запобігти використання властивості у конкретному екземплярі Node.js.

Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 629
Приєднався: 1 рік тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід