Пара подводных камней при разработке на Kotlin

в 15:21, , рубрики: java, kotlin, Программирование
Пара подводных камней при разработке на Kotlin - 1Давно хотелось поделиться списком потенциально опасных конструкций, возникающих при разработке на Kotlin. Даже если Вам он покажется простым — то для людей, которые ещё не писали на Kotlin активно, данное знание будет весьма полезным.
На Хабре уже была подобная статья — но в ней больше рассматривались способы специально «выстрелить себе в ногу», а я хочу рассказать про непреднамеренные случаи.

1. Переопределение Java-методов в Kotlin-коде

На тему взаимодействия Kotlin с Java-кодом сломано немало копий, выскажусь и я.
Одной из главных фич языка является то, что nullability объектов внутри Kotlin-кода проверяется уже на этапе компиляции. То есть, если попробовать обратиться к полю/методу объекта (через оператор .), объявленного как nullable (или передать его как аргумент в функцию, принимающую на вход notnull-переменную) — то такой код даже не скомпилируется. Чтобы компиляция прошла, Вам придётся воспользоваться оператором безопасного вызова ?. — или же добавить для такого объекта явную проверку на null.

Такой подход действительно хорошо защищает нас от NPE. Однако, если вы обращаетесь к объекту, «пришедшему» из Java-кода — то этот подход не применяется:

Any reference in Java may be null, which makes Kotlin's requirements of strict null-safety impractical for objects coming from Java. Types of Java declarations are treated in Kotlin in a specific manner and called platform types. Null-checks are relaxed for such types, so that safety guarantees for them are the same as in Java.

Таким образом, в Kotlin-коде необходимо обрабатывать все объекты из Java-кода как nullable. Всегда и везде, иначе есть риск получить NPE. Даже если от этого код будет выглядеть менее красивым, зато он будет более надёжным.

Наверное, об этом знают уже все, кто пишет на Kotlin. Но наиболее ярко проблема проявляется при переопределении методов Java-класса в Kotlin-классе. Дело в том, что IDE при вводе слова override заботливо предлагает авто-дополнение со списком доступных методов родительского класса. И nullability аргументов каждого метода (равно как и его возвращаемого значения) IDE проставляет, исходя из наличия аннотации @Nullable: если она указана для аргумента, то используется nullable-тип (т.е. с «вопросительным знаком») — иначе, используется «обычный» тип.

То есть, если для объекта в Java-коде не проставлена ни одна из аннотаций @Nullable/@NotNull — то в Kotlin-коде по умолчанию для этого объекта будет использован тип без «вопросительного знака», и при обращении к его полям/методам мы сможем получить NPE. Но на самом деле, даже если в Java-коде использовалось @NotNull — то всё равно лучше не полагаться на IDE и самим добавить «вопросительный знак» к Kotlin-типу, и обрабатывать его как nullable. Почему так? Потому что, если в дальнейшем кто-то захочет расширить функционал Java-метода, добавив обработку null-значения, и уберёт аннотацию @NotNull (не проставив @Nullable) — то это никак не отразится на компиляции Kotlin-кода, и в нём опять возникнет риск получить NPE в рантайме…

Данная проблема может возникнуть, к примеру, при обновлении какого-то утилитарного Java-пакета — и её сложно заметить. Поэтому лучше не доверять IDE в вопросе nullability типов, и явно обрабатывать в Kotlin-коде типы всех объектов, пришедших из Java, как nullable.

2. Использование delay() внутри synchronized-методов

В старой доброй Java почти не было разницы между подходами, когда:

  • всё тело метода обёрнуто в synchronized-блок
  • сам метод объявлен с ключевым словом synchronized

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

В Kotlin по-прежнему можно использовать synchronized-блоки из Java («первый» подход), а вот для «второго» подхода вместо ключевого слова synchronized нужно использовать одноимённую аннотацию @Synchronized. Однако при использовании Kotlin-корутин, вызывающих delay(), можно столкнуться с неожиданной разницей для вышеуказанных подходов. Это иллюстрирует следующий код:

val lockObject = Object()

suspend fun methodUsingSynchronizedBlock()
{
    synchronized(lockObject)
    {
        println("before methodUsingSynchronizedBlock")
        Thread.sleep(5)
        println("after methodUsingSynchronizedBlock")
    }
}

@Synchronized
suspend fun fullySynchronizedMethod()
{
    println("before fullySynchronizedMethod")
    Thread.sleep(5)
    println("after fullySynchronizedMethod")
}

