Головна / Статті

Пояснення рівності в JavaScript: ===, ==, оператори відношень та Object.is

Дізнайтеся, як сувора та вільна рівність, порівняння залежно від стосунків та метод Object.is насправді визначають ідентичність у JavaScript, а також про пастки примусової трансформації, які приховує кожен з них.

Видавець: REACTAPP.TOP10 хв читання2405 слів

Джерела: Day 4 of Learning JavaScript: The Three Ways JavaScript Asks “Are These the Same Thing?”

Уявіть собі форму, де користувач вводить свій вік у вигляді тексту "18", і перевірка на збіг із числом 18 проходить без проблем, але згодом обчислення з цим значенням дають неправильний результат. Або перевірка доступу, яка проходить там, де не повинна, оскільки два різні значення випадково перетворюються на одне й те саме. Такі помилки виникають через одну причину: у JavaScript існує кілька способів перевірити, чи є два значення однаковими, і кожен з них визначає поняття „однаковості“ по-різному. Прочитавши це, ви зрозумієте, що насправді роблять ===, ==, оператори відношень та Object.is(), і який з них слід використовувати в кожній ситуації.

Чому мова має кілька понять рівності

Спочатку JavaScript працював у браузерах, де майже кожен вхідний даний надходив у вигляді тексту з форми. Замість того, щоб викидати помилку кожного разу, коли число з’являлося як рядок, мова була спроєктована так, щоб бути толерантною: вона припускала, що ви мали на увазі, та автоматично перетворювала типи. Ця зручність стала одним із найбільш постійних джерел помилок у мові, а більш суворі оператори існують переважно для того, щоб уникнути цього.

Сувора рівність (===) як стандарт

Перевірка типу, потім перевірка значення

Сувора рівність виконує двоступеневу перевірку без прихованих перетворень. Спочатку вона порівнює типи двох операндів. Якщо вони відрізняються, результатом є false, причому значення навіть не перевіряються. Лише тоді, коли типи збігаються, вона порівнює значення.

console.log(1 === 1);
// true (both are numbers, both are 1)

console.log(1 === "1");
// false (the types fail immediately: number versus string)

console.log(0 === false);
// false (number versus boolean)

console.log("" === false);
// false (string versus boolean)

console.log(null === null);
// true

console.log(null === undefined);
// false (different types)

Кожне значення false у цьому фрагменті виникає під час порівняння різних типів: число з рядком, число з логічним значенням, рядок з логічним значенням, null з undefined.

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

console.log("hello" === "Hello");
// false (different capitalization)

console.log("john@email.com" === "john@email.com ");
// false (the second string has a hidden space at the end)

На практиці це означає нормалізацію вхідних даних користувача, зазвичай за допомогою функції trim(), а іноді й toLowerCase() для поля, які не чутливі до регістру, перед їх порівнянням.

Дві навмисні винятки з стандарту IEEE 754

Строга рівність є надійною майже скрізь, за двома числовими винятками. Жоден з них не є багом; обидва відповідають стандарту IEEE 754 для чисел з плаваючою комою, на основі якого будуються числа в JavaScript. Перший виняток полягає у тому, що NaN, значення „не число“, не дорівнює нічому, включаючи саме себе.

const result = 0 / 0;
// result is NaN

console.log(result === NaN);
// false

console.log(NaN === NaN);
// false

Оскільки перевірка рівності ніколи не може цього виявити, слід використовувати спеціальну функцію Number.isNaN().

console.log(Number.isNaN(NaN));
// true

Краще використовувати її замість старішої глобальної функції isNaN(), яка спочатку перетворює аргумент на число, тому повертає true для рядків на кшталт "abc". Другий виняток стосується нуля. JavaScript представляє позитивний та негативний нулі окремо, що має значення в деякому числовому коді, проте строга рівність вважає їх однаковими.

console.log(+0 === -0);
// true

Об’єкти порівнюються за ідентичністю

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

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

const userA = { name: "Alex" };
const userB = { name: "Alex" };

console.log(userA === userB);
// false (two different objects sitting in different memory slots)

console.log([] === []);
// false (two different arrays sitting in different memory slots)

Якщо одній змінні присвоюється значення іншої, обидві посилаються на один і той самий об’єкт, і порівняння вдається.

const original = { name: "Alex" };
const pointer = original;

console.log(original === pointer);
// true (both point to the exact same memory location)

Щоб перевірити, чи два різні об’єкти містять однакові дані, самостійно порівняйте їхні примітивні властивості.

console.log(userA.name === userB.name);
// true (now we are comparing two strings, not two object addresses)

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

Строга нерівність (!==)

Перевірка на різницю значень є такою ж поширеною, як і перевірка на їхню збігність. Строга нерівність — це пряма протилежність до ===: вона повертає true, коли відрізняються або типи, або значення.

