Типи даних та структури даних JavaScript

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

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

Динамічна і слабка типізація

JavaScript – це динамічна мова програмування з динамічними типами. Змінні в JavaScript не пов'язані напряму з жодним конкретним типом даних, і будь-якій змінній можуть бути присвоєні (й присвоєні знову) значення всіх типів:

let foo = 42; // тепер foo – число
foo = "bar"; // тепер foo – рядок
foo = true; // тепер foo – булеве значення

Крім того, JavaScript – це слабко типізована (англ.) мова, тобто вона дозволяє неявне приведення типів, коли операція залучає типи, що не збігаються, замість викидання помилок типів.

const foo = 42; // foo є числом
const result = foo + "1"; // JavaScript зводить foo до рядка, щоб його можна було склеїти з іншим операндом
console.log(result); // 421

Неявне зведення типів – це дуже зручно, але воно може призводити до неочевидних вад, коли перетворення відбуваються там, де не очікуються, або там, де повинні були б відбутися в протилежному напрямку (наприклад, рядків до чисел, а не чисел до рядків). Для символів та BigInt JavaScript свідомо забороняє певні неявні перетворення типів.

Примітивні значення

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

Усі примітивні типи, окрім null, можна перевірити за допомогою оператора typeof. typeof null повертає "object", тож для перевірки на null слід використовувати === null.

Усі примітивні типи, крім null і undefined, мають власні типи об'єктів-обгорток, котрі надають корисні методи для роботи з примітивними значеннями. Наприклад, об'єкт Number надає методи штибу toExponential(). Коли відбувається звертання до властивості на примітивному значенні, JavaScript автоматично загортає це значення у відповідний об'єкт-обгортку і звертається до властивості цього об'єкта. Проте звертання до властивості на null чи undefined викидає виняток TypeError, що призвело до запровадження оператора необов'язкового зв'язування.

Тип Повернене значення typeof Об'єкт-обгортка
Null "object" Немає
Undefined "undefined" Немає
Boolean "boolean" Boolean
Number "number" Number
BigInt "bigint" BigInt
String "string" String
Symbol "symbol" Symbol

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

Тип Null

Тип Null населений винятково одним значенням: null

Тип Undefined

Тип Undefined населений винятково одним значенням: undefined.

Концептуально undefined вказує на відсутність значення, натомість null вказує на відсутність об'єкта (що може бути певного роду виправданням для typeof null === "object"). Мова зазвичай використовує undefined, коли щось позбавлено значення:

  • Інструкція return без значення (return;) неявно повертає undefined.
  • Звертання до відсутньої властивості об'єкта (obj.iDontExist) повертає undefined.
  • Оголошення змінної без її ініціалізації (let x;) неявно ініціалізує змінну значенням undefined.
  • Чимало методів, як то Array.prototype.find() і Map.prototype.get(), повертає undefined, коли елемент не знайдено.

null куди рідше використовується в ядрі мови. Найважливіше місце – кінець ланцюжка прототипів – як наслідок, методи, що працюють з прототипами, як то Object.getPrototypeOf(), Object.create() тощо, приймають чи повертають null, а не undefined.

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

Тип Boolean

Тип Boolean представляє логічну сутність і населений двома значеннями: true і false.

Булеві значення зазвичай використовується в умовних операціях, серед яких тернарні оператори, if...else, while тощо.

Тип Number

Тип Number є 64-бітним значенням двійкового формату IEEE 754 подвійної точності. Він здатний зберігати додатні числа з рухомою комою між 2-1074 (Number.MIN_VALUE) і 21023 × (2 - 2-52) (Number.MAX_VALUE), а також від'ємні числа з рухомою комою аналогічного діапазону, але може надійно зберігати цілі числа лише в діапазоні від -(253 − 1) (Number.MIN_SAFE_INTEGER) до 253 − 1 (Number.MAX_SAFE_INTEGER). Поза цим діапазоном JavaScript не може надійно представляти цілі числа; замість цього вони представляються у вигляді наближення з рухомою комою подвійної точності. Перевірити число на попадання в діапазон надійних цілих чисел можна за допомогою Number.isSafeInteger().

Значення поза діапазоном, числа якого можна подати з точністю, автоматично перетворюються:

  • Додатні значення, більші за Number.MAX_VALUE, перетворюються на +Infinity.
  • Додатні значення, менші за Number.MIN_VALUE, перетворюються на +0.
  • Від'ємні значення, менші за -Number.MAX_VALUE, перетворюються на -Infinity.
  • Від'ємні значення, більші за -Number.MIN_VALUE, перетворюються на -0.

