Функциональное программирование с примерами на JavaScript. Часть вторая. Аппликативные функторы, curryN и валидации

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

Пример 3.  Задание значений объектам, которые могут равняться Null

Используемые концепты ФП: Аппликативные функторы.

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

скидка

Для этой задачи мы будем использовать метод applyDiscount, представленный ниже. Этот метод может выбрасывать ошибки типа null в случае, если пользователь или скидка равняется null.

// Предлагает пользователю скидку, если и пользователь, и скидка существуют
// Выбрасывает ошибку, если пользователь или скидка равны null
const applyDiscount = (user, discount) => {
    let userClone = clone(user); // используем какую-нибудь библиотеку, чтобы создать копию объекта
    userClone.discount = discount.code;
   return userClone;
}

Давайте посмотрим, как мы можем решить эту задачу, используя аппликативные функторы.

Аппликативный функтор

Любой класс, у которого есть метод ap и который имплементирует спецификацию Applicative, называется аппликативным функтором. Аппликативные функторы используются в функциях, которые работают с возможными null-значениями в правой и левой части присваивания.

Оказывается, Maybe-монады также реализуют метод ap, и, следовательно, являются аппликативными функторами. Таким образом, мы можем использовать Maybe-монады для решения этой задачи.

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

Шаг 1: Обернем потенциальные null-объекты в Maybe-монады.

const maybeUser = Maybe(user);
const maybeDiscount = Maybe(discount);

Шаг 2: Перепишем функцию так, чтобы она могла принимать один параметр за раз (каррируем её).

// Каррирование
var applyDiscount = curry(function(user, discount) {     
       user.discount = discount.code;     
       return user; 
});

Шаг 3: Передадим первый агрумент (maybeUser) в метод applyDiscount, используя map.

const maybeApplyDiscountFunc = maybeUser.map(applyDiscount);
// applyDiscount каррирована и функция map передает только один параметр, следовательно, возвращаемым результатом
// (maybeApplyDiscountFunc) будет функция, обернутая в монаду, которая хранит переменную maybeUser в замыкании

Шаг 4: Используем maybeApplyDiscountFunc.

Значение maybeApplyDiscountFunc может быть:

  1. функцией, обернутой в Maybe, если пользователь существует.
  2. Nothing, если пользователь равен null.

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

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

maybeDiscount.map(maybeApplyDiscountFunc)! // Проблема!

Мы столкнулись с проблемой: map не знает, как запустить функцию, когда она обернута в Maybe-монаду.

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

Шаг 5: Используем функцию ap. Этот метод принимает монаду Maybe и выполняет функцию, хранящуюся внутри.

class Maybe {
  constructor(val) {
    this.val = val;
  }
  ...
  ...
  // реализация ap
  ap(differentMayBe) { 
     return differentMayBe.map(this.val); 
  }
}

Применим метод ap:

maybeApplyDiscountFunc.ap(maybeDiscount)

Подведем итог: если у вас есть функция, которая работает с несколькими переменными, значения которых может быть null, вы должны сначала каррировать её, а затем обернуть в Maybe. Кроме этого, поместите все параметры в Maybe и используйте ap, чтобы запустить функцию.

Множественное каррирование

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

// Пример каррирования
const add = (a, b) => a+b;
const curriedAdd = R.curry(add);
const add10 = curriedAdd(10); // Передаем первый аргумент. Нам возвращается функция, принимающая второй параметр.
// Вызываем функцию, передавая второй аргумент.
add10(2) // -> 12

Но что если у нас будет функция, которая может суммировать не два, а несколько аргументов?

const add = (...args) => R.sum(args); // Суммируем все аргументы

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

// Пример множественного каррирования:
const add = (...args) => R.sum(args);
const add3Numbers = R.curryN(3, add);
const add5Numbers = R.curryN(5, add);
const add10Numbers = R.curryN(10, add);
add3Numbers(1,2,3) // 6
add3Numbers(1) // Возвращает функцию, которая принимает 2 параметра.
add3Numbers(1, 2) // Возвращает функцию, которая принимает один параметр.

Использование curryN для ожидания определенного количества вызовов функции.

Предположим, что мы хотим реализовать функцию, которая выводит лог только после 3х её вызовов.

// не чистая реализация
let counter = 0;
const logAfter3Calls = () => {
 if(++counter == 3)
   console.log('called me 3 times');
}
logAfter3Calls() // Ничего не происходит
logAfter3Calls() // Ничего не происходит
logAfter3Calls() // 'called me 3 times'

Мы можем написать эту функцию в функциональном стиле, используя curryN:

// Чистая реализация
const log = () => {
   console.log('called me 3 times');
}

const logAfter3Calls = R.curryN(3, log);

// Вызов
logAfter3Calls('')('')('') // 'called me 3 times'

// Мы передаем '' в качестве аргумента, т.к. curryN ожидает параметры

Пример 4.  Сбор и отображение нескольких ошибок

Освещенные темы: Валидации (Валидационный функтор, Валидационный аппликативный функтор, Валидационная монада)

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

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

Сценарий использования: У нас есть форма регистрации, в которой валидируются имя пользователя, пароль и e-mail с помощью трех функций: isUsernameValid, isPwdLengthCorrect и isEmailValid. Мы должны показать одну, две или три ошибки в зависимости от введенных данных.

форма регистрации

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

Мы будем использовать библиотеку data.validation из folktalejs, поскольку в ramda-fantasy еще не реализованы валидации.

У валидационного функтора есть два конструктора: Success и Failure, по аналогии с монадой Either.

Шаг 1: Чтобы использовать валидации, все, что нам нужно сделать — обернуть валидные значения и ошибки в Success и Failure.

const Validation = require('data.validation') // из folktalejs
const Success = Validation.Success
const Failure = Validation.Failure
const R = require('ramda');
// Вместо:
function isUsernameValid(a) {
    return /^(0|[1-9][0-9]*)$/.test(a) ? 
           ["Username can't be a number"] : a
}
// Используйте:
function isUsernameValid(a) {
    return /^(0|[1-9][0-9]*)$/.test(a) ? 
         Failure(["Username can't be a number"]) : Success(a)
}

Проделайте это для всех полей формы.

Шаг 2: Создайте функцию-заглушку.

const returnSuccess = () => 'success'; // возвращает success

Шаг 3: Используйте curryN, чтобы повторно применить ap.

Проблема с функцией ap в том, что левая часть выражения должна быть функтором или монадой, содержащей функцию.

Например, предположим, что мы хотим повторно применить ap, как показано ниже. Это будет работать только в том случае, когда monad1 содержит функцию. Результат monad1.ap(monad2) также должен быть монадой, содержащей функцию, чтобы мы могли использовать ap на monad3.

let finalResult = monad1.ap(monad2).ap(monad3)
// Может быть переписано, как:
let resultingMonad = monad1.ap(monad2)
let finalResult = resultingMonad.ap(monad3)

В нашем случае у нас есть 3 функции, которые нам надо применить

Давайте предположим, что мы сделали что-то вроде:

Success(returnSuccess)
  .ap(isUsernameValid(username)) // сработает
  .ap(isPwdLengthCorrect(pwd)) // не сработает
  .ap(ieEmailValid(email)) // не сработает

Код выше не сработает, потому что Success(returnSuccess).ap(isUsernameValid(username)) вернет значение, и мы не сможем вызвать от него метод ap.

Мы можем использовать curryN, чтобы возвращать функцию, пока она не вызвана N раз.

function validateForm(username, pwd, email) {
    let success = R.curryN(3, returnSuccess);
    return Success(success)
        .ap(isUsernameValid(username))
        .ap(isPwdLengthCorrect(pwd))
        .ap(ieEmailValid(email))
}

В результате мы получаем такой код:

const Validation = require('data.validation') // из folktalejs
const Success = Validation.Success
const Failure = Validation.Failure
const R = require('ramda');

function isUsernameValid(a) {
    return /^(0|[1-9][0-9]*)$/.test(a) ? Failure(["Username can't be a number"]) : Success(a)
}

function isPwdLengthCorrect(a) {
    return a.length == 10 ? Success(a) : Failure(["Password must be 10 characters"])
}

function ieEmailValid(a) {
    var re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;

    return re.test(a) ? Success(a) : Failure(["Email is not valid"])
}

const returnSuccess = () => 'success'; 

function validateForm(username, pwd, email) {
    let success = R.curryN(3, returnSuccess);
        .ap(isPwdLengthCorrect(pwd))
        .ap(ieEmailValid(email))
}


validateForm('raja', 'pwd1234567890', 'r@r.com').value;
// Вывод: success

validateForm('raja', 'pwd', 'r@r.com').value;
// Вывод: ['Password must be 10 characters' ]


validateForm('raja', 'pwd', 'notAnEmail').value;
// Вывод: ['Password must be 10 characters', 'Email is not valid']

validateForm('123', 'pwd', 'notAnEmail').value;
// ['Username can\'t be a number', 'Password must be 10 characters', 'Email is not valid']

Перевод статьи «Functional Programming In JS   With Practical Examples (Part 2)»