furry.cat 15 октября 2021

☕ Каррирование и функции высшего порядка в JavaScript за 5 простых шагов

furry.cat

Frontend-разработчик в Foquz. https://www.cat-in-web.ru/
Улучшаем производительность и увеличиваем возможность повторного использования JavaScript кода с помощью функционального программирования.
☕ Каррирование и функции высшего порядка в JavaScript за 5 простых шагов

Каррирование и функции высшего порядка – это основы функционального программирования. JavaScript поддерживает их "из коробки", а значит, мы просто обязаны использовать эти возможности для создания чистого высокопроизводительного кода, который легко переиспользовать.

Шаг 1. Функции первого класса

Одна из важных особенностей функционального программирования на JavaScript основана на том, что функции в языке являются "гражданами первого класса" (first-class citizens). Это означает, что вы можете делать с ними все, что придет в голову, например, передавать их как аргументы другим функциями или возвращать из других функций.

        // простая JS-функция
const firstClassType = input => input;

// возвращаем функцию из другой функции
const myFunctionAsOutput = function () {
    return firstClassType;
};

firstClassType('value'); // => 'value'
myFunctionAsOutput()('value'); // => 'value'
    

firstClassType – это пример функции идентичности, которая принимает некоторое значение в качестве аргумента и его же возвращает, не изменяя.

myFunctionAsOutput не принимает никаких параметров, а просто возвращает переменную firstClassType, которая содержит функцию.

По сути вызов myFunctionAsOutput() – это то же самое, что и прямое обращение к firstClassType – эти выражения взаимозаменяемы. Поэтому мы можем вызвать результат работы myFunctionAsOutput() как обычную функцию с помощью круглых скобок.

Следующий фрагмент делает то же самое, но более многословно.

        const intermediateFn = myFunctionAsOutput();
intermediateFn('value');
    

Посмотрим на еще один пример, в котором функция выступает как параметр другой функции:

        // принимаем функцию как аргумент в другую функцию и возвращаем ее
const myFunctionAsInputAndOutput = function(inputFn) {
    return inputFn;
};

myFunctionAsInputAndOutput(firstClassType)('value'); // => 'value'
    

Функция myFunctionAsInputAndOutput принимает функцию и ее же возвращает. По сути это просто еще один пример функции идентичности. То есть вызов myFunctionAsInputAndOutput(firstClassType) – это то же самое, что и прямое обращение к firstClassType, значит, мы тоже можем его вызвать.

То же самое можно сделать и с firstClassType, если в качестве параметра input передать функцию.

        firstClassType(firstClassType)('value'); // => 'value'
    

Выражение firstClassType(firstClassType) возвращает саму функцию firstClassType, которую вновь можно вызвать :)

Единственная разница между двумя функциями идентичности firstClassType и myFunctionAsInputAndOutput состоит в том, что первая – это стрелочная функция, а вторая – обычная.

Шаг 2. Функции высшего порядка

В функциональном программировании функция высшего порядка (higher-order function) – это самая обычная функция, которая оперирует другими функциями: принимает их как входные параметры или возвращает в качестве выходных.

Таким образом, все функции из предыдущего примера – это функции высшего порядка.

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

        const memo = fn => {
    // переменная memory находится в области видимости функции memo 
    // и не видна снаружи
    let memory = []; 

    return anything => { 
        // область видимости вложенной функции
        if(anything in memory) {
            // вложенная функция читает переменную memory
            return memory[anything]; 
        } else {
            const result = fn(anything);
            // вложенная функция изменяет переменную memory
            memory[anything] = result;
            return result;
        }
    };
};

const upperCase = memo(a => a.toUpperCase()); 
upperCase('7urtle'); // => '7URTLE'
    

Это упрощенная реализация функции мемоизации. Давайте разбираться, что здесь происходит.

Мемоизация – это техника оптимизации вычислений в функциональном программировании. Она сохраняет результаты предыдущих вычислений в виде ассоциативного массива (пары ключ-значение) и использует их многократно.

Например, у нас есть функция, которая приводит строку к верхнему регистру:

        a => a.toUpperCase()
    

Мы хотим мемоизировать ее (предположим, что изменение регистра – это очень трудозатратная операция). Мы передаем эту функцию в функцию высшего порядка memo и получаем взамен другую функцию, которую сохраняем в переменную upperCase.

        const upperCase = memo(a => a.toUpperCase());
    

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

Используемый стиль кода, при котором промежуточные переменные опускаются, называется point free. Это своего рода "конвейер", при котором значение, возвращенное одной функцией, сразу же используется как входной параметр для следующей функции. Внутренняя функция приводит строку к верхнему регистру, а memo сразу использует возвращенный ей результат.

Шаг 3. Каррирование функций в JavaScript

Вам тоже кажется, что каррирование – это что-то из кулинарии? :)

На самом деле это техника преобразования функций. Свое название она получила в честь математика Хаскелла Карри (Haskell Curry). Да-да, это тот же самый человек, в честь которого назвали язык программирования Haskell. Его исследования комбинаторной логики вместе с лямбда-исчислением Алонсо Черча (Alonzo Church) легли в основу функционального программирования как парадигмы.

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

