О чём статья

Сегодня говорим об одном из принципов функционального программирования — чистых функциях. Познакомимся с сопутствующими терминами, раскрывающими суть принципа, и обсудим место концепции Dependencies Injection в функциональном программировании.

«Стоп, стоп, стоп. Функциональное программирование? Что?» — если вы задались этим вопросом, значит, ещё не читали предыдущие статьи моего цикла о функциональщине в Android. Прикладываю ссылки:

  1. Функциональное программирование в Android. Знакомство с парадигмой;

  2. Функциональное программирование в Android. Структуры данных и State Machine.

А мы переходим к теме статьи. Начнём с теории категорий.

Теория категорий — это не то, чего стоит бояться

Для понимания чистых функций важна теория категорий. Непосвящённому человеку она покажется сложной и напичканной алгеброй. Я делал несколько подходов к адаптированной для программистов версии книги «Теория категорий» Бартоша Милевски. Освоил я её как раз перед написанием этой статьи и рекомендую тем, кто хочет погрузиться в мир функционального программирования.

В качестве примеров чисто функционального кода автор использует язык Haskell и императивный язык для проведения аналогий C++. Так что перед прочтением книги стоит хотя бы поверхностно ознакомиться с синтаксисом.

К счастью, я успел повзаимодействовать с обоими языками. В этой статье я постараюсь адаптировать некоторые части книги на язык Kotlin и приведу несколько примеров, которые помогут понять суть особенно сложных терминов.

Тут важно напомнить, что я не матёрый функциональщик. Так что если вы эксперт в этой области и считаете, что какие-то вещи я интерпретировал не так, поправляйте меня в комментариях. Буду рад.

Ключевую роль в теории категории играют два понятия: категория и морфизм. Категория состоит из объектов и морфизмов.

Морфизмы — это переходы между объектами. В функциональном программировании их ещё называют стрелками.

Наиболее лаконичная визуализация категории — граф. Его вершины можно рассматривать как объекты, а рёбра — как морфизмы:

Граф
Граф

В категории, если есть стрелка от A к B и стрелка от B к C, то должна быть стрелка от A до C, которая называется их композицией. В математике композиция обозначается символом ◦.

Граф с возможной композицией
Граф с возможной композицией

Чтобы стать полным определением категории, этой схеме не хватает тождественных морфизмов. Да и в целом не любой граф можно назвать категорией. Для этого должны быть выполнены некоторые свойства:

  1. Ассоциативность композиции. Если у вас есть три морфизма f, g, h, которые можно последовательно комбинировать, то их порядок выполнения не требует скобок. Результат будет одинаковым в любом случае:

    h ◦ (g ◦ f) = (h ◦ g) ◦ f = h ◦ g ◦f
  2. Для каждого объекта A категории есть морфизм единичной композиции. Это морфизм от объекта к самому себе. Другими словами, при композиции единицы с любой стрелкой, которая начинается или заканчивается на A, композиция возвращает ту же стрелку. Единичная стрелка объекта A обозначается id_a (тождественность на A).

f◦id_a = f, f◦id_b = f

А зачем вообще кому-то возиться с тождественной функцией, которая ничего не делает? Вспомним пустые контейнеры — структуры данных, которые самих данных не содержат: пустые списки и массивы.

На первый взгляд, они бесполезны, но именно с их помощью разработчики инициализируют структуры данных, которые заполняются позже. Например, в некоторых алгоритмах пустые контейнеры нужны, чтобы хранить данные в будущем, даже если в конкретный момент они пусты. Такая же история и с тождественной функцией.

Возвращаясь к нашему графу — тождественным морфизмом будет ребро из вершины в саму себя или так называемая петля:

Петли в графах
Петли в графах

Теперь наш граф можно смело называть категорией. Давайте реализуем тождественную функцию и функцию композиции на Kotlin, а также проверим, что наша функция учитывает идентичность:

fun <T> id(value: T): T {
    return value
}

fun <A> identity(): (A) -> A {
    return { it }
}

fun <A, B, C> compose(f: (B) -> C, g: (A) -> B): (A) -> C {
    return { a: A -> f(g(a)) }
}
test("Compose identity test") {
        // Определяем тождественную функцию
        val id = identity<Int>()
        // Пример функции, которая увеличивает число на 1
        val addOne: (Int) -> Int = { x -> x + 1 }
        // Композиция с тождественной функцией
        val composedWithId = compose(id, addOne)

        val testValue = 5
        val actual = composedWithId(testValue)

        val expected = addOne(testValue)
        actual shouldBe expected
}
Результат выполнения теста, проверяющего тождественность
Результат выполнения теста, проверяющего тождественность

Процесс «составления» одной функции из другой похож на то, как мы комбинируем разные действия. Представим, что у нас два действия: добавить к числу 1 и умножить результат на 2. Выполняя их в таком порядке, мы получим тот же результат, что и при использовании комбинации, совершающей два действия одновременно.

Если мы добавим ещё одну тождественную функцию — она возвращает число без изменений —, то в комбинации с любой другой функцией результат останется прежним. То есть тождественная функция «не влияет» на результат, когда мы комбинируем её с другими функциями. Так при взаимодействии функций друг с другом некоторые из них могут оставаться «невидимыми» в процессе, сохраняя исходные значения.

Почему свойство чистоты функций так важно в функциональном программировании

В математике функция — это просто соответствие между входными и выходными значениями, тогда как в программировании функции могут иметь побочные эффекты и состояние.

На языке программирования можно реализовать математическую функцию: имея входное значение, она рассчитает выходное. Функция вычисления площади круга, вероятно, будет умножать входное значение само на себя и на число π при каждом вызове. Результат будет одинаковый каждый раз, пока функция вызывается с одним и тем же аргументом:

S_{circle}=\pi*r^2

Кроме того, вычисление квадрата числа не должно иметь побочного эффекта (Side effect). Представьте, что у нас есть другая версия этой функции, которая, помимо вычисления площади, также решает, что пора полить цветы на подоконнике.

Это полезно для растений, но неуместно, если вы вычисляете площадь круга для математических расчётов. Если вы будете использовать эту функцию в своём проекте, вам придётся следить и за растениями — тут уж всякое может быть. Например, они будут требовать столько воды, что ваши расходы на коммуналку увеличатся кратно.

Функция, которая вычисляет площадь круга, должна быть чистой и не иметь никаких побочных эффектов, чтобы не создавать лишних забот. В противном случае вы получите требующую внимания экосистему, а не простой математический расчёт.

Как добиться чистоты в Android-разработке? Ограничить себя чистым подмножеством или размышлять о побочных эффектах отдельно. В следующей части мы поговорим о том, как с помощью монады моделировать различные виды эффектов, используя чистые функции.

Мемоизация — «кэш» для функций

Хороший пример для обсуждения чистых функций — работа с кэшем. В функциональном программировании тоже есть своего рода кэш — мемоизация или запоминающаяся функция.

Для примера создадим простую функцию, которая возвращает значение с определённой задержкой:

fun expensive(x: Int): Int {
    Thread.sleep(x * 500L)
    return x
}

Для демонстрации работы мемоизации воспользуемся библиотечной реализацией из Arrow:

import arrow.core.memoize

val memoizedExpensive = ::expensive.memoize()

fun main() {
    println(expensive(5))
    println(expensive(5))
    println(memoizedExpensive(5))
    println(memoizedExpensive(5))
}

Если мы запустим этот код, то увидим, что вызов метода без запоминающейся функции всегда выполняется с задержкой. Но если мы вызываем memoize, каждый последующий запуск функции возвращает результат мгновенно, несмотря на задержку.

Демонстрация работы memoize()
Демонстрация работы memoize()

Если мы заглянем внутрь функции memorize, то увидим AtomicRef и Map. Они и выполняют роль кэша. Описанный метод можно применить к любой функции, независимо от её происхождения. За кэш придётся заплатить расходом памяти и полной неспособностью метода работать с рекурсиями.

Решаем возникшие проблемы. Для работы с рекурсиями библиотека предлагает DeepRecursiveFunction решение. Для оптимизации работы кэша используем различные кэш-стратегии: LIFO, FIFO, MRU, LFU, TTL. Их вы можете реализовать самостоятельно, а статью о них найти в документации Arrow или вот тут.

Механизмы мемоизации нужно использовать осторожно и только в подходящих местах, особенно в случае с Android-разработкой. Запоминаем мемоизацию и возвращаемся к теме чистых функций.

Представим, что мы работаем с алгоритмом, основанном на генерации случайных чисел:

fun generateRandomWithRange(range: IntRange): Int {
    return expensive(range.random())
}

val memoizedRandomWithRangeExpensive = ::generateRandomWithRange.memoize()

fun main() {
    println(memoizedRandomWithRangeExpensive(0..10))
    println(memoizedRandomWithRangeExpensive(0..10))
}
memoize() и генератор случайных чисел
memoize() и генератор случайных чисел

Сколько бы раз мы не запускали код мемоизированной версии, результат будет совпадать с предыдущим. А от генератора случайных чисел мы вообще-то ожидаем рандомный результат при каждом новом вызове.

Существуют несколько подходов для решения этой задачи. Мы можем использовать версию генератора, инициализированного начальным значением. Принцип работы такого генератора предполагает возвращение идентичного случайного числа при одинаковых инициализирующих значениях или так называемый генератор псевдослучайных чисел.

Во-вторых, можно передавать состояние генератора в аргумент функции, абстрагируясь от глобального состояния, или использовать монаду.

Запоминающие функции работают только с чистыми функциями, которые не имеют побочных эффектов и всегда возвращают один и тот же результат для одинаковых вводных. «Грязные» функции, скорее, не подходят для запоминания — их поведение зависит от состояния, которое может измениться.

Классификация категории — Порядки

Рассмотрим категории, в которой морфизмы — это отношения между объектами: меньше или равно. Как мы помним, в категории должны быть единичные морфизмы, а композиция— ассоциативна.

Если a\leq b и b \leq c, то a \leq c. Единичные морфизмы есть, а каждый объект меньше или равен самому себе — оба свойства выполнены. Множество с таким отношением называется предпорядком.

Можно ввести более строгое условие зависимости объектов: еслиa\leq b  и b, то a должен быть таким же, как и b. Это называется частичным порядком.

Если в категории учесть оба условия, мы получим связность объектов друг с другом. Такую категорию можно считать полным порядком.

Классификация порядков
Классификация порядков

А для чего эти понятия нужны в программировании? Приведу несколько примеров:

  1. Понимание порядков помогает в проектировании некоторых структур данных: деревьев и граф. Например, бинарные деревья поиска используют частичный порядок для организации элементов, а это помогает более эффективно выполнять операции поиска и вставки.

  2. Концепции частичных и полных порядков могут быть использованы в языках программирования с поддержкой типов для определения их иерархий. Так мы можем создавать более абстрактные и гибкие системы типов, в которых можно легко управлять зависимостями между ними.

  3. Верификация программного обеспечения использует частичные порядки для формализации свойств программ. Так мы можем использовать теоретические методы для доказательства корректности программ на основе их структурных свойств. Об этом, кстати, я рассказывал в одной из предыдущих статей о функциональном программировании.