console.log(1 !== 2);
// true (they are different numbers)

console.log(1 !== "1");
// true (one is a number, one is a string)

console.log("apple" !== "orange");
// true

Це ідеальний варіант тоді, коли код має виконуватися лише у разі, якщо певна умова не виконується, наприклад під час перевірки дозволів або видалення небажаних елементів.

const currentRole = "guest";

if (currentRole !== "admin") {
  console.log("Access denied. Admins only.");
}
// "Access denied. Admins only."

Вільна рівність (==) та її перетворення

Як примусове перетворення дає несподівані результати

До того, як сувора рівність стала нормою, у більшості коду використовувався оператор ==. Коли типи операндів відрізняються, слабка рівність перетворює одне або обидва значення на спільний тип, а потім порівнює їх. Результати такого порівняння швидко суперечать інтуїції.

console.log(1 == "1");
// true (the string "1" becomes the number 1)

console.log(0 == false);
// true (false becomes 0)

console.log("" == false);
// true (empty string and false both become 0)

console.log(0 == "");
// true (empty string becomes 0)

console.log([] == "");
// true (empty array becomes empty string)

console.log([1] == 1);
// true (array becomes "1", which becomes 1)

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

console.log([] == 0);
// true

Щоб отримати значення true, движок спочатку перетворює порожній масив на примітивний тип, що дає порожню рядок "". Типи все ще відрізняються, оскільки порівнюється рядок з числом, тому рядок перетворюється на число, що дає значення 0. Кінцеве порівняння — це 0 == 0, яке дорівнює true.

Булеві значення також є складними у використанні, оскільки під час порівняння з не-булевими значеннями вони спочатку перетворюються на числа. true стає 1.

console.log(true == "true");
// false

Рядок "true" не перетворюється на 1; після перетворення на число він стає NaN, а 1 не дорівнює NaN. Саме тому value == true майже ніколи не є тим, чого ви хочете.

null та undefined підкоряються особливому правилу: за правилом вільної рівності вони дорівнюють один одному та нічому іншому, навіть не 0, false чи порожньому рядку.

console.log(null == undefined);
// true

console.log(null == 0);
// false

console.log(null == false);
// false

console.log(null == "");
// false

console.log(undefined == 0);
// false

Перевірка на null, де використання == є доцільним

У сучасному коді краще уникати вільної рівності, за одним поширеним винятком. Під час обробки даних з API часто потрібно з’ясувати, чи є значення null або undefined, і == null дає відповідь за одну порівняння.

function checkStatus(status) {
  if (status == null) {
    console.log("Status is missing entirely");
  } else {
    console.log("Status is:", status);
  }
}

checkStatus(null);
// "Status is missing entirely"

checkStatus(undefined);
// "Status is missing entirely"

checkStatus(0);
// "Status is: 0"

Це працює саме завдяки вищенаведеному правилу: null є вільно рівним лише null та undefined, тому значення, які вважаються хибними, але мають сенс, такі як 0 чи "", проходять непорушеними. Цей синтаксис замінює довший варіант status === null || status === undefined. Зверніть увагу, що багато команд налаштовують інструменти перевірки коду так, щоб повністю заборонити використання ==, у такому разі потрібно писати пряму строгу версію. Наприклад, правило eqeqeq у ESLint має опцію, яка дозволяє саме таке порівняння з null, забороняючи при цьому інші варіанти використання. У багатьох випадках оператор об’єднання значень, що дорівнюють null, ??, або оператор послідовного доступу з можливістю відсутності елемента ?. виражають цю мету ще більш прямо.

Вільне нерівність (!=)

!= застосовує ті самі перетворення, що й ==, а потім заперечує результат, тож успадковує всі „сюрпризи“.

console.log(1 != "1");
// false (it converts "1" to a number, decides they are the same, so they are not different)

Тут "1" перетворюється на число 1, значення вважаються рівними, тому перевірка „нерівності“ повертає false. Ставтеся до != з такою ж підозрою, як і до ==, та використовуйте !== за замовчуванням.

Відносні порівняння: <, >, <= та >=

Числа та змішані типи

При використанні двох чисел оператори відношень поводяться точно так само, як і в арифметиці.

console.log(5 > 3);
// true

console.log(10 <= 10);
// true

Коли рядок зустрічається з числом, спочатку рядок перетворюється на число. Рядок, який не представляє собою число, стає NaN, і будь-яке відносне порівняння з участю NaN повертає false.

console.log("10" > 5);
// true

console.log("abc" > 3);
// false ("abc" becomes NaN, and NaN comparisons always return false)

Де різняться null та undefined

