четвер, 27 липня 2017 р.

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

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

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

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

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

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

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

Inline функции с return

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

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

fun main(args: Array<String>) {
    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<String>) {
    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<String>) {
    someFun {
        return
    }
    println("boom!")
}

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

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

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

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

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

fun main(args: Array<String>) {
    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<String>) {
    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<String>) {
    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<String>) {
    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<Int>()
val a = 1
println(a in c)

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

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

fun main(args: Array<String>) {
    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 функции. Мы указываем на какой класс она нацелена (здесь может быть даже дженерик тип), а затем на этом классе можем её вызывать. Обращайте внимание на подсветку среды разработки. Extention функции ниже по приоритету, чем существующие в классе функции, по этому при вызове функции с таким названием extention функция будет проигнорирована.

Платформенные типы

Как я писал в статье о миграции из Java - в Kotlin есть понятие платформенного типа. Для того, чтобы не получить исключение из-за null в value проверяйте и обрабатывайте код заранее. При взаимодействии с Java из Kotlin, старайтесь указать типы изначально и явно, либо сразу их обработать. Пример ниже

fun main(args: Array<String>) {
    val value = System.getProperty("someKey")
    println(value.toLowerCase())
}

Здесь падает ошибка

Exception in thread "main" kotlin.TypeCastException: null cannot be cast to non-null type java.lang.String

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

Правильно было бы либо обработать value как nullable, либо, если ситуация с null не допустима, то явно указать тип и тогда ошибка упадёт сразу на стыке взаимодействия с Java

val value: String = System.getProperty("someKey")

Рекурсивный аксессор get property

В Kotlin'e нет Getters и Setters в виде методов, но есть аксессоры для property. Те же яйца только в профиль и читается лучше, а так же я слышал (из публичных источников), что проперти инлайнятся (как нибудь мы это проверим), т.е. вызов во время выполнения происходит напрямую к полю снимая оверхед от посредников типа методов.

Чем опасны аксессоры? Давайте взглянем на код ниже

class Taxi {
    var name: String = "Uber"
        get() = if(name == "Uber") "Yandex" else name
}

С первого взгляда всё в порядке, если в поле name был "Uber", то возвращаем Yandex, иначе то значение, которое находится в филде. Однако, попытка выполнить получение name завершится с

Exception in thread "main" java.lang.StackOverflowError

Но почему? Дело в том, что компилятор считает, что мы пытаемся рекурсивно получить name у объекта и уходит в бесконечность. Здесь мы хотели сказать, что нам нужен так называемый backing field для name и сделать это можно очевидным путём. Используем слово field! Небольшие изменения и вуаля!

class Taxi {
    var name: String = "Uber"
        get() = if(field == "Uber") "Yandex" else field
}

Теперь всё работает отлично. И, как всегда, будьте внимательны.

Unit можно сложить в переменную

Как вы знаете, если не написать возвращаемый тип у функции, то он будет по умолчанию Unit. Это аналог void в Java. Отличие в том, что в Java void функция не возвращает ничего, а в Kotlin это Unit. И результаты таких вызовов можно даже сравнить! Ожидаемо, что все Unit' ы одинаковые и проверка "==" всегда дает true.

fun doNothing() {
}
fun main(args: Array<String>) {
    val result = doNothing()
    println(result) //kotlin.Unit
}

Заключение

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

Немає коментарів:

Дописати коментар

HyperComments for Blogger

comments powered by HyperComments