fun main()
{    
    repeat(2) {
        GlobalScope.launch { methodUsingSynchronizedBlock() }
    }
}

После запуска данного кода, в консоль выведется:

Результат

before methodUsingSynchronizedBlock
after methodUsingSynchronizedBlock
before methodUsingSynchronizedBlock
after methodUsingSynchronizedBlock

Что вполне соответствует ожидаемому.

Что же будет, если внутри main() заменить methodUsingSynchronizedBlock() на fullySynchronizedMethod()?

Результат

before fullySynchronizedMethod
after fullySynchronizedMethod
before fullySynchronizedMethod
after fullySynchronizedMethod

Это тоже соответствует ожидаемому и вполне логично. Потоки работают по очереди, как мы и хотели, т.е. подходы ведут себя одинаково.

Пока всё хорошо. Но что будет, если мы решим воспользоваться всей мощью Kotlin и приостановить каждую корутину, не блокируя весь поток? Т.е. заменим в обоих методах вызовы Thread.sleep(5) на delay(5) и посмотрим, что будет:

Результат

Ошибка компиляции: The 'delay' suspension point is inside a critical section

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

Но ошибка, как говорит нам компилятор, касается лишь метода methodUsingSynchronizedBlock! А что будет, если мы удалим этот метод (и оставим внутри main() вызов fullySynchronizedMethod вместо methodUsingSynchronizedBlock)?

Исходный код

@Synchronized
suspend fun fullySynchronizedMethod()
{
    println("before fullySynchronizedMethod")
    delay(5)
    println("after fullySynchronizedMethod")
}

fun main()
{    
    repeat(2) {
        GlobalScope.launch { fullySynchronizedMethod() }
    }
}

Результат

before fullySynchronizedMethod
before fullySynchronizedMethod
after fullySynchronizedMethod
after fullySynchronizedMethod

Получается, что если весь метод объявлен с аннотацией @Synchronized — то использование delay() для него разрешается! И оно приводит к тому, что оба потока заходят внутрь критической секции — т.е. второй заходит в неё тогда, когда первый выполнил delay(), но ещё не успел выполнить завершающий println(). И получается весьма неожиданный результат…

Способов избежать данной проблемы много. Можно явно блокировать поток через Thread.sleep(), можно оборачивать в synchronized лишь блок кода, а можно воспользоваться мьютексами — в отличие от synchronized, они корректно работают с delay() в корутинах (и результат будет правильный, т.е. как в случае с Thread.sleep()).

3. Вызовы getter'ов из Java-кода, выглядящих как поля класса

Одна из фич Kotlin (являющаяся, как и большинство других, синтаксическим сахаром) — это "synthetic properties", то есть возможность обращения к уже существующим геттерам/сеттерам Java-класса так, как будто вы обращаетесь к полям этого класса. Иначе говоря, Kotlin (неявно) автоматически генерирует эти properties для всех подходящих Java-классов.

Простой пример использования synthetic properties

Возьмём стандартный Android-класс android.view.View, в котором уже имеется поле mTag и соответствующие ему геттер/сеттер:

public class View implements Drawable.Callback, KeyEvent.Callback
{
    protected Object mTag = null;
    ...
    public Object getTag()  { return mTag; }
    public void setTag(final Object tag)  { mTag = tag; }
}

Теперь, если при разработке на Kotlin мы захотим узнать значение mTag для какого-то объекта этого класса, то это можно будет сделать двумя способами:

val myView: android.view.View = /* какой-то инициализатор */
...
val myTag1 = myView.getTag()  // старый способ с использованием геттера
val myTag2 = myView.tag  // новый способ с использованием synthetic properties 

Таким образом, Kotlin даёт нам удобную возможность обращаться к mTag так, как будто мы обращаемся к полю с именем tag (это имя берётся из имён геттера/сеттера). Оба этих способа совершенно эквивалентны — но «старый» способ не нравится Android Studio, и она выдаст предупреждение при его использовании.

Казалось бы, простая и удобная фича — что же в ней может быть опасного? А то, что геттер может быть не тривиальный (т.е. не вида «return value»), а довольно сложный — то есть, выполняться продолжительное время. Что может привести к проблемам при использовании подобного кода (пример для класса FirebaseDatabase, входящего состав Firebase):

val dbTableNames = arrayOf("first", "second", "third", "fourth")
...

fun clearAllDbTables(db: FirebaseDatabase)
{
    for (currentName in dbTableNames)
        db.reference.child(currentName).setValue(null)
}

