29 серпня 2024 р.

Перетворення об’єктів в примітиви

Що відбувається, коли об’єкти додаються obj1 + obj2, віднімаються obj1 - obj2 або друкуються за допомогою alert(obj)?

JavaScript не дозволяє налаштувати, як працюють оператори з об’єктами. На відміну від деяких інших мов програмування, таких як Ruby або C++, ми не можемо реалізувати спеціальний об’єктний метод для обробки додавання (або інших операторів).

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

Це важливе обмеження, оскільки результат obj1 + obj2 (або інша математична операція) не може бути іншим об’єктом!

Наприклад, ми не можемо зробити об’єкти, що представляють вектори або матриці (або досягнення або що завгодно) та додати їх і очікувати, що “підсумковий” результат буде об’єктом. Автоматично такі архітектурні особливості недоступні.

Отже, оскільки ми не можемо автоматично за допомогою мови програмування виконувати подібні операції над об’єктами, то в реальних проєктах немає “математики з об’єктами”. Коли подібні операції все ж таки відбуваються, причиною цьому зазвичай є помилка програмування.

У цьому розділі ми розглянемо те, як об’єкти перетворюється на примітиви і як налаштувати це.

У нас є дві цілі:

  1. Це дозволить нам зрозуміти, що відбувається у випадку помилок коду, коли така операція відбулася випадково.
  2. Є винятки, де такі операції можливі та доцільні. Наприклад, віднімання або порівняння дати (Date об’єкти). Ми будемо зустрічатися з ними пізніше.

Правила перетворення

У розділі Перетворення типу ми бачили правила для перетворення чисел, рядків та булевих примітивів. Але ми залишили пробіл для об’єктів. Тепер, оскільки ми знаємо про методи та символи, можемо заповнити його.

  1. Всі об’єкти – це true в булевому контексті. Є лише числові та рядкові конверсії.
  2. Числове перетворення відбувається, коли ми віднімаємо об’єкти або застосовуємо математичні функції. Наприклад, Date об’єкти (розглянуті в розділі Дата і час) можуть відніматися, і результат date1 - date2 – це різниця у часі між двома датами.
  3. Що стосується перетворення рядків – це зазвичай відбувається, коли ми виводимо об’єкт, наприклад alert(obj) і в подібних контекстах.

Ми можемо точно налагоджувати перетворення рядків та чисел, використовуючи спеціальні методи об’єкта.

Тепер перейдемо до технічних деталей, бо це єдиний спосіб детально висвітити тему.

Підказки

Як JavaScript вирішує, яке перетворення застосувати?

Є три варіанти перетворення типів, що відбуваються в різних ситуаціях. Вони називаються “підказками” (“hints”), і описані в специфікації:

"string"

Перетворення об’єкта в рядок відбувається коли ми виконуємо операцію, яка очікує рядок, над об’єктом. Наприклад, alert:

// вивід
alert(obj);

// використання об’єкта як ключа властивості об’єкта
anotherObj[obj] = 123;
"number"

Перетворення об’єкта в число, коли ми робимо математичні операції:

// явне перетворення
let num = Number(obj);

// математичні операції (крім бінарного додавання)
let n = +obj; // унарне додавання
let delta = date1 - date2;

// порівняння менше/більше
let greater = user1 > user2;

Більшість вбудованих математичних функцій також включають таке перетворення.

"default"

Виникає в рідкісних випадках, коли оператор “не впевнений”, який тип очікується.

Наприклад, бінарний плюс + може працювати як з рядками (об’єднувати їх), так і з цифрами (додавати їх), тому обидва випадки – рядки та цифри – будуть працювати. Отже, якщо бінарний плюс отримує об’єкт як аргумент, він використовує підказку "default", щоб перетворити його.

Також, якщо об’єкт порівнюється (==) з рядком, числом чи символом, тоді незрозуміло, яке порівняння використати, тому використовується підказка "default".

// бінарний плюс використовує підказку "default"
let total = obj1 + obj2;

// obj == цифра використовує підказку "default"
if (user == 1) { ... };

Оператори порівняння більше та менше, такі як < >, також можуть працювати як з рядками, так і з цифрами. Проте, вони з історичних причин використовують "number" підказку, а не "default".

Але на практиці все трохи простіше.

Всі вбудовані об’єкти, крім одного випадку (об’єкт Date, ми дізнаємося пізніше) реалізовують "default" перетворення так само як "number". І ми можемо зробити те ж саме.

Проте важливо знати про всі три підказки, незабаром ми побачимо, чому.