+Infinity і -Infinity поводяться подібно до математичної нескінченності, але з певними невеликими відмінностями; за подробицями зверніться до Number.POSITIVE_INFINITY і Number.NEGATIVE_INFINITY.

Тип Number має лише одне значення з кількома представленнями: 0 представлений і як -0, і як +0 (де 0 – псевдонім для +0). На практиці між різними представленнями майже немає різниці; наприклад, +0 === -0 дає true. Проте різницю можна помітити, якщо поділити на нуль:

console.log(42 / +0); // Infinity
console.log(42 / -0); // -Infinity

NaN ("Not a Number" – "не число") – числове значення особливого роду, котре зазвичай зустрічається, коли результат арифметичної операції не може бути представлений у вигляді числа. Крім цього, це єдине значення в JavaScript, котре не рівне саме собі.

Попри те, що число концептуально є "математичним значенням" і завжди неявно кодується з рухомою комою, JavaScript пропонує бітові оператори. При застосуванні бітових операторів число спершу перетворюється на 32-бітове ціле.

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

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

Тип BigInt

Тип BigInt – числовий примітив JavaScript, що може представляти цілі числа з довільною точністю. За допомогою BigInt можна надійно зберігати й використовувати великі цілі числа, котрі лежать поза межею надійних цілих чисел (Number.MAX_SAFE_INTEGER) для Number.

Значення BigInt створюється шляхом додавання n у кінець цілого числа чи викликом функції BigInt().

Наступний приклад показує, як збільшення Number.MAX_SAFE_INTEGER на одиницю повертає очікуваний результат

// BigInt
const x = BigInt(Number.MAX_SAFE_INTEGER); // 9007199254740991n
x + 1n === x + 2n; // false, адже 9007199254740992n і 9007199254740993n не рівні одне одному

// Number
Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2; // true, адже обидва значення рівні 9007199254740992

З BigInt можна використовувати більшість операторів, у тому числі +, *, -, ** і %: єдиний заборонений оператор – >>>. BigInt не має строгої рівності щодо Number з таким само математичним значенням, але має нестрогу.

Значення BigInt не є ані завжди більш точними, ані завжди менш точними за звичайні числа, адже BigInt не може представляти дробові числа, зате може з більшою точністю представляти великі цілі. Жоден з цих типів не тягне за собою інший, і вони не є взаємозамінними. Викидається TypeError, коли значення BigInt змішуються зі звичайними числами в арифметичних виразах, або коли ці типи неявно перетворюються один на одного.

Тип String

Тип String представляє текстові дані й кодується як послідовність 16-бітових беззнакових цілочислових значень, що представляють кодові одиниці UTF-16. Кожний елемент рядка займає в ньому якусь позицію. Перший елемент розташований за індексом 0, наступний – за індексом 1, і так далі. Довжина рядка – число кодових одиниць UTF-16 у ньому, що може не відповідати реальній кількості символів Unicode; дивіться подробиці на довідковій сторінці String.

Рядки JavaScript є незмінними. Це означає, що відколи рядок створений, його неможливо змінити. Методи рядка створюють нові рядки на основі вмісту поточного – наприклад:

  • Підрядок вихідного рядка – за допомогою substring().
  • Зчеплення двох рядків за допомогою оператора зчеплення (+) або метода concat().

Обережно з "рядковим типуванням" коду!

Використання рядків для представлення складних даних може здаватись спокусливим. Це надає короткострокові переваги:

  • Легко формувати складні рядки за допомогою зчеплення.
  • Рядки легко зневаджувати (те, що надруковано – завжди саме те, що знаходиться в рядку).
  • Рядки є спільним знаменником багатьох API (полів введення, значень локального сховища, відповідей fetch() при використанні Response.text() тощо), і може здаватись спокусливим працювати лише з рядками.

За допомогою певних домовленостей можна представити будь-яку структуру даних як рядок. Проте це не робить таку ідею доброю. Наприклад, можна імітувати список за допомогою розділювача (при тому, що масив JavaScript є більш підхожим). На жаль, коли розділювач зустрічається в одному з елементів "списку", список буде зламано. Можна обрати символ екранування тощо. Все це вимагає домовленостей і накладає зайвий тягар підтримування.

Рядки слід використовувати для текстових даних. При представленні складних даних слід розбирати рядки й використовувати відповідну абстракцію.

Тип Symbol

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

Об'єкти

В комп'ютерній науці об'єкт – це значення в пам'яті, на котре може існувати посилання за допомогою ідентифікатора. У JavaScript об'єкти – єдині мінливі значення. Функції фактично також є об'єктами, з додатковою можливістю – викличністю.

Властивості

