Типи даних та структури даних 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]("default")
→valueOf()
→toString()
- Зведення до числового, зведення до числа, зведення до BigInt:
[Symbol.toPrimitive]("number")
→valueOf()
→toString()
- Зведення до рядка:
[Symbol.toPrimitive]("string")
→toString()
→valueOf()
У всіх випадках властивість [Symbol.toPrimitive]()
, якщо є, мусить бути викличною та повертати примітив, натомість valueOf
і toString
ігноруватимуться, якщо не є викличними або повертають об'єкт. У кінці процесу, якщо він успішний, результат гарантовано є примітивом. Після цього результівний примітив підлягає подальшому зведенню, залежно від контексту.
Дивіться також
- Структури даних та алгоритми JavaScript від Олексія Трехлеба
- Інформатика в JavaScript від Ніколаса Закаса