Хабрахабр

Функциональное программирование — это не то, что нам рассказывают

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

Хотя люди обычно признают удобства ФП фич, ведь намного приятнее писать:

int Factorial(int n)
"); return Enumerable.Range(1, n).Aggregate((x, y) => x * y);
}

чем ужасные императивные программы вроде

int Factorial(int n)
{ int result = 1; for (int i = 2; i <= n; i++) { result *= i; } return result;
}

С одной стороны да. Так ведь? А с другой именно вторая программа в отличие от первой является функциональной.

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

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

Функциональная программа — программа, состоящая из чистых функций.

Чистая функция — функция, результат вызова которой является ссылочно прозрачным. Ок, это мы знали, но что такое чистая функция? Или, если формально:

Функция f является чистой если выражение f(x) является ссылочно прозрачным для всех ссылочно прозрачных x

Разве чистая функция — это не та, которая стейт не мутирует? А вот тут начинаются различия с тем, что люди обычно представляют под "чистой функцией". Да и что это за "ссылочная прозрачность" такая? Или там в глобальные переменные не залезает? На самом деле корреляция с этими вещами действительно есть, но сама суть чистоты не в том, чтобы ничего не мутировать, а именно эта самая прозрачность.

А вот что: Так что же это такое?

Ссылочная прозрачность — свойство, при котором замена выражение на вычисленный результат этого выражения не изменяет желаемых свойств программы

Именно это и является главным требованием чистоты. Это значит что если у нас где-то написано var x = foo() то мы всегда можем заменить это на var x = result_of_foo и поведение программы не поменяется. Единственный момент тут — философский, что считать "поведением программы". Никаких других требований (вроде неизменяемости) ФП не накладывает. Например, если исполнение кода выделяет чуть больше или чуть меньше тепла на CPU — то нам скорее всего это пофиг (хотя если нет, то мы можем с этим работать специальным образом). Его можно определить интуитивно как свойства, которые нам критично важно соблюдать. А вот если у нас программа в базу ходить перестала и закэшировала одно старое значение — то это нас это очень даже волнует!

