Kotlin. Часть 4. Неловкие моменты

По заголовку становится ясно, что мы проделали уже очень большой путь. В серии Kotlin вышло целых три статьи из пяти (Введение, Незнакомые конструкции, Мигрируем из Java) и это четвертая в которой я хотел бы рассказать вам о том, как не выстрелить себе в ногу, применяя этот язык и как понять причину странного поведения программы. В следующей заключительной статье мы будем обсуждать как реализовать свой DSL (проблемно-ориентированный язык) с помощью Kotlin на примере библиотеки для создания Telegram ботов.

Если вдруг вам не знакомо что-то, что я использую без объяснений, то рекомендую прочитать предыдущие статьи цикла

Типичный рабочий стол Kotlin разработчика

Неизвестный синтаксис:

Если последним параметром функции в Kotlin передается лямбда, то её можно вынести за скобки, если это единственный параметр, то скобки можно не писать, очевидный пример — forEach

Начнем мы с самого популярного места: inline функции.

Inline функции с return

inline функция — функция код которой будет встроен в место вызова, особенно полезна при передаче в неё лямбд, которые тоже встраиваются. Специальный модификатор для функции — inline

Inline (встраиваемые) функции таят в себе небольшие, но лакомые для ошибок моменты о которых должен знать каждый уважающий себя Kotlin разработчик. Такие функции будут встроены в место вызова и код лямбд, передаваемых в них, будет встроен тоже. В такой ситуации нужно понимать как правильно манипулировать ключевым словом return. Давайте рассмотрим следующий пример:

fun main(args: Array) {
listOf("three", "two", "one").forEach {
if(it == "one") {
return
}
println(it)
}
println("boom!")
}

Как вы думаете что увидит пользователь?

Ответ тру Java разработчика — «three», «two», «boom!»

Ответ тру Kotlin разработчика — «three», «two»

И действительно, при запуске такого метода в Kotlin последняя строчка метода выполнена не будет. Причина этому функция forEach, а вернее её модификатор — inline.

Что делает return в Kotlin? Простой вопрос и ответ соответствующий: Return либо возвращает из функции значение, либо как в этом примере прерывает её выполнение. Только вот если встроить функцию, то формально никакого вызова в этом месте уже не будет. Почему в Java не так? Как вы знаете в Java каждая лямбда это инстанс анонимного класса, у которого определен метод, в Kotlin это не всегда так. Естественно, так как мы работаем в рамках JVM, то другой реализации добиться довольно трудно, да и лично я никогда не понимал «а как иначе?». Есть кусок кода и его нужно хранить. Также мы видели альтернативный вариант это передача ссылки на метод, который в себе содержит нужный код, однако, я решил заглянуть «под капот», подготовил вот такой пример:

fun example() {
printUUID(::uuidGenerator)
}
fun printUUID(supplier: () -> UUID) {
println(supplier())
}
fun uuidGenerator() : UUID {
return UUID.randomUUID()
}

Для передачи ссылки на first-class функции используется «::»

И что я вижу в байт коде Kotlin?

GETSTATIC ExampleKotlinKt$example$1.INSTANCE : LExampleKotlinKt$example$1;
CHECKCAST kotlin/jvm/functions/Function0
INVOKESTATIC ExampleKotlinKt.printUUID (Lkotlin/jvm/functions/Function0;)V

Если верить спецификации виртуальной машины, то мы с вами являемся свидетелями единственного инстанса (ExampleKotlinKt$example$1.INSTANCE) анонимного класса, а значит и здесь без них никак.

Давайте вернемся к первому примеру. Функция forEach — это встраиваемая функция. Существует понятие, которое применимо в Kotlin — non-local return. Именно его мы и наблюдаем. Простыми словами, non-local return, это такой return, который способен прервать выполнение функции, которая окружает встраиваемую. Для того чтобы получить ожидаемое поведение нам следует воспользоваться return к метке вот так

fun main(args: Array) {
listOf("three", "two", "one").forEach {
if(it == "one") {
return@forEach
}
println(it)
}
println("boom!")
}

«three»,»two»,»boom!»

