[Перевод] Функциональное мышление
TL; DR: Конструирование приложения с чистой функцией в основе может стать первым шагом к идеалу «функционально-центричной императивной оболочки», что может упростить тестирование приложения и снизить планку его сложности.
Зачем?
Упрощение.
Поскольку речь идёт о новой альтернативе KISS, начнем с цитаты из поста «Разработчик с мозгом груга»:
сложность очень, очень плохо
И для полноты картины:
Сложность: мера того, насколько трудно понять, как поведет себя система, или предсказать эффект внесённых в неё изменений.
Для многих приложений проблема заключается в том, как просто и правильно представить в коде большое количество требований к предметной области и как поднять планку сложности настолько, чтобы обеспечить высокую производительность труда, а затем — стабильную работу во время выполнения.
Низкая планка сложности сокращает время, за которое разработчик доходит до кондиции «расплавление активной зоны мозга — точка невозврата». После этой точки выполнение новой работы тормозится экспоненциально из-за огромной сложности кодовой базы и набора тестов, из-за чего код всё хуже грокается (его всё сложнее понять или выполнить), увеличению доли спонтанных вариантов поведения. Всё вероятнее становится переход разработки в регрессию, и сама разработка просто замедляется.
Высокая планка сложности, наоборот, позволяет установить желаемый порядок и успокоиться, а далее уделять больше времени повышению ценности продукта, а не борьбе с трудностями.
Мне известны различные причины, по которым устанавливается низкая планка сложности, в том числе:
- Требования предметной области распространяются на все компоненты кодовой базы.
- Отсутствующая / неполная / неорганизованная документация по требованиям к предметной области.
- Отсутствующий / неорганизованный / обширный / слабо работающий набор тестов.
- Некачественное именование.
- Манипуляции с такими состояниями в ООП, где в изобилии возникают условия гонок.
- Слабая сплочённость архитектуры на уровне базы кода / команды разработчиков.
Кроме того, если ваше приложение сильно зависит от событий (пользовательский ввод / ввод от сети / ОС / периферийных устройств / датчиков), код получится хаотичным, если не выработан простой подход к обработке входящих событий. Ситуация усугубляется, если вы работаете в конфиденциальной области, где есть строгие требования к бережному хранению данных во время выполнения.
Я считаю, что наличие единой функции в сердцевине приложения может помочь решить все вышеперечисленные проблемы.
Как?
В принципе, приложение может быть написано так, чтобы в его основе лежала единственная чистая функция, не имеющая состояния, вот!
fun reduce(action: Action, state: State): Effect
Action представляет собой некоторое действие, (уже) произошедшее в системе, например.
- UserSelectedLogin
- ApplicationForegrounded
- UserInteractionTimeOutLimitReached
- BleDeviceConnectionLost
State как таковое представляет состояние приложения, которое может быть реализовано как неизменяемая уплощенная иерархия данных, подобно Redux.
Effect в самом простом виде может представлять новое неизменяемое состояние приложения и, по желанию, любые команды Commands, которые необходимо обработать, например, Effect (State, Command).
Command представляет событие, которое должно произойти (в будущем), вероятно, в императивном порядке и, вероятно, в ходе взаимодействий с реальным миром, скажем, при вводе-выводе, например, AttemptLoginToRemoteServer (userName: String): Command. Обратите внимание, что команда Command может легко представлять «побочный эффект» (в терминах функционального программирования). Похоже на команды Elm, в отличие от феномена вроде Middleware в Redux или препроцессинга в MVI.
Простое представление архитектуры
Приведенный выше пример, вероятно, является самым простым представлением архитектуры, выполненной в таком стиле. Важно повторить, что здесь представлено всё приложение целиком.
Левая часть схемы демонстрирует фундаментальные принципы однонаправленного потока данных.
Пример теста
Независимо от того, как именно вы решите обозначить State, сохранение чистоты основной функции reduce () позволяет писать молниеносные автоматизированные функциональные модульные тесты, которые выполняются на JVM. Эти тесты потенциально могут покрыть большую часть функциональных требований вашего приложения.
Например, такие требования, как:
ПРИ УСЛОВИИ того, как пользователь вошел в систему
КОГДА приложение переходит в фоновый режим
ТОГДА пользователь должен выйти из системы.
Может быть выражено во время тестирования следующим образом:
@Test
fun `GIVEN user is logged in WHEN app moves to the background THEN user is logged out`() {
//GIVEN
val inState = State(user = LoggedInUser(username = "Dori"))
//WHEN
val outState = reduce(ApplicationBackgrounded, inState)
//THEN
assertThat(outState.user).isOfType(AnonomousUser)
}
Стоит отметить, что тесты такой формы часто очень удобочитаемости, а также, фактически, документируют данное приложение и описывают текущий набор вспомогательных возможностей / функций / требований. Именно такого эффекта часто пытаются достичь на уровне организаций и тестовых комплектов, в результате значительно снижается производительность.
Поскольку я профессионально разрабатываю под Android более 12 лет, я сам писал и видел, как другие пишут тесты для более традиционных архитектур Android. В таких архитектурах при попытке выразить поток, подобный приведенному выше, получается вот что:
- Работа идёт медленно: из-за сочетания сложности на уровне инструментария и на уровне интеграции.
- Работа идёт нестабильно: из-за конкурентности в тестируемом коде.
- Получается сложно и хрупко: из-за чрезмерной интеграции или мокинга.
Я полагаю, что исправный комплект тестов == исправный приложение, и приведенный выше тест как раз очень простой, так как он:
- Работает быстро: Нет необходимости в инструментировании, поэтому имеем легковесное тестирование под JVM.
- Работает стабильно: Нет конкурентности.
- Устроен просто: Невозможно применить мокинг или интеграционное тестирование, так как это чистая функция.
Особенности Android
На такой системе, как Android, следуя этому подходу, я обнаружил, что мне не нужно ничего больше, чем одна активность и куча мелких представлений. ViewModels становятся немного избыточными, а UI-слой приложения довольно упрощается, поскольку в пользовательском интерфейсе нужно только отображать состояние и соотносить пользовательский ввод с Actions.
Замечание о функциональном программировании
Это и есть функциональное программирование?
Ну, не совсем. Однако в этом посте представлены концепции чистых функций и моделирования побочных эффектов в виде типов значений Effect (или Command). Оба этих варианта являются концепциями функционального программирования. Однако здесь не говорится о более экзотических функциональных концепциях, таких как ссылочная прозрачность, моноиды, керринг и монады. Нам повезло, что Kotlin позволил нам работать с функциями как с сущностями первого класса. Поэтому разработчики, могли бы гораздо глубже погрузиться в мир функционального программирования, чем предложено здесь. При этом просматриваются некоторые простые, но ощутимые преимущества, привлекая некоторые из более простых и доступных функциональных концепций, как описано в этом посте.
Также этот подход является первым шагом к функциональному идеалу отделения решений от зависимостей и получения выгоды от такого подхода.
Функциональное ядро, императивная оболочка
Концепция функционального ядра и императивной оболочки является мощным и прагматичным способом приобрести некоторые преимущества функциональных подходов, в частности, разделение ответственности и удобство поддержки/тестируемость.
Основным принципом чисто функционального программирования является максимально возможное разделение эффектов и данных. Это естественным образом приводит к созданию приложений с функциональным ядром и императивной оболочкой. Подавляющее большинство кода пишется как функции и данные без побочных эффектов, и только на границах приложения проявляются эффекты. Границы приложения — это контур, где наша основная логика контактирует с внешним миром, будь то API-запросы, внешний ввод, компоненты, отображаемые на странице, и так далее.
Подход «приложение как функция» — это одна из многих возможных интерпретаций идеи функционального ядра, императивной оболочки. Существует множество интересных ресурсов, позволяющих глубже изучить эту тему.
Смена парадигм
Архитектура настолько субъективна, что то, что подходит одному разработчику, может быть совершенно чуждым или неприятным для другого. Кроме того, архитектура, которая может идеально подходить для одного проекта, может быть ужасной для другого. Плюс, если у вас большая команда с высокой текучкой кадров, вам нужно будет тщательно взвесить плюсы и минусы сравнительно сложной архитектуры, которая замедляет процесс внедрения, соотнести её с более распространенной архитектурой, которая может медленнее усваиваться некоторыми членами команды.
Для меня (так уж устроен мой мозг), размышление о структуре приложения в плоскости приложения-как-функции/функционального-ядра императивной оболочки значительно упрощает разработку и тестирование для многих видов Android-приложений.
Разработка может:
- Быть быстрой, действительно самодокументирующейся и иметь высокую планку сложности.
- Быть избавлена от общих проблем, связанных с каркасом и инструментарием Android. Это касается и тестировочного инструментария.
- Хорошо вписываться в UDF-мышление, что полезно для обеспечения чистой реализации пользовательского интерфейса (Compose или иного).
- Отмежеваться от разнообразных конфликтов гонки, обычно встречающихся при программировании в ООП-стиле, когда приходится изменять состояния в сочетании с распараллеливанием кода.
- Включить логирование основных событий приложения, используя всего одну строку кода перед функцией reduce.
Его потенциал восхищает меня, и пока что он ощущается как пьянящий шлюз в функциональный мир.
Сопутствующие мысли
Приведенные выше концепции не являются новыми для архитектуры программного обеспечения. Умение мыслить в контексте пользовательских функций известно уже давно, как и функциональное программирование (~1950-е годы). То же касается архитектур, вдохновленных Flux / Elm / Redux. Мы можем найти похожие концепции в Чистой архитектурe, Гексагональной архитектуре / Ports & Adapters и Луковой архитектурe.
В Android похожие концепции могут проявляться в концепции Model-View-Intent (MVI) для реализации на уровне функций, но с меньшим акцентом на функциональное мышление, тестируемость и простоту.
Приложение-как-функция — это простая реализация аналогичных идей, но с акцентом на функционально-ядерное императивно-оболочечное мышление в масштабах всего приложения.
Заключение
Я надеюсь, что эта статья дала пищу для размышлений и познакомила некоторых читателей с понятием проектирования приложений с точки зрения функционально-ядерного императивно-оболочечного мышления, а также показала, как может быть реализован этот принцип на мобильной платформе, например, на Android.
P.S книга по теме: «Грокаем функциональное мышление» Эрика Норманда