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

Рассказывает rajaraodv


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

В первой части вы изучите основы ФП, такие как каррирование, чистые функции, fantasy-land, функторы, монады, Maybe-монады и Either-монады на нескольких примерах.

Функциональное программирование — это стиль написания программ через составление набора функций.

Основной принцип ФП — оборачивать практически все в функции, писать множество маленьких многоразовых функций, а затем просто вызывать их одну за другой, чтобы получить результат вроде (func1.func2.func3) или в композиционном стиле func1(func2(func3())).

Кроме этого, структура функций должна следовать некоторым правилам, описанным ниже.

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

  1. Как реализовать условия (if-else)? (Совет: используйте монаду Either);
  2. Как перехватить исключения типа Null Exception? (В этом может помочь монада Maybe);
  3. Как убедиться в том, что функция действительно “многоразовая” и может использоваться в любом месте? (Чистые функции);
  4. Как убедиться, что данные, которые мы передаем, не изменяются, чтобы мы могли бы использовать их где-то еще? (Чистые функции, иммутабельность);
  5. Если функция принимает несколько значений, но цепочка может передавать только одно значение, как мы можем сделать эту функцию частью цепочки? (Каррирование и функции высшего порядка).

Чтобы решить все эти проблемы, функциональные языки, вроде Haskell, предоставляют инструменты и решения из математики, такие как монады, функторы и т.д., из коробки.

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

Спецификация Fantasy-Land и библиотеки ФП

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

Fantasy-Land — одна из таких спецификаций, в которой описано, как должна действовать та или иная функция или класс в JS.

функциональное программирование

На рисунке выше показаны все спецификации и их зависимости. Спецификации — это, по существу, описания функционала, подобные интерфейсам в Java. С точки зрения JS вы можете думать о спецификациях, как о классах или функциях-конструкторах, которые реализовывают некоторые методы (map, of, chain), следуя спецификации.

Например, класс в JavaScript является функтором, если он реализует метод map. Метод map должен работать, следуя спецификации.

По аналогии, класс в JS является аппликативным функтором, если он реализует функции map и ap.

JS-класс — монада, если он реализует функции, требуемые функтором, аппликативным функтором, цепочкой и самой монадой.

Библиотеки, следующие спецификациям Fantasy-Land

Есть несколько библиотек, следующих спецификациям FL: monet.js, barely-functional, folktalejs, ramda-fantasyimmutable-ext, Fluture и т.д.

Какие же из них мне использовать?

Такие библиотеки, как lodash-fp и ramdajs, позволяют вам начать программировать в функциональном стиле. Но они не реализуют функции, позволяющие использовать ключевые математические концепты (монады, функторы, свертки), а без них невозможно решать некоторые из реальных задач в функциональном стиле.

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

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

Пример 1: справляемся с проверкой на NULL

Тема покрывает: функторы, монады, Maybe-монады и каррирование.

Сценарий использования: Мы хотим показать различные стартовые страницы в зависимости от языка, выбранного пользователем в настройках. В данном примере мы реализовываем функцию getUrlForUser, которая возвращает правильный URL из списка indexURLs для испанского языка, выбранного пользователем joeUser.

Проблема: язык может быть не выбран, то есть равняться null. Также сам пользователь может быть не залогинен и равняться null. Выбранный язык может быть не доступен в нашем списке indexURLs. Так что мы должны позаботиться о нескольких случаях, при которых значение null или undefined может вызвать ошибку.

const getUrlForUser = (user) => {
}
// Объект пользователя
let joeUser = {
    name: 'joe',
    email: 'joe@example.com',
    prefs: {
        languages: {
            primary: 'sp',
            secondary: 'en'
        }
    }
};
// Список стартовых страниц в зависимости от выбранного языка
let indexURLs = {
    'en': 'http://mysite.com/en',  // Английский
    'sp': 'http://mysite.com/sp', // Испанский
    'jp': 'http://mysite.com/jp'   // Японский
}
// Перезаписываем window.location
const showIndexPage = (url) => { window.location = url };

Решение (Императивное против Функционального):

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

// Императивный стиль:
// Слишком много if-else и проверок на null
const getUrlForUser = (user) => {
  if (user == null) { // не залогинен
    return indexURLs['en']; // возвращаем страницу по умолчанию
  }
  if (user.prefs.languages.primary && user.prefs.languages.primary != 'undefined') {
    if (indexURLs[user.prefs.languages.primary]) { // Если существует перевод
      return indexURLs[user.prefs.languages.primary];
    } else {
      return indexURLs['en'];
    }
  }
}

