Что объединяет «каррирование», «монады», «алгебраические типы данных»? Не только тот факт, что часть разработчиков старается обходить эти слова стороной, но еще и функциональное программирование. Под заботливым руководством Евгения Елчева мы погрузились в функциональную парадигму и почти все поняли. Не пугайтесь раньше времени, смело читайте расшифровку десятого выпуска подкаста AppsCast.
Даниил Попов: Всем привет. Сегодня у нас в гостях Евгений Елчев из солнечного Красноярска. Женя, расскажи, чем ты занимаешься и как пришел в функциональное программирование?
Евгений Елчев: Всем привет. Я iOS-разработчик в Redmadrobot, как и все остальные крашу кнопки, иногда пишу бизнес-логику.
С функциональным программированием я сначала познакомился через статьи. Я, не совсем понимая суть, думал, что это вроде процедурного программирования, без классов. Когда прочел одну из статей повнимательнее, осознал свою неправоту и начал копать. Нельзя сказать, что я прямо-таки пришел в функциональное программирование, так как настоящие адепты костьми за него лягут и пишут на Haskell, используя монады где только можно. Я же просто погрузился и использую только в продакшене.
Даниил Попов: Так, уже монады пошли.
Евгений Елчев: Уже сложно?
Даниил Попов: Я пытался пройти такой же дорогой, но открывал статью, видел слова «каррирование», «монады» и сразу закрывал, думая, что пока не достоин. У меня вообще есть шансы?
Евгений Елчев: Конечно. Этого можно вообще не знать.
Простыми словами про функциональщину
Даниил Попов: Давай для тех, кто никогда не слышал о функциональной парадигме, дадим простое определение.
Евгений Елчев: Парадигмы каждый понимает по-своему. Если брать объяснение из Википедии, то это использование математических функций, где вся программа трактуется как математическая функция.
Функциональный подход (ФП) — это когда ты используешь в своей работе функции, в которых есть только входные аргументы и выходное значение. Если вся программа состоит из таких функций, то это функциональная программа.
Даниил Попов: ООП было логическим продолжением обычного процедурного программирования и решало вопрос инкапсулирования данных в классы. Какие проблемы призвано решить функциональное программирование?
Евгений Елчев: Функциональное программирование изобрели математики. Собрались ребята и решили создать парадигму, где все можно доказывать. Есть код, он еще не запущен, но уже весь доказуем. Любую точку программы можно рассчитать, понимая, куда мы придем, когда допустим какое-либо действие.
Это звучит абстрактно, поэтому разберем на примере чистой функции. Пишем функцию sum, которая принимает два аргумента, передаем ей 2 и 3, получаем 5 и можем это доказать. Это всегда истина. Если вся наша программа состоит из таких функций, то она вся доказуема.
При создании языков, базовых функций стало не хватать, и появились дополнительные возможности: лямбды, функции высшего порядка, монады, моноиды.
Функциональная парадигма не решает отдельную проблему, это все то же желание написать хороший код как можно проще, чтобы программы были стабильными и их было легко поддерживать.
Если присмотреться, многие вещи, которые мы используем в ООП, нашли свое отражение в функциональном подходе. В ОПП есть классы, которые инкапсулируют набор полей. В ФП тоже можно так сделать с помощью type-классов. Как любит говорить Виталий Брагилевский: «Если ты смотришь на табличку, где по строкам идут данные, а по колонкам функции, то ФП идет по колонкам, ООП по строкам». Вот и все.
Даниил Попов: Как ФП соотносится с другими парадигмами? Могу ли я на ООП-языке писать функционально? Как миксовать парадигмы, и есть ли в этом смысл?
Евгений Елчев: Парадигма ограничивается тем, что ты пишешь функции с данными. Одна из особенностей ФП — отсутствие изменяемых состояний. Если твои данные — это класс, то проблем нет. Если класс полностью иммутабельный, то его можно использовать. Класс — это просто тип, как строка или число, только более сложный, состоящий из нескольких значений.
Даниил Попов: Ты ранее сказал, что можно доказать математическую корректность программы, если писать исключительно функционально. Тогда шутка про «скомпилировалось — работает» для функциональных языков перестает быть шуткой, так?
Евгений Елчев: Если смотреть на ошибки ввода/вывода, то да. Раньше программисты боролись с проблемой: подключился к сети, сети нет, вернулся nill, и все упало. Для решения проще всего было проверить, что пришло — nill/не nill, но, так как оставался риск, что не все учтено, программа могла скомпилироваться и упасть.
В современных языках это решено. В Haskell можно написать программу, которая будет работать и не падать, но насколько корректно она работает, никто не скажет. Конечно, там строгие типы, и нельзя совершить ошибку, сложив число со строкой, но всегда можно оставить в приложении баги, а оно будет работать.
Место функционального подхода в Swift
Алексей Кудрявцев: Насколько Swift можно назвать функциональным языком?
Евгений Елчев: Можно. Функциональщина позиционируется как stateless, но на Swift можно писать, избегая таких состояний. При этом Swift — это не то же самое, что писать под iOS, где везде есть состояния. Конечно, в Swift нет специальных инструкций как в Haskell, где все функции чистые по умолчанию и компилятор не разрешит обратиться к состоянию и изменить его. Если же пометить функцию как «грязную», то изменения становятся доступны.
Алексей Кудрявцев: Во втором или третьем Swift был модификатор pure, но он действовал только на уровне компиляции, чтобы глобальные значения не изменялись. Ты в них что-то записывал, но компилятор все вырезал.
Евгений Елчев: Да, в iOS компилятор за таким следить не будет. Все целиком на нашей совести: как напишешь, так и будет.
Алексей Кудрявцев: Ты говоришь, что в iOS-приложениях много состояний, а где какие и что с ними делать, если ты пишешь в функциональном стиле?
Евгений Елчев: Самое главное состояние — это UI, например, поля ввода. Сделать с ними практически ничего нельзя. Можно попробовать от них абстрагироваться, собрать в одном месте и писать как можно больше кода без их учета. Например, пишешь одну грязную функцию, которая получает все данные из UI.
В своей статье я приводил пример формы авторизации, где важно, чтобы пользователь ввел логин/пароль. Пишем одну грязную функцию, которая возвращает структуру с авторизационными данными, а затем пишем на нее чистый код. Получили эти данные, провалидировали, если результат валиден, отправляем запрос на сервер. Запрос на сервер — тоже грязная функция, а его обработка целиком может быть чистой. «Получили, распарсили» — это линейная функция: на вход data, на выход — наша структура. Дальше преобразовали, отфильтровали и можно снова показать на экране.
Алексей Кудрявцев: В Haskell компилятор сильно помогает. Если откуда-то приходит state, вся цепочка вызовов будет считаться грязной и нужно оборачивать все в монады. Если же функция чистая, то работает кэширование результатов — на одни и те же данные всегда один и тот же выход. В Swift приходится самостоятельно реализовывать мапы и пытаться возвращать результат, если он уже закэширован.
Даниил Попов: Большинство современных языков считаются мультипарадигменными и во многих есть функциональные особенности. Например, в Java есть специальная аннотация для интерфейса — @FunctionalInterface
, которая обязывает разработчика определить в интерфейсе только один метод, чтобы затем этот интерфейс в виде лямбд использовался во всем коде. При добавлении второго метода или удалении существующего, компилятор начнет ругаться, что это перестало быть функциональным интерфейсом. Есть ли в Swift, в отрыве от iOS-платформы, такие функциональные фишки?
Евгений Елчев: Мне сложно понять, что делает такая аннотация в Java. Если ты имеешь в виду, что имплементируешь этот интерфейс к классу, а потом реализуешь всего один метод, то в Swift таких ограничений нет. Можно создать typealias, назвать его и как тип функции использовать в качестве типа аргументов, типа переменной для того, чтобы присвоить замыкание. Можно определить ограничения — входные и выходные аргументы замыкания. Сами функции высшего порядка, которые могут принимать замыкания — это полиморфизм, и в Swift можно построить полиморфизм на типах, не ограничиваясь объектами.
А вот конкретных функциональных вещей я не знаю. Когда-то в первом Swift было каррирование, но его выпилили. Сейчас мы можем сами написать функцию для каррирования, либо написать функцию так, чтобы она возвращала замыкания одно в другом, но это не совсем то.
У нас нет никаких коробочных функторов или монад. Их даже нельзя написать. Новые фишки в Swift 5.1 должны помочь это сделать, но я пробовал написать такой код, и xCode упал.
В принципе, в Swift при желании несложно все сделать самому. Есть уже из коробки монада optional (в Haskell — maybe). У нее есть map и flatmap для построения линейного вычисления.
В Swift мощный pattern matching. Switch, который есть почти в каждом языке и в большинство случаев сопоставляет integer с единицей, может сопоставлять переменную с конкретным образцом, диапазонами, типами, извлекать значения из связанных типов. Есть carthage — составляешь новый тип, передавая в него несколько других. На их основе тоже можно делать pattern matching. Есть перечисление, которое может ограничивать типы, подвязывать к ним связанные типы.
Алексей Кудрявцев: Уточню, что связанные типы похожи на котлиновские sealed-классы. Это enum внутри кейса, в который можно положить связанное значение. В switch можно написать: вот case, разверни, внутри объект. Например, кейсы user и company с соответствующими объектами могут быть enum и можно свитчиться. Только sealed-классы расширяемы, а switch конечен.
Зачем мобильщику функциональщина?
Даниил Попов: Чем же функциональный подход полезен для мобильной разработки? Есть ли проблемы, которые он решает?
Евгений Елчев: Нет конкретной проблемы, которую можно решить именно с помощью функционального программирования.
Самое важное, что следуя этим принципам, даже если не получается, мы должны отказываться от состояний, потому что именно они — главная боль.
Отказываясь от них, ты делаешь свой код более понятным. Я не говорю, что будет меньше ошибок, потому что это надо как минимум замерять. Тем не менее, когда ты начинаешь что-то внедрять, код меняется. Часто бывает, что смотришь на код и в нем все по делу, а начинаешь переписывать, менять местами, убирать лишнее и читается легче.
Следуя функциональной парадигме, получаешь дополнительный источник вдохновения.
Даниил Попов: Если я начну писать в ООП-языке такие иммутабельные классы и использовать иммутабельные методы, можно будет сказать, что я пишу функционально?
Евгений Елчев: Да, при этом ты начинаешь видеть плюсы. Становится проще тестировать методы из-за отсутствия глобального состояния, проще составлять из методов цепочки вычислений.
Даниил Попов: В своей статье ты объясняешь, что такое чистая функция и side-эффекты. Ты приводишь пример с суммированием, где функция еще и модифицирует внешнее состояние. Проблема в том, что когда ты читаешь такой код, сложно держать в голове все изменения: нужно посмотреть на эту глобальную переменную, кто еще в нее читает, кто еще в нее пишет, что может произойти. Зато функциональный подход позволяет тебе держаться в потоке, не ходить в соседние классы, ты просто читаешь код.
Алексей Кудрявцев: Если ты в функциональном языке, то с одной стороны тебе легче код писать, но с другой, приходится понимать, в какой ты сейчас монаде.
Евгений Елчев: Да, но когда ты начинаешь писать все на чистых функциях, возникают другие проблемы. Например, как выстроить длинную цепочку вычислений. В обычном стиле ты, не задумываясь об этом, легко докидываешь данные, которых не было изначально. В функциональном подходе этого делать нельзя: приходится дробить цепочки, все вычисления, используемые в нескольких методах, соединять в состояния. К этому нужно привыкать.
С другой стороны, в отличие от классов в ОПП, которые делают код закостенелым и мало поддающимся композиции, функции могут быть более гибкими. Можно написать одну функцию, добавить свободы с помощью замыкания, накидать таких функций и объединять их в цепочки.
Алексей Кудрявцев: Это похоже на идеологию Unix: есть bash, terminal и можно передавать данные из маленьких программ, которые делают одно небольшое действие, в другие.
Даниил Попов: Мне это напомнило Rx-подход, где пишут гигантские цепочки.
Евгений Елчев: Вы оба правы. И Unix-way про это, а Rx — это сплав идеи биндинга и реактивщины. В ФП мы биндимся на источник события и в цепочке вычислений изменяем его, подвязывая результат на конечное состояние.
Даниил Попов: Хороши ли вообще мультипарадигменные языки, насколько это удобно и полезно, что язык умеет и так, и сяк?
Евгений Елчев: Если четко следовать какой-то парадигме, всегда будут вещи, которые делать неудобно. Есть вещи, которых сложно добиться в функциональном стиле, например, хранить state и сделать cache.
Когда есть возможность выбрать инструмент, который больше подходит под конкретную задачу — это круто.
Можно создать класс, внутри него сделать несколько методов в функциональном стиле и организовать код лаконично цепочками, или отказаться полностью от класса, наделать нужных функций и использовать их.
Минус в том, что возникает дилемма выбора и чем больше вариантов, тем сложнее выбирать. Разобраться тоже становится сложнее: чем больше вариантов, тем сложнее читать код.
Про варенье из монад
Алексей Кудрявцев: Вернемся к функциональщине, что такое монада?
Евгений Елчев: Я бы назвал это контейнером, в который можно объединять цепочки вычислений. Самый просто способ — это контейнер, к которому можно применить функцию и преобразовать в новый контейнер с измененным значением.
Представьте коробку, в которой лежит клубника, и есть прибор, которая позволяет из клубники делать варенье, но ты не можешь в него положить коробку с клубникой, ее надо высыпать. Монады — эта та самая вещь, которая позволяет засунуть в прибор коробку.
Это не state в прямом понимании, так как state хранится отдельно, а здесь контекст (коробка) со значением и ты передаешь из одного в другое. Это передача информации от одного вычисления к другому.
Алексей Кудрявцев: Получается, что в функциональном подходе для того, чтобы сделать варенье, нужно залезть внутрь коробки…
Евгений Елчев: Прелесть в том, что в коробку лезть не надо. Можно кидать коробку.
Функциональщина для избранных?
Даниил Попов: Бытует мнение, что функциональным программированием нельзя заниматься без докторской степени по математике. Правда ли это?
Евгений Елчев: Это неправда. Знание математики, конечно, делает все лучше, но я забыл математику после окончания университета и нормально живу. По сути все это инструменты, которые воплотились в языках в решение конкретных задач. Их можно использовать, не пытаясь доказать математически. Пока ты будешь составлять уравнение с математической точки зрений, быстрее и проще будет накидать методом тыка парочку строк кода, и они будут работать.
Алексей Кудрявцев: Насколько увлечение функциональным подходом может мешать продуктовой разработке? Если часть кода уже написана функционально, нет ли сложности в работе с ним?
Евгений Елчев: Вообще нет. Если ты не маньяк и не будешь писать огромную экосистему с декораторами, то можно использовать тот же pattern matching.
Сложнее будет, если захочешь перейти на новый элемент функциональщины. Например, недавно появился пятый Swift и монада result, раньше ты ей не пользовался, а теперь решил, что все будет на ней. Берешь функцию запроса в сеть и пишешь, что ее результат теперь result (либо данные, либо ошибка), и решаешь объединить со следующим запросом, а там у тебя отдельно замыкание со значением и error, и это нужно переписать. Начал так писать в одном месте, очнулся через два дня, когда переписал половину кода, еще и новые обертки для библиотек сделал, чтобы красиво объединялось.
С чего начать?
Даниил Попов: Что почитать новичку, чтобы понять функциональное программирование?
Евгений Елчев: Надо взять чисто функциональный язык, например, Haskell и попробовать на практике. Берешь учебник и делаешь самые простые примеры. Тут ты и понимаешь подход — когда нет for, нельзя создать переменную, в которой можно поменять значение. Лично я в свое время взял книжку «Изучай Haskell во имя добра», где все описано простым языком. После можно начать читать статьи в интернете: про то, как выглядят монады в Swift, про алгебраические типы данных. Пара статей, и становится понятно, что этого не стоит бояться.
Даниил Попов: Самое сложное, это сломать парадигму в собственной голове.
Евгений Елчев: Не надо резко погружаться в функциональное программирование. Многие считают, что как сядут, так и начнут функционально писать — это неправильно.
Алексей Кудрявцев: Самое классное, что я видел — это курс на Stepic по Haskell от Дениса Москвина. Начинаешь с того, что пару чисел складываешь, а заканчиваешь тем, что монады в монады заворачиваешь. А если хочется совсем сломать голову, то есть книжка «Структура интерпретации компьютерных программ» — это курс на Lisp от простых примеров до того, что ты пишешь интерпретатор Lisp на Lisp.
Если первичный страх перед функциональщиной прошел, то гляньте еще доклад Виталия Брагилевского с весеннего AppsConf. Впрочем, в осеннем сезоне AppsConf мы затронем темы не менее интересные — iOS-сообщество с нетерпением ждет доклад Даниила Гончарова по реверсинженирингу Bluetooth, а android-разработчики вместе с Александром Смирновым обсудят актуальные подходы к построению анимаций
Автор: Алексей Кудрявцев