noinline

Иногда не нужно встраивать все параметры, в такой ситуации помогает модификатор на параметре функции noinline. Рассмотрим следующий пример:

inline fun someFun(lambda: () -> Unit) {
lambda()
}
fun main(args: Array ) {
someFun {
return
}
println("boom!")
}

Путь изначально у нас есть inline функция someFun у которой все параметры встраиваются (по умолчанию). Что если мы захотим не встраивать передаваемую лямбду? Пример ниже не компилируется, т.к. non-local возврат из лямбды, которая точно не будет встроена невозможен

inline fun someFun(noinline lambda: () -> Unit) {
lambda()
}

fun main(args: Array) {
someFun {
return
}
println("boom!")
}

Как исправить? Очень просто! Добавляет return к метке. Это мы уже умеем. В такой ситуации не получится сделать non-local return, пожалуй, это и хорошо

inline fun someFun(noinline lambda: () -> Unit) {
lambda()
}

fun main(args: Array) {
someFun {
return@someFun
}
println("boom!")
}

Рассмотрим еще один модификатор, который используется для параметров inline функций — crossinline

crossinline

Представим что мы передали в inline функцию лямбду, которая по умолчанию тоже inline, а значит в ней может быть вызван non-local return. Если мы захотим использовать эту лямбду в другом контексте, как в примере ниже, то это не скомпилируется. Повторюсь, пример ниже не компилируется из-за попытки использовать лямбду внутри Store.

class Store(val lambda: () -> Unit)
inline fun someFun(lambda: () -> Unit) {
Store {
lambda()
}
lambda()
}
fun main(args: Array) {
someFun {
return
}
println("boom!")
}

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

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

class Store(val lambda: () -> Unit)

inline fun someFun(crossinline lambda: () -> Unit) {
Store {
lambda()
}
lambda()
}

fun main(args: Array) {
someFun {
return@someFun
}
}

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

class Store(val lambda: () -> Unit)

inline fun someFun(inlineLambda: () -> Unit,
noinline noinlineLambda: () -> Unit,
crossinline crossinlineLambda: () -> Unit) {
Store {
//inlineLambda cannot be used
noinlineLambda() //not inlined
crossinlineLambda() //not inlined
}
inlineLambda() //inlined
noinlineLambda() // not inlined
crossinlineLambda() //inlined
}

fun main(args: Array) {
someFun({
println("Print 1")
return //it is non-local return and it is ok for inline lambda
}, {
println("Print 1")
return@someFun //non-local return is not compiled here
}) {
println("Print 3")
return@someFun //non-local return is not compiled here
}
}

Вот ссылка на Gist, чтобы сохранить себе.

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

Наиболее насыщенная часть на этом заканчивается и «неловкие моменты», которые вы встретите дальше будут значительно проще в понимании.

Переопределенные операторы

В Kotlin вы можете переопределить операторы. Отнеситесь к этому внимательно, т.к. по ошибке можно не правильно понять принцип действия того или иного оператора, например «==». В Java «==» означает ссылочное равенство, в Kotlin всё чуть более сложнее. Оператор «==» соответствует методу equals, т.е. проверяет структурное равенство, соответственно в примере выше (с forEach) «==» для сравнения строк — правильный синтаксис. Для того, чтобы получить аналогичную Java проверку на ссылочное равенство в Kotlin используют «===». Посмотрите полный перечень операторов Kotlin и вы обнаружите, что проверка на вхождение в коллекцию (in) это тоже оператор связанный с методом contains. Вы можете перейти к реализации in и убедиться в этом сами.

val c = ArrayList()
val a = 1
println(a in c)

Вложенные лямбды

Иногда, например при построении DSL, когда вы используете вложенные лямбды может сложиться неловкая ситуация. Очень важно следить за контекстом, т.к. this это не всегда то, что вы мож
ете подумать. Давайте посмотрим на пример:

fun main(args: Array) {
Context().apply {
println(this)
innerContext {
println(this)
doSomething()
}
}
}
class Context {
private val innerContext: InnerContext = InnerContext()

fun innerContext(init: InnerContext.() -> Unit) {
innerContext.init()
}
fun doSomething() {
println("Outer context")
}
}
class InnerContext {
fun doSomething() {
println("Inner context")
}
}

В примере есть функция main и она создает контекст и у него вызывает функцию apply. Всё что вам нужно знать сейчас об этой функции, так это то, что внутри неё this — это тот объект на котором она вызвана. Как этого достичь я расскажу в следующей заключительной статье основного цикла Kotlin.

Итак, мы видим, что внутри apply вызывается функция innerContext в которую передается лямбда с обработчиком (также работает apply, но об этом в 5й части). Фактически, метод служит для запуска лямбды и всё. Как вы видите в метод main мы передали лямбду и что же вернет println? Не буду томить, внутри этой функции он напечатает совсем другой this. Давайте посмотрим на вывод:

Context@511d50c0 //println(this) внутри apply
InnerContext@2b193f2d //println(this) внутри innerContext
Inner context //результат doSomething

Как видно из вывода, что при смене контекста меняется не только this, но и вызываемые методы (doSomething). Это очень важно, по этому следите за контекстом, чтобы не допускать таких ошибок.

Метод который возвращает лямбду

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

fun printA() = { println("a")}

В этом коде есть одна большая проблема, он возвращает лямбду, а не выполняет печать, т.е. всё что после «=» — лямбда, а «=» это присваивание её как возвращаемого значения функции. Следовательно печать может быть достигнута только так printA()(). Жутко, не правда ли? Не возвращайте лямбды в совокупности с сокращенной записью метода.

Smart cast

Не забывайте про smart cast. Компилятор после вашей проверки на null внутри if конструкции приводит String? к String, то есть внутри стейтмента if он не требует от вас явных проверок и в этом примере «!!» не нужно.

val a : String? = "a"
if(a != null) {
print(a!!.isBlank())
}

Методы вызываемые на null

Удивительно, но в Kotlin сделали методы, которые можно вызывать даже при null по ссылке у которой вызывают метод, такой метод вы можете объявить для на nullable типа в качестве extention функции (о них поговорим в последней статье). Давайте посмотрим на следующий пример.

val s: String? = null
println(s.isNullOrEmpty())

Не смотря на то, что метод вызывается на строке в которой может быть null, компилятор разрешает это делать, т.к. в декларации метода прописано, что для этого метода this может быть равен null и безопасность nullability обеспечивается уже на другом уровне — внутри метода. Декларируется метод, например так:

fun CharSequence?.isNullOrEmpty(): Boolean

Отладка Kotlin + Java

На данный момент известно одно, отладка Kotlin + Java совсем немного хромает, не значительно. В принципе на уровне приложения всё ок, но вот я захотел поставить точку останова внутри println и этого сделать уже не смогу, вернее смогу, но она будет проигнорирована, хотя глубже в System.out.println (который вызывается в println) уже работает. Неведомые вещи творятся, еще в интернете можно встретить цитирование вот этих слов:

The currently selected Java debugger doesn't support breakpoints 
of type 'Kotlin Line Breakpoints'. As a result, these breakpoints
will not be hit. The debugger selection can be modified in the run
configuration dialog.

Сам глубоко не копал, об этом можно будет поговорить отдельно, но будьте бдительны.

«Переопределяющая» extention function

В Kotlin есть понятие extention функции. Это функция которая может быть добавлена в тот или иной класс без изменения кода этого класса. В Java такая функция будет выглядеть как статический метод, а в Kotlin работает как часть синтаксиса. Важно понимать, что такие функции не «встраиваются» в класс буквально, они лишь работают с его публичным API. Так например есть много функций для обработки Java коллекций при этом сами классы коллекций не тронуты. Но что произойдет, если такая функция перекроет существующую? Давайте взглянем на код.

class Abc {
fun someFun() {
println("Source fun")
}
}
fun Abc.someFun() {
println("Updated fun")
}

fun Abc.someFun — так выглядит определение extention функции. Мы указываем на какой класс она нацелена (здесь может быть даже дженерик тип), а з