Комментарии 15
Про рекурсию я уточнил бы: где возможно – используют не её, а фвп типа fold, zip и так далее.
Спасибо! Согласен с вами, в Android разработке сам тоже не часто сталкиваюсь с рекурсиями, чаще можно встретить операторы fold и тому подобные, как вы верно подметили. Конкретно в данном случае, скорее хотелось подсветить концептуальную альтернативу циклам из ФП и математики в виде рекурсий. Лично для меня хорошим примером отдачи предпочтения рекурсии в противовес циклам являются рекурсивные реализации алгоритмов на графах, например DFS или в некоторых сортировках. Понятное дело, что в большинстве продуктовых задач это не сильно актуально. Поэтому ваше уточнение отличное вписывается в контекст Андроид.
Вот это поворот! А как, по-вашему, реализованы фолды и зипы в хаскеле? Да и сам тезис довольно спорный, в хаскеле — из-за невнятности фолда, который по умолчанию правый, что идеологически неправильно (что породило триста разных фолдов в прелюде), в остальных языках — потому что фолд — это строго редьюсер, а рекурсия — совершенно не факт.
Для сравнения: в декларативном подходе мы бы просто описали, что хотим получить, не указывая, как хотим это сделать. Теперь мы используем метод
filter
для фильтрации сотрудников по возрасту и зарплате, а затем применяем методsumOf
, чтобы получить сумму зарплат отфильтрованных сотрудников.
Это распространенное заблуждение которое кочует из статьи в статью про ФП.
val totalSalary = employees
.filter { it.age > 30 && it.salary > 50000 }
.sumOf { it.salary }
Это не декларативный стиль, это такой же императивный стиль. Вы буквально описали что делать: отфильтровать и затем просуммировать. Просто это спрятано внутри.
Декларативный пример это SQL ил html. Где вообще нет прямой связи между описываемым результатом и тем как это будет достигнуто.
Спасибо за ваше замечание! Да действительно, в какой-то мере я с вами согласен. Если открыть большинство реализаций high-level функций в Kotlin там под капотом императивный стиль внутри во всей красе. Однако такой вариант через цепочки вызовов функций чуточку ближе к декларативному стилю, чем лобовое решение через циклы. В следующих частях цикла статей будет затронуто больше тем ФП. Поглубже раскрыта суть чистых функций, теория категорий и библиотека Arrow. Возможно в них вы сможете найти для себя больше интересного. Скоро этот материал будет опубликован. Буду рад вашим комментариям!
Привет. Спасибо за статью и твои старания над ней)
Я сам пишу про архитектуру приложений, поэтому хочу подсветить и обсудить с тобой некоторые моменты
1) Dependency Injection Free
Философия этого подхода говорит об осознанном отказе от DI библиотек в пользу ручного управления зависимостями. То есть она не отменяет DI и не отказывается от него.
Суть DI заключается в разрыве прямой связи между реализациями сущностей, что достигается прокидыванием зависимостей в объект извне самого объекта.
Твой пример:
class FoodMenuViewModel(
private val client: FoodMenuClient = FoodMenuClient.live(),
private val dispatchers: DispatcherProvider = DefaultDispatcherProvider()
) : ViewModel() {
val client: FoodMenuClient = FoodMenuClient.live()
client
не прокидывается извне, а создается именно внутри самого объекта.
Да, его можно заменить для тестирования. И для проектов с конечным жизненным циклом (зарелизил и забыл), вполне себе отличный вариант.
Однако будет сильно проблематично разнести интерфейс FoodMenuClient
и его реализацию по api/impl модулям. Для этого придется избавиться от дефолтного создания и передавать зависимость извне, что приведет к большому и сложному созданию графа зависимостей, вместо таких дефолтных значений.
Этим пунктом просто хочу предостеречь от использования такого подхода в больших приложениях, т.к. с ростом кодовой базы, вам скорее всего придется прийти к много-модульности и столкнуться с проблемой организации DI.
2) Функции в конструктор класса
class FoodMenuClient(
val fetchFoods: (
categoryId: String,
callback: (Loadable<List<Food>, Error>) -> Unit
) -> Unit,
val createFood: (
food: FoodDTO,
categoryId: String,
callback: (Loadable<Food, Error>) -> Unit
) -> Unit,
) {
companion object {
@JvmStatic
fun live() = FoodMenuClient(
fetchFoods = {},
createFood = {},
)
}
}
В данном случае class FoodMenuClient
заменяет собой интерфейс, просто перечисляя список доступных функций внутри конструктора, а его реализацией является этот код FoodMenuClient(fetchFoods = {}, createFood = {})
Скорее всего тут еще будет проблема логирования, потому что в стектрейсе будут указания на аннонимные функции и возможно будет не понятно какая реализация вызвала ошибку.
Можешь, пожалуйста, подробнее рассказать, почему ты выбрал именно такой подход?
Возможно, кроме сокращения кода, есть что еще, что я упускаю?
Для наглядности вот сразу тот же пример через интерфейс:
(Лучше не использовать анонимный объект, а дать реализации свое имя, чтобы его было видно в логах)
interface FoodMenuClient {
fun fetchFoods(
categoryId: String,
callback: (Loadable<List<Food>, Error>) -> Unit
)
fun createFood(
food: FoodDTO,
categoryId: String,
callback: (Loadable<Food, Error>) -> Unit
)
) {
companion object {
@JvmStatic
fun live() = object : FoodMenuClient {
override fun fetchFoods(
categoryId: String,
callback: (Loadable<List<Food>, Error>) -> Unit
) {}
override fun createFood(
food: FoodDTO,
categoryId: String,
callback: (Loadable<Food, Error>) -> Unit
) {}
}
}
}
3) Callback
Вероятно ты использовал его чисто для статьи, но тем, кто только учится, лучше сразу подсветить, что callback: (Loadable<Food, Error>) -> Unit
стоит заменить на возвращаемый Flow<Loadable<Food, Error>>
4) MVI, UDF и потоки данных
val foods get() = _foods.asStateFlow()
private val _foods = MutableStateFlow<List<Food>?>(null)
Хочу накинуть еще в этом месте, что использование MutableStateFlow, создает разрыв потока данных. И является местом хранения состояния, которое управляется в этом же классе, а не чисто на основе данных.
Что может приводить к появлению гонок и в следствии неконсистентному стейту.
Лучше использовать .stateIn(...) и выстраивать не прерываемые потоки.
Как пример, если бы client.fetchFoods возвращал Flow:
class FoodMenuViewModel(
val foodMenuListState: LazyListState,
private val client: FoodMenuClient = FoodMenuClient.live(),
private val dispatchers: DispatcherProvider = DefaultDispatcherProvider()
) : ViewModel() {
private val selectedCategoryId = MutableSharedFlow<String>(replay = 1)
// на Ui when по Loadable, чтобы отобразить соответствующее значение
val fetchFoodState = selectedCategoryId
.flatMapLatest { id -> client.fetchFoods(id) }
.onEach {
if (it is Loadable.Success) foodMenuListState.animateScrollToItem(0)
it.failure?.let { /* Обработка ошибки логгирование */ }
}
.stateIn(viewModelScope + dispatchers.io(), SharingStarted.Eagerly, Loadable.Idle)
fun onSelectedCategory(categoryId: String) {
selectedCategoryId.tryEmit(categoryId)
}
}
На UI по хорошему отдавать вообще только один готовый стейт, который собирается подобным образом, с использованием combine, transform, map, flatMap, fold и т.д.
Надеюсь коммент будет полезен)
Суть DI заключается в разрыве прямой связи между реализациями сущностей, что достигается прокидыванием зависимостей в объект извне самого объекта.
Разрыв связи, о котором вы пытаетесь сказать это Dependency Inversion. Суть его в том, чтобы изменить направление зависимости: Получатель зависимости зависит от ее абстракции, а не реализации.
Инъекция зависимости к этому не имеет отношения. В примере зависимость закрыта своим интерфейсом и создается за пределами объекта-приемника, с масштабируемостью тут никаких проблем нет. А вот создавать единый граф и протягивать его сквозь приложение - подход действительно плохой, по той же причине, по которой андроидеры ругаются на глобальные объекты предлагаемые SDK.
Чтобы понять и верно построить архитектуру в принципах, о которых говорит автор, нужно придерживаться нескольких правил: модуляризация всего, зависимости без состояния, дробление функционала сложных зависимостей на атомарные подзависимости в высококомпозируемых функциях, использование статического инстанцирования (все объекты, какие возможно в рамках здравого смысла, а таких должен быть минимум, создаются на статическими при старте приложения).
Большие кодовые базы UI (~1,5 млн строк) прекрасно живут с этим подходом. Попробуйте! Уверен, вам понравится простота, от которой поначалу будет казаться, что где-то должен быть подвох.
Да Dependency Inversion разворачивает зависимость.
Как пример View и Presenter. Когда Presenter зависит от View, это зависимость от центра, что является плохим решением. В таком случае применяют инверсию, создавая интерфейс для View. И этот интерфейс, по своей сути, остается частью Presenter (связан с ним), что дает нам правильно направление от View к центру.
View -> (interface View <- Presenter)
Я же имел ввиду именно Dependency Injection.
Когда класс А создает класс B внутри себя, он напрямую зависит от класса B.
Если класс А будет получать класс B в конструктор, то он все еще зависит от класса В, что уже является иньекцией, но не дает разрыва реализаций. Создание интерфейса для класса B как раз и создает разрыв, чтобы зависимость была на абстракцию. Направление зависимостей в этом смысле вообще не важно.
client: FoodMenuClient =
FoodMenuClient.live
()
И вот ключевой момент в этом подходе будет в том, что класс А, знает о месте где можно создать класс B.
Да, само создание вынесено, в функцию live, но этот live должен знать про реализацию, что является неявной транзитивной зависимостью класса A на реализацию класса B.
Чтобы полностью избавиться от такой транзитивной зависимости, создание зависимости должно быть вне класса A.
Dependency Injection призван как раз избавлять от таких транзитивных зависимостей, чтобы избавить нас от проблем в будущем.
Возможно, то о чем я говорю, кажется не важным в рамках одного модуля. Это так. Проблема возникнет в будущем, когда потребуется вынести FoodMenuClient
в отдельный модуль и дополнительно разделить его на api/impl. В этом случае придется переписывать код, чтобы избавиться от неявной транзитивной зависимости.
Вы рассуждаете по ООП-шному и как будто хотите склеить два разноплановых подхода ООП-шными принципами.
Я примерно понимаю на чем стоит ваша аргументация, поправьте если я неправ, что применение FoodMenuClient.live()
дефолтным значением аргумента плохо тем, что о существовании live имплементации становится известно модулю (не классу) и он начинает от нее зависеть. В этом нет никакой проблемы, так как в сниппетах кода из статьи конечно же не полный production-grade пример как скомпоновать зависимости. Полная картина может быть несколько сложнее, но принцип тот же. В ней не используются классовые View, Presenter, Interactor или что там еще можно придумать в качестве ООП-like архитектуры.
Любая высокоабстрактная ООП архитектура в итоге заканчивается классами с реализацией, но создает неповоротливость, большое кол-во бойлерплейта. В функциональном мире, созданию абстракций отводится меньше значения так как атомарность логики компенсирует связность, не требует сложных подходов к подмене имплементаций, инъекции зависимостей и тд. То есть формально передача зависимости аргументом функции это тоже DI, но инъекция через аннотацию всё таки на другой стороне архитектурного спектра.
При таком проектировании, всё исходит из идеи, что View - функция от State (что и представляет собой Compose). А State - кусочек иммутабельных данных без логики.
Логика с зависимостями от компонентов с версткой отделяется созданием двухэтажных компонент: FoodMenuLogicComposable + FoodMenuLayoutComposable. К этому прилагается тонкая View модель реагирующая на события и обрабатывающая их в Клиентах. Вот и вся архитектура. Под клиентами может быть масса других частей: кеши, база, неворкинг и тд и тп скрытых под своими клиентами. Компонуются плюс-минус тем же способом по принципу фрактала - множество треугольничков имеют похожую форму, но в составной объект может принимать любые формы. То есть при необходимости перекомпоновки под новые требования атомарные компоненты перекомпонуются на раз-два.
применение
FoodMenuClient.live
()
дефолтным значением аргумента плохо тем, что о существовании live имплементации становится известно модулю (не классу) и он начинает от нее зависеть.
Да, я об этом.
Так и есть, мои аргументы исходят из мира ООП. Просто в примерах из этой статьи я пока не увидел какое-то отличие в реализации от ООП подхода. Просто заменили интерфейс с реализацией, на класс с функциями, что не поменяло сути.
Хочется почитать след статьи, надеюсь там будет пример чистой ФП фичи, чтобы была видна полная разница в проектировании по сравнению с ООП подходом.
Привет. Спасибо за комментарии и интерес к моей статье!
По поводу DI в 3 и 4 частях цикла я более глубоко буду погружаться в тему DI в ФП. Там будет показан подход с closure и возможно некоторые вопросы отпадут, либо можно будет продолжить дискуссию там.
С неразрывным потоком управления стейта, и клиаентами согласен. Возможно в данном примере в отрыве от общей картины может сбивать с толку. Это было лишь пример моей точки входа в знакомство с ФП. Вообще большинство UDF архитектур, которые я видел и реализовывал сам пытаются управлять единым стейтом экрана или приложения. В финальной части статьи я так же постараюсь глубже раскрыть этот момент.
Текст очень внятный, спасибо. Теперь придирки:
Функции должны быть чистыми — для одних и тех же входных значений они должны возвращать одинаковые выходные значения. Они также не должны иметь побочных эффектов — изменять состояние вне функции, например.
Кому должны? Только на чистых функциях далеко не уедешь: рано, или поздно, захочется отписаться в лог, сохраниться в базу, или даже, свят-свят-свят, сходить в соседний микросервис.
повторное использование кода в ООП стало возможно благодаря наследованию и полиморфизму.
Полиморфизм — не прерогатива ООП. Более того, полиморфизм прекрасно и гораздо более внятно реализуется в функциональной парадигме.
Добрый день, спасибо за комментарии!
По полиморфизму - хорошее замечание, но я вроде не писал, что полиморфизм это прерогатива ООП. Вы правы, он отлично себя показывает, как минимум в функциях с обобщенными типами. Я не пытаюсь в данном предложении противопоставить ООП и ФП по использованию полиморфизма.
Тут, как говорится инструмент хорош, чтобы быть полезным в обеих парадигмах. И это не мешает написать о том, что благодаря нему в ООП удается добиться повторного использования кода. По чистоте функций, согласен формулировка должны звучит агрессивно, возможно стоило написать «должны стремится к чистоте». От побочных эффектов никто не застрахован. Пример, который вы привели, на мой взгляд частично перекликается с другими комментариями. Видимо эта тема наиболее интересна сообществу :) не хочется постоянно, делать отписки в стиле об этом будет в следующей части цикла. Но в эту часть статьи это к сожалению никак не уместилось. В третьей части у меня как раз есть именно такой пример: с записью в логи/аналитику и походами в фейковую базу данных. В Kotlin в контексте ФП, тяжело придумать что-то кроме Closure (если дело касается DI) и Launch/Disposable Effect (если речь идет о UI слое). В документации библиотеки Arrow есть примеры минимизации влияния побочных эффектов. Так же если вы знакомы с другими языками и вдруг как-то можете дополнить в комментариях по этой части со своим взглядом - буду очень признателен!
Дополнить что?
Я не особый поклонник академического программирования, чистота функций ради чистоты функций — скорее вредит, нежели помогает. Event log, например, невозможен с чистыми функциями (более того, нарушается даже идемпотентность, причем by design, и это очень помогает как в отладке, так и во всякого рода телеметрии).
В Kotlin в контексте ФП, тяжело придумать что-то кроме […]
Я не считаю, что парадигмам может помогать (или вредить) какой-то из современных языков. На джаве можно писать в абсолютно функциональной парадигме (см. первые реализации всяких хадупов и спарков), да даже на идрисе — можно успешно эмулировать ООП.
Классы питона, имплицитно принимающие self
в качестве первого аргумента — это ФП в чистом виде. И так далее.
Функциональное программирование в Android. Знакомство с парадигмой