Главная » Статьи » Forever Functional: функции высшего порядка

Forever Functional: функции высшего порядка

От автора: общие особенности функционального программирования (FP) включают передачу функций в качестве параметров другим функциям и возвращение в результате новых функций! В строгом теоретическом смысле функция высшего порядка (HOF) принимает одну или несколько функций в качестве аргументов и, может также возвращать функцию в качестве результата.

В этой статье мы рассмотрим HOF, возвращающие новые функции. Мы можем разделить такие HOF на три группы:

Оболочка функционала: исходная функциональность сохранена, но добавлена некоторая новая функция. Мы видели яркие примеры этого в наших статьях о запоминании функций и запоминании промисов. В обоих случаях созданные функции выполняли ту же работу, но более эффективно благодаря кешированию. Другими примерами обертки могут быть время (получение данных о производительности времени) или ведение журнала (создание журналов), мы увидим это ниже.

Изменение функционала: исходная функциональность каким-либо образом модифицируется. Например, у нас может быть логическая функция, которая проверяет условие и изменяет его, чтобы инвертировать его результат. Другой случай: мы можем исправить арность функции, чтобы избежать проблем, которые вы видели в статье о программировании без точек.

Создание функционала: эти HOF предоставляют новые собственные функции. В качестве примера мы можем отделить методы от объектов, чтобы использовать их как общие функции, или преобразовать функцию, которая работает с обратными вызовами в промис для более чистой обработки; мы займемся этим ниже.

Давайте рассмотрим примеры этих преобразований, чтобы вы могли получить представление о многих возможных вариантах использования HOF!

Обертывание оригинальной функциональности

Обертывание функции подразумевает создание новой версии с той же функциональностью, что и исходная, с добавлением некоторой новой функциональности.

Функции времени

Предположим, мы хотим измерить производительность функции. Мы могли бы изменить функцию, чтобы получать текущее время в начале и непосредственно перед возвратом результата, но с HOF нам это не нужно. Мы напишем функцию addTiming(…), которая вернет новую функцию, которая будет регистрировать данные о времени в дополнение к основной функциональности.

const addTiming = (fn) => (...args) => { let start = performance.now(); /* [1] */ try { const toReturn = fn(...args); /* [2] */ console.log("Normal exit", fn.name, performance.now()-start, "ms"); return toReturn; } catch (thrownError) { /* [3] */ console.log("Exception thrown", fn.name, performance.now()-start, "ms"); throw thrownError; }
};

Функция, которую мы возвращаем, начинается с получения начального времени ([1]) с помощью performance.now() для большей точности. Затем она вызывает исходную функцию ([2]) и, если нет проблем, записывает «Normal exit», имя функции и время, которое потребовалось. Если функция сгенерировала исключение ([3]), она регистрирует «Exception thrown», плюс имя функции и общее время, и снова выбрасывает то же исключение для дальнейшего процесса. Посмотрим на пример.

function add3(x, y, z) { for (let i = 1; i < 100000; i++); return x + y + z;
} add3 = addTiming(add3); /* [1] */ add3(22,9,60); /* [2] */
// logs: Normal exit add3 3.200000047683716 ms
// returns: 91

Мы меняем нашу исходную функцию на новую версию, которая включает время ([1]). Когда мы вызываем эту новую функцию ([2]), мы получаем некоторый журнал в консоли, и возвращаемое значение — это то, что рассчитала исходная функция… Теперь с помощью HOF мы можем синхронизировать любую функцию, не изменяя ее!

Функции регистрации

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

Подобно тому, что мы делали в предыдущем разделе, мы напишем функцию addLogging(…), которая будет принимать функцию в качестве параметра и возвращать новую функцию, которая начнет с регистрации ее аргументов, затем вызовет исходную функцию, запишет все, что она вернула, и, наконец, вернет это значение вызывающей стороне. Мы также учтем исключения. Возможная реализация могла быть следующей.

const addLogging = (fn) => (...args) => { /* [1] */ console.log("Enter", fn.name, ...args); try { const toReturn = fn(...args); /* [2] */ console.log("Exit ", fn.name, toReturn); return toReturn; } catch (err) { console.log("Error", fn.name, err); /* [3] */ throw err; } };

Мы начинаем с регистрации аргументов функции ([1]). Затем мы вызываем исходную функцию и сохраняем ее результат ([2]). Если проблем нет, мы просто регистрируем «Exit» и результат. Если есть какая-то ошибка, мы регистрируем «Error» и исключение. Мы можем просто использовать эту функцию; давайте повторно воспользуемся примером из предыдущего раздела.

function add3(x, y, z) { // same as avoe for (let i = 1; i < 100000; i++); return x + y + z;
} add3 = addTiming(add3); /* [1] */ addLogging(add3)(22,9,60); /* [2] */
// logs: Enter add3 22 9 60
// logs: Exit add3 91
// returns: 91

Мы создаем новую функцию ([1]), которая будет вести журнал, и когда мы вызываем ее ([2]), мы получаем дополнительный вывод. Кстати, вы можете добавить и логирование, и тайминг, например: addTiming(addLogging(add3)).

Изменение исходной функциональности

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

Отрицание условия

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

const goodAccounts = listOfAccounts.filter(isGoodAccount);

