Символы, итераторы, генераторы, async/await и асинхронные итераторы в JavaScript неотделимы друг от друга. Разбираем все концепции разом.
Некоторые концепции JavaScript, введенные последними обновлениями языка, оказались довольно сложными для понимания. Среди них, например, генераторы, смахивающие на указатели из C-языков. Или символы, которые выглядят как объекты и примитивы одновременно.
Загвоздка в том, что все новшества представляют собой единую систему. Понять одно без другого практически невозможно. Поэтому сегодня мы произведем массированную атаку на символы, итераторы и другие интересные фичи JavaScript одновременно.
Статья затрагивает довольно сложные моменты языка и рекомендуется читателя, владеющим базовыми знаниями JavaScript.
Символы и «известные» символы
Символы были введены в язык стандартом ES2015. Для чего понадобились эти странные конструкции? Оказывается, у них сразу три важных задачи.
Задача #1 – Безопасное добавление функциональности
Чтобы язык развивался, нужна возможность вводить в объекты дополнительные служебные методы, не ломая при этом циклы for . in и функции, подобные Object.keys() .
Для примера возьмем следующую структуру:
Если передать этот объект функции Object.keys(myObject) , мы получим на выходе массив с ключами [“firstName”, “lastName”] . Все ожидаемо и правильно.
Теперь определим еще одно поле, скажем, newProperty . Задача заключается в том, чтобы Object.keys() по-прежнему возвращал только старые свойства, пропуская новое. Другими словами, мы хотим снова получить [“firstName”, “lastName”] , а не [“firstName”, “lastName”, “newProperty”] .
Раньше JavaScript такой логики не предусматривал, поэтому пришлось придумать Symbol .
Секрет в том, чтобы определить newProperty как символ. В этом случае Object.keys() не узнает о его существовании. Мы получим [“firstName”, “lastName”] , как и хотели.
Задача #2 – Защита от совпадения имен
При добавлении ключей в глобальные объекты всегда есть риск переопределить что-то важное. Чтобы не волноваться о совпадениях имен, нужно обеспечить их уникальность.
К примеру, разработчик добавил в объект Array.prototype полезный метод toUpperCase() .
А теперь он подключает к проекту стороннюю JavaScript-библиотеку, создатели которой тоже додумались до такой полезной фичи, но реализовали ее немного по-другому. Библиотечный метод перекроет пользовательский с тем же именем, в результате работа программы может нарушиться.
А ведь программист может даже не подозревать об этой проблеме! Здесь в дело вступают символы. При создании каждый из них получает уникальное значение, благодаря которому можно не опасаться совпадений имен.
Задача #3 – Хуки во встроенных методах
Сразу перейдем к примеру. В JavaScript есть встроенный метод String.prototype.search . Задача состоит в том, чтобы этот метод при вызове передавал управление некоторой пользовательской функции, которая реализует собственную поисковую логику.
Иначе говоря, требуется, чтобы команда
отрабатывала следующим образом:
Ух ты! Встроенный метод объекта String должен вызвать метод search у своего аргумента, передать ему саму строку, да еще, видимо, каким-то образом обработать результат. Неужели это возможно в JavaScript?
Теперь возможно благодаря «известным» символам и хукам внутри встроенных функций. Чтобы перехватить управление, такой символ должен быть ключом объекта. Этот чудесный фокус мы разберем позже, а сейчас уделим чуть больше внимания новой концепции.
Основы работы
Создание
Чтобы создать символьное значение, нужно воспользоваться глобальной функцией Symbol() .
Значения типа symbol нетрудно спутать с объектами, ведь у них тоже есть методы. Однако это неизменяемые примитивы, больше похожие на строки.
А где «new»?
Обратите внимание, при создании символьного значения мы не добавили оператор new . Он предполагает создание нового объекта, а мы имеем дело с примитивом. Для других примитивных типов (например, для строк) new возвращает объектную обертку. Для символов и эта возможность недоступна. Просто не используйте new .
Дескриптор символа
Для символа можно определить дескриптор. Он передается параметром в функцию Symbol() и используется только для логирования.
Дескриптор не влияет на уникальность. Два символа с одинаковым описанием будут отличаться.
Метод Symbol.for()
Symbol() не единственный способ создать символ. Есть еще метод Symbol.for() . Он принимает в качестве параметра ключ, под которым символ будет занесен в глобальный реестр.
Если запрашиваемый ключ в реестре уже есть, то метод вернет соответствующий ему символ без перезаписи. Таким образом реализуется паттерн Синглтон, обеспечивающий существование каждого символа в единственном экземпляре.
Так как символы, созданные методом .for() , заносятся в глобальный реестр, доступ к ним можно получить из любого места программы, зная ключ.
Помните, что нельзя переопределить символ, ассоциированный с существующим ключом.
Описание vs. ключ
Как говорилось раньше, символьные дескрипторы не влияют на уникальность. И наоборот, Symbol.for(key) всегда сопоставляет одинаковым ключам один символ.
Символьные ключи объектов
Символы, как и строки, могут быть ключами объектов. Фактически это основной способ их использования.
Свойства объектов, ключами которых являются символы, для удобства будем называть символьными свойствами.
Скобки или точка?
Доступ к свойствам, представленным строками, можно получить как через квадратные скобки, так и с помощью оператора точка. С символьными свойствами точку использовать нельзя.
3 задачи символов
Теперь мы узнали о символах почти все. Самое время вернуться к их основным задачам и разобраться, как же они решаются.
Задача #1 – Символы не обрабатываются циклами
Цикл for . in игнорирует символьные свойства prop3 и prop4 :
Методы Object.keys() и Object.getOwnPropertyNames() также их пропускают:
Задача #2 – Символы уникальны
Предположим, требуется включить в объект Array.prototype новый метод includes . Однако в стандарте ES2015 уже есть встроенный метод с тем же названием.
Чтобы избежать конфликтов, нужно сделать свойство includes символьным. Обратиться к новому методу можно через квадратные скобки, в которые передается имя переменной, содержащей символ.
Задача #3 – «Известные» символы
Глобальный объект Symbol (тот самый, который создает уникальные символы) имеет ряд свойств, например, Symbol.search или Symbol.iterator . Они содержат символьные значения и называются «известными» символами. Их можно использовать, чтобы перехватывать управление у некоторых встроенных методов JavaScript-объектов.
Использование «известных» символов
Мы уже вспоминали о встроенном методе String.prototype.search . Он возвращает индекс вхождения в исходную строку подстроки или регулярного выражения.
Новый стандарт установил в эту функцию хук, который проверяет, не определен ли метод Symbol.search у аргумента (например, у регулярного выражения). Если этот метод находится, то управление передается ему.
Стандартная реализация
-
- Интерпретатор встречает команду “rajarao”.search(“rao”) и анализирует ее.
- Строка “rajarao” конвертируется в объект String, а аргумент “rao” в объект RegExp.
- Запускается метод search , внутри него происходит проверка существования метода Symbol.search у объекта регулярного выражения. Если таковой найден, то ему делегируется управление:
- Метод объекта Regexp получает результат 4 и передает его методу объекта String, который в свою очередь возвращает значение в код программы.
Вот так выглядит внутренняя реализация этого процесса на псевдокоде:
Вся прелесть такого механизма заключается в том, что на месте регулярного выражения может находиться любой объект, имеющий метод Symbol.search .
Пользовательская реализация
Давайте заставим встроенный метод String.prototype.search делегировать управление методу пользовательского класса Product .
Действия JavaScript-интерпретатора аналогичные: он встречает команду и начинает ее разбор. Сначала строка “barsoap” конвертируется в объект String. soapObj уже является объектом, поэтому не нуждается в конвертации. Метод search после запуска первым делом проверяет, нет ли у аргумента свойства Symbol.search . Оно находится, поэтому управление передается объекту soapObj .
Метод получает значение FOUND , которое по цепочке возвращается в программу.
Вот мы и разобрались с символами. Теперь можно двигаться дальше.
Итераторы и итерируемые объекты
В JavaScript существуют удобные методы перебора объектов, например, цикл for . of или spread-оператор. Однако их можно использовать не для всякой структуры. Зачастую приходится писать собственные get-методы для получения данных.
Например, объект класса Users просто так не перебрать:
Было бы очень удобно использовать встроенную функциональность языка для пользовательских объектов. Теперь это возможно!
Для того чтобы стандартные инструменты JavaScript могли обрабатывать объект, он должен быть итерируемым (iterable). Итерируемый объект соответствует ряду условий:
- Хранит некоторый набор данных.
- Имеет метод Symbol.iterator , результатом работы которого является объект «итератор», имеющий доступ к данным.
- У итератора определен метод next , результатом работы которого является объект с полями value и done .
- Поле done имеет булево значение. Если итератор закончил работу и данных больше нет, оно равно true .
Давайте сделаем объекты класса Users итерируемыми:
Цикл for . of и spread-оператор вызывают метод Symbol.iterator автоматически, «под капотом».
Функции-генераторы
Основное предназначение генераторов:
- создать удобную обертку для итераторов;
- упорядочить поток выполнения кода.
Функция #1 – Обертка для итераторов
Только что мы сделали итерируемым целый класс, чтобы иметь возможность перебирать хранящиеся в нем данные. Ту же самую задачу можно решить проще, используя «генератор».
В чем особенности этой новой конструкции?
- Для обозначения генераторов введен оператор * (звездочка), который ставится после слова function или непосредственно перед названием метода.
- Возвращаемый функцией объект реализует интерфейс итератора. Для удобства его тоже называют генератором.
- Возврат данных из функции-генератора осуществляется командой yield .
- Вызов оператора yield приостанавливает работу функции. Место прерывания при этом запоминается.
- Если yield находится внутри цикла, то он будет выполняться однократно при каждом вызове next() .
Генератор вместо итератора
Генератор вместо класса
Объект генератора также имеет методы throw() и return() .
Функция #2 — управление потоком
Одна из важнейших миссий генераторов – спасение разработчиков от пирамиды коллбэков. Они способны прерывать свое выполнение, сохраняя состояние, а также принимать новые значения в момент остановки. Это дает возможность избавиться от функций обратного вызова и перенести их логику внутрь генераторов.
На картинке изображена работа генератора. Каждая команда yield прерывает выполнение функции и возвращает значение. А с помощью вызова извне generator.next(“some value”) можно передать внутрь промежуточные данные.
Более детальный пример управления потоком кода:
Синтаксис
Несколько способов объявить генератор:
yield и return
Оператора yield немного напоминает команду return , он также прерывает выполнение функции и возвращает некоторое значение. Однако генератор при этом не прекращает работу полностью, а лишь ожидает нового вызова. Таким образом, после оператора yield может быть другой код.
Генераторы могут иметь множество точек прерывания, обозначенных командой yield .
Передача данных в функцию
Методу next() можно передать параметры. Они будут отправлены генератору, и он сможет использовать их в своей работе.
Этот механизм спасает разработчиков от пирамиды коллбэков. Он активно используется различными JavaScript-библиотеками, например, redux-saga.
Здесь первый вызов метода next() без параметров возвращает вопрос. А во второй передается значение 23, которое будет использовано функцией.
Ад коллбэков
Посмотрим, как генераторы борются с длинными последовательностями асинхронных функций на примере библиотеки co. Она позволяет пользоваться всеми преимуществами асинхронности, сохраняя привычный «синхронный» стиль кода.
Разберемся, как работает этот код.
- Генератор передается функции co .
- Вызывается метод Post.findByID() , который возвращает Promise.
- Функция временно приостанавливает работу.
- После получения результата его нужно вернуть в генератор через next() .
- Значение записывается в post .
- Вызывается еще один асинхронный метод post.getComments() , который вновь возвращает обещание.
- Генератор ожидает, затем записывает полученное значение в сomments .
- Данные выводятся на консоль.
Следующая связанная концепция, которую необходимо изучить, – async/await.
Async/await
Генераторы – отличная штука, но для борьбы с пирамидой коллбэков приходится подключать сторонние библиотеки. ES-комитет постановил, что проблему такого масштаба нужно решать средствами самого языка и добавил в стандарт async/await. Это своего рода синтаксический сахар над генераторами для удобной работы с обещаниями.
- Оператор * заменяется на оператор async .
- Вместо команды yield используется await .
Когда интерпретатор видит async , он понимает, что имеет дело с особенной функцией. Встретив внутри нее оператор await, он предполагает, что это обещание, и останавливается, ожидая его разрешения.
Здесь внутри getAmount последовательно выполняются две асинхронные функции getUser() и getBankBalance() .
То же самое можно сделать с помощью обещаний, но решение с async/await более изящно.
Асинхронные итераторы
Иногда возникает необходимость вызвать асинхронно выполняющуюся функцию внутри цикла. Это довольно сложная задача, поэтому ES-комитет решил добавить в стандарт еще один «известный» символ Symbol.asyncIterator и цикл for . await . of .
В чем разница между обычным итератором и асинхронным?
- Метод next() изначально возвращает обещание, которое разрешается в стандартный формат
- Вместо простого вызова iterator.next() необходимо использовать следующую конструкцию:
Вот пример работы с асинхронным циклом:
Подведем итоги
Символы – особый уникальный тип данных. Используются в основном как свойства объектов, невидимые для цикла for . in .
Известные символы – встроенные символы языка, использующиеся для перехвата управления в стандартных методах.
Итерируемые объекты – объекты, удовлетворяющие ряду правил, с которыми может работать цикл for . of .
Итераторы – имеют метод next() и обеспечивают извлечение данных из итерируемых объектов.
Генераторы – обеспечивают более удобную работу с итераторами, помогают избавиться от пирамиды коллбэков.
Async/await – абстракция над генераторами для удобной работы с промисами.
Асинхронные итераторы – новая функциональность языка, предназначенная для запуска асинхронных функций внутри циклов.