Большинство сортировок — Quick, Bubble, Merge и т.д. — корректно работают только на полных порядках. Это логично: полный порядок предполагает однозначное отношение для любых двух элементов. В противном случае логика перестановки элементов сортировки не сможет определить, в какой последовательности их располагать. Для корректной сортировки частичного порядка можно использовать Topological sort.

Моноид — популярная концепция в программировании

В функциональном программировании есть такая концепция, как моноид (не путайте с монадой). Сейчас расскажу, что это и как отражается на наших повседневных задачах.

В теории категорий моноид — структура, состоящая из объекта и двух морфизмов, которые удовлетворяют определённым свойствам. Моноид можно описать как тройку (M, \mu, \eta), где:

M- объект;

\mu: M\otimes M \to M— бинарная операция (морфизм), объединяющая два элемента;

\eta: I \to M — морфизм, представляющий нейтральный элемент;

I— единичный объект в категории.

Свойства моноида:

  1. Замкнутость: для любых двух элементов a и b из M результат операции (a, b) также принадлежит M.

  2. Ассоциативность: операция ассоциативна, то есть \mu(\mu(a, b), c) = \mu(a, \mu(b,c)) для любых a,b,c , принадлежащих M.

  3. Существует нейтральный элемент.

Сложно получилось... Давайте проще: моноид можно представить как набор объектов. Их можно комбинировать с помощью определённой операции. Она работает так, что:

  • вы всегда получаете результат, который принадлежит этому набору (замкнутость);

  • порядок, в котором вы комбинируете объекты, не имеет значения (ассоциативность);

  • есть специальный объект, которые не меняет другие объекты при комбинации (нейтральный элемент).

Мы получили определение ближе к теории множеств. Однако теория категорий стремится мыслить не множествами и элементами, а объектами и морфизмами. Так что бинарный оператор мы воспринимаем как сдвиг или перестановку элементов в множестве. Вернёмся к роли моноидов в выполнении наших повседневных задач. Реализуем конкатенацию строк, но посмотрим на неё под другим углом:

class ConcatMonoid {
    // Нейтральный элемент
    val identity: String = ""

    // Операция конкатенации
    fun concat(a: String, b: String): String {
        return a + b
    }
}

Конкатенация строк всегда возвращает строку. Если у нас две строки a и b, то результат операции a + b тоже будет строкой, а значит мы и после её применения остаёмся в рамках нашего множества строк.

Конкатенация строк — ассоциативная операция. Значит, порядок её выполнения не влияет на конечный результат.

Нейтральный элемент для операции конкатенации — пустая строка "". Конкатенация любой строки с пустой строкой вернёт ту же строку. Это свойство гарантирует существование пустой строки, которая не изменяет другие строки при конкатенации.

Напишем тест, чтобы убедиться в правильности наших рассуждений:

val monoid = ConcatMonoid()
test("Monoid concat associativity test") {
  val a = "Knowledge"
  val b = "is"
  val c = "power"
  monoid.concat(a, monoid.identity) shouldBe a
  monoid.concat(monoid.identity, a) shouldBe a
  monoid.concat(monoid.concat(a, b), c) shouldBe
    monoid.concat(a, monoid.concat(b, c))
}
Результат выполнения теста ассоциативности конкатенации
Результат выполнения теста ассоциативности конкатенации

Это один из элементарнейших примеров моноида. В чисто функциональных программах используются более сложные моноиды.

Однако для понимания общей картины нам хватит и нашего примера. Если вы хотите потренироваться и интерпретировать привычные операции в виде монад, оставлю список тех, что вы можете реализовать самостоятельно:

  • логическое И/ИЛИ;

  • арифметическое сложение/умножение;

  • добавление элемента в список;

  • операция минимум, максимум;

  • слияние двух деревьев;

  • объединение множеств.

Объект инициальный и терминальный. Отношение изоморфизма

Инициальный объект в теории категорий — отправная точка для других объектов. Если он есть, то мы можем провести морфизм или единственный путь от него к любому другому объекту в нашей категории.

Терминальный объект в теории категорий — конечная точка для других объектов. Если он есть, то мы можем провести морфизм из любого другого объекта в нашей категории к этому терминальному объекту.

Это два противоположных друг другу понятия. Чтобы лучше в них разобраться, можно провести следующую аналогию: инициальный объект — это пустая корзина, из которой можно добавить товары (морфизмы) в любую другую корзину или список покупок. То есть из пустой корзины можно провести единственный путь к любой другой корзине, когда пользователь начинает добавлять товары.

Инициальный объект
Инициальный объект

Представим завершённый заказ. Этот объект будет терминальным, потому что из любого товара в корзине можно провести единственный путь к заказу, когда пользователь завершает покупку. После оформления заказа возвращаться к товарам в корзине не нужно — заказ завершён, это конечный результат процесса.

Терминальный объект
Терминальный объект

В программировании не так просто определить равенство. Существует ссылочное сравнение, equals и hashCode. В математике существуют более слабые понятия отношений — изоморфизм и эквивалентность. Остановимся на первом.

Изоморфизм описывает взаимно однозначное соответствие между двумя объектами, которое сохраняет их структуру. Если два объекта изоморфны, то они одинаковы, даже если выглядят по-разному. Например, два автомобиля одной модели изоморфны. Да, у них могут быть разные цвета, детали и т.д., но это одно транспортное средство.

Вернёмся к примеру с корзиной и заказом. Можно сказать, что пустая корзина — это множество, состоящее из нуля элементов. Если у нас есть две пустые корзины, мы можем установить между ними изоморфизм, поскольку обе корзины не содержат товаров и имеют одинаковую структуру.

