28 июля в мире Android произошло важное событие: анонсировали Jetpack Compose 1.0. Вместе с этим нововведением места для ключевого слова class
стало ещё меньше. Kotlin поддерживает парадигму функционального программирования (ФП), и разработчики Google этим умело пользуются.
Часто объектно-ориентированный подход (ООП) ставят в противовес ФП. Это ошибка: они не соперники и могут друг друга дополнить. Одно из понятий ФП — каррирование функций. Оно позволяет произвести частичное применение в ожидании данных, минимизируя количество ошибок в коде: это может пригодиться при разработке в Compose и не только :)
Давайте посмотрим, что такое каррирование и как его можно использовать на практике.
Несколько слов об аргументах функции
Для ООП-мира привычно думать, что функции могут иметь несколько аргументов. На самом деле это не так. Функция описывает связь между исходным (областью определения) и конечным множеством данных (областью значений). Она не связывает несколько исходных множеств с конечным множеством.
Таким образом, функция не может иметь несколько аргументов. По сути, аргументы — это множество:
// Привычный вид функции
fun f(a: Int, b: Int) = a + b
// Скрытый вид функции
fun f(vararg a: Int) = a.first() + a.last()
Следуя соглашению, лишний код опущен. Но всё же это функция одного аргумента, а не двух.
Что такое каррирование функций
Итак, теперь мы знаем, что аргумент один — массив данных (кортеж). Тогда функцию f(a, b)
можно рассматривать как множество всех функций на N от множества всех функций на N. Это выглядело бы так: f(a)(b)
. В таком случае можно записать: f(a) = f2; f2(b) = a + b
.
Когда применяется функция f(a)
, аргумент a
перестаёт быть переменной и превращается в константу для функции f2(b)
. Результат выполнения f(a)
— функция, которую можно выполнить отложенно.
Преобразование функции в вид f(a)(b)
и называется каррированием — в честь математика Хаскелла Карри. Хотя он это преобразование и не изобретал :)
// Пример каррированой функции с применением fun
fun f(a: Int) = { b: Int -> a + b }
// Пример каррированой функции с применением переменной
val f2: (Int) -> (Int) -> Int = { b -> { a -> a + b } }
Jetpack Compose и частичное применение функций
Каррирование позволяет разбить функцию на несколько блоков и выполнить их отложенно по мере надобности. Для этого подхода существует термин — «частичное применение».
Приведу практический пример частичного применения в Jetpack Compose. Добавим простой BottomNavigation с логикой переключения в приложение, любезно сгенеренное нам Android Studio.
CurryingTheme {
var currentTab by remember { mutableStateOf(0) }
Scaffold(
bottomBar = {
BottomNavigation {
BottomNavigationItem(
selected = currentTab == 0,
icon = { Icon(Icons.Filled.Person, null) },
onClick = {
currentTab = 0
}
)
BottomNavigationItem(
selected = currentTab == 1,
icon = { Icon(Icons.Filled.Phone, null) },
onClick = {
currentTab = 1
}
)
}
}
) {
Greeting("Android")
}
}
Подправим Greeting для переключения текста в зависимости от состояния BottomNavigation.
when (currentTab) {
0 -> Greeting("Selected - Person")
1 -> Greeting("Selected - Phone")
}
Это работает, но подобный подход чреват багами: использование случайных констант принесёт путаницу в них. Давайте заменим произвольные константы на sealed class
, который поможет нам избежать возможных проблем.
sealed class BottomNavigationTab {
object Person : BottomNavigationTab()
object Phone : BottomNavigationTab()
}
Выглядит намного надёжней. Добавим ещё небольшую фичу — вывод Snackbar при переключении. В данном случае он хорошо подчёркивает важность упрощения onClick
и карринга в дальнейшем.
CurryingTheme {
val scope = rememberCoroutineScope()
val scaffoldState: ScaffoldState = rememberScaffoldState()
var currentTab: BottomNavigationTab by remember { mutableStateOf(BottomNavigationTab.Person) }
Scaffold(
scaffoldState = scaffoldState,
bottomBar = {
BottomNavigation {
BottomNavigationItem(
selected = currentTab == BottomNavigationTab.Person,
icon = { Icon(Icons.Filled.Person, null) },
onClick = {
currentTab = BottomNavigationTab.Person
scope.launch {
scaffoldState.snackbarHostState.showSnackbar("Selected - ${currentTab::class.simpleName}")
}
}
)
BottomNavigationItem(
selected = currentTab == BottomNavigationTab.Phone,
icon = { Icon(Icons.Filled.Phone, null) },
onClick = {
currentTab = BottomNavigationTab.Phone
scope.launch {
scaffoldState.snackbarHostState.showSnackbar("Selected - ${currentTab::class.simpleName}")
}
}
)
}
}
) {
when (currentTab) {
BottomNavigationTab.Person -> Greeting("Selected - Person")
BottomNavigationTab.Phone -> Greeting("Selected - Phone")
}
}
}
Допустим, BottomNavigationItem будет далеко не один. onClick
выглядит удручающе — много дублирующего кода. Это можно легко подправить, вынеся код во вложенную функцию.
fun onClick(tab: BottomNavigationTab) {
currentTab = tab
scope.launch {
scaffoldState.snackbarHostState.showSnackbar("Selected - ${currentTab::class.simpleName}")
}
}
С применением этой функции onClick
будет выглядеть следующим образом:
BottomNavigationItem(
selected = currentTab == BottomNavigationTab.Person,
icon = { Icon(Icons.Filled.Person, null) },
onClick = {
onClick(BottomNavigationTab.Person)
}
)
BottomNavigationItem(
selected = currentTab == BottomNavigationTab.Phone,
icon = { Icon(Icons.Filled.Phone, null) },
onClick = {
onClick(BottomNavigationTab.Phone)
}
)
Это рабочий вариант, но карринг с помощью частичного применения поможет ещё больше упростить onClick
.
// каррированая функция
fun onClick(tab: BottomNavigationTab): () -> Unit = {
currentTab = tab
scope.launch {
scaffoldState.snackbarHostState.showSnackbar("Selected - ${currentTab::class.simpleName}")
}
}
// частичное применение
BottomNavigationItem(
selected = currentTab == BottomNavigationTab.Person,
icon = { Icon(Icons.Filled.Person, null) },
onClick = onClick(BottomNavigationTab.Person)
)
BottomNavigationItem(
selected = currentTab == BottomNavigationTab.Phone,
icon = { Icon(Icons.Filled.Phone, null) },
onClick = onClick(BottomNavigationTab.Phone)
)
Мы рассмотрели один из примеров применения каррированной функции в Jetpack Compose, но цели данного преобразования значительно шире.
Каррирование широко используется в языках программирования, поддерживающих функциональную парадигму. Все языки, поддерживающие замыкание, позволяют записывать каррированные функции: например, JavaScript, C#, Kotlin, Haskell. Имеет смысл освоить этот приём, чтобы минимизировать баги в коде, упростить его для понимания, сократить количество строк, обогатить ООП-код. Удачи!
Проект доступен на GitHub
Комментарии (6)
Beholder
05.10.2021 12:41Что-то вы туману напустили, мягко говоря. Нет тут никакого каррирования. Это всего лишь "extract function".
Каррирование — преобразование функции от многих аргументов в набор функций, каждая из которых является функцией от одного аргумента.
Где тут оно?
Вариант когда функция возвращает
() -> Unit
сомнителен, так как никакой экономии не тут добиться, а смысл кода может быть не очевиден.sealed class тоже ни к чему, можно обойтись просто enum.
mayorovp
05.10.2021 13:26Итак, теперь мы знаем, что аргумент один — массив данных (кортеж). Тогда функцию f(a, b) можно рассматривать как множество всех функций на N от множества всех функций на N. Это выглядело бы так: f(a)(b). В таком случае можно записать: f(a) = f2; f2(b) = a + b.
Не вижу связи.
Типы функций
a -> (b -> c)
и(a, b) -> c
ни разу не эквивалентны. Я бы даже сказал, каррированный вид функции и функция от кортежа — в некотором смысле противоположны друг другу.MentalBlood
05.10.2021 14:29Да там вообще каша какая-то
- Сначала сказали что аргумент один — множество аргументов, т.е. (a, b), окей
- Потом из неоткуда достали "множество всех функций от множества всех функций",
- раздробили "единственный" аргумент и
- сказали, что f(a, b) ~ f(a)(b) (полная чушь)
Можно ж было написать что-то вроде f(a, b) ~ g(a)(b), где g — такая-растакая, к чему этот мудреж с множествами функций? Мозг перекашивает при чтении
MentalBlood
Ну зачем же так жестко, вы же просто другое определение дали, что множество аргументов — это и есть единственный аргумент (звучит странно, не правда ли?)
RuslanShirkhanov
Думаю, автор не подменял определения, а говорил об этом же подходе, но с другой стороны. Например, у нас есть функция с сигнатурой
f : {a : Type} -> (a, a) -> a
. Она принимает кортеж(a, a)
, который является лишь одним аргументом, ведь это всего-то терм типа(a, a)
. И если вместо него там будет гетерогенный список или любая другая структура данных, все равно ничто не изменится. Таким образом, обычно мы используем две операции, одна из которых отображает тип(a, a)
в типa -> a
, а вторая делает обратное.