** Щоб зробити перетворення, JavaScript намагається знайти та викликати три методи об’єкта: **

  1. Викликати obj[Symbol.toPrimitive](hint) – метод з символьним ключем Symbol.toPrimitive (системний символ), якщо такий метод існує,
  2. Інакше, якщо підказка – це "string"
    • спробує obj.toString() або obj.valueOf() – будь-що, що існує.
  3. Інакше, якщо підказка – "номер" або "default"
    • спробує obj.valueOf() або obj.toString() – будь-що, що існує.

Symbol.toPrimitive

Почнемо з першого методу. Є вбудований символ під назвою 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 обробляє всі випадки перетворення.

toString/valueOf

Якщо немає Symbol.toPrimitive тоді JavaScript намагається знайти методи toString і valueOf:

  • Для "string" підказка: виклик методу toString, і якщо цей метод не існує або якщо він повертає об’єкт замість примітивного значення, то викликати valueOf (таким чином toString має пріоритет при перетворенні в рядок).
  • Для інших підказок: valueOf, і якщо це не існує або якщо він повертає об’єкт замість примітивного значення, то toString (таким чином valueOf має пріоритет для математики).

Методи toString і valueOf походять з давніх часів. Вони не є символами (багато часу назад символи не існували), а скоріше є “звичними” методами, що названі за допомогою рядків. Вони забезпечують альтернативний шлях “старого стилю” для реалізації перетворення.

Ці методи повинні повертати примітивне значення. Якщо toString чи valueOf повертає об’єкт, то він ігнорується (так само, якби цього методу не існувало).

За замовчуванням, простий об’єкт має слідувати методами toString та valueOf:

  • Метод toString повертає рядок "[object Object]".
  • Метод valueOf повертає сам об’єкт.

Ось демо:

let user = {name: "Іван"};

alert(user); // [object Object]
alert(user.valueOf() === user); // true

Отже, якщо ми спробуємо використовувати об’єкт як рядок, наприклад в alert та ін., то за замовчуванням ми бачимо [object Object].

За замовчуванням метод 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 буде обробляти всі примітивні перетворення.

Перетворення може повернути будь-який примітивний тип

Важливо знати про всі методи примітивні перетворення те, що вони не обов’язково повертають “підказаний” примітив.

Немає контролю, чи повертає toString саме рядок, або чи symbol.toprimitive метод повертає номер для підказки "number".

Єдина обов’язкова річ: ці методи повинні повертати примітивний тип, а не об’єкт.

Історичні нотатки

З історичних причин, якщо toString чи valueOf повертає об’єкт. В цьому немає помилки, але таке значення ігнорується (так само, якби цей метод не існував). Це тому, що в давнину в JavaScript не було хорошого концепту “помилка”.

Навпаки, Symbol.toPrimitive повинен повернути примітив, інакше буде помилка.

Подальші перетворення

Як ми вже знаємо, багато операторів та функцій виконують перетворення типу, наприклад, множення * перетворює операнди в цифри.

Якщо ми передамо об’єкт як аргумент, то є два етапи:

  1. Об’єкт перетворюється на примітив (використовуючи правила, описані вище).
  2. Якщо отриманий примітив не є правильним типом, він перетворюється.

Наприклад:

let obj = {
  // toString обробляє всі перетворення за відсутності інших методів
  toString() {
    return "2";
  }
};

alert(obj * 2); // 4, об’єкт перетворився на примітив "2", потім множення зробило це числом
  1. Множення obj * 2 спочатку перетворює об’єкт в примітив (це рядок "2").
  2. Тоді "2" * 2 стає 2 * 2 (рядок перетворюється на номер).

Бінарний плюс буде об’єднувати рядки в такій же ситуації, оскільки він приймає рядки:

let obj = {
  toString() {
    return "2";
  }
};

alert(obj + 2); // 22 ("2" + 2), перетворення до примітиву повернуло рядок => Конкатенація

Підсумки

Об’єктно-примітивне перетворення викликається автоматично багатьма вбудованими функціями та операторами, які очікують примітиву як значення.

Є 3 типи (підказки) цього:

  • "string" (для alert та інших операцій, які потребують рядка)
  • "number" (для математичних операцій)
  • "default" (кілька операторів, зазвичай об’єкти реалізують це так само як і "number".)

Специфікація явно описує, який оператор використовує яку підказку.

Алгоритм перетворення це:

  1. Викликати obj[Symbol.toPrimitive](hint), якщо метод існує,
  2. Інакше, якщо підказка – це "string"
    • спробувати obj.toString() або obj.valueOf(), залежно від того, що існує.
  3. Інакше, якщо підказка – це "number" чи "default"
    • спробувати obj.valueOf() або obj.toString(), залежно від того, що існує.

Усі ці методи повинні повернути примітив до роботи (якщо визначені).

На практиці досить часто для реалізації достатньо лише obj.toString() як універсального методу для перетворення рядків, який повинен повернути “читабельне для людини” представлення об’єкта, для цілей логування або пошуку помилок.

Навчальна карта