Давно хотелось поделиться списком потенциально опасных конструкций, возникающих при разработке на 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. Данная статья не ставит цель создать негативное впечатление о Котлине. Наоборот, лично я считаю его весьма приятным языком — но, как и остальные языки, со своими особенностями.
А на какие «подводные камни» натыкались Вы в своей практике? Предлагаю поделиться примерами в комментариях.

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


  1. Andreas84
    21.08.2021 21:09
    +2

    если в дальнейшем кто-то захочет расширить функционал Java-метода, добавив обработку null-значения, и уберёт аннотацию @NotNull (не проставив @Nullable) — то это никак не отразится на компиляции Kotlin-кода, и в нём опять возникнет риск получить NPE в рантайме

    О да, у нас был как раз такой случай. У нас старый код написал на Джаве, а новый на Котлине. И один из разработчиков решил добавить в метод джавакода возможность обрабатывать null, и просто убрал NotNull, а Nullable не проставил... Итог был предсказуем :(

    Другие описанные случаи я не встречал.


    1. fougasse
      21.08.2021 21:36

      Такое на код-ревью не ловится разве?


      1. Andreas84
        22.08.2021 12:33

        В теории должно ловиться, а на практике ревьювер не заметил.


  1. quaer
    21.08.2021 22:04
    +4

    Какой всё-таки смысл переходить с Java на Котлин?

    Это не из-за этого в Андроиде вдруг getText() у EditText начал требовать проверку на null, хотя в здравом уме такое представить нереально?


    1. ChPr
      21.08.2021 22:28
      +2

      Какой всё-таки смысл переходить с Java на Котлин?

      Гугл будет дальше еще сильнее пушить Котлин через свои библиотеки. Компоуз работает только с Котлином, а новые АндроидХ либы уже имеют курутины как часть стандартного апи, а не в виде дополнительных Колин экстеншонов.


      1. quaer
        21.08.2021 22:55
        +2

        Ну и что? Сейчас можно написать код на яве и отделив представление получить с мнимальными правками универсальное приложение работающее на всех основных ОС.

        Google выиграл у Oracle в суде многолетнюю тяжбу об использовании Java.

        Таким образом, ничто не мешает на Android использовать Java.

        В чём выгода разрабочикам в сегментировании?


        1. ChPr
          21.08.2021 23:47

          Google выиграл у Oracle в суде многолетнюю тяжбу об использовании Java

          Суд был про реализацию Java API, а не саму Java. Если бы проиграли, то Котлин бы не спас, т.к. он сам использует Java API.


        1. qoj
          22.08.2021 12:52
          +3

          Котлин полностью совместим с джавой. Почему нельзя написать код на котлине, и запускать его везде, где работает джава? К тому же у котлина есть своя мультиплатформа которая активно развивается. Возможности котлина позволяют писать код, более короткий, чем на джаве. Меньше кода -> меньше ошибок, и проще его читать.


          1. quaer
            22.08.2021 13:46

            Если он действительно полностью совместим, то видимо проблем нет.


    1. Efrit Автор
      22.08.2021 08:56
      +4

      Какой всё-таки смысл переходить с Java на Котлин?

      Лично мне Kotlin кажется просто более приятным и удобным, чем Java. В нём имеется множество фич, которые уже давно есть в других языках, но в Java нет - например, дефолтные значения у аргументов функций. Когда возвращаешься назад на Java, то подобных фич как раз не хватает.

      Ну и в целом код, написанный на Kotlin, лично мне становится немного проще читать. Он выглядит лаконичнее, что ли.


      1. quaer
        22.08.2021 12:25

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

        Ну и в целом код, написанный на Kotlin, лично мне становится немного проще читать.

        Разве это не противоречие? Если в вызовах не видны значения всех аргументов, разве это упрощает понимание? Это несомненно упрощает написание в момент, когда помнишь и знаешь особенности вызова, но упрощает ли чтение и понимание?


        1. Efrit Автор
          22.08.2021 13:52
          +2

          Фича "дефолтных значений аргументов" опциональна, никто не заставляет её использовать. Если не нужна читаемость - можно не указывать дефолтные аргументы, если нужна - тогда можно указать. Более того, если нужна совсем максимальная читаемость - то Котлин для этого как раз поддерживает вызовы функций с именованными аргументами , чего в Java тоже нет. В общем, у программиста хотя бы есть выбор)


        1. avost
          22.08.2021 14:06
          +6

          >Разве это не противоречие? Если в вызовах не видны значения всех аргументов, разве это упрощает понимание?

          Если писать в блокноте, то - противоречие. А если в IDE, то наоборот. Потому, что вы в подсказке увидите сигнатуру метода со всеми дефолтами.

          В plain java же, в этом случае у вас будет цепочка перегруженных методов в которые добавляются параметры по-одному. И писанины больше и итоговый вызов вы увидите только пройдя по цепочке.

          private String whatWasTheQuestion() {
              return whatWasTheQuestion("The Ultimate Question of Life, the Universe, and Everything");
          }
          private String whatWasTheQuestion(String question) {
              return someMethod(question, 42);
          }
          private String whatWasTheQuestion(String question, int answer) {
              return someMethod(question, answer, "mice");
          }
          private String whatWasTheQuestion(String question, int answer, String builders) {
              return makeOrderToConstructSupercomputerForLookingForTheQuestionForTheAnswer(builders)
                  .setQuestion(question)
                  .setAnswer(answer)
                  .build()
                  .ask();
          }

          В котлине будет сразу всё видно:

          fun whatWasTheQuestion(question: String = "The Ultimate Question of Life, the Universe, and Everything",
               answer: Int = 42,
               builders: String = "mice"): String =
                    makeOrderToConstructSupercomputerForLookingForTheQuestionForTheAnswer(builders).question(question).answer(answer).build().ask()


          1. quaer
            23.08.2021 01:20

            А разве такая перегрузка функций, как вы показали для явы, не должна ставится под сомнение на этапе их создания? Как вариант - сменить название функций.


            1. avost
              23.08.2021 15:07
              +1

              А разве такая перегрузка функций, как вы показали для явы, не должна ставится под сомнение на этапе их создания?

              А почему должна? Мой пример, разумеется, утрированный, но это общепринятая практика. Чаще, наверное, в виде набора конструкторов с пачкой необязательных параметров. Ну, чтобы не писать new MyObject("important param", null, null, null, null). Понятно, что для этого есть паттерн Builder, но писать билдеры, поддерживать и копаться в них - тот ещё геморрой. Конечно, мы их сейчас не пишем - всё делает Lombok, но это как раз костыль и покрывает он только вариант с конструкторами, а так-то и обычные методы достаточно часто перегружают таким образом.

              Как вариант - сменить название функций.

              А смысл? Они же не исчезнут от этого и способ их использования не изменится, только путаницы прибавится. Если семантика у методов разная, то, да, разумеется, нужно называть по-разному. Но тут речь именно от случае когда семантика одна, просто набор параметров разный.
              Простой пример - логгер
              log.error("message");
              и
              log.error("message", exception);
              их можно поименовать log.errorWithMessage и log.errorWithMessageAndException соответственно. Писать дольше, путаницы больше, а реализация первого метода - это всё равно вызов this.errorWithMessageAndException(message, null) - никакой пользы, кроме вреда :).


  1. slonopotamus
    22.08.2021 09:28
    +8

    геттера getReference() класса FirebaseDatabase, который не просто возвращает нам уже существующий объект класса DatabaseReference, а каждый раз создаёт его заново.

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


    1. Efrit Автор
      22.08.2021 09:52

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


  1. ov7a
    23.08.2021 13:02
    +1

    Отмечу, что в упомянутой в начале статье упомянуты не какие-то синтетические методы выстрелов в ногу, а "основанные на реальных событиях". Стоит учесть, что это 2016 год, 5 лет назад. Сам язык мы начали использовать еще до официального релиза 1.0, и некоторые концепты, очевидные сейчас, тогда были еще новы.


  1. chemtech
    20.09.2021 16:42
    +1

    Еще один подводный камень при разработке на Kotlin.
    Обновил PATCH версию com.soywiz.korlibs.klock:klock c 2.0.1 до 2.0.7.

    Получил несовместимость ABI

    The abi versions don't match. Expected '[1.4.1]', found '1.4.2'. The library produced by 1.4.31 compiler