Оператори відношень виявляють неузгодженість із оператором слабкої рівності. == відмовляється перетворювати null на число, тоді як <, >, <= та >= роблять це, перетворюючи його на 0.

console.log(null == 0);
// false

console.log(null > 0);
// false (0 is not greater than 0)

console.log(null >= 0);
// true (0 is greater than or equal to 0)

Отже, null не є ні рівним 0, ні більшим за 0, проте водночас є більшим або рівним 0. Останній результат має сенс, якщо знати, що a >= b оцінюється як „a < b не є істинним“, а 0 < 0 є хибним.

undefined знову поводиться інакше. Після перетворення на число воно стає NaN, тому кожне порівняння, що включає undefined, має значення false.

console.log(undefined > 0);
// false

console.log(undefined < 0);
// false

Рядки порівнюються за кодовими одиницями, а не за порядком у словнику

Коли обидва операнди є рядками, не відбувається числове перетворення. Рядки порівнюються символ за символом зліва, використовуючи числову кодову одиницю UTF-16 кожного символа.

console.log("apple" < "banana");
// true (a comes before b)

console.log("b" > "a");
// true (b comes after a)

Великі літери мають нижчі кодові одиниці, ніж малі літери, що пояснює, чому примітивний сортування може поставити „Zebra“ перед „apple“.

console.log("Z" < "a");
// true (Z=90, a=97 in Unicode)

Для сортування, орієнтованого на людину, використовуйте localeCompare() або Intl.Collator, які дотримуються мовних правил замість простих кодових одиниць.

Ще більш небезпечна проблема стосується рядків, які містять цифри. При порівнянні як рядків „10“ вважається меншим за „9“.

console.log("10" < "9");
// true (the character "1" is smaller than "9")

JavaScript не має уявлення про те, що ці рядки представляють собою кількості. Він дивиться на перший символ кожного з них — "1" порівняно з "9", бачить, що "1" йде першим, і на цьому зупиняється, повертаючи true. Такий самий ефект спостерігається, коли масив чисел сортується за допомогою sort() без компаратора, оскільки стандартний сортування перетворює елементи на рядки перед їх порівнянням; щоб сортувати числовим чином, потрібно передати (a, b) => a - b.

Рішенням є явне перетворення числових рядків у числа перед порівнянням їхніх значень.

console.log(Number("10") < Number("9"));
// false (correct: 10 is not less than 9)

Object.is() для точної ідентичності

Заповнення недоліків оператора ===

Object.is() — це найточніший спосіб перевірки рівності, який пропонує JavaScript. Для звичайних значень він дає ті самі результати, що й оператор суворої рівності.

console.log(Object.is(1, 1));
// true

console.log(Object.is(1, "1"));
// false

Різниця полягає саме у двох випадках стандарту IEEE 754. Функція Object.is() вважає NaN таким самим, як і NaN:

console.log(Object.is(NaN, NaN));
// true

а також розрізняє додатню нульову величину та від’ємну нульову величину:

console.log(Object.is(0, -0));
// false

Три перевірки поруч

  • 1 та "1": оператор == повертає true; оператори === та Object.is() повертають false.
  • 0 та false: оператор == повертає true; оператори === та Object.is() повертають false.
  • null та undefined: оператор == повертає true; оператори === та Object.is() повертають false.
  • NaN та NaN: оператори == та === повертають false; Object.is() повертає true.
  • +0 та -0: оператори == та === повертають true; Object.is() повертає false.
  • [] та [], або {} та {}: усі три оператори повертають false, оскільки це окремі об’єкти.
  • 5 та 5: усі три оператори повертають true.
  • Ви рідко будете викликати Object.is() безпосередньо, але він має значення у інструментах, якими ви користуєтесь щодня. React покладається на Object.is(), щоб визначити, чи змінився стан або залежність та чи потрібно перерендерувати, і саме тому зміна об’єкта на місці не спричиняє оновлення: посилання залишається ідентичним. Бібліотеки для керування станом застосовують ту саму ідею; детальніше дивіться у як React-Redux використовує перевірки рівності для вирішення питань про перерендерування.

    Ключові висновки

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

    1. За замовчуванням скрізь використовуйте === та !==.
    2. Уникайте == та !=, за винятком навмисного скорочення value == null, якщо це дозволяють ваші правила лінтингу.
    3. Для виявлення NaN використовуйте Number.isNaN(), а не оператори рівності.
    4. Пам’ятайте, що порівняння об’єктів перевіряють ідентичність, а не їхній вміст.
    5. Перед використанням < чи > перетворюйте числові рядки за допомогою Number(), а також вказуйте компаратор для чисел у функції sort().
    6. Використовуйте Object.is(), коли необхідно точно обробляти NaN чи число нуль з знаком.

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