У JavaScript об'єкти можуть розглядатися як колекції властивостей. За допомогою синтаксису об'єктного літерала ініціалізується обмежений набір властивостей; після цього властивості можна додавати й видаляти. Властивості об'єктів рівносильні парам ключ-значення. Ключі властивостей є або рядками, або символами. Коли для індексування об'єктів використовуються інші типи (наприклад, число), то такі значення неявно перетворюються на рядки. Значення властивостей можуть бути значеннями будь-яких типів, включно з іншими об'єктами, що дає змогу вибудовувати складні структури даних.

Є два типи властивостей об'єкта: властивість даних і властивість доступу. Кожна властивість має відповідні атрибути. Рушій JavaScript внутрішньо звертається до кожного атрибута, задати ж ці атрибути можна за допомогою Object.defineProperty(), а отримати – за допомогою Object.getOwnPropertyDescriptor(). Більше про різні нюанси – на сторінці Object.defineProperty().

Властивість даних

Властивості даних пов'язують ключ зі значенням. Вони можуть бути описані наступними атрибутами:

value

Значення, отримане звертанням для отримання властивості. Може бути будь-яким значенням JavaScript.

writable

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

enumerable

Булеве значення, котре вказує, чи може властивість бути перелічена в циклі for...in. Про те, як перелічуваність взаємодіє з іншими функціями й синтаксичними конструкціями – на сторінці Перелічуваність та власність властивостей.

configurable

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

Властивість доступу

Зв'язує ключ з однією чи двома функціями доступу (get і set) для отримання чи збереження значення.

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

Властивість доступу має наступні атрибути:

get

Функція, що викликається з порожнім списком аргументів для отримання значення властивості, коли виконується операція отримання значення. Більше - на сторінці гетерів. Може мати значення undefined.

set

Функція, що викликається з аргументом, котрий містить присвоюване значення. Виконується щоразу, коли відповідну властивість намагаються змінити. Більше – на сторінці сетерів. Може мати значення undefined.

enumerable

Булеве значення, що вказує, чи може властивість бути перелічена в циклі for...in. Про те, як перелічуваність взаємодіє з іншими функціями й синтаксичними конструкціями – на сторінці Перелічуваність та власність властивостей.

configurable

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

Прототип об'єкта вказує на інший об'єкт або на null – це концептуально прихована властивість об'єкта, загальноприйнято представлена як [[Prototype]]. До властивостей [[Prototype]] об'єкта можна звернутися також на самому об'єкті.

Об'єкти – пари ключ-значення, тож вони нерідко використовуються як відображення. Проте це може складати проблему щодо ергономіки, безпеки та швидкодії. Замість цього слід використовувати для зберігання довільних даних Map. Довідка Map містить більш докладне обговорення переваг і недоліків використання простих об'єктів та відображень для запису асоціацій ключ-значення.

Дати

Для представлення дат у JavaScript найкращим варіантом є використання вбудованих значень Date.

Індексовані колекції: масиви й типізовані масиви

Масиви – звичайні об'єкти, для котрих є особливий зв'язок між цілочисловими властивостями й властивістю length.

Крім того, масиви успадковують прототип Array.prototype, котрий надає жменю зручних методів для обробки масивів. Наприклад, indexOf() шукає значення в масиві, push() додає елемент до масиву, і так далі. Це робить масиви досконалим кандидатом для представлення впорядкованих списків.

Типізовані масиви пропонують подібний до масиву прихований двійковий буфер даних і чимало методів, що мають семантику, що нагадує семантику їх аналогів для масиву. "Типізований масив" – узагальнення низки структур даних, в тому числі Int8Array, Float32Array тощо. Докладніше про це на сторінці типізованого масиву. Типізовані масиви нерідко використовуються у поєднанні з ArrayBuffer і DataView.

Ключеві колекції: Map, Set, WeakMap, WeakSet

Ці структури даних приймають посилання на об'єкти за ключі. Set і WeakSet представляють колекції унікальних значень, а Map і WeakMap — колекції асоціацій ключ-значення.

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

Map і Set можна реалізувати самотужки. Проте оскільки об'єкти не можуть порівнюватися (в тому розумінні, в якому порівнює, наприклад, < "менше ніж"), а також оскільки рушій не дає доступу до його геш-функції для об'єктів, швидкодія операції пошуку обов'язково буде лінійною. Нативні реалізації цих структур даних (включно з WeakMap) можуть мати швидкодію пошуку, що лежить між логарифмічною та сталою.

Зазвичай для пов'язування даних з вузлом DOM можна задавати властивості напряму на об'єкті, або використовувати атрибути data-*. Недолік такого підходу – те, що такі дані доступні будь-якому сценарієві, котрий працює в тому самому контексті. Map і WeakMap дають змогу легко і приватно пов'язати дані з об'єктом.

