Введение

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

Определение

Если говорить простым языком, каррирование это преобразование функции нескольких аргументов к набору функций одного аргумента. Таким образом, функция f будет преобразована в fCurried:

fun f(a: A, b: B, c: C) { ... }

fun fCurried(a: A) = fun(b: B) = fun(c: C) { f(a,b,c) }

Тут мы используем анонимные функции, делая возвращаемым значением первой функции (B) → (C) → Unit, а для второй (C) → Unit.

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

Выглядит это примерно так:

f :: A -> B -> C -> Nothing

Что-то похожее мы увидим и на Kotlin, если определим функцию не стандартным способом, а как переменную:

val g: (A, B, C) -> Unit = { ... }

val gCurried: (A) -> (B) -> (C) -> Unit = {
  a: A -> { 
    b: B -> { 
      c: C -> g(a,b,c)
    } 
  } 
}

Применяем каррирование правильно

Раз уж я упомянул Haskell, в случае с ним сам компилятор заточен под вычисления именно с такими функциями, однако Kotlin такими плюсами не обладает. Так зачем же они нам?

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

fun foo(a: A, b: B, c: C) = { ... }

Очевидно, что прописывать все аргументы каждый раз - не очень хороший подход. Что же делать?

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

Опыт работы в объектно ориентированной парадигме подсказывает очевидное, но некачественное решение:

class Bar(val a: A, val b: B) {
  
 fun foo(c: C) { ... }
 
}

Проблема такого подхода в том, что нам будет необходимо создавать и хранить несколько экземпляров класса.

Тут-то на помощь и приходит каррирование - оно позволяет нам частично определить функцию и использовать ее позднее без создания лишних объектов, усложняющих понимание кода:

val f = foo(a1, b1)
val g = foo(a2, b2)

// ...
if (expression) f(c1) else g(c1)

// ...
if (expression) f(c2) else g(c2)

А что же "под капотом"?

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

P.S.

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

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


  1. IL_Agent
    05.02.2022 01:04
    +1

    Проблема такого подхода в том, что нам будет необходимо создавать и хранить несколько экземпляров класса.

    Где здесь проблема? Каринованные функции - те же объекты.


    1. yhwh0 Автор
      05.02.2022 01:42

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


  1. vsh797
    06.02.2022 08:56
    +1

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