Статьи

Нулевая безопасность в Котлине

Kotlin  — это статически типизированный язык JVM, разработанный
Jetbrains . У него есть 
хорошая документация,  поэтому сегодня я остановлюсь на крошечной части — 
нулевой безопасности .

Существует как минимум пара подходов к 
nullобработке на языках JVM: 

  • Java  не намного дальше C — каждая ссылка («указатель») может быть  null, нравится вам это или нет. Если это не примитив, каждое поле, параметр или возвращаемое значение могут быть  null.
  • Groovy  имеет схожий фон, но добавляет немного  синтаксического сахара , а именно  Elvis Operator  ( ?:) и Safe Navigation Operator ( ?.).
  • Clojure  переименовывается  null в  nil, дополнительно обрабатывая его как  false в логических выражениях. NullPointerException все еще возможно.
  • Scala  первым принимает систематическую, безопасную от типов  Option[T] монаду  (Java 8 также будет иметь Optional<T> !). Идиоматический код Scala не должен содержать  nulls, но при взаимодействии с Java вы должны иногда оборачивать пустые значения. 

Котлин использует еще один подход. Ссылки, которые могут 
null иметь различный тип, таким образом, нулевая безопасность кодируется в системе типов и применяется только во время компиляции. Мы получаем
NullPointerExceptionбесплатный код и никаких накладных расходов из-за дополнительной 
Optionоболочки.

На уровне синтаксиса каждый тип 
T имеет супер тип, 
T? который позволяет 
null. Посмотрите на эти тривиальные примеры:

fun hello(name: String) {
    println("Hello, ${name}")
}
 
fun main(args: Array<String>) {
    val str = "Kotlin"
    hello(str)
 
    val maybeStr: String? = "Maybe?"
    hello(maybeStr)     //doesn't COMPILE
 
    if(maybeStr != null) {
        hello(maybeStr)
    }
 
}

Тип  str выводится  String. Функция  hello() принимает  String так  hello(str) хорошо. Однако мы явно объявляем  maybeStr как  String? тип (nullable  String). Компилятор позволяет нам вызов  hello() с  String? из — за несовместимый тип.

Однако, если компилятор может  доказать,  что вызов безопасен, например, потому что мы только что проверили null, компиляция завершится успешно. Чтобы быть точным, компилятор может доказать, что даунктинг от String? до  String безопасен. Точно так же я всегда находил раздражающим в Java то, что после использования instanceof оператора (который сам по себе раздражает) мне все равно приходится бросать объект вниз:

Object obj = "Any object"
 
if(obj instanceof String) {
    hello((String)obj)
}

Не в Котлине: 

val obj: Any = "Any object"
 
if(obj is String) {
    hello(obj)
}

Увидеть? obj имеет тип  Any ( Object в терминах Java), поэтому вызов  hello(obj) обречен на неудачу, верно? Не совсем. Компилятор может  доказать,  что  obj он на самом деле является типом,  String поэтому он выполняет автоматический безопасный переход для нас. Ухоженная! Но вернемся к  null обработке.

Я много говорил о даункинге, вспоминая, что у любого ненулевого типа  T есть супер-тип обнуляемый  T?. Как и в любом другом полиморфном языке, апкастинг неявен. Другими словами, мы можем передать тип,  T когда  T? требуется — что вполне очевидно:

val str: String = "Hello"     //String type can be inferred here
unsafeHello(str)
 
fun unsafeHello(name: String?) {
 
}

Интересно, что примитивы также могут быть обнуляемыми:

fun safePositive(x: Int) = x > 0
fun unsafePositive(x: Int?): Boolean = x != null && x > 0

В сгенерированном байт-коде первый метод занимает  int время, а второй  java.lang.Integer. Пока мы это делаем, компилируются первые два выражения, но не последнее:

if(unsafePositive(maybeInt)) {
    //...
}
 
if(maybeInt != null && safePositive(maybeInt)) {
    //...
}
 
if(safePositive(maybeInt)) {
    //...
}

Первое выражение имеет идеальное совпадение типов ( Int? против  Int?). Во втором случае компилятор может  доказать,  что  maybeInt может быть понижен до  Intтребуемого  safePositive(). В последнем случае это невозможно доказать, что приведет к ошибке компиляции несоответствия типов.

Пока это выглядит великолепно — нулевая безопасность без дополнительных затрат времени выполнения. Однако совместимость Java является ахиллесовой пятой Котлина. В Scala  Option[T] оболочка реализована поверх языка, а сама Scala допускает  null взаимодействие с Java. Вы не увидите  null в идиоматическом коде Scala, но он иногда появляется при взаимодействии с коллекциями и библиотеками Java. Обычно Option(javaMethod()) требуется дополнительное  делегирование.

Однако Kotlin использует гораздо более агрессивный подход: каждый параметр каждого метода Java считается обнуляемым (что нас не волнует), но также каждое возвращаемое значение обнуляется — если не указано иное. Оказывается, компилятор Kotlin обладает некоторыми знаниями о JDK:

val formatted: String = String.format("Kotlin-is-%s", "cool")
val joined:    String = String.join("-", "Kotlin", "is", "cool")

Первая строка компилируется просто отлично, Котлин знает, что  String.format() никогда не вернется  null. Однако этого нельзя сказать о  String.join()новом для Java 8. Таким образом, даже если он String.join()никогда не  вернется  null , вы все равно получите  String? предполагаемый тип. То же самое относится к любой библиотеке или вашему пользовательскому коду Java. К сожалению, @javax.validation.constraints.NotNull аннотации не помогают, не говоря уже о том, что вы не можете добавлять аннотации в код библиотеки / JDK.

Ну … вы вроде как можете … IntelliJ IDEA имеет скрытую функцию под названием  Внешние аннотации, которая позволяет вам аннотировать произвольный метод, даже во внешних JAR-файлах. Вы не можете изменить внешний код, поэтому такие аннотации хранятся в специальном  annotations.xml файле:

<root>
    <item name='java.lang.String java.lang.String join(java.lang.CharSequence, java.lang.CharSequence...)'>
        <annotation name='org.jetbrains.annotations.NotNull'/>
    </item>
</root>

Это объявление (конечно, IntelliJ управляет им для вас) сообщает компилятору Kotlin, что он String.join() не может вернуться  null. Поскольку наш код не будет компилироваться без него, он должен быть проверен в системе контроля версий и станет частью вашей базы кода.

Не похоже, что эта проблема скоро исчезнет. Всегда будут библиотеки без @NotNull аннотаций, и компилятор не сможет определить, является ли метод Java обнуляемым или нет (особенно принимая динамический характер загрузки классов и CLASSPATH). Более портативное решение — просто прекратить приведение к нулевому типу

String.join("-", "Kotlin", "is", "cool") as String

… но это кажется излишним.

Подводя итог:  null обработка в Kotlin является одновременно радикальной (безопасность типов и проверка во время компиляции) и консервативной ( null все еще здесь, без функционального монадического стиля). Я только надеюсь, что грубые грани в совместимости с Java со временем исчезнут.