Ленивый, компонуемый и модульный 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.

Теперь реализуем задачу, которая покажет, как эти три подхода помогают очистить код приложения.

Пример: задача и решение

Исходные данные:

  • файл, который на каждой строке содержит имя пользователя и по размеру превышает объём оперативной памяти используемого устройства;
  • функция, которая считывает блок данных с диска и возвращает его же, дополнив символом новой строки.

Задача:

  1. Получить имена пользователей, которые начинаются с «A», «E» или «M».
  2. Выполнить запрос с использованием полученных данных к странице http://jsonplaceholder.typicode.com/users?username=<username>.
  3. Применить к первым четырём ответам сервера заданный набор из четырёх функций.

Содержимое файла:

Разбиваем задачу на блоки поменьше, чтобы для каждого написать отдельную функцию. В результате получаем следующие функции:

  1. Первая возвращает каждое имя (getNextUsername).
  2. Вторая отбирает имена, которые начинаются с «A», «E»или «M» (filterIfStartsWithAEOrM).
  3. Третья делает сетевой запрос и возвращает Promise, объект-заглушку для вывода результата вычисления (makeRequest).

Эти функции оперируют значениями. Для того, чтобы применить их к списку, введём три функции высшего порядка:

  1. Первая выбирает элементы списка на основе заданных параметров (filter).
  2. Вторая применяет функцию к каждому элементу списка (map).
  3. Третья применяет функции из одного итератора к данным другого итератора (zipWith c функцией упаковки).

«Ленивость» этого подхода может принести пользу, так как сетевые запросы делаются не для всех подходящих под критерии фильтрации имен.

Итак, у нас есть массив функций, которые должны выполняться для обработки окончательных ответов, и функция, которая возвращает «ленивым» способом блоки данных. Напишем функцию с применением генераторов для получения имен пользователей, используя «ленивый» подход.

Теперь для работы со значениями необходимы следующие функции:

  1. Первая возвращает True, если значение удовлетворяет критериям фильтра, False — в противном случае.
  2. Вторя возвращает URL-адрес при получении имени пользователя.
  3. Третья при получении URL-адреса делает запрос и возвращает Promise для этого запроса.

Promise — «контейнер» для хранения значения выполняемой операции, которое появится в будущем. Интерфейс Promise позволяет определить, какие действия выполнять после успешного завершения операции или сбоя. Если операция пройдёт успешно, вызывается обработчик удачного результата со значением операции. В противном случае вызывается обработчик ошибки.

Теперь напишем функции высшего порядка, которые обеспечат работу с «ленивыми» списками. Их задача — отложить выполнение до тех пор, пока не появится запрос. В этом случае нужны значения по требованию, поэтому на помощь придут генераторы.

Необходимые функции написаны. Используем композицию, чтобы решить поставленную задачу.

lazyComposedResult — «ленивая» цепочка вычислений (pipeline), составленная из композиций функций. Ни одно звено не выполнится, пока не запущен верхний блок композиции, то есть lazyComposedResult. Мы сделали только четыре вызова, хотя результат фильтрации может содержать более четырех значений.

В итоге получили лаконичное решение задачи, в котором появились функции высшего уровня, композиции и повторно используемые функции.

+ +