Що відбувається, коли об’єкти додаються obj1 + obj2
, віднімаються obj1 - obj2
або друкуються за допомогою alert(obj)
?
JavaScript не дозволяє налаштувати те, як працюють оператори з об’єктами. На відміну від деяких інших мов програмування, таких як Ruby або C++, ми не можемо реалізувати спеціальний об’єктний метод для обробки додавання (або інших операторів).
У разі таких операцій, об’єкти автоматично перетворюються на примітиви, а потім операція здійснюється над цими примітивами та повертає результат саме у вигляді примітивного значення.
Це важливе обмеження, оскільки результат obj1 + obj2
(або інша математична операція) не може бути іншим об’єктом!
Наприклад, ми не можемо зробити об’єкти, що представляють вектори або матриці (або досягнення або що завгодно) та додати їх і очікувати, що "сплюсованим" результатом буде об’єкт. Такі архітектурні особливості автоматично недоступні.
Отже, оскільки ми не можемо технічно нічого з цим зробити, у реальних проектах немає жодних математичних дій з об’єктами. Навіть коли в рідкісних винятках це трапляється, то це через помилку в коді.
У цьому розділі ми розглянемо те, як об’єкти перетворюється на примітиви і як налаштувати це.
У нас є дві цілі:
- Це дозволить нам зрозуміти, що відбувається у випадку помилок в коді, коли така операція відбулася випадково.
- Є винятки, де такі операції можливі та доцільні. Наприклад, віднімання або порівняння дати (
Date
об’єкти). Ми будемо зустрічатися з ними пізніше.
У розділі info:type-conversions ми розглянули правила числових, рядкових та логічних перетворень примітивів. Але ми пропустили перетворення об'єктів. Тепер, коли ми знаємо про методи і символи, стає можливим це зробити.
- Немає ніякого перетворення в тип Boolean. Всі об'єкти є істинними (true) в булевому контексті, ось так просто. Існують лише числові та рядкові перетворення.
- Числове перетворення відбувається коли ми віднімаємо об’єкти або застосовуємо математичні функції. Наприклад,
Date
об’єкти (розглянуті в розділі info:date) можуть відніматися, і результатdate1 - date2
-- це різниця у часі між двома датами. - Що стосується перетворення рядків -- це зазвичай відбувається коли ми виводимо об’єкт (наприклад
alert(obj)
та в подібних ситуаціях).
Ми можемо реалізувати перетворення рядків і чисел самостійно, використовуючи спеціальні методи об’єкта.
Тепер давайте перейдемо до технічних деталей, тому що це єдиний спосіб глибоко розкрити тему.
Як JavaScript вирішує, яке перетворення застосувати?
Є три варіанти перетворення типів, що відбуваються в різних ситуаціях. Вони називаються "підказками" (англ. hints), і описані в специфікації:
"string"
: Для перетворення об’єкта в рядок. Тоді, коли ми виконуємо над об'єктом операцію, яка очікує рядок. Наприклад, alert
:
```js
// вивід
alert(obj);
// використання об’єкта як ключа властивості об’єкта
anotherObj[obj] = 123;
```
"number"
: Для перетворення об’єкта в число. Тоді, коли ми робимо математичні операції:
```js
// явне перетворення
let num = Number(obj);
// математичні операції (крім бінарного додавання)
let n = +obj; // унарне додавання
let delta = date1 - date2;
// порівняння менше/більше
let greater = user1 > user2;
```
Більшість вбудованих математичних функцій також включають і такі перетворення.
"default"
: Трапляється в рідкісних випадках, коли оператор "не впевнений", який тип очікувати.
Наприклад, бінарний плюс `+` може працювати як з рядками (об’єднує їх), так і з цифрами (додає їх), тому обидва випадки -- і рядки, і цифри -- будуть працювати. Отже, якщо бінарний плюс отримує об’єкт як аргумент, він використовує підказку `"default"` для його перетворення.
Також, якщо об’єкт порівнюється за допомогою (`==`) з рядком, числом або символом, то також незрозуміло, яке перетворення слід виконати, тому використовується підказка `"default"`.
```js
// бінарний плюс використовує підказку "default"
let total = obj1 + obj2;
// порівняння obj == число використовує підказку "default"
if (user == 1) { ... };
```
Оператори порівняння більше та менше, такі як `<` `>`, також можуть працювати як з рядками, так і з числами. Проте, вони з історичних причин використовують підказку `"number"` , а не підказку `"default"`.
Але на практиці все трохи простіше.
Всі вбудовані об’єкти, за винятком лише об’єкту Date
(про який ми дізнаємося пізніше), реалізовують перетворення з підказкою "default"
так само як з підказкою "number"
. І нам, мабуть, слід робити так само.
Проте важливо знати про всі три підказки і незабаром ми побачимо чому.
Щоб зробити перетворення, JavaScript намагається знайти та викликати три методи об’єкта:
- Намагається викликати метод
obj[Symbol.toPrimitive](hint)
(це такий метод з системним символомSymbol.toPrimitive
в якості символьного ключа), якщо в цього об'єкта такий метод існує, - Інакше, якщо підказка має значення
"string"
- намагається викликати
obj.toString()
абоobj.valueOf()
-- залежно від того, який з цих методів існує в конкретного об'єкта.
- намагається викликати
- Або, якщо підказка має значення
"number"
або"default"
- спробує викликати
obj.valueOf()
абоobj.toString()
-- залежно від того, який з цих методів існує в конкретного об'єкта.
- спробує викликати
Почнемо з першого методу. Є вбудований символ під назвою Symbol.toPrimitive
, який слід використовувати в якості ключа методу перетворення, як, наприклад:
obj[Symbol.toPrimitive] = function(hint) {
// тут йде код, щоб перетворити цей об’єкт в примітив
// він повинен повернути примітивне значення
// в hint надходить або "string", або "number", або "default"
};
Якщо метод symbol.toPrimitive
в конкретного об'єкта існує, він буде використовуватись для всіх підказок, і ніякі інші методи не потрібні.
Наприклад, тут об’єкт user
імплементує цей метод:
let user = {
name: "Іван",
money: 1000,
[Symbol.toPrimitive](hint) {
alert(`hint: ${hint}`);
return hint == "string" ? `{name: "${this.name}"}` : this.money;
}
};
// демонстрація перетворення:
alert(user); // виведе 'hint: string' і поверне '{name: "Іван"}'
alert(+user); // виведе 'hint: number' і поверне 1000
alert(user + 500); // виведе 'hint: default' і поверне 1500
Як ми бачимо з коду, user
стає самоописуючим рядком або грошовою сумою в залежності від способу перетворення. Один єдиний метод [Symbol.toPrimitive]
об’єкта user
обробляє всі випадки перетворення.
Якщо немає Symbol.toPrimitive
тоді JavaScript намагається знайти методи toString
і valueOf
:
- Якщо підказка має значення
"string"
: JavaScript спершу спробує викликати методtoString
, і якщо в цього об'єкта цей метод відсутній або якщо якщо цей метод повертає об'єкт замість примітивного значення, тоді JavaScript викличеvalueOf
(таким чином, при перетворенні об'єкта в рядок пріоритет маєtoString
). - Якщо підказка має значення
"number"
/"default"
, JavaScript спершу спробує викликатиvalueOf
, і якщо цей метод відсутній або якщо якщо цей метод повертає об'єкт замість примітивного значення, тоді JavaScript викличеtoString
(таким чином, для математичних дій пріоритет маєvalueOf
).
Методи toString
і valueOf
походять з стародавніх часів. Вони не є символами (багато років назад символів в JavaScript не існувало), а скоріше є "звичними" методами, що названі за допомогою рядків. Вони надають альтернативний «старомодний» спосіб реалізації перетворення.
Ці методи повинні повертати примітивне значення. Якщо toString
чи valueOf
повертає об’єкт, то цей метод ігнорується (так само, якби цього методу не існувало).
В звичайного об'єкта методи toString
та valueOf
присутні за замовчуванням. І за замовчуванням вони вони працюють наступним чином:
- Метод
toString
повертає рядок"[object Object]"
. - Метод
valueOf
повертає сам об’єкт.
Ось демо:
let user = {name: "Іван"};
alert(user); // [object Object]
alert(user.valueOf() === user); // true
Отже, якщо ми спробуємо використовувати об’єкт як рядок, наприклад в alert
та ін., то за замовчуванням ми побачимо [object Object]
.
Вбудований метод valueOf
згадується тут лише заради повноти картини, щоб уникнути непорозумінь. Бо JavaScript цей метод ігнорує. Ігнорує, бо як бачите, цей метод повертає сам об’єкт. Не питайте мене чому так. Просто так склалось з історичних причин. Власне ми можемо уявити ніби об'єкта valueOf
не існує.
Давайте самостійно реалізуємо ці методи, щоб налаштувати перетворення.
Наприклад, тут user
при перетворенні стає тим самим, що й в прикладі вище, але цього разу використовуючи комбінацію toString
і valueOf
замість Symbol.toPrimitive
:
let user = {
name: "Іван",
money: 1000,
// для hint="string"
toString() {
return `{name: "${this.name}"}`;
},
// для hint="number" чи "default"
valueOf() {
return this.money;
}
};
alert(user); // toString -> {name: "Іван"}
alert(+user); // valueOf -> 1000
alert(user + 500); // valueOf -> 1500
Як бачимо, поведінка така ж, як і в попередньому прикладі з Symbol.toPrimitive
.
Часто нам потрібне одне єдине «універсальне» місце для обробки всіх перетворень примітивів. У цьому випадку ми можемо реалізувати тільки toString
, ось так:
let user = {
name: "Іван",
toString() {
return this.name;
}
};
alert(user); // toString -> Іван
alert(user + 500); // toString -> Іван500
Якщо відсутній Symbol.toPrimitive
і valueOf
, то всі перетворення примітивів буде обробляти toString
.
Важливо знати, що методи перетворення примітивів не обов’язково повертають примітив "підказаного" типу.
JavaScript не вимагає від toString
повернути саме рядок якщо підказка має значення "string"
. І не вимагає від Symbol.toPrimitive
повернути число якщо підказка має значення "number'
.
Єдина вимога до методів перетворення примітивів: ці методи повинні повертати саме примітивний тип, а не об’єкт.
З історичних причин, якщо `toString` чи `valueOf` поверне об’єкт, це не призведе до помилки. Таке значення буде просто проігнороване (так само, якби цей метод не існував). Воно не призведе до помилки лише тому, що в давнину JavaScript не вмів нормально видавати помилки.
Але, вимоги до `Symbol.toPrimitive` суворіші. Він *мусить* повернути примітив, інакше JavaScript видасть помилку.
Як ми вже знаємо, багато операторів та функцій виконують перетворення типів. Наприклад, множення *
перетворює операнди в числа.
Якщо ми передамо об’єкт як аргумент, то відбувається два етапи обчислень:
- Об’єкт буде перетворено на примітив (використовуючи правила, описані вище).
- Якщо це необхідно для подальших обчислень, то й отриманий примітив також буде перетворено.
Наприклад:
let obj = {
// За відсутності інших методів, toString обробляє всі перетворення
toString() {
return "2";
}
};
alert(obj * 2); // 4, бо спершу об’єкт перетворено на примітив "2", а тоді множенням отримано число
- Множення
obj * 2
спочатку перетворює об’єкт в примітив (вийде рядок"2"
). - Тоді
"2" * 2
стане2 * 2
(рядок перетворюється на число).
Бінарний плюс натомість, в точно такій же ситуації буде з радістю приймати рядки, оскільки він вміє працювати не лише з числами, а й з рядками:
let obj = {
toString() {
return "2";
}
};
alert(obj + 2); // 22 ("2" + 2), перетворення до примітиву повернуло рядок => Конкатенація
Перетворення об’єкта на примітив викликається автоматично багатьма вбудованими функціями та операторами, які очікують примітив як значення.
Існує 3 типи (підказки) для цього перетворення:
"string"
(дляalert
та інших операцій, які потребують рядка)"number"
(для математичних операцій)"default"
(для дуже небагатьох операторів; зазвичай об’єкти реалізують це так само як і"number"
.)
Специфікація явно описує який оператор використовує яку підказку.
Алгоритм перетворення наступний:
- Викликати
obj[Symbol.toPrimitive](hint)
(якщо цей метод в цього об'єкта існує), - Інакше, якщо підказка має значення
"string"
- спробувати викликати
obj.toString()
абоobj.valueOf()
, залежно від того, який з цих методів в цього об'єкта існує.
- спробувати викликати
- Інакше, якщо підказка має значення
"number"
чи"default"
- спробувати викликати
obj.valueOf()
абоobj.toString()
, залежно від того, який з цих методів в цього об'єкта існує.
- спробувати викликати
Виклик усіх цих методів повинен повертати примітив (якщо ці методи визначені).
На практиці часто достатньо реалізувати лише obj.toString()
як універсальний метод для перетворення рядків, який повинен повернути "читабельне для людини" представлення об’єкта для цілей логування або пошуку помилок.