Текст статьи взят из презентации, которую я показывал в LinkedIn в2016 году. В презентации была предпринята попытка объяснить функциональное программирование без использования таких понятий, как «монады», «неизменность» или «побочные эффекты». Вместо этого она фокусируется на том, как размышления о композиции могут сделать вас лучшим программистом, независимо от того, какой язык вы используете.
40 лет назад, 17 октября 1977 года, премия Тьюринга была вручена Джону Бэкусу за его вклад в разработку систем программирования высокого уровня, прежде всего языка программирования Fortran. Всем лауреатам премии Тьюринга предоставляется возможность выступить с лекцией по выбранной ими теме в течение года, в котором они получили премию. Как создатель языка программирования Фортран, можно было ожидать, что Бэкус выступит с лекцией о преимуществах Фортрана и будущих разработках в этом языке. Вместо этого он прочитал лекцию под названием «Можно ли освободить программирование от стиля фон Неймана»? в котором он критиковал некоторые из основных языков того времени, включая Фортран, за их недостатки. Он также предложил альтернативу: функциональный стиль программирования.
Лекция противопоставляет традиционные программы и их «неспособность эффективно использовать мощные комбинирующие формы» с функциональными программами, которые «основаны на использовании комбинированных форм». В последние несколько лет функциональное программирование вызвало новый интерес в связи с ростом масштабируемых и параллельных вычислений. Но главное преимущество функционального программирования — это независимо от того, будет ли ваша программа распараллелена или нет: функциональное программирование лучше в композиции.
Композиция — это способность собрать сложное поведение путем объединения простых частей. На уроках информатики большое внимание уделяется абстракции: взятию большой проблемы и разделению ее на части. Меньший акцент делается на обратном: как только вы реализуете небольшие части, как соединить их вместе. Кажется, что некоторые функции и системы легко соединить вместе, в то время как другие намного сложнее. Но нам нужно сделать шаг назад и спросить: какие свойства этих функций и систем облегчают их компоновку? Какие свойства затрудняют их компоновку? После прочтения достаточного количества кода шаблон начинает появляться, и этот шаблон является ключом к пониманию функционального программирования.
Давайте начнем с рассмотрения функции, которая действительно хорошо скомпонована:
String addFooter(String message) {
return message.concat(" - Sent from LinkedIn");
}
Мы можем легко скомпоновать ее с другой функцией без необходимости вносить какие-либо изменения в наш исходный код:
boolean validMessage(String message) {
return characterCount(addFooter(message)) <= 140;
}
Это здорово, мы взяли небольшой кусочек функциональности и собрали его вместе, чтобы сделать что-то большее. Пользователям функции
validMessage
даже не нужно осознавать тот факт, что эта функция была построена из меньшего; это абстрагировано как деталь реализации.Теперь давайте взглянем на функцию, которая не так хорошо скомпонована:
String firstWord(String message) {
String[] words = message.split(' ');
if (words.length > 0) {
return words[0];
} else {
return null;
}
}
А затем попробуйте скомпоновать ее с другой функции:
// “Hello world” -> “HelloHello”
duplicate(firstWord(message));
Несмотря на простоту на первый взгляд, если мы запустим приведенный выше код с пустым сообщением, то получим ужасное исключение
NullPointerException
. Один из вариантов — модифицировать функцию duplicate для обработки того факта, что ее входные данные иногда могут быть null
:String duplicateBad(String word) {
if (word == null) {
return null;
} else {
return word.concat(word);
}
}
Теперь мы можем использовать эту функцию с функцией
firstWord
из предыдущего примера и просто передать нулевое значение null
. Но это против композиции и абстракции. Если вам постоянно приходится заходить и модифицировать составные части каждый раз, когда вы хотите сделать что-то большее, то это не поддается компоновке. В идеале вы хотите, чтобы функции были похожи на черные ящики, где точные детали реализации не имеют значения.Нулевые объекты плохо компонуются.
Давайте рассмотрим альтернативную реализацию, в которой используется тип Java 8
Optional
(также называемый Option
или Maybe
на других языках):Optional<String> firstWord(String message) {
String[] words = message.split(' ');
if (words.length > 0) {
return Optional.of(words[0]);
} else {
return Optional.empty();
}
}
Теперь мы попытаемся скомпоновать ее с неизмененной функцией
duplicate
:// "Hello World" -> Optional.of("HelloHello")
firstWord(input).map(this::duplicate)
Оно работает! Опциональный тип заботится о том, что firstWord иногда не возвращает значение. Если
Optional.empty()
возвращается из firstWord
, то функция .map
просто пропустит запуск функции duplicate
. Мы смогли легко объединить функции без необходимости модифицировать duplicate
. Сравните это с null
случаем, когда нам нужно было создать функцию duplicateBad
. Другими словами: нулевые объекты плохо компонуются, а опционалы хорошо.Функциональные программисты помешаны на том, чтобы сделать вещи составными. В результате они создали большой набор инструментов, заполненный структурами, которые делают некомпозируемый код композируемым. Одним из таких инструментов является опциональный тип для работы с функциями, которые возвращают валидный вывод только в определенное время. Давайте посмотрим на некоторые другие инструменты, которые были созданы.
Асинхронный код, как известно, сложно скомпоновать. Асинхронные функции обычно принимают «обратные вызовы», которые запускаются после завершения асинхронной части вызова. Например, функция
getData
может выполнить HTTP-вызов веб-службы, а затем запустить функцию для возвращаемых данных. Но что, если вы хотите сделать еще один HTTP-вызов сразу после этого? А потом еще? Выполнение этого быстро приводит вас в ситуацию, нежно известную как ад обратного вызова.getData(function(a) {
getMoreData(a, function(b) {
getMoreData(b, function(c) {
getMoreData(c, function(d) {
getMoreData(d, function(e) {
// ...
});
});
});
});
});
Например, в более крупном веб-приложении это приводит к очень вложенному спагетти-коду. Представьте себе попытку выделить одну из функций
getMoreData
в ее собственный метод. Или представьте, что вы пытаетесь добавить обработку ошибок к этой вложенной функции. Причина, по которой она не может быть скомпонована, состоит в том, что к каждому блоку кода предъявляется много контекстных требований: для самого внутреннего блока требуется доступ к результатам из a, b, c и т. д. и т. д.Значения легче компоновать вместе, чем функции
Давайте посмотрим на инструментарий функционального программиста чтобы найти альтернативу:
Promise
(иногда называемое Future
на других языках). Вот как выглядит код сейчас:getData()
.then(getMoreData)
.then(getMoreData)
.then(getMoreData)
.catch(errorHandler)
Функции
getData
теперь возвращают значение Promise
вместо принятия функции обратного вызова. Значения проще компоновать вместе, чем функции, потому что они не имеют тех же предварительных условий, что и обратный вызов. Теперь легко добавить обработку ошибок ко всему блоку благодаря функциональности, которую предоставляет нам объект Promise
.Еще одним примером некомпозируемого кода, о котором говорят меньше, чем об асинхронном коде, являются циклы или, в более общем смысле, функции, возвращающие несколько значений, таких как списки. Давайте рассмотрим пример:
// ["hello", "world"] -> ["hello!", "world!"]
List<String> addExcitement(List<String> words) {
List<String> output = new LinkedList<>();
for (int i = 0; i < words.size(); i++) {
output.add(words.get(i) + “!”);
}
return output;
}
// ["hello", "world"] -> ["hello!!", "world!!"]
List<String> addMoreExcitement(List<String> words) {
return addExcitement(addExcitement(words));
}
Мы составили функцию, которая добавляет один восклицательный знак в функции, которая добавляет два знака. Это работает, но неэффективно, потому что проходит через цикл дважды, а не только один раз. Мы могли бы вернуться и изменить исходную функцию, но, как и раньше, это нарушает абстракцию.
Это немного надуманный пример, но если вы представите себе код, разбросанный по большей кодовой базе, то он иллюстрирует важный момент: в больших системах, когда вы пытаетесь разбить вещи на модули, операции над одним фрагментом данных не будут жить все вместе. Вы должны сделать выбор между модульностью или производительностью.
При императивном программировании вы можете получить только модульность или только производительность. С функциональным программированием вы можете иметь и то, и другое.
Ответ функционального программиста (по крайней мере, в Java 8) — это
Stream
. Stream
по умолчанию ленив, что означает, что он проходит через данные только тогда, когда это необходимо. Другими словами, «ленивая» функция: она начинает выполнять работу только тогда, когда ее просят о результате (функциональный язык программирования Haskell построен на концепции лени). Давайте перепишем приведенный выше пример, используя вместо этого Stream
:String addExcitement(String word) {
return word + "!";
}
list.toStream()
.map(this::addExcitement)
.map(this::addExcitement)
.collect(Collectors.toList())
Таким образом цикл по списку пройдет только один раз и вызовет функцию
addExcitement
дважды для каждого элемента. Опять же, нам нужно представить, что наш код работает с одним и тем же фрагментом данных в нескольких частях приложения. Без такой ленивой структуры, как Stream
, попытка повысить производительность за счет объединения всех обходов списков в одном месте означала бы разрушение существующих функций. С ленивым объектом вы можете достичь как модульности, так и производительности, потому что обходы откладываются до конца.Теперь, когда мы рассмотрели некоторые примеры, давайте вернемся к задаче чтобы выяснить, какие свойства облегчают создание некоторых функций по сравнению с другими. Мы видели, что такие вещи, как нулевые объекты, обратные вызовы и циклы, плохо компонуются. С другой стороны, опциональные типы, промисы и потоки действительно хорошо компонуются. Почему это так?
Ответ в том, что составные примеры имеют четкое разделение между тем, что вы хотите сделать, и тем, как вы на самом деле это делаете.
Во всех предыдущих примерах есть одна общая черта. Функциональный способ делать вещи фокусируется на том, что вы хотите, чтобы результат был. Итеративный способ действий фокусируется на том, как вы на самом деле добираетесь туда, на деталях реализации. Оказывается, что составление итеративных инструкций о том, как делать вещи, не выглядит так же хорошо, как высокоуровневые описания того, что должно быть сделано.
Например, в случае Promise: что в этом случае делает один HTTP-вызов, за которым следует другой? Вопрос не имеет значения и абстрагируется: возможно, он использует пулы потоков, блокировки мьютексов и т. д., Но это не имеет значения.
Функциональное программирование разделяет то, что вы хотите, чтобы результат был от того, как этот результат достигается.
Именно таково мое практическое определение функционального программирования. Мы хотим иметь четкое разделение проблем в наших программах. Часть “что вы хотите" хороша и композиционна и позволяет легко создавать большие вещи из меньших. В какой-то момент требуется часть “как вы это делаете”, но, отделяя ее, мы убираем материал, который не так композиционен, от материала, который более композиционен.
Мы можем видеть это в реальных примерах:
- API Apache Spark для выполнения вычислений на больших наборах данных абстрагирует детали того, на каких машинах он будет работать и где хранятся данные
- React.js описывает представление и делает преобразование DOM эффективным алгоритмом
Даже если вы не используете функциональный язык программирования, разделение того, что и как из ваших программ, сделает их более компонуемыми.
Узнайте подробности, как получить востребованную профессию с нуля или Level Up по навыкам и зарплате, пройдя платные онлайн-курсы SkillFactory:
- Курс по Machine Learning (12 недель)
- Обучение профессии Data Science с нуля (12 месяцев)
- Профессия аналитика с любым стартовым уровнем (9 месяцев)
- Курс «Python для веб-разработки» (9 месяцев)
go-prolog
Очень странная история, и это в 1977 году! А в 71-ом там же был Маккарти, цитирую энциклопедию:
Похоже, любители монад, приватизируют функциональный подход, а у него длинная история,… может Хаскелу нужна отдельная категория: "языки категорий", "категорианство", "категориальное программирование"?
AnthonyMikh
Lisp — не функциональный язык программирования. Во всех смыслах этого слова.
go-prolog
Конечно, Лисп развивался и в него встроили все, он "мульти". Его основы отобразили "функциональное программирование" по мотивам формализмов Черча, а это лямбды, функции, функторы, замыкания и тд. Удивлен сложившейся однобокостью, как будто теория категорий и вывод типов основы функциональщины, в Лиспе уже все основы были и в Эрланге они же...
khim
Lisp — динамически типизированный строгий язык. При попытке добавить туда ленивость, статическую типизацию и отделить чистые функции от нечистых — вы примерно Haskell или что-нибудь подобное и получите.
go-prolog
Лисп ровесник Фортрана, но суть у них разная, Фортран — машина Тьюринга, с инструкциями и данными на
бесконечнойленте, а Лисп использует абстракции Черча, на которых он желал построить основывсегоматематики. Лисп демонстрирует принцип "единства представления" — вычисления и данные имеют одинаковую форму записи: символы и списки.Ленивость — о том, что функции могут не "вычислять" возвращаемый результат, а возвращать функцию, которая "потом вычислит" результат. Такое и в современном С++ можно, странно, а где статьи про библиотеки с "революционными ленивыми" списками для С++25 ))
А замечание мое такое — когда декларативность, в частности функциональное представление, преподносят демонстрацией Хаскела, то только отпугивают и запутывают заинтересовавшихся, а его мощь "категориальности", контроля типов или ленивости можно бы называть отдельным определением, для ясности. А функциональщина без лишних "заморочек" присутствует в Эрланге.
khim
Тоже динамически типизированный язык, как бы.
Ну это всё равно как отделить BASIC (оригинальный, не Visual Basic) с его двухбуквенными глобальными переменными отдельно, а все остальные языки — отдельно. Можно, да… но зачем?
go-prolog
Только про ясность, про восприятие, если язык следует Тьюрингу, то в нем инструкции и
лентапамять, а если Черчу, то композиция функций, рекурсия…Повторяюсь, наблюдаю за новостями и удивляюсь, как-то часто Хаскел фигурирует как "функциональный", а не "статически типизированный ленивый функциональный язык". В статье https://habr.com/ru/post/505928/ вижу это:
это статья "Почему функциональное программирование такое сложное"…
И автор этой заметки не упоминает о старейшем языке, который ровня Фортрану, а зачем то, рядом с функциональный указывает Хаскел… и монады во втором предложении!?.. о-о-о простите за резкость…
khim
Монады имеют примерно такое же отношение к ФП, как объекты к императивному: да, можно без них, вообще всё можно… но зачем?
Сегодня Haskell, так получилось, считается «основным» языком ФП… хотя формально Lisp популярнее. Но он, видимо, не считается модным.
А кроме того, есть проблема: этих Lisp'ов — как грязи, сотни, если не тысячи, диалектов. Часто несовместимых. Все вместе — они популярнее Haskell. Но вот каждый конкретный…
А Haskell, сегодня, де-факто один GHC. Это упрощает первоначальное обучение.
Хотя да, он сложнее, чем Lisp, конечно…
go-prolog
Ой, спасибо, интересная параллель получилась, объекты: монады ))
Объектам как структурам данных нормально быть в императивном, это же развитие Вирта — "Алгоритмы+Структуры данных=....".
Да и Питон демонстрирует функциональные элементы, по-моему, нормально, а вот с классами там странности...
khim
В императивных языках — всё, вот совсем всё, без ислючения всё, можно реализовать без всяких объектов и интерфейсов. Чисто на массивах и простых переменных.
Однако они, тем не менее, вводятся — для удобства. Чтобы в их терминах структурировать код программы.
В функциональных языках — всё, вот совсем всё, без ислючения всё, можно реализовать без всяких монад и аппликативов. Чисто на функциях и их композиции.
Однако они, тем не менее, вводятся — для удобства. Чтобы в их терминах структурировать код программы.
То, что в Haskell, без монад, невозможно ничего ни ввести, не вывести — имеет ту же природу, по которой в java константы E и PI, внезапно, объединены в классе java.lang.Math. Почему так? Ну потому что если их языка изгнать «свободные» переменные и функции, то куда-то же их нужно засунуть… В Java без работы с классами вы даже «hello, world» не напишите…
А если мы говорим, что в языке нельзя создать функцию с сайд-эффектами, то вам нужна хоть одна сущность в которой эти сайд-эффекты можно сконцентрировать. Потому что иначе ваша программа не может ничего изменить в окружающем мире — что как-то несколько… грустно. Ну вот в Haskell — их собрали в монаду IO. Которая передаётся в main — ну и дальше уже расходится по программе.
go-prolog
… и в 70-х по структурному программированию, предлагалось соблюдать модулям метрику, требование высокая связность, и такой пример называют логическая связность (это Паскалем/Ада/Модула, по памяти))):
https://ru.wikipedia.org/wiki/Связность_(программирование)
Все же, никакие классы, не устранят проблемы группировки функций в модуль, той самой высокой цельности модулей, вот так писать можно(С++/Ява ))):
А факты про main(IO) из Хаскел, очень подходят к определению "низкое сцепление" модулей, а именно один ее положительный вид: "сцепление по данным":
такие абстракции Хаскела не странны, это соображения из структурного подхода, а "точку входа" в программу настроит и установит окружение/операционная_система/интерпретатор.
Еще в С было, функция printf() использует файл con для вывода, а его можно передавать в программу (чем не ИО):
Помню, забавная идея из языка CLIPS https://en.wikipedia.org/wiki/CLIPS, и это называли продукционным подходом к вычислениям(и сейчас существуют языки CHR), там правило выполнялось(файер), когда глобальное состояние фактов совпадет с указанным предусловием, а после выполнения правила и изменения глобального состояния будет файер у другого правила… А начинают с единого первого факта (initial-state), плюс синтаксис там лисп-овский. Порядок обхода правил, настраивался снаружи, это опция интерпретатора, текст программы этого не учитывает, система переберет правила "вширину", "вглубину", "приоритетно" или еще как, это не формулировки решаемой задачи, это механизмы "решателя" формулировок. Вот так будет машина Тьюринга:
Мне как раз показалось, что обсуждения в статье "Почему функциональное программирование такое сложное" и отражают, что основы Хаскел не сколько функциональность, а только детали теории категорий, которая пугающе все абстрагировала…
Лисповская однородность представления позволяла генерировать программу, а потом ее выполнять, чем не ленивость или отложенность выполненя, в список собранные символы запустятся на самом верхнем уровне, пусть так:
khim
public static void main(String[] args)
.Или вы думаете человека, который первый раз за компьютер сел такое нагромождение непонятных слов не пугает? Пугает конечно. Сам много раз видел.
Только при обочунии императивному програмированию обычно говорят «ты пока над этим не задумывайся, потом поймёшь»… а при обучении функциональному — начинают рассказывать про теорию категорий.
Вот и вся разница.
Ну разумеется ленивость можно имитировать в неленивом языке — в конце-концов «ленивых» процессоров пока не придумали, а любой Haskell исполняется, в реальном мире, на каком-то процессоре.
Но если у вас язык, изначально, «неленив», то вы не будете изображать ленивость вручную в каждой строчке.
Ну вот возьмите классическую программку из учебника:
Обратите внимание —
processData
это совершенно обычная, вот тривиальная просто, функция, которая берёт буковки из списка, делает для каждой из нихtoUpper
и возвращает кладёт в другой список.Однако благодаря ленивости — вы можете использовать эту функцию для любого потока данных — в частности для обработки файлов терабайтного размера.
А в Lisp, в этом месте, возникают потоки. И вам приходится придумывать как делить работу на части — причём не в реализации
hGetContents
, а реализации функции, которая работает с данными.И всё: иллюзия «чистого», «функционального» мира — рассыпалась в прах.
Можно ли на Lisp писать чисто функциально? Да не вопрос — это можно хоть в машинных кодах делать! Вопрос в том — насколько это удобно.
Так вот «идеоматически правильные» программы на Haskell — как правило — функциональны. «Идеоматически правильные» программы на Lisp — нет. Потому что вам нужно возиться с потоками и со всеми вот этим прочим — в достаточно большой части вашей программы. Фактически везде, где вы не можете быть уверены, что работаете с «небольшими объектами». Которые можете «позволить себе» копировать, условно говоря.
go-prolog
Спасибо, попробую пояснить рассуждения. Функциональное программирование, введенное Маккарти в работе над Лиспом, основано на лямбда-исчислении Черча, абстракции на которой можно строить математическую базу вычислимости как комбинации функций.
Теория категорий занимает место у основ математики, ей можно выражать и вычислимость и лямбда-исчисление, видимо, и логику предикатов — ее мощные абстракции, позволяют складывать программы от типов и описаний зависимостей между ними. Это не комбинирование функций, тут конструирование из особых функциональных объектов, которые можно вычислять, это же не чистота функций, это отсутствие средства конструировать такие объекты. Хаскел называть императивным не начинают, а механизмы линзирования встроили.
И, да, это только "название", это путаница из-за отнесения конструкций из объектов к функциональной парадигме, почему такой стиль не может иметь отдельного термина?
Бэкус из текущей истории, в упомянутой своей статье, размышляет и о Лиспе, и о его чистоте, а еще классифицирует несколько "стилей" функционального программирования: комбинации функций без использования переменных, алгебраическое ФП как у Черча, какие-то формальные ФП и "applicative state transition systems"…
Теорию множеств не используют при обучении арифметики или геометрии, а сейчас теория категории стала базой и для множеств, как же так? Почему Хаскелу не стать машинным языком, лисп-процессоры уже бывали)))
khim
Вот уже в ВУЗах, когда начальное понимаение есть — и то и другое могут использовать для формализации.
Для начала ему бы было неплохо стать хотя бы популярным языком, тогда и в железе чего-нибудь можно сделать…
Gryphon88
go-prolog
Попробую пояснить, это же демонстрация "ленивости" строка "inpStr <- hGetContents inh" показала, что inpStr это не сами данные, а механизм/"функция обратного вызова"/поток, который предоставит порции данных потом...
Gryphon88
Я понимаю это, но в зависимости от размера входных данных эффективен разный способ чтения. Для hGetContents мы должны верить, что для текущего размера файла будет выбран оптимальный способ чтения.
go-prolog
Ого, это было обсуждение формы записи программ, а такой вопрос из сферы вычислительной сложности… Я не припоминаю средств, может профилирование, для определения "оптимальности" вычислений функции…
Gryphon88
Понятно дело, если перестает устраивать скорость выполнения, то профилирование. Просто с декларативным или логическим программированием это тот момент, когда очень легко может оказаться, что код разматывается во что-то, не приспособленное к имеющимся входным данным, и тут уже становится важно «как». Я на это напрыгивал и в прологе, и в sql, и с сишными макросами.
khim
Ну как-то же вы, всё-таки, должны выбрать стратегию чтения?
Задаёте буферизацию, передаёте это в hGetContents, дальше — в чистую функцию, обрабатывающую уже всё это…
Gryphon88
Я не писал на хаскеле дальше хелловордов, так что я не знаю, как и насколько кастомизируются IO функции.
khim
А это, как раз, и неважно. Грубо говоря в ленивом языке у вас нет принципиальной разницы между генератором и актуальной структурой данных. В большинстве случаев вы работаете с генераторами, и только иногда, в целях оптимизации — что-то актуализируете явно.
Соответственно вопрос «как и насколько кастомизируются IO функции» — он вообще неважен. Если они недостаточно хорошо кастомизируются для ваших нужд (например вы хотите использовать не буфер, а
mmap
) то вы просто-напросто создаёте ещё один генератор — который можно использовать с любой «чистой» функцией так же, какhGetContents
.В случае же с языками типа Lisp или Python — вы должны создавать и использовать (там где это нужно и полезно) генераторы явно. Вот через все эти
yield
и прочее.Это, в принципе, работает, но, в некотором смысле, «переворачивает» подход…
khim
Двавайте я ещё раз напомню: это — пример из учебника. Глава 7я, посвящённая вводу-выводу, даже не какое-то там приложение «для особо крутых».
Так что в каком-то глубоко философском смысле, может быть, вам так делать и не нужно — но тогда вам и писать на Haskell, скорее всего, не очень нужно.
Ибо там такие вещи — как раз составляют основу языка. И да, hGetContents ровно-таки и предназначен для того, чтобы его вот именно так и использовали.
AnthonyMikh
Возражаю, Haskell проще, чем Lisp.
go-prolog
О, если сложностью называть количество исходных абстракций, то надо сосчитать где их меньше…
Не проверил, но популярность Common лиспа точно все еще высока, поэтому и вспоминаю про него, раз тут возникает Фортран, да, ладно…
Преимущества Хаскел не могу отрицать, выражаю только недовольство его "ярлыком", странное ощущение складывается, а не какой-то ли тут план, не направленное ли переиначивание взглядов…
Доступный Эрланг без Фейсбука растерял популярность, чем плохо на нем демонстрировать подходы? Ага, это все про модность.