Итак, вы хотите научиться функциональному программированию (Часть 1)
Перевод статьи Charles Scalfani: So You Want to be a Functional Programmer (Part 1) с наилучшими пожеланиями от автора.
Первый шаг к пониманию идей функционального программирования — самый важный и иногда самый сложный шаг. Но с правильным подходом никаких трудностей быть не должно.
Обучение вождению
Когда мы только учились водить машину, мы старались изо всех сил. Конечно, это выглядело легко, когда мы смотрели, как водят другие люди. Но на деле всё оказывалось сложнее.
Мы упражнялись на машине родителей и не выезжали на автострады, пока в совершенстве не осваивали улицы родного района.
После множества практических занятий и нескольких щекотливых моментов, о которых наши родители хотели бы забыть, мы выучивались вождению и наконец получали свои водительские права.
С правами в руках мы садились за руль при любом удобном случае. С каждой новой поездкой наши навыки становились всё лучше и лучше, уверенность росла. Затем наступал день, когда нам приходилось вести другую машину или наша собственная приказывала долго жить, и мы покупали новую.
Вспомните, на что был похож первый раз за рулём другой машины? Было ли это похоже на самый первый раз за рулём машины вообще? Даже не близко. В первый раз всё было таким незнакомым. Конечно, мы сидели до этого в машине, но только в роли пассажира. На этот раз мы оказывались в сидении водителя. Один на один со всеми рычагами, кнопками и педалями.
Но когда мы вели нашу вторую машину, мы просто задавали себе несколько простых вопросов по типу: куда вставляется ключ, где переключаются фары ближнего и дальнего света, как использовать поворотники и как настроить зеркала заднего вида.
После всего этого мы вели свою машину как по маслу. Но почему в этот раз всё было так просто по сравнению с первым разом?
Потому что новая машина была достаточно похожа на старую. Она имела все те базовые элементы, что нужны машине, и в большинстве случаев они находились на тех же местах, что и в старой.
Может, несколько вещей были реализованы как-то иначе и, может быть, они имели какие-то дополнительные функциональные возможности, но мы и так не использовали их во всём нашем водительском опыте. Рано или поздно мы изучали всё новые примочки. Как минимум, те, что нам реально требовались.
Что ж, процесс изучения языков программирования похож на процесс обучения вождению. Первый раз — самый сложный. Но с багажом опыта за плечами всё последующее обучение становится проще.
Когда вы начинаете изучать второй язык, вы спрашиваете себя: “Как мне создать модуль? Как реализовать поиск по массиву? Какие параметры принимает функция нахождения подстроки?”.
Вы уверены, что можете «научиться водить» на этом новом языке, потому что он напоминает вам о предыдущем языке, может, с несколькими новыми элементами, которые, надо надеяться, сделают вашу жизнь легче.
Ваш первый космический корабль
Ездили ли вы на одном автомобиле всю свою жизнь или на десятках автомобилях, представьте, что собираетесь сесть за штурвал космического корабля.
Если вы собираетесь летать на таком аппарате, то вряд ли будете надеяться, что навыки вождения на дороге как-то особенно вам помогут. Вы начнёте всё с нуля (Мы же программисты, чёрт возьми. Мы начинаем считать с нуля.).
Вы начнёте свои тренировки с расчётом, что в космосе всё работает иначе и что полёты на этой штуковине довольно отличны от вождения машины по земле.
Однако физика не изменилась. Путь, по которому вы двигаетесь, находится в пределах всё той же Вселенной.
Такой же подход должен быть к изучению функционального программирования. Вы должны учитывать, что всё будет по-другому. И то многое, что вы знали о программировании не будет перенесено в новую область.
Программировать — значит мыслить и функциональное программирование научит вас мыслить совсем по-другому настолько, что вы вероятно никогда не вернётесь назад на старый путь образа мышления.
Забудьте всё, что вы знаете
Люди любят говорить эту фразу и в ней действительно есть доля правды. Изучать функциональное программированию — значит учить всё с нуля. Не полностью, конечно, но фактически это так. В этой теме существует множество простых концепций, но вам лучше приготовиться к тому, что придётся переучивать всё.
С правильным подходом у вас будут правильные ожидания, а с правильными ожиданиями вы не захотите бросить дело, когда начнутся вещи по-тяжелее.
Есть также многие вещи, которые вы привыкли делать, как программист, но которые вы не сможете больше делать, занимаясь функциональным программированием.
Вспомните, как вы, чтобы выехать с проезжей части, давали задний ход на машине. Но на космическом корабле нет механизма реверса. Теперь вы должны подумать: “ЧТО? НЕТ ЗАДНЕГО ХОДА? КАК Я ДОЛЖЕН ВОДИТЬ БЕЗ ЗАДНЕГО ХОДА?”.
Что ж, оказывается вам не нужен реверс на космическом корабле, способном маневрировать в трёхмерном пространстве космоса. Как только вы поймёте это, вы больше не будете вспоминать о возможности заднего хода. И однажды, вы даже задумаетесь о том, насколько, на самом деле, ограничены обыкновенные машины.
Изучение функционального программирования требует времени. Запаситесь терпением.
Так что давайте покинем холодный мир императивного и медленно окунёмся в горячие источники функционального программирования.
То, что следует в этой комплексной статье, — концепции функционального программирования, которые помогут вам перед полным погружением в первый функциональный язык. Или, если вы уже сделали решительный шаг в этой сфере, эти параграфы помогут заточить понимание идей.
Пожалуйста, не спешите. С этого момента не торопитесь и находите время, чтобы понять примеры кода. Лучше будет даже сделать небольшую паузу в прочтении после этой части статьи и дать озвученным идеям “устаканиться”. Затем возвращайтесь к чтению.
Самое главное — это то, чтобы вы поняли.
Чистота
Если говорится о чистоте в функциональном программировании, значит подразумеваются чистые функции.
Чистые функции — очень простые. Они всего лишь производят операция над входными данными.
Вот пример чистой функции:
var z = 10;
function add(x, y) {
return x + y;
}
Заметьте, что функция add
не прикасается к переменной z
. Она не читает её значения и ничего не пишет в неё. Функция читает только x
и y
, свои входные данные, и возвращает результат их суммы.
Это и есть чистая функция. Если функция add
имеет доступ к переменной z
, она больше не может быть чистой.
Это пример другой чистой функции:
function justTen() {
return 10;
}
Если функция justTen
чистая, она может возвращать только значение-константу. Почему?
Потому что мы не даём ей никаких входных данных. А значит, чтобы быть чистой, она не должна изменять никаких переменных, кроме тех, что были ей переданы. Единственное, что может возвратить такая функция — константа.
Пока функции, не принимающие параметров, не работают, они не очень полезны. Было бы лучше объявить justTen
просто как константу.
Более полезные чистые функции принимают хотя бы один параметр.
Взгляните на этот пример:
function addNoReturn(x, y) {
var z = x + y
}
Посмотрите, эта функция ничего не возвращает. Она складывает x
и y
, записывает результат в переменную z
, но не возвращает её.
Эта чистая функция работает только с входными данными. Да, она выполняет сложение, но пока обратно возвращается ничего, функция бесполезна.
Все полезные чистые функции должны возвращать что-нибудь.
Давайте рассмотрим пример с первой функцией add
ещё раз:
function add(x, y) {
return x + y;
}
console.log(add(1, 2)); // выводит 3
console.log(add(1, 2)); // всё ещё выводит 3
console.log(add(1, 2)); // БУДЕТ ВСЕГДА выводить 3
Обратите внимание, что add(1, 2)
в результате всегда даёт 3
. Конечно, сюрприз не большой, но это потому что функция чистая. Если бы функция add
брала значение откуда-то снаружи, вы бы никогда не могли наверняка предсказать её поведение.
Чистая функция всегда возвращает одинаковые значения для одинаковых входных данных.
Поскольку чистые функции не могут изменять внешние переменные, все эти функции являются нечистыми:
writeFile(fileName);
updateDatabaseTable(sqlCmd);
sendAjaxRequest(ajaxRequest);
openSocket(ipAddress);
Все функции в примере имеют то, что называют побочными эффектами. Когда Вы вызываете их, они меняют файлы и таблицы баз данных, отправляют данные на сервер или обращаются к операционной системе, чтобы получить сокет. Они делают куда больше, чем просто оперирование входными данными и возвращение значений. Следовательно, вы никогда не можете предсказать, что функция возвратит.
Чистые функции не имеют побочных эффектов.
В таких языках императивного программирования как JavaScript, Java и C# побочные эффекты везде. Это делает отладку проблематичной, потому что в коде Вашей программы переменная может быть изменена где угодно. В общем, если у Вас баг из-за переменой, принявшей неверное значение в неподходящее время, где Вы будете искать ошибку? Везде? Так дело не пойдёт.
На этом месте, вы, вероятно, думаете: “КАК, ЧЁРТ ПОБЕРИ, Я СДЕЛАЮ ХОТЬ ЧТО-НИБУДЬ ОДНИМИ ТОЛЬКО ЧИСТЫМИ ФУНКЦИЯМИ?”.
В функциональном программировании вы не пишите только чистые функции.
Функциональные языки не могут исключить побочных эффектов, они могут только изолировать их. Пока у программ будут интерфейсы, взаимодействующие с реальным миром, некоторые части любой программы должны быть нечистыми. Цель — это свести к минимуму количество нечистого кода и отделить его от остальной части программы.
Неизменяемость
Вы помните, когда впервые увидели следующий код:
var x = 1;
x = x + 1;
И тот, кто учил вас программированию, говорил забыть изученное на уроках математики. Ведь в математике x
никогда не мог равняться x + 1
.
Но в императивном программировании данный код означает «взять текущее значение x
, прибавить к нему 1
, положить результат обратно в x
».
Что ж, в функциональном программировании выражение x = x + 1
недопустимо. Так что Вам надо вспомнить то, что вы забыли из математики... Если так можно выразиться.
В функциональном программировании нет переменных.
Сохранённые значения всё ещё называются переменными по историческим причинам, но они являются константами, то есть x, однажды приняв какое-либо значение, сохраняет его на всю жизнь.
Не волнуйтесь, x
– это обычно локальная переменная, так что её жизнь достаточно коротка. Но пока она жива, она никак не изменится.
Вот пример переменной-константы в Elm — чистом языке функционального программирования для web-разработки:
addOneToSum y z =
let
x = 1
in
x + y + z
Если вы не знакомы с синтаксисом семейства языков программирования ML, позвольте мне объяснить. addOneToSum
– это функция, принимающая 2 параметра: y
и z
.
Внутри блока let
x
приписывается значение 1
, то есть он равен 1
до конца своей жизни. Его жизнь кончается, когда происходит выход из функции, или, более точно, когда исполняется блок let
.
Внутри блока in
вычисления могут включать значения, объявленные в блоке let
, а именно: x
. Возвращается результат вычисления x + y + z
или, в точности, возвращается 1 + y + z
, так как x = 1
.
И снова я могу услышать, как вы вопрошаете: “КАК, ЧЕРТ ПОБЕРИ, Я ДОЛЖЕН СДЕЛАТЬ ХОТЬ ЧТО-НИБУДЬ БЕЗ ПЕРЕМЕННЫХ?!”.
Давайте подумаем, когда обычно мы хотим изменить переменную. Всего две основных причины, которые приходят на ум: многозначные изменения (например, изменение отдельного значения объекта или записи) и однозначные изменения (например, счётчики цикла).
Функциональное программирование решает проблему изменений значения записи, делая копию уже изменённой записи. Это происходит оперативно, без копирования всех частей записи, используя определённые структуры данных, делающие это возможным.
Функциональное программирование решает также проблему однозначных изменений переменных, в сущности, тем же путём, просто делая их копию.
Да, кстати, и всё это без циклов.
“СНАЧАЛА БЕЗ ПЕРЕМЕННЫХ, А ТЕПЕРЬ ЕЩЁ И БЕЗ ЦИКЛОВ? Я ТЕБЯ НЕНАВИЖУ!!!”
Попридержите коней. Это не значит, что мы не можем использовать циклы, просто здесь нет таких характерных операторов как for
, while
, do
, repeat
и так далее.
Функциональное программирование использует рекурсию для выполнения цикла.
Вот два примера реализации цикла в JavaScript.
// простой оператор цикла
var acc = 0;
for (var i = 1; i <= 10; ++i)
acc += i;
console.log(acc); // выводит 55// без оператора цикла или переменных (рекурсия)
function sumRange(start, end, acc) {
if (start > end)
return acc;
return sumRange(start + 1, end, acc + start)
}
console.log(sumRange(1, 10, 0)); // выводит 55
Обратите внимание, как рекурсия в функциональном подходе осуществляет то же самое, что и оператор цикла for
, вызывая саму себя с новым параметром запуска (start + 1)
и с новым счетчиком (acc + start)
. Она не изменяет старых значений. Вместо этого она использует новые значения, высчитанные из старых.
К сожалению, такие примеры не очевидны в JavaScript (даже если вы потратили некоторое время на их изучение) по двум причинам. Во-первых, синтаксис JavaScript засорён, а во-вторых, вы, вероятно, не привыкли думать рекурсивно.
Пример на языке Elm читать и, следовательно, понимать легче:
sumRange start end acc =
if start > end then
acc
else
sumRange (start + 1) end (acc + start)
Так этот код выполняется:
sumRange 1 10 0 = -- sumRange (1 + 1) 10 (0 + 1)
sumRange 2 10 1 = -- sumRange (2 + 1) 10 (1 + 2)
sumRange 3 10 3 = -- sumRange (3 + 1) 10 (3 + 3)
sumRange 4 10 6 = -- sumRange (4 + 1) 10 (6 + 4)
sumRange 5 10 10 = -- sumRange (5 + 1) 10 (10 + 5)
sumRange 6 10 15 = -- sumRange (6 + 1) 10 (15 + 6)
sumRange 7 10 21 = -- sumRange (7 + 1) 10 (21 + 7)
sumRange 8 10 28 = -- sumRange (8 + 1) 10 (28 + 8)
sumRange 9 10 36 = -- sumRange (9 + 1) 10 (36 + 9)
sumRange 10 10 45 = -- sumRange (10 + 1) 10 (45 + 10)
sumRange 11 10 55 = -- 11 > 10 => 55
55
Вам скорее всего кажется, что циклы for
гораздо легче для понимания. Хотя это спорно и скорее всего является вопросом осведомлённости, не-рекурсивные циклы подразумевают изменчивость, что по своей сути плохо.
Я не объясняю здесь преимущества использования парадигмы неизменяемости, но вы можете посмотреть параграф под названием Global Mutable State в статье Why Programmers Need Limits, если хотите изучить эту тему.
Одно очевидное преимущество — то, что если вы имеете доступ к какому-либо значению в вашей программе, это доступ только для чтения, а значит никто другой не может изменить это значение. Даже вы сами. Вследствие, никаких случайных изменений.
Также, если программа многопоточная, исполнение никакого потока не разрушит ваши планы. Поскольку значение — константа и если поток захочет изменить его, ему придётся создать новое значение из старого.
Ещё в середине девяностых я написал игровой движок для Creator Crunch и самый большой источник ошибок был связан с вопросом многопоточности. Я хотел бы знать про неизменяемость в то время. Но тогда меня больше волновала разница между двух и четырёх скоростными приводами CD-ROM при игре.
Неизменяемость делает код проще и безопаснее.
Мой мозг!!!!
Пока что достаточно.
В последующих частях этой статьи я расскажу про функции более высокого порядка, функциональную композицию, каррирование и ещё много о чём.
Следующая часть: Часть 2.
Слушайте наш подкаст в iTunes и SoundCloud, читайте нас на Medium, контрибьютьте на GitHub, общайтесь в группе Telegram, следите в Twitter и канале Telegram, рекомендуйте в VK и Facebook.