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