С первого взгляда, в этом коде не видно каких-либо проблем. Но на самом деле, поле reference объекта db — это не поле, а вызов геттера getReference() класса FirebaseDatabase, который не просто возвращает нам уже существующий объект класса DatabaseReference, а каждый раз создаёт его заново.

То есть, в цикле for поочерёдно будут созданы 4 одинаковых объекта, что плохо — хотя мы могли бы избежать этого, если бы просто вынесли получение reference за пределы цикла:

fun clearAllDbTables(db: FirebaseDatabase)
{
    val rootReference = db.reference
    
    for (currentName in dbTableNames)
        rootReference.child(currentName).setValue(null)
}

В общем, в первом варианте кода налицо проблема с производительностью из-за того, что геттер getReference() не тривиальный. Способ избежать проблемы очевиден — нужно проверять, является ли геттер тривиальным, и избегать его множественного вызова в противном случае.

Наверняка кто-то возразит — «постойте, ведь в других языках программирования properties тоже есть, и никто не жалуется!».

Да, это так, но в случае с Java и Kotlin есть пара нюансов

  • во-первых, в Java нет properties, поэтому при кодинге на Kotlin привыкший к Java программист может просто не знать о том, что при этом производится вызов геттера;
  • во-вторых, Kotlin всячески подталкивает программиста к использованию полей вместо методов при разработке собственного класса. Например, если в класс нужно добавить поле, доступное сторонним классам только на чтение, то рекомендуется делать это как:
    
    var fieldName: FieldType
        private set

    вместо объявления private-поля «fieldName» с публичным тривиальным геттером. Таким образом, в Kotlin мы намного чаще обращаемся к полям класса напрямую (т.е. не через геттеры), чем в Java. И в итоге программист привыкает к этому, поэтому однажды он вполне может не заметить вызов геттера при работе с объектом Java-класса.

4. Различные способы объявления функции

И сразу вопрос. Какой текст будет выведен при запуске данного кода?

fun example1() { System.out.println("example1") }
fun example2() = System.out.println("example2")
val example3   = { System.out.println("example3") }
fun example4() = { System.out.println("example4") }
val example5   = run{ System.out.println("example5") }

fun main()
{
    example1()
    example2()
    example3()
    example4()
    example5
}

Ответ

example5
example1
example2
example3

Как же так получилось?

Ну, в случае с example5 всё понятно — здесь используется так называемый non-extension run, который сразу же выполняет требуемый блок кода. Так что этот пункт — просто «ловушка» (строка с example5 внутри main() даже не обязательна, текст всё равно выведется при входе в main(), поскольку val example5 стоит на top level). И вообще, так писать не надо :)

example1 и example2 — это два эквивалентных способа объявления функции. Первый — классический Java-стиль, второй — это синтаксический сахар Kotlin. Результат для них, разумеется, тоже будет одинаков.

example3, в отличие двух предыдущих, является лямбдой. Её вызов также приводит к ожидаемому результату.

А вот почему вызов example4 ничего не вывел? Дело в том, что example4 — это функция, которая лишь возвращает лямбду, но не выполняет её. Чтобы было понятнее, её объявление эквивалентно следующей конструкции:

val lambdaForExample4 = { System.out.println("example4") }
fun example4(): () -> Unit
{
    return lambdaForExample4
}

Поэтому в main() строка example4() просто возвращает нам эту самую lambdaForExample4, т.е. до println исполнение не доходит.

Получается, что если мы действительно хотим увидеть строку «example4» в консоли, соответствующий вызов в main() нужно заменить на:

example4()()

Первые «круглые скобки» вернут нам лямбду, а вот вторые уже вызовут её и напечатают желанную строку.

Таким образом, опасность состоит в том, что при написании классов на Kotlin часто приходится смешивать классический «Java-стиль» объявления методов (если метод содержит несколько выражений) с новым «Kotlin-стилем» (для методов из одного выражения). И есть риск вместо правильного варианта «example2» поставить по-привычке фигурные скобки… и получить «example4» — вызов которого, фактически, ничего не сделает, но компилятор при этом ругаться не будет. Такую ошибку сложно заметить в рантайме. Особых рекомендаций по предотвращению этой ошибки нет — нужно просто быть внимательным при записи single-expression functions.

P.S. Данная статья не ставит цель создать негативное впечатление о Котлине. Наоборот, лично я считаю его весьма приятным языком — но, как и остальные языки, со своими особенностями.
А на какие «подводные камни» натыкались Вы в своей практике? Предлагаю поделиться примерами в комментариях.

Автор: Константин Барсов

Источник

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


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