WeakMap і WeakSet дозволяють як ключі виключно значення, що можуть бути прибрані збирачем сміття, тобто або об'єкти, або нереєстрові символи, і такі ключі можуть бути прибрані навіть тоді, коли ще присутні в колекції. Такі колекції використовуються спеціально для оптимізації використання пам'яті.

Структуровані дані: JSON

JSON (JavaScript Object Notation – запис об'єктів JavaScript) – це легковагий формат обміну даних, похідний від JavaScript, що, однак, використовується в багатьох мовах програмування. JSON вибудовує універсальні структури даних, що можуть бути передані між різними середовищами, і навіть між різними мовами. Дивіться подробиці в JSON.

Більше об'єктів стандартної бібліотеки

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

Зведення типів

Як згадувалося вище, JavaScript є слабко типізованою мовою. Це означає, що нерідко можна використати значення одного типу там, де очікується інший тип, і мова перетворить значення до коректного типу за вас. Для цієї потреби JavaScript визначає певні правила зведення.

Зведення до примітива

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

  • Конструктор Date(), коли він отримує один аргумент, що не є примірником Date: рядки представляють рядки дат, натомість числа – мітки часу.
  • Оператор +: якщо один з операндів є рядком, то виконується зчеплення, інакше – арифметичне додавання.
  • Оператор ==: якщо один з операндів є примітивом, а інший – об'єктом, то об'єкт перетворюється на примітивне значення без преференцій у бік конкретного типу.

Ця операція не виконує жодних перетворень, якщо значення вже є примітивом. Об'єкти перетворюються на примітиви шляхом виклику їх методів [Symbol.toPrimitive]() (з підказкою "default"), valueOf() та toString() – у такому порядку. Зверніть увагу, що перетворення на примітив викликає valueOf() раніше, ніж toString(), що подібно до логіки зведення до числа, але відрізняється від зведення до рядка.

Метод [Symbol.toPrimitive](), якщо є, мусить повертати примітив: повернення об'єкта призведе до TypeError. Що до valueOf() і toString(), то якщо якийсь із цих методів поверне об'єкт, то таке повернене значення ігнорується, і натомість використовується повернене значення іншого методу. Якщо такого методу немає, або якщо жоден з методів не повернув примітива, то викидається TypeError. Наприклад, у наступному коді:

console.log({} + []); // "[object Object]"

Ані {}, ані [] не мають методу [Symbol.toPrimitive](). І {}, і [] успадковують valueOf() від Object.prototype.valueOf, що повертає сам об'єкт. Оскільки повернене значення є об'єктом, воно ігнорується. Таким чином, далі викликається toString(). {}.toString() повертає "[object Object]", а [].toString() повертає "", тож результат – це їхнє зчеплення: "[object Object]".

Метод [Symbol.toPrimitive]() завжди має пріоритет над перетворенням на будь-який конкретний примітивний тип. Перетворення на примітив здебільшого працює як перетворення на число, тому що першим викликається valueOf(); проте об'єкти з самописними методами [Symbol.toPrimitive]() можуть вирішити повернути будь-який примітив. Об'єкти Date і Symbol – єдині вбудовані об'єкти, що визначають власні методи [Symbol.toPrimitive](). Date.prototype[Symbol.toPrimitive]() обробляє підказку "default" як ніби це "string", натомість Symbol.prototype[Symbol.toPrimitive]() ігнорує підказку і завжди повертає символ.

Зведення до числового

Є два числові типи: Number і BigInt. Іноді мова очікує конкретно число або конкретно BigInt (наприклад, Array.prototype.slice(), для якого індекс мусить бути числом); в інших випадках можуть прийматися обидва типи й виконуватися різні операції залежно від типу операнда. Щодо процесів зведення до рядка, котрі не дозволяють неявного перетворення з інших типів, дивіться зведення до числа і зведення до BigInt.

Числове зведення – майже таке ж, як зведення до числа, окрім того, що значення BigInt повертаються як є, а не спричиняють TypeError. Числове зведення використовується всіма арифметичними операторами, адже вони визначені як для чисел, так і для BigInt. Єдиний виняток – унарний плюс, котрий завжди виконує зведення до числа.

Інші зведення

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

Як можна було помітити, є три відмінні шляхи, якими об'єкти можуть бути перетворені на примітиви:

У всіх випадках властивість [Symbol.toPrimitive](), якщо є, мусить бути викличною та повертати примітив, натомість valueOf і toString ігноруватимуться, якщо не є викличними або повертають об'єкт. У кінці процесу, якщо він успішний, результат гарантовано є примітивом. Після цього результівний примітив підлягає подальшому зведенню, залежно від контексту.

Дивіться також