Завершённый заказ — это множество, содержащее информацию о заказанных товарах. Если у нас есть два завершённых заказа с одинаковыми товарами и информацией о них, то мы можем установить и изоморфизм между этими двумя заказами — у них одинаковая структура и содержимое.

Два объекта с одинаковой структурой — изоморфные. Да, даже если физически это два разных объекта.

Произведения — вернёмся немного назад

Следующая универсальная конструкция — произведение двух объектов. В теории категорий это способ объединить объекты так, чтобы можно было «собрать» информацию из двух сразу. Если у вас есть пара объектов A и B, то их произведение A \times B позволит создать новый объект со всеми возможными парами, состоящими из элементов A и B.

Например, если A — это множество людей, а B — множество автомобилей, то их произведение может содержать все возможные пары «человек-автомобиль».

Копроизведение двух объектов — это способ объединить их так, чтобы информация из каждого объекта распределялась в новый объект. Если у вас есть объекты A и B, то их копроизведение A \sqcup B позволяет создать новый объект, включающий все элементы из A и все элементы из B.

Например, если A — это множество фруктов, а B — множество овощей, то их копроизведение будет содержать все возможные пары всех фруктов и всех овощей вместе.

В теории категорий произведение и копроизведение определяются через свои универсальные свойства. Если в категории существуют произведение и копроизведение для некоторого набора объектов, то они единственны с точностью до изоморфизма. Это означает, что любые два произведения или копроизведения одних и тех же объектов изоморфны между собой, что подчёркивает их каноничность.

В функциональном программировании концепции произведения и копроизведения из теории категорий применяют в создании сложных типов данных и в управлении их взаимодействием. Один из примеров применения копроизведения — Either. В Kotlin для этого нужно немного потанцевать с бубном или обратиться к библиотеке Arrow. Последнее мы уже делали в предыдущей статье.

Функторы

Ещё одна важная концепция — функторы. Это математическая структура, которая преобразует объекты и морфизмы одной категории в объекты и морфизмы другой категории с сохранением структуры. Например, функтор может преобразовывать типы данных и функции, и даёт возможность работать с ними в более абстрактной форме.

Визуализация функтора
Визуализация функтора

В программировании необходимо ввести функцию map(), чтобы тип данных стал функтором. Для многих коллекций в Kotlin эта функция определена по умолчанию:

// Контейнер для хлеба
class BreadLoaf(val bread: String) {
    // Функтор - преобразуем булку в ломтики
    fun slice(): List<String> = bread.split("").filter { it.isNotBlank() }.map { "?" }
}

fun main() {
    val loaf = BreadLoaf("Хлебная булка")
    println(loaf.slice()) // [?, ?, ?, ?, ?, ...]
}

Почему это функтор? Во-первых, исходный объект (BreadLoaf) не изменяется. Во-вторых, результат (List<BreadSlice>) — новый «контейнер».

Эндофунктор — частный случай функтора. Он отображает объекты и морфизмы в самих себе.

Визуализация эндофунктора
Визуализация эндофунктора
class PotatoBox(val potatoes: List<String>) {
    // Эндофунктор - остаёмся в том же типе (List<String>)
    fun wash(): PotatoBox = PotatoBox(potatoes.map { "$it(чистая)" })
}

fun main() {
    val box = PotatoBox(listOf("?", "?", "?"))
    println(box.wash().potatoes) // [?(чистая), ?(чистая), ?(чистая)]
}

Почему это эндофунктор? Во-первых, вход и выход — один и тот же тип (PotatoBox). Во-вторых, он сохраняет структуру: клубни картофеля остаются в коробке, просто становятся чистыми.

Бифункторы используются для работы с двумя разными типами данных одновременно. В функциональном программировании это может быть полезно, когда нужно обрабатывать пары значений.

Визуализация бифунктора
Визуализация бифунктора
class FruitMix<P, L>(val pineapple: P, val lemon: L) {
    // Бифунктор - преобразуем оба компонента
    fun <R1, R2> bimap(
        pineappleTransform: (P) -> R1,
        lemonTransform: (L) -> R2
    ): FruitMix<R1, R2> = FruitMix(pineappleTransform(pineapple), lemonTransform(lemon))
}

fun main() {
    val mix = FruitMix("?", "?")
    
    val cocktail = mix.bimap(
        { "Кусочки $it" },  // Преобразуем ананас
        { "Сок $it" }       // Преобразуем лимон
    )
    
    println("${cocktail.pineapple} + ${cocktail.lemon} = ?") 
    // "Кусочки ? + Сок ? = ?"
}

Почему это бифунктор? Во-первых, он может независимо преобразовывать оба типа в контейнере. Во-вторых, он не меняет структуру. Результат всё ещё FruitMix, но с новыми типами.

В этом примере методы преобразования работают так, что меняется всего одна строка внутри контейнера — это упрощение. В действительности методы map и bimap выглядят сложнее — в них можно поместить и кастомную логику преобразования.

Частичное применение и каррирование

В мире функционального программирования функторы дают работать с вычислениями в контексте и безопасно применять функции к «упакованным» значениям. А что если мы хотим не только преобразовывать данные внутри контейнера, но и гибко настраивать поведение функций, фиксируя часть аргументов заранее? Тут нам понадобится частичное применение и каррирование — две концепции, которые превращают обычные функции в модульные строительные блоки, готовые к композиции и повторному использованию.

С математической точки зрения частичное применение:

f(x,y,z) \to fₓ(y,z), где fₓ = f(x,•,•)

f₃(b) = f(3,b) = 3 + b