Давайте проверим, выполняется ли наше правило для первой функции? Вернемся к нашим примерам. Но, наверное мы не просто так ту строчку в функции написали, и логи в кибане все же хотели бы увидеть, поэтому сочтем такую точку зрения маловероятной). Оказывается, что нет, потому что если мы заменим где-нибудь Factorial(5) на 120 то у нас поменяется поведение программы — в логи перестанет писаться информация которая раньше записывалась (хотя если мы подойдем с позиции "да и хрен ними, с логами" и не будем считать это желаемым поведением, то программу можно будет считать чистой.

Во втором случае всё остается как было: можно все вхождения заменить на результат функции и ничего не изменится. А что насчет второго варианта?

Это называется "Equational reasoning", то есть "Рассуждения в терминах эквивалентности". Важно отметить, что это свойство должно работать и в обратную сторону, то есть мы должны иметь возможность поменять все var x = result_of_foo на var x = foo() без изменения поведения программы. В рамках этой парадигмы что функции, что значения — суть одно и то же, и можно менять одно на другое совершенно безболезненно.

Достаточно, чтобы эти изменения не были видны стороннему наблюдателю. Отсюда важное следствие: программа не обязана работать с неизменяемыми данными чтобы считаться функциональной. Типичный пример — пишем инплейс быструю сортировку и забыли скопировать входной массив: ST помогает превратить это в ошибку компиляции. Для этого даже придумали специальный механизм называющийся ST, который на уровне типов помогает вам не дать утечь случайно мутабельному состоянию наружу. Неизменяемость является важным удобным свойством, но вас никто не заставляет пользоваться только им, при необходимости можно мутировать в хвост и гриву, главное — не нарушить ссылочную прозрачность.

Зачем это нужно

Зачем так мучиться? Наверное — самый главный вопрос. В своё время goto очень невзлюбили именно потому, что с ним очень трудно понять как на самом деле программа себя ведет и какой на самом деле поток данных и управления, и переиспользовать функцию написанную с goto было сложно, ведь тогда он умел даже в середину тела функции прыгнуть без каких-либо проблем. Копировать данные вместо того чтобы изменить напрямую, оборачивать объекты в эти ваши ST чтобы изменения (если они есть) не утекали наружу, и вот это всё… Ответ — для лучшей композиции.

Вам не нужно думать, в каком порядке функции вычисляются, не надо переживать насчёт того как оно поведет если поменять пару строчек местами, программа просто передает результаты одних функций в другие. С Equational reasoning всегда просто понять, что происходит: вы можете заменить результат на функцию и всё.

Писал я самый типовой ООП код на C#, и понадобилось мне влезть в старый кусок, где был написан вот такой код (пример упрощён) В качестве примера почему это хорошо могу привести случай из жизни который случился как раз со мной пару месяцев назад.

var something = function();
DoStuff(this.Field, something);

И понадобилось мне во время выполнения задачи их немного отрефакторить, что я и сделал:

DoStuff(this.Field, function());

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

... что-то считаем
this.Field = GetUpdatedVersion(this.Field, localData) // ой! ... продолжаем считать и возвращаем результат

Соответственно если раньше с точки зрения компилятора оно выглядело так:

var something = function();
var arg1 = this.Field; // после вызова function - новое значение!
var arg2 = something;
DoStuff(arg1, arg2);

То после рефакторинга получилось следующее:

var arg1 = this.Field; // до вызова function - остаётся старое значение!
var arg2 = function();
DoStuff(arg1, arg2);

Соответственно если раньше функция DoStuff вызывалась с обновленной версией поля, то после рефакторинга начала вызываться со старой.

"Нефиг писать функции которые и мутируют, и данные возвращают"? Какую мораль тут можно вынести? В функциональной программе замена любых двух строчек никогда не приведет к изменению семантики программы. Соглашусь, и отмечу, что ссылочная прозрачность является следующим логичным шагом в этом направлении.

Если вы, как и я, пишете на каком-нибудь C# в обычном императивном стиле, вам кроме этого нужно понимать, как у вас DI работает, что конкретно делает функция function или DoStuff, можно ли эту функцию безопасно из разных потоков вызывать или нет. В общем и целом, ФП направлено на то, чтобы можно было судить о поведении функции наблюдая только её одну. В ФП вы смотрите на одну функцию, смотрите на её данные, и этой информации вам достаточно чтобы полностью понимать как она работает.

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

Как эти принципы отражаются в коде

Правда, для большей понятности я адаптировал текст и код к C#. В качестве сравнения могу предложить вам такой пример, который я взял из Красной книги Scala (совершенно шикарная книга, очень доходчиво и интересно рассказывает о ФП, c крутыми задачками).

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

ООП вариант

Окей, как нам сказали, так и пишем:

public class Cafe
{ public Coffee BuyCoffee(CreditCard card) { var cup = new Coffee() card.Charge(cup.Price) return cup }
}

Price) является примером побочного эффекта. Строка cc.charge (cup. Побочным эффектом оно называется потому, что все эти действия не имеют отношения к созданию экземпляра Coffee, то есть они как бы находятся "с боку" от основного результата функции "вернуть стаканчик кофе". Оплата кредитной картой предполагает некоторое взаимодействие с внешним миром — например, для этого может потребоваться связаться с компанией-эмитентом кредитной карты через какой-либо веб-сервис, авторизовать транзакцию и всё такое.

Любой опытный ООП разработчик скажет "Да сделай ты интерфейс для того чтобы списывать деньги!". В результате из-за побочного эффекта код трудно тестировать. Разумное требование, так и поступим:

public class Cafe
{ public Coffee BuyCoffee(CreditCard card, IPaymentProvider paymentProvider) { var cup = new Coffee() paymentProvider.Charge(card, cup.Price) return cup }
}

Но и тут есть свои недостатки. Несмотря на побочные эффекты у нас появилась возможность тестировать процесс покупки: достаточно в тестах замокать интерфейс IPaymentProvider.

  • Во-первых нам пришлось ввести IPaymentProvider, хотя если бы не тесты одна конкретная реализация нас бы вполне устроила.
  • Во-вторых моком реализующим нужный функционал может быть неудобно пользоваться. Типичный пример — InMemory DB, где мы мокаем Insert/Save/… методы, а потом достаем внутренний стейт (как правило в виде списков) и смотрим, что всё сохранилось куда надо. Надо ли говорить, что инспектировать внутреннее состояние объектов — это нехорошо? И да, можно конечно использовать какой-нибудь фреймворк который сделает за нас большую часть работы, но не всю, да и тащить целый фреймворк просто чтобы протестировать что мы можем купить чашечку кофе выглядит оверкиллом.
  • Ну а в-третьих есть проблемы с переиспользованием этой функции. Допустим мы хотим купить N чашечек кофе. В текущих интефрейсах у нас нет простого способа это сделать кроме как написать полностью новую функцию (если мы конечно не хотим заддосить наш платёжный шлюз однотипными запросами):

public class Cafe
{ public Coffee BuyCoffee(CreditCard card, IPaymentProvider paymentProvider) { var cup = new Coffee() paymentProvider.Charge(card, cup.Price) return cup } public Coffee[] BuyCoffees(int count, CreditCard card, IPaymentProvider paymentProvider) { // нам теперь еще и случай 0 чашек надо обработать, // чтобы не выставить случайно чек на 0 рублей if (count == 0) return Array.Empty<Coffee>(); var cups = Enumerable.Range(0, count).Select(_ => new Coffee()).ToArray(); paymentProvider.Charge(card, cups[0].Price * count) return cups }
}

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

ФП вариант

Функциональный подход — вместо фактического списания средств просто выставить счет, а вызывающий код пусть сам решает, что с эти делать. Как же нам написать код так, чтобы не столкнуться с этими проблемами? Тогда наша функция будет иметь вид:

public class Cafe
{ public (Coffee, Charge) BuyCoffee(CreditCard card) { var cup = new Coffee() return (cup, new Charge(card, cup.Price)) }
}

Теперь вызывающий код, если это реальное приложение, может произвести транзакцию и списать деньги. Да, вот так просто. Никаких моков больше не надо: мы разделили события выставления счёта и интерпретацию этого счёта. А вот если это тест, то он просто может проверить возвращенный объект Charge на все интересующие его свойства. Легко видеть, что наша функция стала чистой. Charge это простая DTO которая хранит с какой карты сколько надо списать. Мы можем заменить вызов этой функции на результат, и смысл программы не поменяется. Она просто возвращает кортеж из двух объектов, которые являются простым описанием данных. И нам на этом уровне больше не нужен никакой провайдер платежей, ура!

Благодаря тому что мы избавились от эффектов, нам не нужно бояться что N вызовов BuyCoffee заспамят наш платежный шлюз, поэтому просто переиспользуем её. Что насчёт покупки N стаканчиков кофе?

public class Cafe
{ public (Coffee, Charge) BuyCoffee(CreditCard card) { var cup = new Coffee() return (cup, new Charge(card, cup.Price)) } public (Coffee[], Charge) BuyCoffees(int count, CreditCard card) { var (coffees, charges) = Enumerable.Range(0, count) .Select(_ => BuyCoffee(card)) .Unzip(); return (coffees, charges.Aggregate((c1, c2) => c1.Сombine(c2)) }
}

Ну и дописываем хэлпер-функцию Combine:

public class Charge
{ public CreditCard Card { get; set; } public double Amount { get; set; } public Charge(CreditCard card, double amount) { Card = card; Amount = amount; } public Charge Combine(Charge other) { if (Card != other.Card) throw new ArgumentException("Can't combine charges to different cards"); return new Charge(Card, Amount + other.Amount); }
}

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

IEnumerable<Charge> Coalesce(IEnumerable<Charge> charges) => charges.GroupBy(x => x.Card).Select(g => g.Aggregate((c1, c2) => c1.Combine(c2))

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

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

В общем, как ни крути, этот подход получается ощутимо хуже. Вторым возражением может быть то, что в ООП варианте мы могли бы настроить IPaymentProvider на то, что он будет заниматься батчингом операций, но и тут возможны сложности: нужно настраивать таймауты, подбирать значения, чтобы батчинг был эффективным и при этом латентность операцией не сильно выросла, плюс вы всё еще будете бояться "плохих" реализаций, которые не будут заниматься батчингом, и так далее.

Откладывания принятия решения "что нам делать с этими данными" открывает большой простор для действий, и делает многие вещи вроде отмены или повтора операции намного более тривиальными. Разделение выполнения задачи на создание описателя этой задачи и интерпретацию кажется очень незначительным перекладыванием из пустого в порожнее, однако это очень важная вещь, которую трудно переоценить. Концепция на мой взгляд схожа по мощности с RAII: одно простое правило, и очень много далеко идущих хороших последствий.

И это всё?

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

Дело в том, что в отличие от "грязных" функций, сигнатура чистой обычно дает столько информации, что количество возможных вариантов её реализации снижается до жалких единиц, а в экстремальных случаях компилятор может сгенерировать тело функции по её сигнатуре. Одним из самых крутых преимуществ распространенных ФП языков, на мой взгляд, является ценность сигнатур функций и типов. Потому что там void UpdateOrders() и void UpdateUsers() имеют одну и ту же сигнатуру () -> (), но совсем разное значение. Почему это не работает в императивных программах? Именно потому, что функции разрешено только вычислять значение (а не делать произвольную дичь) мы и можем с уверенностью судить о многих её свойствах, просто глядя на сигнатуру. В ФП они будут иметь тип навроде () -> OrdersUpdate и () -> UsersUpdate.

Ну, например предположим у нас есть такая функция (пример на Rust) Что же нам это дает?

// принимаем массив объектов, еще какой-то объект, и возвращаем значение того же типа
fn foo<T>(a: &[T], b: T) -> T { ...какое-то тело... }

Откуда я это знаю? Я не знаю что внутри этой функции, но по сигнатуре я вижу, что результатом будет один из элементов массива, либо в случае пустого массива — элемент b который я передал. Поэтому она никак не может создать экземпляр самостоятельно. Оттуда, что функция не делает никаких предположений о типе T. Следовательно, единственный способ вернуть значение того же типа — передать один из объектов которые я её отдал.

Соответственно я могу написать такой тест

let a = [1,2,3,4,5];
let b = foo(a, 10);
assert!(b == 10 || a.iter().any(|x| x == b))

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

А теперь давайте уберем второй параметр и посмотрим что произойдет:

fn foo<T>(a: &[T]) -> T { ...какое-то тело... }

Или, если говорить формально, вернёт Bottom-тип . Обратите внимание, что для пустого массива эта функция кинет исключение, панику, войдет в вечный цикл или сделает еще что-то нехорошее. А потому что функция обазалсь вернуть значение T, а мы ей ни одного не передали. Откуда я это знаю? То есть функция является частично-рекурсивной, и следовательно не определена для пустых массивов. То есть её контракт невозможно соблюсти для любого значения аргумента a. А на неопределенных аргументах функции обычно паникуют.

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

А потому что его система типов недостаточно мощная, чтобы гарантировать такое поведение. Почему я взял для примера раст, а не тот же сишарп? Пример функции, которая не пройдет тест:

T Foo<T>(List<T> list, T value) => default(T);

Нужно идти смотреть реализацию функции. Вот хотел бы я в сишарпе положиться на систему типов, да не могу. Чем меньше информации нам нужно чтобы понять, как работает тот или иной код, тем легче и проще вносить изменения, и тем более надежным получается софт. А как только мы пошли смотреть реализацию, то мы потеряли главное преимущество, которое нам даёт программирование на языках высокого уровня — умение инкапсулировать сложность и скрывать её за красивым интерфейсом.

Вот так: А знаете как будет выглядеть в расте функция, которая если массив пустой вернет дефолтное значение T?

fn foo<T: Default>(a: &[T]) -> T { ...какое-то тело... }

В конце концов это лишняя писанина, поэтому если автор это написал, то значит как-то скорее всего это использует. Она всё еще может упасть с паникой на пустом массиве, но учитывая что автор явно затребовал возможность создания дефолтного значения этого типа, разумно предположить что именно это и происходит в теле. И мы сразу видим это требование в сигнатуре. А единственное разумное использование такого аргумента — вернуть дефолтное значение когда массив пустой. Напомню, что в сишарпе для этого нужно пойти в тело функции и увидеть там вызов default(T). Просто превосходно ведь!

Это может показаться неправдоподобным хвастовством, но это так. В функциональной парадигме вам в 99% случаев достаточно просто посмотреть на сигнатуру функций чтобы понять, как она работает. И он отлично работает. Haskell коммьюнити довело эту мысль до абсолюта и создало поисковик Hoogle который позволяет искать функции в библиотеках по её сигнатуре.

Например (a -> Bool) -> [a] -> [a] (функция, принимающая два аргумента: предикат и список, в качестве результата возвращает список таких же элементов) ожидаемым образом находит функции filter и takeWhile.

Подумайте, что вот это за функция? Для закрепления предлагаю небольшую загадку. Она принимает строку, и возвращает совершенно любой тип.

fn bar<T>(s: String) -> T { ... } // раст-вариант
bar :: String -> a // хаскель-вариант

Ответ

Потому единственное, что может сделать эта функция — никогда не вернуть результат. Если подумать, то у нас нет никакого способа сделать объект, про тип которого мы ничего не знаем. Но вспомним, что функция принимает еще и строковую переменную. То есть вернуть вечный цикл или панику, известный нам . Для цикла большого смысла её передавать нет, поэтому быть практически уверенным в том, что это функция занимается бросанием паники:

fn bar<T>(s: String) -> T { panic!(s);
}

Хотя если очень постараться, то можно. Если вы подумали про рефлексию и создание типа в рантайме — это в принципе тоже возможный исход (хотя в расте и в хаскелле её всё равно нет), но тогда непонятно зачем строковый параметр нужен. Так что в принципе если это ваш вариант, то смело добавляете себе балл, это тоже возможный вариант для языков, которые это позволяют.

Даже если функция foo из примера выше занимает 1000 строк, она всё равно обязана вернуть либо один из элементов переданного массива, либо второй аргумент. Навык додумать что делает функция по сигнатуре очень выручает, потому что вам не нужно лезть в тело функций чтобы понять, что она может сделать а что нет. И вам не нужно читать 1000 строк чтобы это понять. Других вариантов нет. Вы просто знаете это глядя на сигнатуру функции.

Разве чисто функциональный язык может сделать что-то полезное?

"Чёрт", думал я, "Но ведь мне надо в базу сходить, HTTP запрос сделать, в консоль написать в конце концов. Этот вопрос меня волновал с тех пор, как я у знал о функциональных языках. Наверное он подходит только чтобы факториалы считать". Но чистый язык этого не разрешает.

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

Ну возьмем для примера тип IO, отвечающий за взаимодействие с внешним миром. Как это выглядит? Сам по себе IO ничего не делает, если мы напишем print "Hello world" в хаскелле ничего не произойдет. Это такой же тип, как наш Charge из примера выше, только вместо списания по карте он описывает ввод/вывод. Как же это происходит? Но если мы напишем main = print "Hello world" то магическим образом текст попадет на экран.

То есть все описанные действия происходят за пределами функции main. А всё дело в том, что рантайм хаскелля занимается интерпретацией этого IO. И этому рантайму разрешено делать "грязные" вещи — ходить в базу, печатать на экран, и делать всё, что угодно. То есть из всей нашей программы мы собираем гигантскую стейт машину, которую затем рантайм начинает интерпретировать. Но с точки зрения кода мы ничего никогда не совершаем.

Но когда интерпретатор выполняя функцию main столкнется с этим значением, он произведет физическое хождение в базу. Если мы хотим в хаскелле сходить в базу, то мы создаем объект СходиВБазу, который сам по себе ничего не делает.

Сам по себе листочек ничего не делает, и просто лежит бездейственно. Если использовать аналогию, то хаскель программа это алгоритм записанный на листочке, а рантайм — это робот, который этот алгоритм выполняет. И пока робот не придет интерпретировать наши записи листочек остается совершенно бездействующим. С точки зрения алгоритма мы не можем ничего "сделать", мы можем только сделать другой листочек с другим набором команда.

Вот программа на Rust: Наверное, я вас только запутал этой аналогией, поэтому давайте покажу на примере.

fn main() { let _ = print!("Hello "); println!("world!");
}

А теперь попробуем написать аналогичную программу на Haskell: И она выводит "Hello world!".

main :: IO ()
main = do let _ = print "Hello " print "world!"

По сути разница между поведением этих программ и является квинтэссенцией различия чистой и нечистой программы. И она выводит "world!". Этот описать не был проинтерпретирован и надписи на экране не появилось. В случае хаскелля мы создали описатель "выведи Hello", но никак им не воспользовались. С другой стороны в случае программы на Rust сам вызов print! В качестве результата main мы вернули единственный описатель с world!, который и был выполнен. уже сам по себе является действием, и мы не можем его никак отменить или преобразовать как-то еще.

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

Заключение

Можно писать и в том, и в другом стиле на одном и том же языке, и в принципе даже совмещать. Как видите, всё противопоставление ООП и ФП совершенно искусственно. Например писать объектно-ориентированно на ANSI C можно, но очень больно. Весь вопрос в том, поощряет ли язык написание в таком стиле или наоборот. С другой стороны писать на джаве в чисто функциональном стиле тяжело, а на Scala или Haskell — просто. А на джаве просто. Ну и дальше ваш выбор как разработчика, какой инструмент вам больше подходит по ситуации. Поэтому вопрос скорее заключается в том, что есть два инструмента, один распространен и поддерживается многими языками, другой более интересен по целому спектру свойств, но поддерживается не везде.

Я очень устал от того, что перестановка двух несвязных строчек местами может что-то поломать в совершенно третьем месте. Лично я для себя вижу очень много преимуществ в функциональной парадигме в плане поддерживаемости кода. Я не хочу ловить в рантайме ошибки "Метод не был замокан"/"Тип не был зарегистрирован"/"...", в конце концов я не для того выбирал статически типизированный язык. Мне надоело конфигурировать моки и DI.

Но на мой взгляд оно намного интереснее распространенных на текущий момент подходов. Конечно, ФП это не серебряная пуля, у него есть свои ограничения, и ему тоже есть куда расти. Но это всё шелуха, и оно становится реально мощным инструментом только в совокупности с самой главной идеей ФП — идеей ссылочной прозрачности. "Фишки" ФП языков вроде лямбд, паттер матчингов, АДТ и прочего давно уже не удивляют в мейнстрим языках.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»