// вызов
showIndexPage(getUrlForUser(joeUser));


// Функциональный стиль:
// Поначалу чуть сложнее понять, но он намного более надежен)
const R = require('ramda');
const prop = R.prop;
const path = R.path;
const curry = R.curry;
const Maybe = require('ramda-fantasy').Maybe;

const getURLForUser = (user) => {
    return Maybe(user) // Оборачиваем пользователя в объект Maybe 
        .map(path(['prefs', 'languages', 'primary'])) // Используем Ramda чтобы получить язык
        .chain(maybeGetUrl); // передаем язык в maybeGetUrl; получаем url или Монаду null
}

const maybeGetUrl = R.curry(function(allUrls, language) { // Каррируем для того, чтобы превратить в функцию с одним параметром
    return Maybe(allUrls[language]); // Возвращаем Монаду(url или null)
})(indexURLs); // Передаем indexURLs вместо того, чтобы обращаться к глобальной переменной


function boot(user, defaultURL) {
   showIndexPage(getURLForUser(user).getOrElse(defaultURL));
}

boot(joeUser, 'http://site.com/en'); // 'http://site.com/sp'

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

Функторы

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

Например, Array — это функтор, потому что массив хранит значения и реализует метод map, позволяющий нам применять функцию к значениям, которые он хранит.

const add1 = (a) => a+1;
let myArray = new Array(1, 2, 3, 4); // хранит значения
myArray.map(add1) // -> [2,3,4,5] // применяет функции

Давайте напишем свой собственный функтор “MyFunctor”. Это просто JS-класс (функция-конструктор). Метод map применяет функцию к хранимым значениям и возвращает новый экземпляр MyFunctor.

const add1 = (a) => a + 1;
class MyFunctor {
  constructor(value) {
    this.val = value;
  }
  map(fn) {   // Применяет функцию к this.val + возвращает новый экземпляр Myfunctor
   return new Myfunctor(fn(this.val));
  }
}
// temp --- это экземпляр Functor, хранящий значение 1
let temp = new MyFunctor(1); 
temp.map(add1) // -> temp позволяет нам применить add1

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

Монады

Монады — это подтип функторов, так как у них есть метод map, но они также реализуют другие методы, например, ap, of, chain.

Ниже представлена простая реализация монады.

// Монада - простая реализация
class Monad {
    constructor(val) {
        this.__value = val;
    }
    static of(val) { // Monad.of проще, чем new Monad(val)
        return new Monad(val);
    };
    map(f) { // Применяет функцию, возвращает новый экземпляр Monad
        return Monad.of(f(this.__value));
    };
    join() { // используется для получения значения монады
        return this.__value;
    };
    chain(f) { // Хелпер, который применяет функцию и возвращает значение монады
        return this.map(f).join();
    };
    ap(someOtherMonad) { // Используется, чтобы взаимодействовать с другими монадами
        return someOtherMonad.map(this.__value);
    }
}

Обычные монады используются нечасто, в отличие от более специфичных монад, таких как “монада Maybe” и “монада Either“.

“Maybe”-монада

Монада “Maybe” — это класс, который имплементирует спецификацию монады. Её особенность заключается в том, что с помощью нее можно решать проблемы с null и undefined.

В частности, в случае, если данные равны null или undefined, функция map пропускает их.

Код, представленный ниже, показывает имплементацию Maybe-монады в библиотеке ramda-fantasy. Она возвращает экземпляр одного из двух подклассов: Just или Nothing, в зависимости от значения.

Классы Just и Nothing содержат одинаковые методы (map, orElse и т.д.). Отличие между ними в реализации этих самых методов.

Обратите особое внимание на функции “map” и “orElse”.

// Самые важные части реализации Maybe из библиотеки ramda-fantasy
// Для того, чтобы посмотреть полный исходный код, посетите https://github.com/ramda/ramda-fantasy/blob/master/src/Maybe.js

function Maybe(x) { // <-- Главный конструктор, возвращающий Maybe.Just или Nothing
  return x == null ? _nothing : Maybe.Just(x);
}

function Just(x) {
  this.value = x;
}
util.extend(Just, Maybe);

Just.prototype.isJust = true;
Just.prototype.isNothing = false;

function Nothing() {}
util.extend(Nothing, Maybe);

Nothing.prototype.isNothing = true;
Nothing.prototype.isJust = false;

var _nothing = new Nothing();

Maybe.Nothing = function() {
  return _nothing;
};

Maybe.Just = function(x) {
  return new Just(x);
};

Maybe.of = Maybe.Just;

Maybe.prototype.of = Maybe.Just;