Если функторы учат нас «применять функцию к значению в контексте», то частичное применение отвечает на вопрос: «а что если саму функцию можно приостановить, зафиксировав лишь некоторые её аргументы?» Это как если бы повар, вместо того чтобы каждый раз заново выбирать ножи и ингредиенты, мог подготовить несколько специализированных версий своего блюда.

Вернёмся к программированию. Давайте напишем функцию приготовления блюда:

fun cookDish(
    prepTime: Int,
    cookTime: Int,
    ingredients: List<String>,
    equipment: List<String>,
    chefName: String
): String {
    val totalTime = prepTime + cookTime
    val ingredientsStr = ingredients.joinToString(", ")
    val equipmentStr = equipment.joinToString(", ")
    return "$chefName готовит блюдо из $ingredientsStr используя $equipmentStr. Общее время: $totalTime минут"
}

Чтобы выполнить частичное применение в библиотеке Arrow, необходимо обратиться к ссылке на методы и вызвать от неё метод partiallyN. Так мы зафиксируем соответствующий аргумент этой функции:

fun main() {
    val cookForChef = ::cookDish.partially5("Константин")
    val cookWithEquipment = cookForChef.partially4(listOf("сковорода", "нож"))
    val cookWithIngredients = cookWithEquipment.partially3(listOf("лук", "чеснок", "помидоры"))
    val cookQuickDish = cookWithIngredients.partially1(15)
    val cookSlowDish = cookWithIngredients.partially1(60)
    println(cookQuickDish(30))
    println(cookSlowDish(120))
    //Константин готовит блюдо из лук, чеснок, помидоры используя сковорода, нож. Общее время: 45 минут
    //Константин готовит блюдо из лук, чеснок, помидоры используя сковорода, нож. Общее время: 180 минут
}

В императивной реализации похожей гибкости можно добиться через создание классов-конфигураций и параметров по умолчанию:

class DishCooker(
    private val chefName: String,
    private val defaultIngredients: List<String>,
    private val defaultEquipment: List<String>
) {
    fun cookDish(
        prepTime: Int,
        cookTime: Int,
        ingredients: List<String> = defaultIngredients,
        equipment: List<String> = defaultEquipment
    ): String {
        val totalTime = prepTime + cookTime
        val ingredientsStr = ingredients.joinToString(", ")
        val equipmentStr = equipment.joinToString(", ")
        return "$chefName готовит блюдо из $ingredientsStr, используя $equipmentStr. Общее время: $totalTime минут"
    }
}

fun main() {
    val antonCooker = DishCooker(
        chefName = "Антон",
        defaultIngredients = listOf("лук", "чеснок", "помидоры"),
        defaultEquipment = listOf("сковорода", "нож")
    )

    // Варианты приготовления:
    println(antonCooker.cookDish(prepTime = 15, cookTime = 30)) 
    // быстрый вариант: Антон готовит блюдо из лук, чеснок, помидоры используя сковорода, нож. Общее время: 45 минут
    println(antonCooker.cookDish(prepTime = 60, cookTime = 120)) 
    // медленный вариант: Антон готовит блюдо из лук, чеснок, помидоры используя сковорода, нож. Общее время: 180 минут

    // Можно переопределить ингредиенты для конкретного случая
    println(antonCooker.cookDish(
        prepTime = 20,
        cookTime = 40,
        ingredients = listOf("картофель", "грибы")
    ))
    // Антон готовит блюдо из картофель, грибы, используя сковорода, нож. Общее время: 60 минут
}

Функциональный подход с частичным применением убирает лишние сущности — классы и промежуточные объекты — и позволяет комбинировать поведение через простые правила.

Каррирование с точки зрения математики:

plus : (Int, Int) → Int;

plus(x,y) = x + y - исходная функция;

curry(plus) : Int → (Int → Int);

curry(plus)(x)(y) = x + y - после каррирования.

Императивный стиль: «съешь готовую котлетку»
Императивный стиль: «съешь готовую котлетку»
Стиль функционального программирования: «держи частично применённый фарш, ещё не применённое яйцо, а лук — опционально. Каррируй сам
Стиль функционального программирования: «держи частично применённый фарш, ещё не применённое яйцо, а лук — опционально. Каррируй сам

Возьмём, например, функторы List и Option. Они реализуют map, позволяя применять функцию ко всем элементам контейнера.

А если функция требует не одного, а нескольких аргументов, как в примере с приготовлением блюда? Функтор тут бессилен — он не «запоминает» часть аргументов для отложенного выполнения.

Тут на помощь и приходит каррирование. Оно позволяет превратить функцию от многих аргументов в цепочку функций от одного аргумента:

val curriedDish =  ::cookDish.curried() 
// (prepTime: Int) -> (cookTime: Int) -> (ingredients: List<String>) -> (equipment: List<String>) -> (chefName: String) -> String

Теперь мы можем фиксировать аргументы шаг за шагом, создавая специализированные версии:

val quickRecipe = curriedCook(15) // фиксируем prepTime=15  
val quickPasta = quickRecipe(30)(listOf("паста", "соус")) // "Общее время: 45 минут" 

Это похоже на map, но работает на уровне аргументов функции, а не элементов контейнера. Пойдём ещё дальше и представим, что у нас есть несколько поваров, и мы хотим легко создавать комбинации:

fun main() {
    val chefs = listOf("Антон", "Мария", "Иван")
    val ingredientsPresets = mapOf(
        "italian" to listOf("паста", "соус", "сыр"),
        "russian" to listOf("картофель", "грибы", "сметана")
    )
    val equipmentPresets = mapOf(
        "basic" to listOf("сковорода", "нож"),
        "pro" to listOf("мультиварка", "блендер", "точные весы")
    )
    val curriedDish =  ::cookDish.curried()

    val dishes = chefs.flatMap { chef ->
        ingredientsPresets.flatMap { (_, ing) ->
            equipmentPresets.map { (_, eq) ->
                curriedDish(30)(60)(ing)(eq)(chef)
            }
        }
    }
    println(dishes.joinToString("\n"))
}
Консольный вывод результата каррирования
Консольный вывод результата каррирования

