Ленивый, компонуемый и модульный JavaScript
Хорошей практикой считается делать код компонуемым и модульным. Он упрощается и к его частям становится проще обращаться несколько раз. Для JavaScript-разработчиков тоже появились инструменты для использования этого подхода.
Остановимся на использовании четырех возможностей ECMAScript: итераторах, генераторах, «жирных» стрелочных функциях и операторе for-of в сочетании с функциями высшего порядка, композициями функций, отложенными вычислениями.
Прежде чем погрузиться с головой в пример, рассмотрим некоторые общие понятия.
Функции высшего порядка
Функция высшего порядка — это функция, которая удовлетворяет хотя бы одному из условий:
- принимает в качестве аргументов одну или более функций;
- возвращает функцию как результат.
Вы наверняка сталкивались с функциями высшего порядка, если писали обработчик событий или применяли Array.prototype.map() .
Например, функция, попадая в Array.prototype.map , ничего не знает о её структуре и методах. Единственное знание — механизм обработки своих входящих данных, поэтому может быть неоднократно применима как для отдельных значений, так и коллекций.
Композиции функций
Композиция функций — применение одной функции к результату другой. В результате чего из простых функций составляются сложные.
Например, есть две функции: f и g . Результат композиции (f, g) — функция f(g(x)) , которую так же можно использовать в композиции или передать как параметр функции высшего порядка.
Допустим, поставили задачу написать программу, которая на входе принимает файл и возвращает массив, содержащий количество гласных в каждом слове на каждой строке. Одно из решений — написать одну большую функцию, которая делает это.
В монолитном решении, представленном ниже, используется новый оператор for-of вместо привычного for-loop для перебора значений массива. Итератор — контейнер, который реализует протокол последовательного перебора и возвращает с помощью оператора yield значения по одному (массивы, строки, генераторы и т. д.).
Это решение не расширяемое, не масштабируемое и не содержит повторно используемых компонентов. Альтернативный подход состоит в использовании функций высшего порядка и композиции.
listOfWordsByLine возвращает массив массивов, где каждый элемент соответствует массиву слов, составляющих строку. Например:
В примере vowelCount подсчитывает количество гласных в слове, vowelOccurrences использует vowelCount на выходе listOfWordsByLine для расчета гласных в каждой строке.
Профит второго способа — универсальные функции, которые будут полезны в дальнейшей работе и будут комбинироваться вместе для решения больших задач.
Таким образом, они приводят к подходу «снизу вверх», в результате чего код становится компонуемым и модульным.
Отложенные вычисления
Отложенные («ленивые») вычисления — операции, выполнение которых откладывается до тех пор, пока не нужен результат.
Рассмотрим на примере «ленивую» обработку данных и построение «ленивых» цепочек вычислений (pipelines).
Дан список целых чисел. Необходимо возвести в квадрат элементы списка и вывести сумму первых четырех полученных значений.
Для написания «ленивой» реализации выясним, когда понадобится произвести вычисления. При суммировании первых четырёх квадратов нужно возвести в квадрат эти элементы, поэтому операцию возведения в квадрат отложим до начала суммирования.
Возводим в степень элементы только тогда, когда начинается суммирование. За счёт контроля итерации и yield обрабатываются только те элементы, которые будут участвовать в итоге. Итерация реализуется таким образом, что элементы возвращаются с помощью оператора yield по одному вплоть до получения сигнала об отсутствии элементов для вывода. Протокол инкапсулируется в объект итератора, который содержит одну функцию — next , принимающую нулевые значения. Следующий элемент возвращается, только при наличии элементов.
Функция squareAndSum принимает в качестве входных данных итератор и n (число элементов в сумме). С помощью вызова метода .next() n раз получает из итератора n значений, возводит каждый из элементов в квадрат и суммирует их.
GetIterator возвращает итератор, сформированный из нашего списка.
squareAndSumFirst4 использует getIterator и squareAndSum , чтобы вернуть сумму первых четырех чисел из входного списка, возведённых в квадрат «ленивым» способом. Использование итераторов позволяет внедрять структуры данных, которые могут вернуть с помощью оператора yield бесконечные значения.
Необходимость выполнения описанных выше действий каждый раз, когда нам нужен итератор, усложняет написание кода. К счастью, ES, начиная с версии 6, предлагает простой способ описания итераторов — генераторы.
Генератор — функция, работу которой можно приостановить, а потом возобновить. Причём генератор может выдать значения несколько раз в ходе исполнения с помощью ключевого слова yield . При вызове возвращает объект-генератор. С помощью метода .next получается следующее значение. В JavaScript генераторы создаются путём определения функции с * .
Генераторы поддерживают оба протокола, поэтому получить значения можно с помощью оператора for-of .
Теперь реализуем задачу, которая покажет, как эти три подхода помогают очистить код приложения.
Пример: задача и решение
Исходные данные:
- файл, который на каждой строке содержит имя пользователя и по размеру превышает объём оперативной памяти используемого устройства;
- функция, которая считывает блок данных с диска и возвращает его же, дополнив символом новой строки.
Задача:
- Получить имена пользователей, которые начинаются с «A», «E» или «M».
- Выполнить запрос с использованием полученных данных к странице http://jsonplaceholder.typicode.com/users?username=<username> .
- Применить к первым четырём ответам сервера заданный набор из четырёх функций.
Разбиваем задачу на блоки поменьше, чтобы для каждого написать отдельную функцию. В результате получаем следующие функции:
- Первая возвращает каждое имя ( getNextUsername ).
- Вторая отбирает имена, которые начинаются с «A», «E»или «M» ( filterIfStartsWithAEOrM ).
- Третья делает сетевой запрос и возвращает Promise , объект-заглушку для вывода результата вычисления ( makeRequest ).
Эти функции оперируют значениями. Для того, чтобы применить их к списку, введём три функции высшего порядка:
- Первая выбирает элементы списка на основе заданных параметров ( filter ).
- Вторая применяет функцию к каждому элементу списка ( map ).
- Третья применяет функции из одного итератора к данным другого итератора (zipWith c функцией упаковки).
«Ленивость» этого подхода может принести пользу, так как сетевые запросы делаются не для всех подходящих под критерии фильтрации имен.
Итак, у нас есть массив функций, которые должны выполняться для обработки окончательных ответов, и функция, которая возвращает «ленивым» способом блоки данных. Напишем функцию с применением генераторов для получения имен пользователей, используя «ленивый» подход.
Теперь для работы со значениями необходимы следующие функции:
- Первая возвращает True , если значение удовлетворяет критериям фильтра, False — в противном случае.
- Вторя возвращает URL-адрес при получении имени пользователя.
- Третья при получении URL-адреса делает запрос и возвращает Promise для этого запроса.
Promise — «контейнер» для хранения значения выполняемой операции, которое появится в будущем. Интерфейс Promise позволяет определить, какие действия выполнять после успешного завершения операции или сбоя. Если операция пройдёт успешно, вызывается обработчик удачного результата со значением операции. В противном случае вызывается обработчик ошибки.
Теперь напишем функции высшего порядка, которые обеспечат работу с «ленивыми» списками. Их задача — отложить выполнение до тех пор, пока не появится запрос. В этом случае нужны значения по требованию, поэтому на помощь придут генераторы.
Необходимые функции написаны. Используем композицию, чтобы решить поставленную задачу.
lazyComposedResult — «ленивая» цепочка вычислений (pipeline), составленная из композиций функций. Ни одно звено не выполнится, пока не запущен верхний блок композиции, то есть lazyComposedResult . Мы сделали только четыре вызова, хотя результат фильтрации может содержать более четырех значений.
В итоге получили лаконичное решение задачи, в котором появились функции высшего уровня, композиции и повторно используемые функции.