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

Одно место, где я часто путался, было различие между «каррированием» и «частичным применением». Учитывая количество сообщений в блогах, объясняющих это, я вижу, что я не единственный.

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

Артистия

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

function add(a, b) {
    return a + b;
}

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

Каррирование

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

function add(a, b) {
    return a + b;
}

И перепишите это так:

function curriedAdd(a) {
    return function (b) {
        return a + b;
    }
}

Мы бы назвали add как add(3, 5), чтобы вернуть 8. Мы могли бы назвать curriedAdd как curriedAdd(3)(5) и получить тот же результат. В исходном add оба аргумента передаются за один вызов функции, сразу же оцениваются и возвращается результат. В curriedAdd мы сначала передаем значение для a и получаем обратно функцию, которая принимает b, а затем сравнивает результат с a (который удерживается в закрытии).

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

function playChord(root, third, fifth, seventh) {
     Piano.play(root, third, fifth, seventh);
}

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

function curriedPlayChord(root) {
    return function (third) {
        return function (fifth) {
            return function (seventh) {
                Piano.play(root, third, fifth, seventh);
            }
        }
    }
}

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

Частичное применение

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

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

Так почему и как это может быть полезно? Приведем пример. Представьте, что мы создаем веб-приложение, которое обрабатывает управление сотрудниками для компаний-клиентов. У нас есть функция под названием getEmployee, которая требует балансовую единицу, код отдела и код сотрудника для получения информации о сотруднике:

function getEmployee(companyCode, deptCode, employeeCode) {
    const employee = lookup({
        company: companyCode,
        department: deptCode,
        employee: employeeCode
    )};
    return employee;
}

(Для простоты мы будем делать вид, что lookup полностью синхронен в нашем примере getEmployee.)

Это относительно просто. А теперь представьте, что нам было поручено создать сайты-бутики для нескольких отделов нескольких компаний. Например, мы будем создавать сайт для отдела маркетинга SodaCo, отдела безопасности пищевых продуктов FoodCo, отдела исследований и разработок Weyland Industries и т. Д. Для каждого из них мы будем делать несколько запросов для сотрудников в коде для этой части сайта. . Мы знаем, что в каждом из этих запросов будут использоваться одни и те же companyCode и deptCode. Мы могли продолжать делать запрос одним и тем же способом, снова и снова, с одними и теми же двумя аргументами, повторяющимися каждый раз. Однако это может быть хорошей возможностью частично применить функцию, чтобы избежать необходимости многократно передавать одни и те же аргументы. Возможно, мы создадим следующий сервис:

import getEmployee from './getEmployee';
export default function GetEmployeeFetcher(companyCode, deptCode) {
    return function (employeeCode) {
        return getEmployee(companyCode, deptCode, employeeCode);
    }
}

Итак, если бы мы создавали сайт для Weyland Industries ’Manufacturing, мы могли бы получить многоразовую функцию для получения только выдающихся сотрудников, например:

import GetEmployeeFetcher from './GetEmployeeFetcher';
const getWeylandManufacturingEmployee = GetEmployeeFetcher('WEY', 'MANUFACTURING');
getWeylandManufacturingEmployee('EMP123');

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

tl; dr Обзор

Прежде чем идти дальше, давайте вспомним термины, которые мы рассмотрели:

Артистичность: слово, описывающее количество аргументов, передаваемых функции. Функция, принимающая один аргумент, имеет арность, равную единице, функция, принимающая два аргумента, имеет арность, равную двум и т. Д.

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

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

Бесконечное приложение

… Для дискретных аргументов

Итак, мы видим, что, выполняя каррирование функции, мы, благодаря определению каррирования, предоставляем себе возможность частично применять функцию к тому, что нам нравится. Это круто. Однако метод каррирования, который мы исследовали в наших функциях, оставляет желать лучшего. Хотя этот метод можно использовать для каррирования любой функции с двоичной арностью или выше, мы можем видеть, что он масштабируется линейно, так что каждый дополнительный аргумент означает запись дополнительной вложенной возвращаемой функции. Что было бы предпочтительнее, так это утилита, которая может выполнять любую функцию без необходимости повторения вручную. Еще лучше, если бы функция могла принимать переменное количество аргументов.