Ключевые преимущества функционального подхода:

  • бесклассовая композиция. Не нужно создавать классы-контейнеры для параметров;

  • произвольный порядок фиксации. Можем фиксировать параметры в любом порядке;

  • лёгкое комбинирование. Функции можно комбинировать разными способами (compose, andThen);

  • ленивые вычисления. Можем строить цепочки преобразований без немедленного выполнения;

  • типобезопасность. Компилятор проверяет корректность комбинаций;

  • неизменяемость. Каждая комбинация создаёт новую функцию, а не изменяет состояние.

Такого уровня гибкости и выразительности сложно достичь в императивном стиле без введения сложных структур данных и шаблонов проектирования. Попробуйте сами реализовать нечто похожее на ООП и напишите в комментариях, какой вариант вам нравится больше.

Однако у каррирования есть одна особенность — оно работает по принципу «всё или ничего». В промежуточных результатах, если они рассматриваются обособленно, не особо много смысла. Важно довести конвейерную цепочку до конца, чтобы извлечь какой-то результат. Поэтому, если ваши вычисления не просто зависят от аргументов, но и порождают контекст (например, ошибки, null'ы, асинхронность), необходимо учитывать этот нюанс. Для таких целей лучше использовать монады — концепцию, которая объединяет функциональные паттерны в единую структуру с предсказуемыми правилами.

Не будем останавливаться на этом понятии. В прошлой статье я уже приводил примеры с Optional и Either в контексте функциональной обработки ошибок. Эти два контейнера и были монадами, просто тогда мы их так не называли.

Альтернатива Dependency Injection в ФП

Вот мы и подобрались к одной из интереснейших частей этой статьи. Разберёмся, как в функциональном программировании обстоят дела с DI.

Традиционные подходы внедрения зависимостей (DI), популярные в объектно-ориентированном программировании, часто оказываются несовместимыми с философией функционального программирования. В ФП мы стремимся к:

  • чистым функциям без побочных эффектов;

  • явности и прозрачности зависимостей;

  • композируемости мелких компонентов;

  • лёгкости тестирования без сложных мок-фреймворков.

Классические DI-контейнеры нарушают эти принципы, внося неявные зависимости и скрытое состояние. Рассмотрим альтернативы, более соответствующие функциональной парадигме.

Самый простой и чистый подход — передача зависимостей явно через параметры функций (Explicit Dependency Passing). Нечто похожее я показывал в первой статье цикла, демонстрируя легковесную сущность Client.

// Вместо:
class Service @Inject constructor(private val repo: Repository) {
    fun operation() = repo.getData()
}

// ФП-вариант:
fun operation(repo: Repository) = repo.getData()

Она позволяет достичь полной прозрачности — сразу видно, от чего зависит функция. Её легко тестировать — не нужны мок-фреймворки, а скрытое состояние отсутствует. Однако применять этот подход можно только в простых случаях с небольшим количеством зависимостей.

Для более сложных случаев существует другая концепция — контекстно-зависимый подход. В Haskell и других чисто функциональных языках программирования для решения проблемы с большим количеством зависимостей используют несколько альтернатив DI: Reader Monad, эффект системы, выражение зависимостей как параметров алгебры и т.д. Но в Kotlin Arrow я не нашёл лаконичный способ реализовать нечто подобное. А вот концепция Capability Passing или Closure выглядит интересно — классический DI заменяется комбинацией интерфейсов и делегатов.

interface Database {
  suspend fun <A> execute(q: Query<A>): Result<A>
}

suspend fun Database.saveUserInDb(user: User) {
  val result = execute<User>(update)
}

class DatabaseFromConnection(conn: DatabaseConnection) : Database {
  override suspend fun <A> execute(q: Query<A>): Result<A> =
    conn.execute(q)
}

suspend fun example() {
  val conn = openDatabaseConnection(connParams)
  with(DatabaseFromConnection(conn)) {
    saveUserInDb(User("Ivan"))
  }
}

Database — это абстракция для работы с базой данных. DatabaseFromConnection — конкретная реализация, принимающая реальное соединение с БД. Выполнение запроса делегируется этому соединению. Иными словами, мы получаем адаптер между интерфейсом и конкретной реализацией. Как это работает:

  1. Открываем соединение с БД.

  2. Создаём адаптер DatabaseFromConnection.

  3. В контексте этого адаптера вызываем saveUserInDb.

  4. Функция saveUserInDb использует наш интерфейс, не зная о реальной реализации.

Преимущества такого подхода:

  • абстракция. Код работы с БД не зависит от конкретной реализации;

  • тестируемость. Можно подменить реальную БД на mock-объект;

  • гибкость. Легко поменять СУБД, изменив только реализацию DatabaseFromConnection;

  • чистота. Бизнес-логика (saveUserInDb) отделена от инфраструктуры.

Если вам интересна тема DI, попробуйте исследовать альтернативные концепции с другими языками — Scala или Haskell. А если вы знаете другие способы заменить DI в Kotlin силами функционального программирования, напишите о них в комментариях. А пока остановимся на Capability Passing и разберём на конкретном примере.

Допустим, у нас есть задача реализовать фичу бронирования столика в ресторане. Простое API интерфейса и модель данных могут выглядеть так:

interface TableRepository {
    suspend fun checkTableAvailability(date: String, guests: Int): Result<Boolean>
    suspend fun reserveTable(tableId: Int, customerName: String): Result<Boolean>
}

data class ReservationRequest(
    val tableId: Int,
    val customerName: String,
    val date: String,
    val guests: Int
)

Также мы хотим трекать аналитику и оповещать пользователя об успешном и неудачном результате бронирования:

interface Notifier {
    fun showSuccess(message: String)
    fun showError(message: String)
}

interface AnalyticsTracker {
    fun trackReservationAttempt()
    fun trackReservationSuccess()
}

И, наконец, сам метод резервирования столика:

suspend fun <Ctx> Ctx.makeReservation(request: ReservationRequest): Result<Serializable>
    where Ctx: TableRepository, Ctx: Notifier, Ctx: AnalyticsTracker {
        return run {
            trackReservationAttempt()
            checkTableAvailability(request.date, request.guests)
                .flatMap { isAvailable ->
                    if (!isAvailable) {
                        showError("Столик на ${request.date} недоступен")
                        Result.failure(IllegalStateException("Table unavailable"))
                    } else {
                        Result.success(Unit)
                    }
                }
                .flatMap {
                    reserveTable(request.tableId, request.customerName)
                        .onSuccess {
                            trackReservationSuccess()
                            showSuccess("Столик #${request.tableId} успешно забронирован!")
                        }
                        .onFailure {
                            showError("Ошибка бронирования")
                        }
                }
                .recoverCatching { e ->
                    showError("Ошибка: ${e.message}")
                    Result.failure<Throwable>(e)
                }
        }
    }

makeRecervation работает с контекстом Ctx, который должен реализовывать три интерфейса: TableRepository, Notifier и AnalyticsTracker. Функция принимает параметр request типа ReservationRequest и возвращает успешный или ошибочный результат.

Функция начинается с вызова trackReservationAttempt() для отслеживания попытки бронирования. Затем проверяется доступность столика через checkTableAvailability, куда передаются дата и количество гостей из запроса. Если столик недоступен, функция показывает ошибку «Столик на [дата] недоступен» и возвращает Result.failure с исключением IllegalStateException. Если столик доступен, выполняется reserveTable с передачей tableId и customerName из запроса.

При успешном бронировании вызывается trackReservationSuccess() для отслеживания успешного завершения операции и отображается сообщение: «Столик #[tableId] успешно забронирован!». В случае ошибки бронирования показывается сообщение: «Ошибка бронирования».

Если в процессе выполнения возникает исключение, функция перехватывает его через recoverCatching, показывает сообщение об ошибке с текстом исключения и возвращает Result.failure с этим исключением.

Теперь осталось только связать это с UI:

// Состояние формы
var tableId by remember { mutableStateOf("") }
var customerName by remember { mutableStateOf("") }
var guests by remember { mutableStateOf("") }

// Состояние загрузки
var isLoading by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
var successMessage by remember { mutableStateOf<String?>(null) }

Сейчас я не хочу усложнять пример, поэтому пусть вас сильно не смущает наличие var и mutableStateOf из библиотеки Compose. В данный момент эти поля-делегаты нужны для удобного обращения к переменным во время присвоения им значений без ключевого слова value. Они не будут использоваться за пределами нашей функции и поэтому не должны создавать побочных эффектов в общей системе. В последней части цикла при построении архитектуры нам придётся избавиться от подобных конструкций, так как это не совсем то, что мы хотим видеть в функциональном подходе. Это решается внедрением единого состояния экрана и нескольких архитектурных компонентов:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReservationScreen() {
    //..//
    // Создаём контекст с зависимостями
    val reservationContext = remember {
        object :
            TableRepository by NetworkTableRepository(),
            Notifier by object : Notifier {
                override fun showSuccess(message: String) {
                    successMessage = message
                    errorMessage = null
                }
                override fun showError(message: String) {
                    errorMessage = message
                    successMessage = null
                }
            },
            AnalyticsTracker by FirebaseAnalyticsTracker() {}
    }
    //..//
}
Button(
	onClick = {
		if (feildsIsBlank()) {
          errorMessage = "Заполните все поля"
          return@Button
        }
        val request = ReservationRequest(
          tableId = tableId.toInt(),                 
          customerName = customerName,
          date = selectedDate,
          guests = guests.toInt()
        )

        // Запускаем корутину
        CoroutineScope(Dispatchers.IO).launch {
          isLoading = true
          delay(1000) //Имитация загрузки
          reservationContext.makeReservation(request)
          isLoading = false
        }
    },
    modifier = Modifier.width(320.dp).height(64.dp),
    enabled = !isLoading
) { /*..*/ }

В примере выше я запустил CoroutineScope прямо в Compose функции, чтобы не раздувать исходный код. В рабочем примере, скорее всего, у вас это будет лежать во ViewModel или другом архитектурном компоненте. Далее регистрируем слушателей результата бронирования и выводим его на экран.

// Сообщения об ошибках/успехе
errorMessage?.let { message ->
  Text(
    text = message,
    color = Color.Red,
    modifier = Modifier.padding(8.dp)
  )
}

successMessage?.let { message ->
  Text(
    text = message,
    color = Color.Green,
    modifier = Modifier.padding(8.dp)
  )
}
Кейс ошибки бронирования
Кейс ошибки бронирования
Кейс успешного бронирования
Кейс успешного бронирования

Всё работает, как мы задумали — без фреймворков Dagger/Hilt или Koin. Весь код можно найти тут.

В обсуждении первой статьи цикла тема DI вызвала наибольший интерес. Как и обещал, раскрываю тему многомодульности и масштабирования такого подхода подробнее. Если вопросов станет только больше, напишите об этом в комментариях :)

