В статьях о функциональном программировании много пишут о том, как ФП подход улучшает разработку: код становится легко писать, читать, разбивать на потоки и тестировать, построить плохую архитектуру невозможно, а волосы становятся мягкими и шелковистыми. Недостаток один — высокий порог входа. Пытаясь разобраться в ФП, я столкнулся с огромным количеством теории, функторами, монадами, теорией категорий и алгебраическими типами данных. А как применять ФП на практике, было неясно. Кроме того, примеры приводились на незнакомых мне языках — хаскеле и скале. Тогда я решил разобраться в ФП самого начала. Разобрался и рассказал на codefest о том, что ФП — это на самом деле просто, что мы уже им пользуемся в Swift и можем пользоваться еще эффективнее. Определить, что означает писать в той или иной парадигме — нелегкая задача. Парадигмы формируются десятилетиями людьми с разным видением, воплощаются в языках с непохожими подходами, обрастают инструментами. Эти инструменты и подходы считаются неотъемлемой частью парадигм, но на самом деле ими не являются. Например, считается, что объектно-ориентированное программирование стоит на трех китах — наследование, инкапсуляция и полиморфизм. Но инкапсуляция и полиморфизм реализуется на функциях с той же легкостью, что и на объектах. Или замыкания — они родились в чистых функциональных языках, но так давно перекочевали в промышленные языки, что перестали ассоциироваться с ФП. Монады тоже пробираются в промышленные языки, но пока не утратили принадлежность к условному хаскелю в умах людей. В итоге получается, что невозможно четко определить, что конкретно представляет из себя та или иная парадигма. Я в очередной раз столкнулся с этим на codefest 2019, где все эксперты ФП, говоря о функциональной парадигме, называли разные вещи. Лично мне понравилось определение из вики: «Функциона́льное программи́рование — раздел дискретной математики и парадигма программирования, в которой процесс вычисления трактуется как вычисление значений функций в математическом понимании последних (в отличие от функций как подпрограмм в процедурном программировании)». Что такое математическая функция? Это функция, результат которой зависит только от данных, к которым она применена. Пример математической функции в четыре строки кода выглядит так: Вызывая функцию summ с входными аргументами 2 и 3, получим 5. Этот результат неизменен. Поменяйте программу, поток, место исполнения — результат останется прежним. А нематематическая функция — это когда где-то объявлена глобальная переменная. Функция суммирования теперь складывает входные аргументы и значение z. Добавилась зависимость от глобального состояния. Теперь нельзя однозначно предсказать значение x. Оно будет постоянно меняться в зависимости от того, когда была вызвана функция. Вызовем функцию 10 раз подряд, и каждый раз можем получить разный результат. Еще один вариант нематематической функции: Помимо возврата суммы входных аргументов, функция меняет глобальную переменную z. Эта функция имеет сайд-эффект. В функциональном программировании есть специальный термин для математических функций — чистые функции. Чистая функция — это такая функция, которая для одного и того же набора входных значений возвращает одинаковый результат и не обладает побочными эффектами. Чистые функции — краеугольный камень ФП, все остальное уже вторично. Предполагается, что, следуя этой парадигме, используем только их. А если никак не работать с глобальными или изменяемыми состояниями, то их и не будет в приложении. Изначально, я думал, что ФП — это только про функции, а классы и структуры используются только в ООП. Но оказалось, классы тоже вписываются в концепцию ФП. Только и они должны быть, скажем так, «чистыми». «Чистый» класс — класс, все методы которого являются чистыми функциями, а свойства неизменяемы. (Это неофициальный термин, придуман во время подготовки к докладу). Взглянем на такой класс: Его можно рассматривать как инкапсуляцию данных... и функций по работе с ними. С точки зрения ФП, использование класса User ничем не отличается от работы с примитивами и функциями. Объявим значение — пользователя Ваню. Применим к нему функцию getFullname. В результате получим новое значение — полное имя пользователя. Так как изменить параметры свойства ivan нельзя, результат вызова getFullname неизменен. Конечно внимательный читатель может сказать: «Постой-ка, метод getFullname возвращает результат на основе глобальных для него значений — свойств класса, а не аргументов». Но на самом деле метод — это просто функция, в которую в качестве аргумента передается объект. Swift даже поддерживает эту запись в явном виде: Если же нам понадобиться изменить какое-то значение у объекта, например email, то придется создавать новый объект. Это можно делать соответствующим методом. Я уже писал о том, что многие инструменты, реализации и подходы, считающиеся частью той или иной парадигмы, на самом деле могут применяться и в других парадигмах. Например, частью ФП считаются монады, алгебраические типы данных, автоматический вывод типов, строгая типизация, зависимые типы, проверка корректности программы во время компиляции. Но многие из этих инструментов мы можем найти и в Swift. Строгая типизация и вывод типов — часть Swift. Их не нужно понимать или вводить в проект, они просто у нас есть. Зависимых типов нет, хотя я бы не отказался от проверки компилятором строки, что она email, массива, что он не пустой, словаря, что он содержит ключ «apple». Кстати, в Haskell зависимых типов тоже нет. Алгебраические типы данных имеются, и это крутая, но сложная для понимания математическая штука. Прелесть в том, что ее не надо понимать математически, чтобы использовать. Например Int, enum, Optional, Hashable — это алгебраические типы. И если Int есть во многих языках, а Protocol есть и в Objective-C, то enum со связанными значениями, протоколы с дефолтной реализацией и ассоциативными типами есть далеко не везде. Проверку корректности во время компиляции часто упоминают, говоря о таких языках, как rust или haskell. Подразумевается, что язык настолько выразителен, что позволяет описать все краевые случаи так, чтобы их проверил компилятор. А значит, если программа скомпилировалась, то она обязательно будет работать. Никто не спорит, что она может содержать ошибки в логике, потому что вы неправильно отфильтровали данные для показа пользователю. Но она не будет падать, потому что вы не получили данные из БД, сервер вернул вам не тот ответ, на который вы рассчитывали, или пользователь ввел дату своего рождения строкой, а не числом. Не могу сказать, что компиляция swift кода может отловить все баги: например, утечку памяти допустить легко. Но строгая типизация и Optional хорошо защищают от множества глупых ошибок. Главное — ограничить принудительное извлечение. Довольно часто ФП и монады используются в одном и том же приложении. Одно время я даже думал, что монады и есть функциональное программирование. Когда же я их понял (но это не точно), то сделал несколько выводов: В Swift уже есть две стандартные монады — Optional и Result. Обе нужны для борьбы с сайд-эффектами. Optional защищает от возможного nil. Result — от различных исключительных ситуаций. Рассмотрим на примере, доведенном до абсурда. Пусть у нас есть функции, возвращающие целое число из базы данных и от сервера. Вторая может вернуть nil, но мы используем неявное извлечение получая поведение времен Objective-C. Продолжаем игнорировать Optional и реализуем функцию для суммирования этих чисел. Вызываем итоговую функцию и используем результат. Сработает ли этот пример? Ну, он определенно скомпилируется, а вот получим мы креш во время выполнения или нет — никому неизвестно. Этот код хорош, он отлично показывает наши намерения (нам необходима сумма каких-то двух чисел) и при этом не содержит ничего лишнего. Но он опасен. Поэтому так пишут только джуниоры и уверенные в себе люди. Изменим пример, сделав его безопасным. Этот код хорош, он безопасен. Используя явное извлечение, мы защитились от возможного nil. Но он стал громоздким, и среди безопасных проверок уже сложно разглядеть наше намерение. Нам все еще необходима сумма каких-то двух чисел, а не проверки безопасности. На этот случай у Optional есть метод map, доставшийся ему от типа Maybe из Haskell. Применим его, и пример изменится. Или еще компактнее. Мы использовали map, чтобы преобразовать intFromServer в необходимый нам результат без извлечения. Мы избавились от проверки внутри summInts, но оставили ее на верхнем уровне. Это сделано намеренно, так как в конце цепочки вычислений мы должны выбрать способ обработки отсутствия результата. Извлечь Использовать значение по умолчанию Или вывести предупреждение если, данные не получены. Теперь код в примере не содержит лишнего, как в первом примере, и безопасен, как во втором. Но map не всегда работает так, как нужно Если в map передать функцию, результат которой опционален, мы получим двойной Optional. Но нам не нужна двойная защита от nil. Достаточно одной. Решить проблему позволяет метод flatMap, это аналог map с одним отличием, он разворачивает матрешки. Еще один пример, где map и flatMap не очень удобно использовать. Что, если функция принимает два аргумента и они оба опциональные? Конечно, у ФП есть решение — это аппликативный функтор и каррирование. Но эти инструменты довольно неуклюже смотрятся без использования специальных операторов, которых нет в нашем языке, а писать кастомные операторы считается дурным тоном. Поэтому рассмотрим более интуитивный способ: напишем специальную функцию. Она принимает в качестве аргументов два опциональных значения и функцию с двумя аргументами. Если оба опционала имеют значения, к ним применяется функция.
Теперь мы можем работать с несколькими опционалами, не разворачивая их. У второй монады, Result, тоже имеются методы map и flatMap. А значит, с ней можно работать точно так же. Собственно, это и роднит монады между собой — возможность работать со значением внутри контейнера, не извлекая его. На мой взгляд, это делает код лаконичнее. Но если вам не нравится, просто используйте явные извлечения, это не противоречит парадигме ФП. К сожалению, в реальных программах повсюду встречаются глобальные состояния и сайд-эффекты — сетевые запросы, источники данных, UI. И только чистыми функциями обойтись нельзя. Но это не значит, что ФП для нас полностью недоступно: мы можем постараться уменьшить число грязных функций, которых обычно очень много. Рассмотрим небольшой пример, приближенный к продакшн-разработке. Построим UI, конкретно форму входа. Форма имеет некоторые ограничения: 1) Логин не короче 3 символов
2) Пароль не короче 6 символов
3) Кнопка «Войти» активна, если оба поля валидны
4) Цвет рамки поля отражает его состояние, черная — валидно, красная — не валидно Код, описывающий эти ограничения, может выглядеть так: Обработка любого пользовательского ввода Обработка завершения ввода логина: Обработка завершения ввода пароля: Нажатие на кнопку войти: Возможно, этот код не самый лучший, но в целом он неплох и работает. Правда, у него есть ряд проблем: Главная проблема состоит в том, что нельзя просто взять и сказать, что происходит с нашим экраном. Глядя на один метод, мы видим, что он делает с глобальным стейтом, но не знаем, кто, где и когда еще трогает стейт. В итоге, чтобы разобраться в происходящем, надо найти все точки работы с вьюшками и понять, в каком порядке какие воздействия происходят. Удержать все это в голове очень сложно. Если процесс изменения состояния линейный, можно изучать его шаг за шагом, что снизит когнитивную нагрузку на программиста. Попробуем изменить пример, сделав его более функциональным. Для начала определим модель, описывающую текущее состояние экрана. Это позволит точно знать, какая информация необходима для работы. Модель, описывающую изменения, применяемые к экрану. Она нужна, чтобы точно знать, что мы будем менять. События, которые могут привести к новому состоянию экрана. Так мы точно будем знать, какие действия изменяют экран. Теперь опишем главный метод изменения. Эта чистая функция на основе события текущего состояния собирает новое состояние экрана. Самое важное в том, что этот метод единственный, кому позволено заниматься конструированием нового состояния — и он чистый. Его можно изучить шаг за шагом. Увидеть, как события преобразуют экран из точки А в точку Б. Если что-то сломается, то проблема точно здесь. И это легко тестировать. Добавим вспомогательное свойство для получения текущего состояния, это единственный метод, зависящий от глобального состояния. Добавим еще один «грязный» метод для создания сайд-эффектов изменения экрана. Хотя метод updateView и не является чистым, но это единственное место, где меняются свойства экрана. Первый и последний пункт в цепочке вычислений. И если что-то пошло не так, именно тут будет стоять брейкпоинт. Осталось только запустить преобразования в нужных местах. Метод loginPressed вышел немного уникальным. Дело в том, что нажатие на кнопку «Войти» запускает две цепочки вычислений, что не запрещается. До начала изучения ФП я делал сильный акцент на парадигмах программирования. Для меня было важно, чтобы код следовал ООП, я не любил статические функции или объекты без состояний, не писал глобальных функций. Сейчас мне кажется, что все те вещи, что я считал частью той или иной парадигмы — довольно условны. Главное — это чистый, понятный код. Для достижения этой цели можно использовать все, что возможно: чистые функции, классы, монады, наследование, композиция, вывод типов. Все они хорошо уживаются вместе и делают код лучше — достаточно применять их к месту. Определение функционального программирования из википедии
Книга о языке Haskell для начинающих
Объяснение функторов, монад и апликативных функторов на пальцах
Книга о практиках использования Maybe(Optional) в языке Haskell
Книга о функциональной природе Swift
Определение алгебраических типов данных из вики
Статья о алгебраических типах данных
Еще одна статья об алгебраических типах данных
Доклад яндекса о функциональном программировании на Swift
Реализация стандартной библиотеки Prelude (Haskell) на Swift
Библиотека с функциональными инструментами на Swift
Еще одна библиотека
И еще одна