Как мы могли этого добиться? Что ж, давайте посмотрим на пример реализации ниже:

function curryifier(fn, ...initialArgs) {
    const cachedArgs = [...initialArgs];
    const curryifierWrappedFunction = function () {
        if (arguments.length === 0) {
            return fn.apply(null, cachedArgs);
        }
        cachedArgs.push(...arguments);
        return curryifierWrappedFunction;
    };
    return curryifierWrappedFunction;
}

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

В результате этого первоначального вызова возвращается новая карризованная версия исходной функции. Новая функция использует специальный объект arguments - если какие-либо аргументы вообще передаются, они помещаются в cachedArgs, и currified функция возвращает себя - это позволяет объединять вызовы в цепочку. Если currified функция вызывается без аргументов без, исходная функция будет вызываться с использованием каждого аргумента, хранящегося в cachedArgs, с использованием apply.

Итак, рассмотрим приведенную ниже функцию speak:

function speak(fname, lname, line) {
    return `${fname}${lname ? ` ${lname}` : ''}: "${line}"`;
}

Ниже приведены несколько примеров различных способов, которыми можно было бы назвать язык:

// with single argument partial applications
let wrappedSpeak = currifier(speak);
wrappedSpeak('Peter');
wrappedSpeak('Venkman');
wrappedSpeak('What do you see?');
wrappedSpeak(); // returns "Peter Venkman: "What do you see?""
// with variable argument partial application
wrappedSpeak = currifier(speak);
wrappedSpeak('Ray', 'Stantz');
wrappedSpeak('This place is great!');
wrappedSpeak(); //returns "Ray Stanz: "This place is great!""
// leveraging the chained function return 
const wrappedSpeak = currifier(speak);
// returns "Egon Spengler: "Don't cross the streams.""
wrappedSpeak('Egon')('Spengler')('Don\'t cross the streams.')();
// presetting args on initial wrapping call
const wrappedSpeak = currifier(speak, 'Winston', 'Zeddemore');
wrappedSpeak('Tell him about the Twinkie.');
wrappedSpeak(); // returns "Winston Zeddemore: "Tell him about the Twinkie."

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

… Для аргумента объекта конфигурации

Эта стандартная реализация частичного приложения все еще имеет некоторые недостатки. Самый большой - это то, что порядок фиксированный; мы не смогли частично применить аргументы line или lname к нашей speak функции, используя этот подход. У нас также нет возможности использовать сценарий, в котором мы хотим изменить значение аргумента, который мы уже частично применили.

Это не большие проблемы, но мне было интересно попытаться найти какое-то решение, которое могло бы их смягчить. Что, если бы у нас была функция, которая принимала объект конфигурации в качестве аргумента вместо дискретных аргументов (шаблон, ставший более популярным со времен ES6 + с деструктуризацией)? Это позволит нам принимать объекты конфигурации с некоторым подмножеством общего набора свойств и просто расширять новый объект в кеш, используя Object.assign. Рассмотрим пример реализации ниже:

function infiniteApplication(fn, ...initialArgs) {
    const cachedArgs = Object.assign({}, ...initialArgs);
    const infiniteApplicationWrappedFunction = function () {
        if (arguments.length === 0) {
            return fn.call(null, cachedArgs);
        }
        Object.assign(cachedArgs, ...arguments);
        return infiniteApplicationWrappedFunction;
    };
    return infiniteApplicationWrappedFunction;
}

На первый взгляд это очень похоже на нашу предыдущую реализацию дискретных аргументов. Основное отличие состоит в том, что вместо того, чтобы вставлять в массив, мы теперь assigning в объект. Когда приходит время выполнить исходную функцию, мы используем метод функции call вместо apply, чтобы избежать необходимости заключать наш объект в массив.

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

function speak({fname, lname, line}) {
    return `${fname}${lname ? ` ${lname}` : ''}: "${line}"`;
}

