Menu
Русский

Symbol в JavaScript: уникальные ключи и Symbol.iterator

Разбираемся, что такое примитив Symbol в JavaScript, зачем он нужен и как well-known символы вроде Symbol.iterator позволяют встроить свои объекты в язык.

Symbol — это гарантированно уникальное значение

Symbol — один из примитивных типов в JavaScript, наряду со string, number, boolean, null, undefined и bigint. Его ключевая особенность проста: каждый созданный символ уникален и никогда не совпадёт ни с каким другим.

index.js
Output
Click Run to see the output here.

Два символа, оба созданы без аргументов — и всё равно не равны друг другу. Это не баг и не случайность, а весь смысл этой штуки. Символ нельзя подделать, нельзя случайно пересоздать и нельзя столкнуться с чужим.

При желании к символу можно приложить описание — оно помогает при отладке, но на идентичность никак не влияет:

index.js
Output
Click Run to see the output here.

Одно и то же описание — а символы всё равно разные. Description нужен исключительно для людей, которые потом будут смотреть логи.

Зачем нужны символы: ключи без коллизий

Главная причина, по которой Symbol вообще появился в JavaScript, — возможность добавлять свойства к объекту, не боясь, что ваш ключ столкнётся с чужим. Строковые ключи живут в одном плоском пространстве имён: если две библиотеки решат хранить метаданные в obj.meta, они молча затрут друг друга. С символьными ключами такого не бывает — ни у кого больше просто нет вашего символа.

index.js
Output
Click Run to see the output here.

Квадратные скобки в [ID]: 42 — это синтаксис вычисляемых свойств, означающий «возьми значение ID как ключ». Прочитать или перезаписать это свойство сможет только тот код, у которого на руках тот же самый символ ID. Любой другой модуль, который оперирует строкой "id" или своим собственным Symbol("id"), будет обращаться совсем к другой ячейке.

Ключи-символы (почти) невидимы

Свойства с ключами-символами не попадают ни в for...in, ни в Object.keys, ни в JSON.stringify. Это не секретность в полном смысле — при желании их всё равно можно достать, — но при обычном обходе они не мешаются под ногами.

index.js
Output
Click Run to see the output here.

Object.keys и JSON.stringify просто игнорируют ключи-символы. Если нужно целенаправленно добраться до свойств-символов, на помощь приходят Object.getOwnPropertySymbols и Reflect.ownKeys — они их покажут. Это как раз то поведение, которое хочется видеть у метаданных: при желании их можно найти, но код, который обходит обычные строковые ключи, их просто не заметит.

Общие символы через Symbol.for

Symbol() создаёт локальный, одноразовый символ. Но иногда нужно ровно противоположное — чтобы символ был одним и тем же во всей программе, в разных модулях, и чтобы разные куски кода могли договориться об общем ключе. Для этого и существует Symbol.for в javascript.

index.js
Output
Click Run to see the output here.

Symbol.for(key) обращается к глобальному реестру: если символ с таким ключом там уже есть, вы получите его же; если нет — он будет создан и сохранён. Symbol.keyFor работает в обратную сторону — возвращает ключ, с которым был зарегистрирован символ (или undefined, если символ не из реестра).

В большинстве случаев достаточно обычного Symbol(). К Symbol.for стоит обращаться только тогда, когда символ действительно нужно разделять между модулями.

Well-known symbols: зацепки внутри самого языка

Вот где символы показывают себя во всей красе. В JavaScript есть набор заранее определённых символов — так называемые well-known symbols, — которые движок сам ищет на ваших объектах. Достаточно реализовать метод по такому ключу, и объект автоматически подключается к встроенным возможностям языка.

Самый полезный из них — Symbol.iterator. Любой объект с методом по этому ключу становится итерируемым: с ним работают for...of, спред-оператор, деструктуризация и Array.from.

index.js
Output
Click Run to see the output here.

range — это не массив. Это обычный объект с единственным методом, у которого вместо строкового ключа стоит специальный символ. Но раз этот метод возвращает итератор, то и for...of, и спред-оператор спокойно с ним работают — точно так же, как с массивами, строками и Map. В этом и весь смысл контракта: реализовал Symbol.iterator — и язык считает тебя итерируемым объектом.

Другие well-known symbols, которые стоит знать

Их в JavaScript ещё несколько, и каждый — это хук в какую-то отдельную часть языка:

index.js
Output
Click Run to see the output here.
  • Symbol.iterator — делает объект итерируемым.
  • Symbol.asyncIterator — то же самое, но для for await...of.
  • Symbol.toPrimitive — управляет тем, как объект приводится к примитиву (числу, строке или «по умолчанию») при неявных преобразованиях.
  • Symbol.hasInstance — настраивает поведение instanceof для вашего класса.
  • Symbol.toStringTag — задаёт тег, который показывает Object.prototype.toString.call(obj).

Заучивать весь список не обязательно. Достаточно помнить, что такие штуки есть: когда вам понадобится, чтобы объект вёл себя как встроенный тип, почти наверняка для этого найдётся подходящий well-known symbol.

Symbol — это не строка

Типичная ловушка: символы не приводятся к строке автоматически. Попытка склеить символ со строкой выбросит ошибку — равно как и передача его в API, ожидающий строку:

index.js
Output
Click Run to see the output here.

Шаблонные литералы выбросят тот же самый TypeError. Если вам действительно нужна строка — используйте String(symbol) или symbol.toString(). Эти острые углы сделаны специально: язык оберегает вас от случайного обращения со значением, чья суть — уникальность, как с обычной строкой.

Когда использовать Symbol в JavaScript, а когда — нет

Symbol пригодится, когда:

  • Вы навешиваете метаданные на чужие объекты и вам нужен ключ, который гарантированно ни с чем не столкнётся.
  • Вы проектируете протокол — мол, «объекты, которые хотят работать с моей библиотекой, должны реализовать метод под таким-то символом».
  • Вам нужно свойство, которое не попадёт ни в JSON.stringify, ни в for...in.
  • Вы реализуете Symbol.iterator или другой well-known symbol.

А вот без Symbol можно обойтись, когда:

  • Вам просто нужен ключ объекта и никакого риска коллизий нет. Строка проще и в логах отображается как есть.
  • Вы хотите «приватные» поля. Свойства с символьными ключами на самом деле не приватные — Object.getOwnPropertySymbols их прекрасно находит. Для настоящей приватности берите #private-поля классов.
  • Вы храните данные, которые должны пережить JSON.stringify. Не переживут.

Большая часть JS-кода спокойно живёт, ни разу напрямую не написав Symbol(...). Но как только вам захочется, чтобы ваш объект работал с for...of или естественно вёл себя при приведении типов — символы и есть та самая дверь в эту внутреннюю кухню.

Дальше: объявление функций

Символы, итераторы и генераторы плотно завязаны на функции — методы, лежащие под Symbol.iterator, фабрики, возвращающие итераторы, генераторы, у которых форма function* прячет бóльшую часть рутины. Функции — тема следующей главы; начнём с того, какими способами их вообще можно объявлять и чем эти способы отличаются друг от друга.

Часто задаваемые вопросы

Что такое Symbol в JavaScript?

Symbol — это примитивный тип, значения которого гарантированно уникальны. Создаётся через Symbol() или Symbol('описание'), и два вызова никогда не вернут равные между собой символы. В основном символы используют в двух случаях: как ключи свойств, которые не конфликтуют с чужим кодом, и как «крючки» для встроенных механизмов языка через well-known символы вроде Symbol.iterator.

Когда стоит использовать Symbol вместо строкового ключа?

Когда нужно добавить свойство объекту и при этом гарантированно не столкнуться с чужим ключом: библиотеки навешивают метаданные, фреймворки помечают объекты, а вы можете скрыть ключ от публичного API. Для обычных свойств, где важнее читаемость и коллизий всё равно не будет, строки по-прежнему удобнее.

Зачем нужен Symbol.iterator?

Symbol.iterator — это well-known символ, по которому for...of, spread-оператор и деструктуризация понимают, как перебирать объект. Достаточно определить метод по ключу Symbol.iterator, возвращающий итератор, и объект становится итерируемым. Именно так под капотом работают массивы, строки, Map и Set.

Чем отличаются Symbol() и Symbol.for()?

Symbol('x') каждый раз создаёт новый символ — два вызова с одинаковым описанием всё равно дадут разные значения. А Symbol.for('x') сначала смотрит в глобальный реестр и возвращает один и тот же символ по одному и тому же ключу в рамках всей программы. Symbol.for подходит, когда символ нужно шарить между модулями или realm'ами, а Symbol() — для локальной уникальности.

Учитесь программировать с Coddy

НАЧАТЬ