// функтор
Just.prototype.map = function(f) { // Применение map на Just запускает функцию и возвращает Just(результат)
  return this.of(f(this.value));
};

Nothing.prototype.map = util.returnThis; // <-- Применение Map на Nothing не делает ничего

Just.prototype.getOrElse = function() {
  return this.value;
};

Nothing.prototype.getOrElse = function(a) {
  return a;
};

module.exports = Maybe;

Давайте поймем, как Maybe-монада осуществляет проверку на null.

  1. Если есть объект, который может равняться null или иметь нулевые свойства, создаем экземпляр монады из него.
  2. Используем библиотеки, вроде ramdajs, чтобы получить значение из монады и работать с ним.
  3. Возвращаем значение по умолчанию, если данные равняются null.
// Шаг 1. Вместо
if (user == null) { // не залогинен
    return indexURLs['en']; // возвращает значение по умолчанию
  }

// Используйте:
 Maybe(user) // Возвращает Maybe({userObj}) или Maybe(null)



// Шаг 2. Вместо
 if (user.prefs.languages.primary && user.prefs.languages.primary != 'undefined') {
    if (indexURLs[user.prefs.languages.primary]) { // если есть перевод
      return indexURLs[user.prefs.languages.primary];
      
// Используйте:
 <userMaybe>.map(path(['prefs', 'languages', 'primary']))
      
   
      
// Шаг 3. Вместо
 return indexURLs['en']; // захардкоженные значения по умолчанию
  
// Используйте:
<userMayBe>.getOrElse('http://site.com/en')

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

Освещенные темы: чистые функции и композиция.

Если мы хотим создавать серии вызовов функций, как то func1.func2.func3 или (func1(func2(func3())), все эти функции должны принимать только один параметр. Например, если func2 принимает два параметра (func2(param1, param2)), мы не сможем включить её в серию.

Но с практической точки зрения многие функции могут принимать несколько параметров. Так как же нам создавать из них цепочки? Ответ: С помощью каррирования.

Каррирование превращает функцию, которая принимает несколько параметров в функцию, которая принимает только один параметр за один раз. Функция не запустится, пока все параметры не будут переданы.

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

Давайте снова взглянем на наше решение:

// Глобальный список языков
let indexURLs = {
    'en': 'http://mysite.com/en',  // Английский
    'sp': 'http://mysite.com/sp', // Испанский
    'jp': 'http://mysite.com/jp'   // Японский
}

// Императивный стиль
const getUrl = (language) => allUrls[language]; // Простой, но склонный к ошибкам и нечистый стиль(обращение к глобальной переменной)


// Функциональный стиль

// До каррирования:
const getUrl = (allUrls, language) => {
    return Maybe(allUrls[language]);
}

// После каррирования:
const getUrl = R.curry(function(allUrls, language) {
    return Maybe(allUrls[language]);
});

const maybeGetUrl = getUrl(indexURLs) // Храним глобальное значение в каррированной функции

// maybeGetUrl требует только один аргумент, так что можем объединить в цепочку:
maybe(user).chain(maybeGetUrl).bla.bla

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

Освещенные темы: Монада “Either”

Монада Maybe подходит нам, чтобы обработать ошибки, связанные с null и undefinedНо что делать с функциями, которым требуется выбрасывать исключения? И как определить, какая из функций в цепочке вызвала ошибку, когда в серии несколько функций, бросающих исключения?

Например, если func2 из цепочки func1.func2.func3... выбросила исключение, мы должны пропустить вызов func3 и последующие функции и корректно обработать ошибку.

Монада Either

Монада Either превосходно подойдет для подобной ситуации.

Пример использования: В примере ниже мы рассчитываем “tax” и “discont” для “items” и в конечном счете вызываем showTotalPrice.

Заметьте, что функции “tax” и “discount” выбросят исключение, если в качестве цены передано не числовое значение. Функция “discount”, помимо этого, вернет ошибку в случае, если цена меньше 10.

// Императивный:
// Возвращает ошибку или цену, включающую налог
const tax = (tax, price) => {
  if (!_.isNumber(price)) return new Error("Price must be numeric");

  return price + (tax * price);
};

// Возвращает ошибку или цену, включающую скидку
const discount = (dis, price) => {
  if (!_.isNumber(price)) return (new Error("Price must be numeric"));

  if (price < 10) return new Error("discount cant be applied for items priced below 10");

  return price - (price * dis);
};

const isError = (e) => e && e.name == 'Error';

const getItemPrice = (item) => item.price;

// Выводит общую цену, включая налог и скидку. Требует обработки нескольких ошибок
const showTotalPrice = (item, taxPerc, disount) => {
  let price = getItemPrice(item);
  let result = tax(taxPerc, price);
  if (isError(result)) {
    return console.log('Error: ' + result.message);
  }
  result = discount(discount, result);
  if (isError(result)) {
    return console.log('Error: ' + result.message);
  }
  // выводим результат
  console.log('Total Price: ' + result);
}

let tShirt = { name: 't-shirt', price: 11 };
let pant = { name: 't-shirt', price: '10 dollars' };
let chips = { name: 't-shirt', price: 5 }; // ошибка

showTotalPrice(tShirt) // Total Is: 9.075
showTotalPrice(pant)   // Error: Price must be numeric
showTotalPrice(chips)  // Error: discount cant be applied for items priced below 10

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

Either-монада предоставляет два конструктора: “Either.Left” и “Either.Right“. Думайте о них, как о подклассах Either. И “Left“, и “Right” тоже являются монадами. Идея в том, чтобы хранить ошибки или исключения в Left и полезные значения в Right.

Экземпляры Either.Left или Either.Right создаются в зависимости от значения функции.

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

Шаг 1: Оберните возвращаемые значения в Left и Right. “Оборачивание” означает создание экземпляра класса с помощью оператора new.

var Either = require('ramda-fantasy').Either;
var Left = Either.Left;
var Right = Either.Right;

const tax = R.curry((tax, price) => {
  if (!_.isNumber(price)) return Left(new Error("Price must be numeric")); // <--Оборачиваем Error в Either.Left

  return  Right(price + (tax * price)); // <-- Оборачиваем результат в Either.Right
});

const discount = R.curry((dis, price) => {
  if (!_.isNumber(price)) return Left(new Error("Price must be numeric")); // <--Оборачиваем Error в Either.Left

  if (price < 10) return Left(new Error("discount cant be applied for items priced below 10")); // <--Оборачиваем Error в Either.Left

  return Right(price - (price * dis)); // <--Оборачиваем result в Either.Right
});

Шаг 2: Оберните исходное значение в Right, так как оно валидно.

const getItemPrice = (item) => Right(item.price);

Шаг 3: Создайте две функции: одну для обработки ошибок, а другую для отображения результата. Оберните их в Either.either (из библиотеки ramda-fantasy.js).

Either.either принимает 3 параметра: обработчик успешного завершения, обработчик ошибок и монаду Either. Сейчас мы можем передать только обработчики, а третий параметр, Either, передать позже.

Как только Either.either получит все три параметра, она передаст третий параметр в обработчик успешного завершения или обработчик ошибок, в зависимости от типа монады: Left или Right.

const displayTotal = (total) => { console.log(‘Total Price: ‘ + total) };
const logError = (error) => { console.log(‘Error: ‘ + error.message); };
const eitherLogOrShow = Either.either(logError, displayTotal);

Шаг 4: Используйте метод chain, чтобы создать цепочку из нескольких функций, выбрасывающих исключения. Передайте результат их выполнения в Either.either (eitherLogOrShow).

const showTotalPrice = (item) => eitherLogOrShow(getItemPrice(item).chain(apply25PercDisc).chain(addCaliTax));

Все вместе выглядит следующим образом:

const tax = R.curry((tax, price) => {
  if (!_.isNumber(price)) return Left(new Error("Price must be numeric"));

  return  Right(price + (tax * price));
});

const discount = R.curry((dis, price) => {
  if (!_.isNumber(price)) return Left(new Error("Price must be numeric"));

  if (price < 10) return Left(new Error("discount cant be applied for items priced below 10"));

  return Right(price - (price * dis));
});

const addCaliTax = (tax(0.1)); // 10%

const apply25PercDisc = (discount(0.25)); // скидка25%

const getItemPrice = (item) => Right(item.price);


const displayTotal = (total) => { console.log('Total Price: ' + total) };

const logError = (error) => { console.log('Error: ' + error.message); };

const eitherLogOrShow = Either.either(logError, displayTotal);

const showTotalPrice = (item) => eitherLogOrShow(getItemPrice(item).chain(apply25PercDisc).chain(addCaliTax));


let tShirt = { name: 't-shirt', price: 11 };
let pant = { name: 't-shirt', price: '10 dollars' }; // ошибка
let chips = { name: 't-shirt', price: 5 }; // ошибка


showTotalPrice(tShirt) // Total Is: 9.075
showTotalPrice(pant)   // Error: Price must be numeric
showTotalPrice(chips)  // Error: discount cant be applied for items priced below 10

В следующей части мы рассмотрим аппликативные функторы, curryN и Validation Applicative.

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