Ленивый, компонуемый и модульный JavaScript
Хорошей практикой считается делать код компонуемым и модульным. Он упрощается и к его частям становится проще обращаться несколько раз. Для JavaScript-разработчиков тоже появились инструменты для использования этого подхода.
Остановимся на использовании четырех возможностей ECMAScript: итераторах, генераторах, «жирных» стрелочных функциях и операторе for-of
в сочетании с функциями высшего порядка, композициями функций, отложенными вычислениями.
Прежде чем погрузиться с головой в пример, рассмотрим некоторые общие понятия.
Функции высшего порядка
Функция высшего порядка — это функция, которая удовлетворяет хотя бы одному из условий:
- принимает в качестве аргументов одну или более функций;
- возвращает функцию как результат.
Вы наверняка сталкивались с функциями высшего порядка, если писали обработчик событий или применяли Array.prototype.map()
.
Например, функция, попадая в Array.prototype.map
, ничего не знает о её структуре и методах. Единственное знание — механизм обработки своих входящих данных, поэтому может быть неоднократно применима как для отдельных значений, так и коллекций.
Композиции функций
Композиция функций — применение одной функции к результату другой. В результате чего из простых функций составляются сложные.
Например, есть две функции: f
и g
. Результат композиции (f, g)
— функция f(g(x))
, которую так же можно использовать в композиции или передать как параметр функции высшего порядка.
Допустим, поставили задачу написать программу, которая на входе принимает файл и возвращает массив, содержащий количество гласных в каждом слове на каждой строке. Одно из решений — написать одну большую функцию, которая делает это.
В монолитном решении, представленном ниже, используется новый оператор for-of
вместо привычного for-loop
для перебора значений массива. Итератор — контейнер, который реализует протокол последовательного перебора и возвращает с помощью оператора yield
значения по одному (массивы, строки, генераторы и т. д.).
function vowelCount(file) {
let contents = readFile(file)
let lines = contents.split(‘\n’) // преобразуем содержимое в массив строк.
let result = [] // массив массивов, где каждый индекс соответствует строке
// и каждый индекс в пределах массива — кол-во гласных.
for (let line of lines) {
let temp = []
let words = line.split(/\s+/)
for (let word of words) {
let vowelCount = 0
for (let char of word) {
if (
‘a’ === char || ‘e’ === char || ‘i’ === char || ‘o’ === char || ‘u’ === char
) vowelCount++
}
temp.push(vowelCount)
result.push(temp)
return result
|
Это решение не расширяемое, не масштабируемое и не содержит повторно используемых компонентов. Альтернативный подход состоит в использовании функций высшего порядка и композиции.
// main.js
function vowelOccurrences(file) {
return map(words => map(vowelCount, words), listOfWordsByLine(read(file)))
function vowelCount(word) {
return reduce((prev, char) => {
if (
‘a’ === char || ‘e’ === char || ‘i’ === char || ‘o’ === char || ‘u’ === char
) return ++prev
else return prev
}, 0, word)
function listOfWordsByLine(string) {
return map(line => split(/\s+/, line), split(‘\n’, string))
// повторно используемые функции из библиотеки util.js
function reduce(fn, accumulator, list) {
return [].reduce.call(list, fn, accumulator)
function map(fn, list) {
return [].map.call(list, fn)
function split(splitOn, string) {
return string.split(splitOn)
|
listOfWordsByLine
возвращает массив массивов, где каждый элемент соответствует массиву слов, составляющих строку. Например:
let input = ‘line one\nline two’
listOfWordsByLine(input) // [[‘line’,’one’],[‘line’,’two’]]
|
В примере vowelCount
подсчитывает количество гласных в слове, vowelOccurrences
использует vowelCount
на выходе listOfWordsByLine
для расчета гласных в каждой строке.
Профит второго способа — универсальные функции, которые будут полезны в дальнейшей работе и будут комбинироваться вместе для решения больших задач.
Таким образом, они приводят к подходу «снизу вверх», в результате чего код становится компонуемым и модульным.
Отложенные вычисления
Отложенные («ленивые») вычисления — операции, выполнение которых откладывается до тех пор, пока не нужен результат.
Рассмотрим на примере «ленивую» обработку данных и построение «ленивых» цепочек вычислений (pipelines).
Дан список целых чисел. Необходимо возвести в квадрат элементы списка и вывести сумму первых четырех полученных значений.
Для написания «ленивой» реализации выясним, когда понадобится произвести вычисления. При суммировании первых четырёх квадратов нужно возвести в квадрат эти элементы, поэтому операцию возведения в квадрат отложим до начала суммирования.
let squareAndSum = (iterator, n) => {
let result = 0
while(n > 0) {
try {
result += Math.pow(iterator.next(), 2)
n—
catch(_) {
// длина перечня меньше `n` следовательно
// iterator.next сообщает, что у него нет значений
break
return result
let getIterator = (arr) => {
let i = 0
return {
next: function() {
if (i < arr.length) return arr[i++]
else throw new Error(‘Iteration done’)
let squareAndSumFirst4 = (arr) => {
let iterator = getIterator(arr)
return squareAndSum(iterator, 4) Добавить в заметки чтобы посмотреть позже?
|
Возводим в степень элементы только тогда, когда начинается суммирование. За счёт контроля итерации и yield
обрабатываются только те элементы, которые будут участвовать в итоге. Итерация реализуется таким образом, что элементы возвращаются с помощью оператора yield
по одному вплоть до получения сигнала об отсутствии элементов для вывода. Протокол инкапсулируется в объект итератора, который содержит одну функцию — next
, принимающую нулевые значения. Следующий элемент возвращается, только при наличии элементов.
Функция squareAndSum
принимает в качестве входных данных итератор и n
(число элементов в сумме). С помощью вызова метода .next()
n
раз получает из итератора n
значений, возводит каждый из элементов в квадрат и суммирует их.
GetIterator
возвращает итератор, сформированный из нашего списка.
squareAndSumFirst4
использует getIterator
и squareAndSum
, чтобы вернуть сумму первых четырех чисел из входного списка, возведённых в квадрат «ленивым» способом. Использование итераторов позволяет внедрять структуры данных, которые могут вернуть с помощью оператора yield
бесконечные значения.
Необходимость выполнения описанных выше действий каждый раз, когда нам нужен итератор, усложняет написание кода. К счастью, ES, начиная с версии 6, предлагает простой способ описания итераторов — генераторы.
Генератор — функция, работу которой можно приостановить, а потом возобновить. Причём генератор может выдать значения несколько раз в ходе исполнения с помощью ключевого слова yield
. При вызове возвращает объект-генератор. С помощью метода .next
получается следующее значение. В JavaScript генераторы создаются путём определения функции с *
.
// генератор, который возвращает бесконечный список последовательных
// чисел, начиная с 0
// знак «*» тспользуется, чтобы сообщить обработчику, что это генератор
function* numbers() {
let i = 0
yield ‘бесконечный список чисел’
while (true) yield i++
let n = numbers() // получить итератор от генератора
n.next() // {value: «бесконечный список чисел», done: false}
n.next() // {value: 0, done: false}
n.next() // {value: 1, done: false}
// и так далее..
|
Генераторы поддерживают оба протокола, поэтому получить значения можно с помощью оператора for-of
.
for (let n of numbers()) console.log(n) // печатать бесконечный список чисел
|
Теперь реализуем задачу, которая покажет, как эти три подхода помогают очистить код приложения.
Пример: задача и решение
Исходные данные:
- файл, который на каждой строке содержит имя пользователя и по размеру превышает объём оперативной памяти используемого устройства;
- функция, которая считывает блок данных с диска и возвращает его же, дополнив символом новой строки.
Задача:
- Получить имена пользователей, которые начинаются с «A», «E» или «M».
- Выполнить запрос с использованием полученных данных к странице
http://jsonplaceholder.typicode.com/users?username=<username>
. - Применить к первым четырём ответам сервера заданный набор из четырёх функций.
Содержимое файла:
Leopoldo_Corkery
Elwyn.Skiles
Maxime_Nienow
Moriah.Stanton
|
Разбиваем задачу на блоки поменьше, чтобы для каждого написать отдельную функцию. В результате получаем следующие функции:
- Первая возвращает каждое имя (
getNextUsername
). - Вторая отбирает имена, которые начинаются с «A», «E»или «M» (
filterIfStartsWithAEOrM
). - Третья делает сетевой запрос и возвращает
Promise
, объект-заглушку для вывода результата вычисления (makeRequest
).
Эти функции оперируют значениями. Для того, чтобы применить их к списку, введём три функции высшего порядка:
- Первая выбирает элементы списка на основе заданных параметров (
filter
). - Вторая применяет функцию к каждому элементу списка (
map
). - Третья применяет функции из одного итератора к данным другого итератора (zipWith c функцией упаковки).
«Ленивость» этого подхода может принести пользу, так как сетевые запросы делаются не для всех подходящих под критерии фильтрации имен.
Итак, у нас есть массив функций, которые должны выполняться для обработки окончательных ответов, и функция, которая возвращает «ленивым» способом блоки данных. Напишем функцию с применением генераторов для получения имен пользователей, используя «ленивый» подход.
// функции, которые выполняются в ответ на запрос
let fnsToRunOnResponse = [f1, f2, f3, f4]
// возвращает следующий блок данных из файла
// символ * обозначает, что эта функция является генератором в JavaScript
function* getNextChunk() {
yield ‘Bret\nAntonette\nSamantha\nKarianne\nKamren\nLeopoldo_Corkery\nElwyn.Skiles\nMaxime_Nienow\nDelphine\nMoriah.Stanton\n’
// getNextUsername принимает итератор, который возвращает следующий фрагмент, заканчивающийся переводом строки
// он сам возвращает итератор, который возвращает имена по одному
function* getNextUsername(getNextChunk) {
for (let chunk of getNextChunk()) {
let lines = chunk.split(‘\n’)
for (let l of lines) if (l !== ») yield l
|
Теперь для работы со значениями необходимы следующие функции:
- Первая возвращает
True
, если значение удовлетворяет критериям фильтра,False
— в противном случае. - Вторя возвращает URL-адрес при получении имени пользователя.
- Третья при получении URL-адреса делает запрос и возвращает
Promise
для этого запроса.
Promise
— «контейнер» для хранения значения выполняемой операции, которое появится в будущем. Интерфейс Promise
позволяет определить, какие действия выполнять после успешного завершения операции или сбоя. Если операция пройдёт успешно, вызывается обработчик удачного результата со значением операции. В противном случае вызывается обработчик ошибки.
// эта функция возвращает True, если имя пользователя соответствует нашим критериям
// и false в противном случае
let filterIfStartsWithAEOrM = username => {
let firstChar = username[0]
return ‘A’ === firstChar || ‘E’ === firstChar || ‘M’ === firstChar
// makeRequest делает AJAX-запрос к URL и возвращает promise
// он использует новый API и fat arrows es6
// это обычная функция, не генератор
let makeRequest = url => fetch(url).then(response => response.json())
// makeUrl принимает имя пользователя и генерирует URL-адрес, к которому хотим обратиться
let makeUrl = username => ‘http://jsonplaceholder.typicode.com/users?username=’ + username
|
Теперь напишем функции высшего порядка, которые обеспечат работу с «ленивыми» списками. Их задача — отложить выполнение до тех пор, пока не появится запрос. В этом случае нужны значения по требованию, поэтому на помощь придут генераторы.
// функция filter принимает другую функцию (предикат). Предикат же принимает значение и возвращает
// булевое значение и сам итератор. Filter возвращает итератор, если предикат при обработке
// входного значения возвращает True.
function* filter(p, a) {
for (let x of a)
if (p(x)) yield x
// map принимает на входе функцию и итератор
// возвращает новый итератор, который возвращает результат применения функции к каждому значению
// входного итератора
function* map(f, a) {
for (let x of a) yield f(x)
// zipWith принимает булеву функцию и два итератора в качестве входных данных
// возвращает итератор, который в свою очередь применяет заданную функцию к значениям из каждого
// итератора и выдаёт результат
function* zipWith(f, a, b) {
let aIterator = a[Symbol.iterator]()
let bIterator = b[Symbol.iterator]()
let aObj, bObj
while (true) {
aObj = aIterator.next()
if (aObj.done) break
bObj = bIterator.next()
if (bObj.done) break
yield f(aObj.value, bObj.value)
// execute запускает отложенный итератор
// как правильно неоднократно обращается к `.next` итератора
// вплоть до выполнения итератора
function execute(iterator) {
for (x of iterator) ;; // извлекаем значения итератора
|
Необходимые функции написаны. Используем композицию, чтобы решить поставленную задачу.
let filteredUsernames = filter(filterIfStartsWithAEOrM, getNextUsername(getNextChunk)
let urls = map(makeUrl, filteredUsernames)
let requestPromises = map(makeRequest, urls)
let applyFnToPromiseResponse = (fn, promise) => promise.then(response => fn(response))
let lazyComposedResult = zipWith(applyFnToPromiseResponse, fnsToRunOnResponse, requestPromises)
execute(lazyComposedResult)
|
lazyComposedResult
— «ленивая» цепочка вычислений (pipeline), составленная из композиций функций. Ни одно звено не выполнится, пока не запущен верхний блок композиции, то есть lazyComposedResult
. Мы сделали только четыре вызова, хотя результат фильтрации может содержать более четырех значений.
В итоге получили лаконичное решение задачи, в котором появились функции высшего уровня, композиции и повторно используемые функции.