Каррирование выглядит точно так же:

        const curry1 = function (a) {
    return function (b) {
        return a + b;
    };
};

const curry2 = a => b => a + b;

curry2(5); // => b => 5 + b;

curry1(2)(3) === curry2(2)(3); // => 5
    

Функции curry1 и curry2 записаны по-разному, но работают абсолютно одинаково. Это сделано для наглядности, чтобы вы не запутались в стрелочных функциях.

Каждая стрелка в curry2 возвращает выражение, которое стоит справа от нее. Первая стрелка (первый вызов функции) возвращает вложенную функцию:

        curry2(5); // b => 5 + b
    

При этом используется входящий аргумент первой функции – a.

Второй вызов (вторые круглые скобки) вернет уже результат выполнения вложенной функции:

        curry2(5)(10); // 15
    

Каррирование – это способ пошагового получения последовательности аргументов.

В curry2 всего два параметра (и две стрелки), но вы можете использовать сколько угодно при необходимости (только не забывайте о читаемости кода).

        // один аргумент
const unary = a => a;

// два аргумента
// без каррирования
const binary = (a, b) => a + b;
// с каррированием
const curriedBinary = a => b => a + b;

// три аргумента
// без каррирования
const ternary = (a, b, c) => a + b + c;
// с каррированием
const curriedTernary = a => b => c => a + b + c;

ternary(1, 2, 3) === curriedTernary(1)(2)(3); // => 6
    

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

Шаг 4. Переиспользование каррированных функций

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

        const filter = checker => list => list.filter(checker);
const lowerCaseOf = input => input.toLowerCase();
const includes = what => where => where.includes(what);

const isTortoise = input => includes('tortoise')(lowerCaseOf(input));

const filterTortoises = filter(isTortoise);

const turtles = ['Greek Tortoise', 'Green Turtle'];

filterTortoises(turtles); // => ['Greek Tortoise']
    

В этом примере мы используем три унарные функции filter, lowerCaseOf и includes. Из них мы собираем сложные функции isTortoise и filterTortoises, как конструктор Lego из отдельных деталек.

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

Шаг 5. Улучшенная композиция функций

Каррировать JavaScript-функции при каждом использовании – занятие раздражающее, поэтому лучше использовать уже готовую библиотеку со множеством утилит, например, @7urtle/lambda. Помимо прочего она предоставляет полезную функцию compose для удобной композиции нескольких функций.

        // без compose
input => includes('tortoise')(lowerCaseOf(input))

// с compose
compose(includes('tortoise'), lowerCaseOf)
    

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

        import {lowerCaseOf, includes, filter, compose, memo} from '@7urtle/lambda';

// мемоизированный конвейер из функций includes и lowerCaseOf
const isTortoise = memo(compose(includes('tortoise'), lowerCaseOf));

const filterTortoises = filter(isTortoise);

const turtles = ['Greek Tortoise', 'Green Turtle'];

filterTortoises(turtles); // => ['Greek Tortoise']
    

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

Функциональное программирование – это настоящая магия, но чтобы овладеть ей, вам потребуется немного практики (в первое время в скобочках легко запутаться).

Альтернативный способ сделать то же самое – функция imperativeFilterTortoises.

        const imperativeFilterTortoises = function(turtles) {
    let memory = [];
    let tortoises = [];
    for (let i = 0; i < turtles.length; i++) {
        let isTortoise;
        if(turtles[i] in memory) {
            isTortoise = memory[turtles[i]];
        } else {
            isTortoise = turtles[i].toLowerCase().includes('tortoise');
            memory[turtles[i]] = isTortoise;
        }
        if(isTortoise) {
            tortoises.push(turtles[i]);
        }
    }
    return tortoises;
};

imperativeFilterTortoises(turtles); // => ['Greek Tortoise']
    

Она написана в императивном стиле и занимает целых 17 строк (515 символов против 115 у декларативного варианта)!

Бонус: функции curry и nary

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

Тогда вам нужны функции curry и nary. Первая принимает n-арную функцию, вторая – каррированную. Обе позволяют вызывать полученные функции в любом стиле (как каррированные или как n-арные).

        const curry = fn =>
    (...args) => args.length >= fn.length
        ? fn(...args)
        : (...args2) => curry(fn)(...args, ...args2);

const nary = fn =>
    (...args) => args.length === 0
        ? fn()
        : args.reduce(
            (accumulator, current) => accumulator(current),
            fn
        );

const fromBinary = curry((a, b) => a + b);
const fromCurried = nary(a => b => a + b);

fromBinary(2, 3) === fromBinary(2)(3); // => true
fromCurried(2, 3) === fromCurried(2)(3); // => true
fromBinary(2, 3) === fromCurried(2, 3); // => true
    

Обратите внимание, что сами функции curry и nary написаны в декларативном функциональном стиле. Они обе доступны в библиотеке @7urtle/lambda. Фактически, библиотека под капотом использует функцию nary, чтобы обеспечить возможность вызывать пользовательские функции любым удобным способом.

***

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

Источники

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