Перевод статьи Charles Scalfani: So You Want to be a Functional Programmer (Part 5) с наилучшими пожеланиями от автора.
Первый шаг к пониманию идей функционального программирования – самый важный и иногда самый сложный шаг. Но с правильным подходом никаких трудностей быть не должно.
Предыдущие части: Часть 1, Часть 2, Часть 3, Часть 4.
Прозрачность ссылок (прим. пер., референциальная прозрачность) - выдуманный термин для описания возможности безопасной замены чистых функций их выражением. Пример наглядно продемонстрирует это.
В алгебре, когда у вас есть следующая формула:
y = x + 10
И утверждается, что:
x = 3
Вы можете подставить x
обратно в уравнение, чтобы получить:
y = 3 + 10
Заметьте, что уравнение остаётся истинным. Мы можем делать подобные замены с чистыми функциями.
Вот функция на Elm, обособляющая одинарными кавычками входящую строку:
quote str =
"'" ++ str ++ "'"
И вот код, использующий её:
findError key =
"Unable to find " ++ (quote key)
В этом примере findError
создаст сообщение об ошибке, если поиск key
не увенчался успехом.
Пока функция quote
чистая, мы можем просто переместить вызов функции в findError
вместе с телом функции quote
(которое, по сути, является выражением):
findError key =
"Unable to find " ++ ("'" ++ key ++ "'")
Это то, что я называю обратным рефакторингом (сюда я вкладываю достаточно широкий смысл) - процессом, который может использоваться программистами или программами (такими как компиляторы или приложения для тестов), чтобы более осмысленно анализировать код.
Это может быть особенно полезным при анализе рекурсивных функций.
Большинство программ однопоточные, то есть одна и только одна часть кода выполняется за определённый промежуток времени. Даже если у вас многопоточная программа, большинство потоков блокируется ожиданием выполнения процессов ввода-вывода, например, загрузкой файла, ответом сети и так далее.
Это одна из причин, почему мы должны мыслить категориями пошаговой инструкции, когда пишем код:
1. Достать хлеб
2. Положить два ломтика в тостер
3. Выбрать максимальную обжарку
4. Опустить рычаг
5. Подождать, пока тосты не вылетят
6. Вынуть тосты
7. Достать масло
8. Взять нож для масла
9. Намазать масло на тосты
В этом примере есть две независимые операции: использование масла и приготовление тостов. Они становятся взаимозависимыми только на девятом шаге.
Мы можем осуществить шаги семь и восемь параллельно с первым по шестой, пока они независимы один от другого.
Но, как только мы сделаем это, всё сильно усложнится:
Поток 1
--------
1. Достать хлеб
2. Положить два ломтика в тостер
3. Выбрать максимальную обжарку
4. Опустить рычаг
5. Подождать, пока тосты не вылетят
6. Вынуть тосты
Поток 2
--------
1. Достать масло
2. Взять нож для масла
3. Подождать, пока поток 1 не выполнится
4. Намазать масло на тосты
Что случится со вторым потоком, если нарушится первый? Каков механизм взаимодействия двух потоков? Какому потоку всё-таки принадлежат тосты: первому, второму или обоим?
Легче всего не думать об этих структурных сложностях и оставить нашу программу выполнятся одним потоком.
Однако, когда для нас важно выжать из программы всю возможную производительность, мы должны приложить титанические усилия, чтобы написать для неё многопоточный программный код.
Так или иначе, с многопоточностью есть две основные проблемы. Во-первых, многопоточные приложения сложно писать, анализировать, тестировать и отлаживать.
Во-вторых, такие языки, как JavaScript, не поддерживают многопоточность (прим. пер., статья была написана в уже далёком 2016 году, и сейчас у нас есть большая надежда в виде Napa.js), а те некоторые, что поддерживают, делают это плохо.
Но что если порядок не имеет значения и всё может выполняться параллельно?
Несмотря на то, что это звучит ненормально, идея не настолько хаотична, насколько может показаться в начале. Давайте взглянем на следующий код Elm, иллюстрирующий это:
buildMessage message value =
let
upperMessage =
String.toUpper message
quotedValue =
"'" ++ value ++ "'"
in
upperMessage ++ ": " ++ quotedValue
Здесь buildMessage
принимает message
и value
, затем приводит message
к верхнему регистру, обрамляет value
кавычками и конкатенирует эти строки, разделяя их символом двоеточия.
Заметьте, что upperMessage
и quotedValue
независимы друг от друга. Откуда нам известно это?
Для условия независимости есть всего две истины. Во-первых, обе функции должны быть чистыми. Это важно, поскольку при выполнении они не должны влиять друг на друга.
Если они не будут чистыми, мы никогда не сможем сказать наверняка, являются ли они независимыми. В этом случае, мы будем вынуждены ориентироваться на тот порядок выполнения, который они самостоятельно запустили. Так работают императивные языки программирования.
Во-вторых, следующее условие: результат выполнения одной функции не является входным значением другой. Если это не так, нам придётся ждать конца выполнения первой функции, чтобы запустилась вторая.
В таком ключе, upperMessage
и quotedValue
- обе чистые функции и ни одна из них не требует результата выполнения другой.
Следовательно, эти функции могут быть вызваны в ЛЮБОМ ПОРЯДКЕ.
Компилятор может определить порядок выполнения без какого-либо участия со стороны программиста. Это возможно только в чистом функциональном языке, потому как очень сложно, если вообще возможно, определять последствия побочных эффектов.
Порядок выполнения кода в чистом функциональном языке программирования может быть самостоятельно определён компилятором.
Это чрезвычайно эффективно, учитывая, что процессоры не ускоряются. Вместо этого процесс обработки подключает всё больше и больше ядер. Это означает, что код может выполняться параллельно на аппаратном уровне.
К несчастью, с императивными языками мы не можем в полной мере использовать мощь этих ядер, кроме как на очень грубом уровне. Однако и для этого потребуется радикальным образом поменять архитектуру наших программ.
С чистым функциональным языком программирования у нас есть потенциал автоматически использовать преимущества ядер процессора на мелкомодульном уровне без изменения отдельной строки кода.
В языках программирования со статической типизацией объявление типов - встроенная возможность. Вот пример Java-кода для иллюстрации:
public static String quote(String str) {
return "'" + str + "'";
}
Обратите внимание, как типизация встроена в само определение функции. Всё становится ещё туманнее, когда у вас появляются обобщения типов данных:
private final Map<Integer, String> getPerson(Map<String, String> people, Integer personId) {
// ...
}
Я отметил жирным шрифтом типы, которые выделяют их, но они всё равно продолжают запутывать определение функции. Вы должны читать аккуратно, чтобы найти имена переменных.
В языках с динамической типизацией такой проблемы не стоит. В JavaScript мы можем писать код вот так:
var getPerson = function(people, personId) {
// ...
};
Гораздо легче читать код без всякой надоедливой информации о типах. Единственная проблема в том, что мы теряем безопасность типизации. Мы можем просто передать параметры в обратном направлении, то есть Number для people
и Object для personId
.
Мы не сможем обнаружить ошибку до падения всего приложения, а это может случиться после нескольких месяцев прилежной работы на продакшене. Этого не произойдёт в Java, поскольку код там просто откажется компилироваться.
Но что, если мы можем взять лучшее из обоих миров. Синтаксическая простота JavaScript вместе с безопасностью Java.
Действительно, можем. Вот функция на Elm с аннотацией типов (прим. пер., сигнатурой типов):
add : Int -> Int -> Int
add x y =
x + y
Заметьте, что информация о типе находится на отдельной строке. Это разделение проводит грань различия с другими мирами.
Теперь вам, наверное, кажется, что в этой аннотации есть опечатка. Я помню, что сделал, когда впервые увидел её. Я подумал, что вместо первого ->
должна быть запятая. Но здесь нет опечатки.
Ситуация приобретёт для вас больше смысла, когда вы увидите аннотацию с подразумеваемыми круглыми скобками:
add : Int -> (Int -> Int)
Здесь говорится, что add
- это функция, принимающая единственный параметр типа Int
, а затем возвращающая функцию, принимающую единственный параметр Int
и возвращающую Int
.
Вот другая аннотация типов с расставленными круглыми скобками:
doSomething : String -> (Int -> (String -> String))
doSomething prefix value suffix =
prefix ++ (toString value) ++ suffix
Здесь говорится, что doSomething
- функция, принимающая единственный параметр типа String
и возвращающая функцию, которая принимает единственный параметр типа Int
и возвращающая функцию, принимающую единственный параметр типа String
и возвращающую String
.
Заметьте, что всё получает единственный параметр. Это потому что все функции в Elm автоматически каррированные.
Пока круглые скобки всегда подразумеваются в правой стороне выражения, они не обязательны. Так что мы можем просто написать:
doSomething : String -> Int -> String -> String
Круглые скобки важны, когда мы передаём функцию входным параметром. Без них аннотация типов будет неясна. Для примера:
takes2Params : Int -> Int -> String
takes2Params num1 num2 =
-- do something
и это очень отлично от:
takes1Param : (Int -> Int) -> String
takes1Param f =
-- do something
takes2Params
- это функция, требующая два параметра, Int
и другой Int
. В то время как, takes1Param
требует одним входным параметром функцию, принимающую Int
и другой Int
.
Вот аннотация типов к map
:
map : (a -> b) -> List a -> List b
map f list =
// ...
Здесь круглые скобки нужны, потому что f
- функция с типом (a -> b)
, то есть функция, принимающая отдельный параметр типа a
и возвращающая что-то с типом b
.
Здесь a
может быть любого типа. Когда имя типа начинается с заглавной буквы, значит это явно заданный тип, например, String
. Если имя типа написано в нижнем регистре, то это может быть любой тип. Здесь a
может быть String
, а может быть и Int
.
Если вы видите (a -> a)
, значит имеется в виду, что входящий и выходящий типы ОБЯЗАНЫ быть одинаковыми. Не имеет значения, какие они именно, главное, чтобы они соответствовали друг другу.
Но в случае с map
у нас есть (a -> b)
. Это означает, что хотя функция и МОЖЕТ возвращать разные типы, она также МОЖЕТ возвращать и одинаковые типы.
Но если тип a
уже определён, он должен распространиться на всю остальную аннотацию (прим. пер., сигнатуру). Для примера, если a
- Int
и b
- String
, тогда аннотация будет подобна:
(Int -> String) -> List Int -> List String
Здесь все типы a
заменены на Int
, а все b
- на String
.
Тип List Int
означает список, содержащий типы Int
, а List String
означает список, содержащий String
. Если вы используете обобщения типов данных в Java или других языках, тогда такая концепция должна быть вам близка.
Пока что достаточно.
В заключительной части этой статьи я расскажу, как вы можете использовать в повседневной работе изученный материал, а именно функциональный JavaScript и Elm.
Слушайте наш подкаст в iTunes и SoundCloud, читайте нас на Medium, контрибьютьте на GitHub, общайтесь в группе Telegram, следите в Twitter и канале Telegram, рекомендуйте в VK и Facebook.