Этот код использует функцию isGoodAccount(…) для извлечения правильных учетных записей из заданного списка. Итак, что бы вы сделали, если бы вместо этого вам нужно было извлечь неверные учетные записи? Вы, конечно, могли бы написать что-то подобное, но это выглядит не так красиво.

const badAccounts = listOfAccounts(v => !isGoodAccount(v));

Мы можем получить лучшее решение, используя HOF, которая будет инвертировать (отрицать) все, что производит данная функция. Запишем эту HOF в одну строку следующим образом.

const not = fn => (...args) => !fn(...args);

Теперь вы можете написать следующее.

const badAccounts = listOfAccounts(not(isGoodAccount));

Теперь код такой же разборчивый, как и код для фильтрации правильных аккаунтов! Вы можете расширить идею, чтобы разрешить объединение нескольких условий с помощью логических операторов: например, вы можете написать HOF and(…) и or(…), которые позволили бы вам написать что-то вроде этого:

const goodInternationalAccounts = listOfAccounts.filter(and(isGoodAccount, isInternationalAccount));

И, конечно, можно все объединить!

const badInternationalAccounts = listOfAccounts.filter(and(not(isGoodAccount), isInternationalAccount));

Изменение арности функций

В статье о безточечном стиле мы видели проблему.

const numbers = [22,9,60,12,4,56];
numbers.map(Number.parseFloat); // [22, 9, 60, 12, 4, 56]
numbers.map(Number.parseInt); // [22, NaN, NaN, 5, NaN, NaN]

Почему вторая карта (…) дала такие странные результаты? Причина в том, что parseInt(…) позволяет использовать второй (необязательный) аргумент, а parseFloat(…) не позволяет. Технически арность этих функций равна 2 и 1 соответственно. Мы можем очень просто преобразовать первую функцию в унарную (арность 1), используя HOF.

const unary = fn => (arg0, ...args) => fn(arg0);

unary(fn) создает новую функцию, которая, учитывая несколько аргументов, вызывает только fn, отбрасывая остальные. Благодаря этому наша проблема легко решается другим способом, нежели тем, что мы видели в нашей предыдущей статье.

numbers.map(unary(Number.parseInt)); // [22, 9, 60, 12, 4, 56]

Таким же образом, как мы трансформировали функцию в унарную, было бы просто писать binary(…), ternary(…), для преобразования функций арности 2, 3 и т.д .; оставим это как еще одно упражнение!

Создание нового функционала

Рассмотрим пару примеров: преобразуем методы в функции, а функции использующие обратный вызов — в промисы.

От методов к функциям

Некоторые методы (например map(…)) доступны для массивов, но если вы захотите использовать их в другом месте, будет проблемма. Однако мы можем написать HOF, который преобразует любой метод в эквивалентную функцию. Вместо object.method(args), вы можете написать method(object,args) — и теперь у вас есть функция, которую вы можете передать в истинном стиле FP!

Как мы можем с этим справиться? Ключевым является метод bind(…):

const demethodize = fn => (...args) => fn.bind(...args)();

(Кстати, есть и другие способы реализации demethodize(…), например, с помощью apply(…) или call(…) — если вы готовы принять вызов, попробуйте сделать это!). Допустим, вы хотели использовать метод .toUpperCase(…) как функцию. Вы бы написали следующее.

const toUpperCase = demethodize(String.prototype.toUpperCase);
console.log(toUpperCase("this works!"));
// THIS WORKS!

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

От обратных вызовов к промисам

Давайте рассмотрим пример из Node. В нем, по определению, все асинхронные функции требуют обратного вызова «error first», например (err, data) => { … }. Если err имеет значение null, предполагается, что операция завершилась успешно и data получает свой результат; в противном случае err указывает причину ошибки. Однако вместо этого мы могли бы предпочесть работу с промисами. Мы можем написать HOF, которая преобразует асинхронную функцию, требующую обратного вызова, в промис, который позволяет использовать .then/.catch или await. (Хорошо, в Node уже есть util.promisify(), чтобы делать именно это, но давайте посмотрим, как мы это сделаем.) Необходимое преобразование несложно.

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

const promisify = fn => (...args) => new Promise((resolve, reject) => fn(...args, (err, data) => (err ? reject(err) : resolve(data))) );

Теперь, как нам прочитать файл в Node с помощью функции fs.readFile(…)? (Да, Node также предоставляет API fs/promises, который уже возвращает промисы. В реальном производстве мы бы использовали это вместо того, чтобы писать промис самостоятельно.) Мы можем сделать следующее:

const ourRead = promisify((...args) => fs.readFile(...args)); ourRead("some_file.txt") .then(data => /* do something with data */) .catch(err => /* process error err */); // or equivalently try { data = await ourRead("some_file.txt"); /* do something with data */
} catch (err) { /* process error err */
}

Легко и приятно!

Резюме

В этой статье мы исследовали концепцию функций высшего порядка (HOF), общую особенность функционального программирования, и увидели несколько примеров их использования для удовлетворения общих повседневных потребностей в разработке. Использование HOF даст вам большую свободу действий при написании более короткого, ясного и эффективного кода; попробуйте попрактиковаться с HOF!

Автор: Federico Kereki

Источник: blog.openreplay.com

Редакция: Команда webformyself.

Читайте нас в Telegram, VK, Яндекс.Дзен