Используя нашу новую сигнатуру объектно-ориентированной функции конфигурации и частичную утилиту приложения, у нас есть более гибкие способы частичного применения speak:

// out of order
let wrappedSpeak = infiniteApplication(speak);
wrappedSpeak({line: "I am The Keymaster!"});
wrappedSpeak({fname: "Louis", lname: "Tully"});
wrappedSpeak(); // returns "Louis Tully: "I am The Keymaster!""
// overriding/revising previously assigned partially applied args
const wrappedSpeak = infiniteApplication(speak, {
    fname: 'Dana',
    lname: 'Barrett'
});
wrappedSpeak({fname: 'The GateKeeper'});
wrappedSpeak({lname: undefined});
wrappedSpeak('I am The GateKeeper!');
wrappedSpeak(); // returns "The GateKeeper: "I am The Gatekeeper!""

Комбинированный

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

function infiniteApplication(fn, useConfigForArgs, ...initialArgs) {
    if (typeof fn !== 'function') {
        throw new Error('infiniteApplication expects to be called with a function as the first argument.');
    }
    if (typeof useConfigForArgs === 'undefined') {
        useConfigForArgs = false;
    }
    if (typeof useConfigForArgs !== 'boolean') {
        throw new Error('infiniteApplication expects that a second argument, if present, be a boolean.');
    }
    let cachedArgs;
    if (useConfigForArgs) {
        cachedArgs = Object.assign({}, ...initialArgs);
    } else {
        cachedArgs = [...initialArgs];
    }
    const infiniteApplicationWrappedFunction = function () {
        if (arguments.length === 0) {
            if (useConfigForArgs) {
                return fn.call(null, cachedArgs);
            } else {
                return fn.apply(null, cachedArgs);
            }
        }
        if (useConfigForArgs) {
            for (const arg of arguments) {
                if(typeof arg !== 'object' || arg === null) {
                    throw new Error('infiniteApplication expects objects as subsequent args when using `useConfigForArgs` mode');
                }
            }
            Object.assign(cachedArgs, ...arguments);
        } else {
            cachedArgs.push(...arguments);
        }
        return infiniteApplicationWrappedFunction;
    };
    return infiniteApplicationWrappedFunction;
}

Вышеупомянутое доступно на моем GitHub как infiniteApplication (версия 0.3.0 на момент написания этой статьи). Он немного менее наивен в отношении потенциальных входных данных, которые он может получить, и будет throw, если получит неверный аргумент. Самый большой компромисс заключается в том, что он требует передачи флага, чтобы указать, будет ли переданная функция использовать дискретные аргументы или один аргумент конфигурации. Это не особенно элегантно, но, к сожалению, нет хорошего способа определить сигнатуру функции. Мы могли бы использовать function.length, но нам нужно было бы сделать некоторые конкретные предположения о том, какие были эти аргументы, которые могут оказаться проблематичными. Если бы мы хотели быть милыми, мы могли бы написать infiniteApplication, чтобы использовать объект конфигурации вместо дискретных аргументов, а затем передать его самому себе, чтобы частично применить флаг useConfigForArgs; Когда я впервые писал, это казалось не стоящим компромисса, но, возможно, это стоит того.

Обратите внимание, что все это не ново - существует несколько утилит для выполнения функций ввода. Lodash даже имеет curry метод, который будет каррировать функцию ввода, и метод curryRight, чтобы сделать то же самое, но изменить порядок аргументов в результирующей каррированной функции. И я не удивлюсь, если узнаю, что другие пришли к такому же выводу в отношении варианта частичного применения с использованием объекта, а не отдельных аргументов. Тем не менее, мы надеемся, что эта статья была информативной по темам арности, каррирования и частичного применения и предоставила вам некоторый код для дальнейшего изучения этих концепций. Не стесняйтесь форк infiniteApplication и возиться с ним. Мне были бы интересны любые отзывы о том, что по поводу реализации работает, не работает, может ли быть улучшено и т. Д. Спасибо за чтение и удачного кодирования!