Функциональный Kotlin. Во имя добра, радуги и всего такого

в 21:15, , рубрики: inline, kotlin, расширения, рефакторинг, ссылки на фукнции, функции, функции высшего порядка, функциональное программирование

Введение

Сам по себе Kotlin очень мощный инструмент, но многие часто используют его не на полную мощность, превращая его в какую-то... Java 6. Попробую рассказать почему так делать не надо и как использовать функциональные фичи языка на полную.

Функции высшего порядка

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

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

val foo: () -> Unit = {  }

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

run(foo)

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

str.run(String::isEmpty)

Перейдем к более конкретному кейсу. Допустим, нам нужно распарсить строки определенным образом, но только в одном месте в программе. Очевидно, что логика будет повторяться. Что делать? Создадим для этого отдельный объект с одним методом? Или пропишем для каждого "ручками" прямо на месте?

Нет, копить технический долг ни к чему, лучше мы сделаем, например, что-то такое:

val parse: (String) -> List<Int> = { it.split(":").map(String::toInt) }

val (xMin, yMin) = parse(data["from"])
val (xMax, yMax) = parse(data["to"])

Функции области видимости

Теперь, когда мы разобрались с ФВП, перейдем к вещам, которыми, скорее всего, пользовались все.  letrunwithapply, и also. Знакомые слова? Надеюсь, но все же разберем их.

inline fun <T, R> T.let(block: (T) -> R): R
inline fun <T> T.also(block: (T) -> Unit): T

Сначала let и also. Они наиболее просты и понятны, потому что все что они делают внутри - это вызов block(this) . По сути, мы просто делаем вызов определенной нами "на месте" функции. Разница лишь в том, что они возвращают. also используется когда вы хотите продолжить работу с тем же объектом, у которого решили вызвать функцию и let, если вам нужно вернуть новый объект.

inline fun <R> run(block: () -> R): R
inline fun <T, R> T.run(block: T.() -> R): R
inline fun <T, R> with(receiver: T, block: T.() -> R): R
inline fun <T> T.apply(block: T.() -> Unit): T

Теперь к run, with и apply:
run очень похож на let, apply на also, а with это почти то же самое что и run, просто receiver получаем разными способами. Чем они удобны и зачем нужны, если есть let и also? Все просто, тут вместо it используется this, который вообще можно опустить и не писать.

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

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

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

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

Классы и Объекты

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

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

let {
  val some = Some()
  it.run(some::doSome)
}

Использование объекта позволило бы нам сделать наш код проще:

let(Some::doSome)

Как хорошо, когда нет ничего лишнего, да?

Но допустим мы все же вынуждены иметь дело с классом, у которого есть поведение, но экземпляр класса создавать не хотим? К счастью, и для этого найдется достаточно простое решение. Мы просто скинем всю статику, которая есть в объекте в companion object:

class Some {
  companion object {
    fun doSome(any: Any) = run {}
  }
}

Теперь мы можем сделать так же, как и с объектом.

Factory методы

Для начала я приведу пример кода:

val other = Other()
val stuff = other.produceStuff()

val some = Some(stuff)

Лично мне он не нравится. Логическая цепочка тут нарушена, поскольку мы в первую очередь хотим понимать что создание экземпляра класса Other и вызов его метода нужны просто чтобы создать экземпляр класса Some, но запись не дает моментально этого понять.

Конечно, мы можем заинлайнить его:

val some = Some(
  Other().produceStuff()
)

Но у меня есть вариант получше. Было бы неплохо, если бы мы могли привязать логику, нужную только для создания объекта к... созданию объекта? И мы можем, для этого нам всего лишь нужно написать вот такой Factory-метод:

class Some {
  companion object Factory {
    inline fun <T>create(t: T?, f: (T?) -> Stuff) = Some(f(t))
  }
}

Теперь мы можем сделать так:

val some = Some(Other()) { it.doStuff() }

Или если класс Other тоже имеет свой фабричный метод:

val some = Some.create(Other) { it.create().doStuff() }

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

Подводные камни функций-расширений

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

fun Some.foo() = run { }

Или если хотите экзотики:

val foo: Some.() -> Unit = {  }

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

Теперь, давайте поговорим о ссылках на функции-расширения. Если мы сделали ее через val то все просто и понятно, передаем ее просто как foo и ничего лишнего, работаем как с любой другой переменной. Но если через fun, появляются нюансы, на которые имеет смысл обратить внимание.

Давайте посмотрим на вот такой код:

class Some {
  fun Other.someExtention() = run { }
}

В принципе, ничто не мешает нам так сделать, но есть одно "но", из-за которого я считаю это очень плохой практикой.

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

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

P.S.

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

fun Some.overlay(f: KFunction1<Some, Any>) = f(this)

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

Автор: yhwh0

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js