Использование подхода функционального программирования к конвейерным функциям в JavaScript
Переписка из моей статьи dev.to с таким же названием.
Недавно я прочитал здесь, на Medium, статью об использовании стиля конвейера в JavaScript. В статье описывается, как связать функции вместе, чтобы данные проходили через все из них.
В этой статье я упустил из виду вкус функционального программирования. Давайте сделаем еще один шаг и добавим немного FP.
Использование конвейеров в оболочке * nix
Представьте себе командную строку *nix
, в которой мы хотим найти все index.js
файлы в определенном каталоге. Когда мы получим список файлов, мы захотим их подсчитать.
Допустим, у нас есть исходный код, помещенный внутри src/
.
Это тривиальный пример, но он объясняет, как мы можем использовать команды конвейера (используя |
) в оболочке * nix для передачи данных через них.
Чтобы добиться того, чего мы хотим, мы должны выполнить следующую команду:
tree src/ | grep index.js | wc -l
Где:
- tree
рекурсивно перечисляет каталоги (в примере я ограничиваю его до src/
каталог)
- grep
используется для фильтрации результатов (одна строка) с заданным шаблоном - нам нужны только строки, содержащие index.js
- wc
(word count) возвращает количество символов новой строки, количество слов и количество байтов. Используется с -l
, возвращает только первое значение, поэтому, сколько раз наш index.js
был найден
Пример вывода вышеприведенной команды может быть любым числом, в моем случае это 26.
Здесь мы видим, как данные передаются от одной команды к другой. Первая команда работает с входными данными и возвращает данные второй. И так до конца - тогда отображаются данные, возвращенные последней командой.
Использование конвейеров в JavaScript
Мы можем добиться того же в JavaScript.
Во-первых, давайте создадим функцию, которая для определенной цели имитирует команды оболочки.
// node’s execSync allows us to execute shell command const { execSync } = require('child_process'); // readFiles = String => Buffer const readFiles = (path = "") => execSync(`tree ${path}`); // bufferToString = Buffer => String const bufferToString = buffer => buffer.toString(); // makeFilesList = String => Array const makeFilesList = files => files.split("\n"); // isIndex = String => Boolean const isIndexFile = file => file.indexOf("index.js") > 0; // findIndexFiles = Array => Array const findIndexFiles = files => files.filter(isIndexFile); // countIndexFiles = Array => Number const countIndexFiles = files => files.length;
Посмотрим, что у нас получилось:
- Функция readFiles()
выполняет команду tree
для предоставленного path
или в том месте, где был выполнен наш JS-файл. Функция возвращает буфер
- функция bufferToString()
преобразует данные буфера в строку
- Функция makeFilesList()
преобразует полученную строку в массив, делая каждую строку текста отдельным элементом массива
- isIndexFile()
функция проверяет, содержит ли предоставленный текст index.js
- Функция findIndexFiles()
фильтрует массив и возвращает новый массив только с записями, содержащими index.js
(внутренне использует функцию isIndexFile()
)
- Функция countIndexFiles()
просто подсчитывает элементы в предоставленном массиве
Теперь у нас есть все необходимое для реализации нашей JavaScript-реализации. Но как это сделать?
Мы будем использовать композицию функций, и ключевым моментом здесь будет использование унарных функций.
Состав функций
Унарные функции - это функции, которые получают ровно один параметр.
Поскольку они принимают один аргумент, мы можем соединить их, создав новую функцию. Этот метод называется композиция функций. Затем данные, возвращаемые одной функцией, используются в качестве входных данных для другой.
Мы можем использовать функцию compose
, которую вы можете найти в популярной библиотеке функционального программирования Ramda. Посмотрим, как это сделать…
// returns function that accepts path parameter passed to // readFiles() const countIndexFiles = R.compose( countIndexFiles, findIndexFiles, makeFilesList, bufferToString, readFiles); const countIndexes = countIndexFiles("src/"); console.log(`Number of index.js files found: ${countIndexes}`);
Примечание: на самом деле мы можем составлять функции, даже не используя функцию compose
(но я думаю, что это менее читаемо):
const countIndexes = countIndexFiles(findIndexFiles(makeFilesList(bufferToString(readFiles("src/"))))); console.log(`Number of index.js files found: ${countIndexes}`);
Как видите, композиция функций позволяет нам объединять функции и не беспокоиться об обработке данных между ними. Вот что нам нужно делать без использования композиции:
const filesBuf = readFiles("src/"); const filesStr = bufferToString(filesBuf); const filesList = makeFilesList(filesStr); const indexFiles = findIndexFiles(filesList); const countIndexes = countIndexFiles(indexFiles);
Сочинить против трубы
Как вы могли заметить, при использовании compose
нам нужно передавать функции в порядке, обратном их использованию (снизу вверх).
Их проще читать в порядке сверху вниз. Именно здесь на помощь приходит pipe
. Он делает то же самое, что и compose
, но принимает функции в обратном порядке.
// NOTE even though not takes functions list in reverse order // it still accepts path parameter passed to readFiles() const countIndexFiles = R.pipe( readFiles, bufferToString, makeFilesList, findIndexFiles, countIndexFiles); const countIndexes = countIndexFiles("src/"); console.log(`Number of index.js files found: ${countIndexes}`); // same result as before 🙌
Только от нас зависит, какой метод мы будем использовать - compose
или pipe
.
Попробуйте использовать тот, который вам (и вашим коллегам) лучше.
Бонус: используйте полную мощность, которую дает вам Ramda
Мы можем использовать другие методы Ramda, чтобы еще больше сократить наш код. Это связано с тем, что все функции Ramda по умолчанию каррированы и имеют стиль «данные последние».
Это означает, что мы можем настроить их перед предоставлением данных. Например, R.split
создает новую функцию, которая разделяет текст по заданному разделителю. Но он ждет передачи текста:
const ipAddress = "127.0.0.1"; const ipAddressParts = R.split("."); // -> function accepting string console.log(ipAddressParts(ipAddress)); // -> [ ‘127’, ‘0’, ‘0’, ‘1’ ]
Достаточно теории 👨🎓
Давайте посмотрим, как наш код может выглядеть в окончательной (больше в стиле FP) форме:
const { execSync } = require("child_process"); const R = require("ramda"); // readFiles = String => Buffer const readFiles = (path = "") => execSync(`tree ${path}`); // bufferToString = Buffer => String const bufferToString = buffer => buffer.toString(); // isIndex = String => Boolean const isIndexFile = file => file.indexOf("index.js") > 0; const countIndexFiles = R.pipe( readFiles, bufferToString, R.split(“\n”), R.filter(isIndexFile), R.length); const countIndexes = countIndexFiles("src/"); console.log(`Number of index.js files found: ${countIndexes}`);