Создадим три дополнительных модулях: :core, :analytics, :feature-reservation. В моём примере фича резервирования импортируется в модуле :app — упростил, чтобы сильно не ломать текущий код. В реальном примере вы, скорее всего, захотите изолировать и всю UI-часть в отдельный модуль.

// новый интерфейс в модуле :core
interface CoreContext {
  val logger: Logger
}
// новые классы и интерфейсы в модуле :analytics
data class Event(val eventParams: Map<String, Any>)

interface AnalyticsTracker {
  fun track(event: Event)
}

interface AnalyticsContext : CoreContext {
  val tracker: AnalyticsTracker
}

По аналогии преобразуем старый код фичи и вынесем в отдельный модуль:

// модуль :feature-reservation
interface ReservationContext : CoreContext {
  val tableRepository: TableRepository
}

interface TableRepository {
  suspend fun checkTableAvailability(date: String, guests: Int): Result<Boolean>
  suspend fun reserveTable(tableId: Int, customerName: String): Result<Boolean>
}

data class ReservationRequest(
  val tableId: Int,
  val customerName: String,
  val date: String,
  val guests: Int
)

Теперь и функция бронирования может переехать в этот модуль:

suspend fun <Ctx> Ctx.makeReservation(request: ReservationRequest) : Result<Serializable>
        where Ctx : ReservationContext, Ctx : AnalyticsContext {
  return run {
    logger.info("Start")
    tableRepository.checkTableAvailability(request.date, request.tableId)
      .flatMap { isAvailable ->
        if (!isAvailable) {
          logger.log(Level.WARNING, "Столик на ${request.date} недоступен")
          Result.failure(IllegalStateException("Table unavailable"))
        } else {
          Result.success(Unit)
        }
      }
      .flatMap {
        tableRepository.reserveTable(request.tableId, request.customerName)
          .onSuccess {
            tracker.track(
              event = Event(eventParams = mapOf("reserve_table_success" to request.tableId))
            )
            logger.log(Level.INFO, "Столик #${request.tableId} успешно забронирован!")
          }
          .onFailure {
            logger.log(Level.WARNING, "Столик #${request.tableId} успешно забронирован!")
          }
      }
      .recoverCatching { e ->
        logger.log(Level.WARNING, "Ошибка: ${e.message}")
        Result.failure<Throwable>(e)
      }
  }
}

