Первое, что нам необходимо сделать — чётко понять концепцию чистой функции.
Чистая функция — это функция, которая при одинаковых значениях аргументов всегда возвращает одинаковые значения и не имеет наблюдаемых побочных эффектов.
Рассмотрим slice
и splice
. Эти две функции имеют одинаковое предназначение, но работают совершенно по-разному. Мы говорим, что slice
является чистой, потому что для одинаковых значений аргументов она всегда возвращает одни и те же значения. splice
— напротив, «изжует» массив, с которым работает, и «выплюнет» его назад, навсегда изменённым, что и является побочным эффектом.
const xs = [1,2,3,4,5];
// чистая
xs.slice(0,3); // [1,2,3]
xs.slice(0,3); // [1,2,3]
xs.slice(0,3); // [1,2,3]
// нечистая
xs.splice(0,3); // [1,2,3]
xs.splice(0,3); // [4,5]
xs.splice(0,3); // []
В функциональном программировании мы плохо относимся к функциям вроде splice
, которые осуществляют мутацию (оставляют аргументы в измененном состоянии). Это никуда не годится, поскольку нам нужны надёжные функции, которые будут работать каждый раз одинаково, а не оставлять за собой беспорядок (как это делает splice
).
Рассмотрим ещё один пример.
// нечистая
let minimum = 21;
const checkAge = age => age >= minimum;
// чистая
const checkAge = (age) => {
const minimum = 21;
return age >= minimum;
};
В нечистом варианте checkAge
использует внешнюю переменную minimum
для того, чтобы определить результат. Другими словами, функция зависит от состояния системы, что вызывает разочарование, поскольку увеличивает когнитивную нагрузку из-за связанности с внешней средой.
В данном примере это может показаться несущественным, но зависимость системы от своего состояния — это один из самых важных компонентов её сложности (http://curtclifton.net/papers/MoseleyMarks06a.pdf). Функция checkAge
может возвращать разные значения в зависимости от внешних факторов (т.е. не от аргументов), что не только исключает её из класса чистых, но также заставляет мозг взрываться каждый раз, когда нужно осмыслить порядок работы программы.
Её чистый вариант — наоборот — полностью самодостаточен. Мы также можем сделать minimum
неизменяемым, что сохраняет чистоту, так как состояние никогда не изменится. Для этого мы должны создать объект для замораживания. (Этот приём применим для организации объекта-состояния (какой-либо системы), состоящего из нескольких значений, и поддержания его в немутабельном виде, где неизменяемость поля minimum
не может быть достигнута одним только использованием const
из современного стандарта JavaScript — прим. пер.)
const immutableState = Object.freeze({ minimum: 21 });
Давайте более подробно разберёмся с этими «побочными эффектами», чтобы развить нашу интуицию. Что же именно скрывается за этими, несомненно, гнусными побочными эффектами, упомянутыми в определении чистой функции? Мы будем считать эффектом всё, что происходит в процессе работы функции, кроме вычисления её результата.
В эффектах как таковых нет ничего плохого, и в следующих главах мы будем использовать их постоянно. Негативную окраску добавляет именно прилагательное побочный. Говоря образно, вода сама по себе не является инкубатором для личинок, а вот застоявшаяся вода — совсем другое дело. Уверяю вас, побочные эффекты — подобный очаг размножения в ваших программах.
Побочный эффект — это изменение состояния системы или наблюдаемое взаимодействие с окружающим миром, происходящее во время вычисления результата.
Побочные эффекты могут включать (но не ограничиваются):
- изменения в файловой системе
- вставку записи в базу данных
- выполнение http-запроса
- мутации
- вывод на экран / запись в лог
- получение данных от пользователя
- выполнение запроса к DOM
- получение доступа к состоянию системы (в том числе, любые операции чтения)
Это далеко не полный список. Любое взаимодействие со средой вне функции уже является побочным эффектом, что может вызвать в вас сомнения: действительно ли так необходимо отказаться от побочных эффектов? Философия функционального программирования постулирует, что побочные эффекты являются основной причиной некорректного поведения.
Это не значит, что эффекты запрещено использовать; скорее, мы хотим ограничить их использование и контролировать их работу. Мы узнаем, как это сделать, когда перейдем к функторам и монадам в следующих главах, но сейчас давайте попробуем отделить эти коварные функции, вызывающие побочные эффекты, от наших чистых.
Побочные эффекты лишают функцию чистоты. И это вполне логично: чистые функции по определению всегда должны возвращать один и тот же результат для одинаковых аргументов, что невозможно гарантировать при взаимодействии с факторами за пределами нашей локальной функции.
Давайте разберёмся, почему мы добиваемся одинакового результата для одинаковых аргументов. Садитесь-ка за парту, сейчас я напомню вам школьную программу по математике 8-го класса.
Функция — это соотношение между значениями, установленное по такому правилу: каждому исходному значению ставится в соответствие только одно значение результата (источник: mathisfun.com).
Другими словами, это просто соотношение между двумя величинами: аргументом и значением. Хотя каждому аргументу ставится в соответствие единственное значение функции, обратное не верно (функция, вызванная с разными аргументами, может возвращать одно и то же значение). На диаграмме ниже изображена вполне допустимая функция x
→ y
.
(https://www.mathsisfun.com/sets/function.html)
Для сравнения, следующая диаграмма показывает отношение, которое не является функцией. Так как для аргумента 5
есть несколько значений:
(https://www.mathsisfun.com/sets/function.html)
Функцию можно описать как набор пар, для элементов которых определены роли (аргумент, значение): [(1,2), (3,6), (5,10)]
(Похоже, что эта функция удваивает аргумент).
Или в виде таблицы:
Аргумент | Значение |
---|---|
1 | 2 |
2 | 4 |
3 | 6 |
Или в виде графика, где по оси x
отложен аргумент, а по оси y
— значение:
Мы можем обойтись и без деталей реализации функции, если аргумент диктует значение. Поскольку функции являются просто отображением множества аргументов на множество значений, можно выписать все соответствия в виде структуры данных и получать результат, обращаясь к её содержимому, вместо вызова функции (в JavaScript для организации пар ключ-значение часто используют обычный объект, используя []
вместо ()
для обращения к его полям).
const toLowerCase = {
A: 'a',
B: 'b',
C: 'c',
D: 'd',
E: 'e',
F: 'f',
};
toLowerCase['C']; // 'c'
const isPrime = {
1: false,
2: true,
3: true,
4: false,
5: true,
6: false,
};
isPrime[3]; // true
Разумеется, вы можете предпочесть вычислять значение функции, вместо того, чтобы выписывать все возможные сочетания вручную, но наш пример иллюстрирует другой подход к функциям.
Возможно, вы думаете: «А как насчет функций с несколькими аргументами?» Действительно, это представляется несколько затруднительным, когда мы рассуждаем о функциях с точки зрения математики. На этот раз мы можем рассматривать несколько аргументов как один набор аргументов, организованный в виде массива или в виде объекта. Когда мы будем говорить о каррировании, мы увидим, как можно напрямую применить математическое определение к нашим функциям.
Здесь нас ждёт откровение: чистые функции являются математическими функциями, и именно в этом заключается функциональное программирование. Программирование с этими маленькими ангелами даёт огромные преимущества. Давайте рассмотрим некоторые причины, по которым мы готовы пойти на многое ради сохранения чистоты.
Начнём с того, что значения чистых функций можно кэшировать по аргументу. Обычно это реализуется с помощью техники «мемоизации»:
const squareNumber = memoize(x => x * x);
squareNumber(4); // 16
squareNumber(4); // 16, возвращает результат из кэша для аргумента 4
squareNumber(5); // 25
squareNumber(5); // 25, возвращает результат из кэша для аргумента 5
Вот упрощенная реализация, хотя существует множество более отлаженных версий.
const memoize = (f) => {
const cache = {};
return (...args) => {
const argStr = JSON.stringify(args);
cache[argStr] = cache[argStr] || f(...args);
return cache[argStr];
};
};
Некоторые функции можно превратить в чистые благодаря отложенному вычислению:
const pureHttpCall = memoize((url, params) => () => $.getJSON(url, params));
Интересно, что на самом деле мы не выполняем http-запрос — вместо этого мы возвращаем функцию, которая будет делать это при вызове. Эта функция является чистой, так как всегда для одинакового аргумента возвращает одно и то же значение — функцию, которая сделает конкретный http-запрос по заданным url
и params
.
Функция memoize
работает нормально, но она закэширует не результат http-запроса, а сгенерированную функцию.
Пока что memoize
не кажется такой уж полезной, однако мы скоро изучим некоторые хитрости, которые помогут сделать её таковой. Вывод же состоит в том, что мы можем кэшировать каждую функцию независимо от того, насколько разрушительными они кажутся.
Чистые функции полностью самодостаточны: всё, что им нужно для работы, они получают на блюдечке. Давайте подумаем, как это может быть полезным? Начнем с того, что зависимости функции объявляются явно, и потому их легче заметить и понять — никаких забавных действий «под капотом» не выполняется.
// нечистая
const signUp = (attrs) => {
const user = saveUser(attrs);
welcomeUser(user);
};
// чистая
const signUp = (Db, Email, attrs) => () => {
const user = saveUser(Db, attrs);
welcomeUser(Email, user);
};
Этот пример показывает, что чистая функция должна быть честной в отношении своих зависимостей и, таким образом, не скрывать того, что именно она сделает. Из одной только сигнатуры функции мы знаем, что она использует Db
, Email
и attrs
, что уже говорит нам о многом.
Мы научимся составлять функции вроде этой изначально чистыми, не ограничиваясь одним только отложенным вычислением. Суть в том, что чистая форма гораздо более информативна, чем ее хитрая нечистая копия, где одной только ей известно, что она собирается натворить.
Следует также отметить, что нам приходится «внедрять» зависимости (передавать их в качестве аргументов), что делает наше приложение куда более гибким. Это происходит за счёт параметризации вашего приложения (не волнуйтесь, мы научимся делать это менее скучно, чем это звучит). Если мы решим использовать в этой функции другую базу данных, то нам всего-навсего потребуется передать нужную базу в качестве аргумента. А если при разработке нового приложения захочется снова использовать эту функцию, в которой мы уже уверены, то нужно будет просто передать ей те Db
и Email
, которыми мы будем располагать на тот момент.
В среде JavaScript портативность может означать сериализацию и отправку функции через сеть или же выполнение всего приложения с помощью web workers. Портативность — впечатляющее качество.
В отличие от «типичных» для императивного программирования методов и процедур, которые жёстко привязаны к окружающей их среде в виде состояния системы, зависимостей и эффектов, которые они могут там производить, чистые функции могут работать везде, где нашей душе угодно.
Вспомните последний раз, когда вы копировали метод из одного приложения в другое. Помните, как выглядел этот процесс? Одна из моих самых любимых цитат была сформулирована создателем языка Erlang, Джо Армстронгом: «Проблема объектно-ориентированных языков заключается в наличии у приложений неявной среды, которая их окружает, и которую они всюду таскают за собой. Вы хотели получить банан, а получили гориллу с бананом... И джунгли целиком».
Далее мы понимаем, что чистые функции значительно упрощают тестирование. Нам не требуется симулировать «реальный» платёжный шлюз или подготавливать состояние всей среды перед каждым тестом, чтобы затем строить ожидания в отношении состояния. Вместо этого мы предоставляем функции необходимые аргументы и строим ожидания в отношении значения.
Именно функциональное сообщество является первооткрывателем методик и инструментов тестирования, суть которых состоит в "обстреливании" наших функций сгенерированными аргументами и построении ожиданий в отношении свойств функций вместо ожиданий конкретных значений. Это выходит за рамки данной книги, но я настоятельно рекомендую вам поискать и попробовать Quickcheck — инструмент тестирования, предназначенный для чисто функциональной среды.
Многие считают, что самый большой выигрыш от использования чистых функций — ссылочная прозрачность. Фрагмент кода можно считать «ссылочно-прозрачным», когда его можно заменить на вычисленный им результат, и при этом поведение программы не изменится (и мы сможем рассчитывать на это качество для организации неограниченно сложных программ).
Поскольку чистые функции не имеют побочных эффектов, они могут повлиять на поведение программы только за счёт возвращаемых ими значений. А так как их возвращаемые значения могут быть вычислены только из их аргументов, сохраняется ссылочная прозрачность. Давайте рассмотрим пример (используем структуру данных Map из библиотеки Immutable.js).
const { Map } = require('immutable');
// Условные обозначения: p = игрок (player), a = атакующий (attacker), t = цель (target)
const jobe = Map({ name: 'Jobe', hp: 20, team: 'red' });
const michael = Map({ name: 'Michael', hp: 20, team: 'green' });
const decrementHP = p => p.set('hp', p.get('hp') - 1);
const isSameTeam = (p1, p2) => p1.get('team') === p2.get('team');
const punch = (a, t) => (isSameTeam(a, t) ? t : decrementHP(t));
punch(jobe, michael); // Map({name:'Michael', hp:19, team: 'green'})
Функции decrementHP
, isSameTeam
и punch
являются чистыми и, следовательно, ссылочно-прозрачными. Мы можем воспользоваться техникой, называемой «эквациональное рассуждение» (equational reasoning), в которой мы заменяем равное равным, чтобы осмыслить код. Это примерно то же самое, что вычислять в уме «крупным планом», без учета мелких деталей его выполнения. Давайте поиграемся с кодом, используя ссылочную прозрачность.
Начнём с того, что встроим функцию isSameTeam
в punch
.
const punch = (a, t) => (a.get('team') === t.get('team') ? t : decrementHP(t));
Так как наши данные не изменяются, то мы просто заменим переменные player.team
и target.team
на их значения.
const punch = (a, t) => ('red' === 'green' ? t : decrementHP(t));
Заметим, что условие ложно, и избавимся от соответствующего ему варианта развития событий:
const punch = (a, t) => decrementHP(t);
И, если мы выпишем decrementHP
, то поймём, что работа функции punch
становится работой по уменьшению hp
на 1 единицу.
const punch = (a, t) => t.set('hp', t.get('hp') - 1);
Возможность подобным образом рассуждать о коде — потрясающая для рефакторинга и понимания кода в целом. Именно эту технику мы использовали, когда рефакторили программу про стаи чаек. Мы применяли эквациональные рассуждения, чтобы использовать свойства сложения и умножения. Более того, мы будем применять эти методы на протяжении всей книги.
Ну и, наконец, вишенка на торте: мы можем запустить любую чистую функцию параллельно, так как ей не нужен доступ в общую память, и она по определению не может привести к состоянию гонки из-за какого-либо побочного эффекта.
Это вполне применимо как к потоковому JS на сервере, так и к браузерному с использованием web workers, хотя сейчас распространено избегание этой возможности из-за сложностей, возникающих при работе с нечистыми функциями.
Мы познакомились с чистыми функциями и с причинами, по которым мы, функциональные программисты, ценим их и уделяем им внимание. С этого момента мы будем стараться писать все функции чистыми, и для этого нам потребуется ещё несколько инструментов, а до тех пор мы будем отделять чистые функции от остального кода.
Писать программы с чистыми функциями без использования некоторых дополнительных инструментов довольно трудоемко. Нам придётся жонглировать данными, постоянно передавая аргументы, и нам запрещено использовать состояние (не говоря уже об эффектах). Как же разрабатывать при таком мазохистском подходе? Давайте подключим новый инструмент — каррирование.