Ленивый, компонуемый и модульный 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) // печатать бесконечный список чисел

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

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

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

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

Задача:

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

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

Bret
Antonette
Samantha
Karianne
Kamren
Leopoldo_Corkery
Elwyn.Skiles
Maxime_Nienow
Delphine
Moriah.Stanton

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

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

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

  1. Первая выбирает элементы списка на основе заданных параметров (filter).
  2. Вторая применяет функцию к каждому элементу списка (map).
  3. Третья применяет функции из одного итератора к данным другого итератора (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
  }
}

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

  1. Первая возвращает True, если значение удовлетворяет критериям фильтра, False — в противном случае.
  2. Вторя возвращает URL-адрес при получении имени пользователя.
  3. Третья при получении 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. Мы сделали только четыре вызова, хотя результат фильтрации может содержать более четырех значений.

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

Перевод статьи «Lazy, composable, and modular JavaScript»