В модуле :app создадим реализации интерфейсов и соответствующий контекст:

// Новая реализация
class RealAnalyticsTracker : AnalyticsTracker {
  override fun track(event: Event) {
    // Реальная отправка аналитики
  }
}

// Остался прежним
class NetworkTableRepository : TableRepository {
  override suspend fun checkTableAvailability(date: String, guests: Int): Result<Boolean> {/**/}
  override suspend fun reserveTable(tableId: Int, customerName: String): Result<Boolean> {/**/}
}
class ReservationContextImpl(
  override val logger: Logger,
  override val tableRepository: TableRepository,
  override val tracker: AnalyticsTracker
) : ReservationContext, AnalyticsContext

С названием можно поиграться, но пока остановимся на приставке Impl. Модифицируем код в точке обращения к контексту, поскольку структура контекста изменилась:

fun buildReservationContext(
  logger: Logger = Logger.getLogger("Reservation"),
  tableRepository: TableRepository = NetworkTableRepository(),
  tracker: AnalyticsTracker = RealAnalyticsTracker()
): ReservationContextImpl {
  return ReservationContextImpl(
    logger = logger,
    tableRepository = tableRepository,
    tracker = tracker
  )
}
@Composable
fun ReservationScreen() {
  // Создаём контекст с зависимостями
  val reservationContext = remember {
    buildReservationContext()
  }

  // Тут всё осталось без изменений
  CoroutineScope(Dispatchers.IO).launch {
    isLoading = true
    delay(1000) //Имитация загрузки
    reservationContext.makeReservation(request)
    isLoading = false
  }
}

Приятный бонус: написание Unit-тестов тоже будет выглядеть не шибко сложно:

class FakeTableRepository: TableRepository {
  override suspend fun checkTableAvailability(date: String, guests: Int): Result<Boolean> {
    return Result.success(true)
  }
  override suspend fun reserveTable(tableId: Int, customerName: String): Result<Boolean> {
    return Result.success(true)
  }
}

class ReservationTest: FunSpec() {
  init {
    val fakeTableRepository = object : TableRepository by FakeTableRepository() {}
    test("Test reservation").config(coroutineTestScope = true) {
      val fakeContext = buildReservationContext(tableRepository = fakeTableRepository)
      val testRequest = ReservationRequest(
        tableId = 1,
        customerName = "Test",
        date = "2023-10-10",
        guests = 2
      )
      val result = fakeContext.makeReservation(testRequest)
      result.isSuccess shouldBe true
    }
  }
}

Нам нужно только создать фейковую реализацию репозитория или других аргументов, которые можно передать в написанную ранее функцию buildReservationContext. А потом сверить фактический и ожидаемый результат.

Результат Unit тестирования репозитория
Результат Unit тестирования репозитория

Исходный код можно посмотреть в репозитории, выбрав ветку feature/di_multi_modules.

Что дальше

В следующей статье зафиналим цикл о функциональном программировании в Android, немного поговорим о побочных эффектах. А затем закрепим знания, полученные в предыдущих статьях на практике, — построим функционально-реактивную архитектуру приложения ELM.

Спасибо, что дочитали статью! Ставьте плюсики, если материал показался вам интересным, и делитесь им с друзьями. А чтобы быть в курсе последних новостей Dodo Engineering, подписывайтесь на наш Telegram-канал.

Комментарии (0)