Использование подхода функционального программирования к конвейерным функциям в 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}`);