Вызов кода Kotlin из Java

В этой лабораторной работе вы узнаете, как написать или адаптировать код Kotlin, чтобы сделать его более удобным для вызова из кода Java.

Чему вы научитесь

  • Как использовать @JvmField , @JvmStatic и другие аннотации.
  • Ограничения при доступе к некоторым функциям языка Kotlin из кода Java.

Что вы уже должны знать

Эта лабораторная работа написана для программистов и предполагает базовые знания Java и Kotlin.

В этой лабораторной работе имитируется миграция части более крупного проекта, написанного на языке программирования Java, для включения нового кода Kotlin.

Для упрощения у нас будет один файл .java с именем UseCase.java , который будет представлять существующую кодовую базу.

Представим, что мы только что заменили некоторую функциональность, изначально написанную на Java, новой версией, написанной на Kotlin, и нам нужно завершить ее интеграцию.

Импортировать проект

Код проекта можно клонировать из проекта GitHub здесь: GitHub

Кроме того, вы можете загрузить и извлечь проект из zip-архива, который можно найти здесь:

Загрузить ZIP-архив

Если вы используете IntelliJ IDEA, выберите «Импортировать проект».

Если вы используете Android Studio, выберите «Импорт проекта (Gradle, Eclipse ADT и т. д.)».

Давайте откроем UseCase.java и начнем разбираться с ошибками, которые мы видим.

Первая функция, вызвавшая проблему, — registerGuest :

public static User registerGuest(String name) {
   User guest = new User(Repository.getNextGuestId(), StringUtils.nameToLogin(name), name);
   Repository.addUser(guest);
   return guest;
}

Ошибки для Repository.getNextGuestId() и Repository.addUser(...) одинаковы: «Нестатический объект не может быть доступен из статического контекста».

Теперь давайте посмотрим на один из файлов Kotlin. Откройте файл Repository.kt .

Мы видим, что наш репозиторий — это синглтон, объявленный с помощью ключевого слова object. Проблема в том, что Kotlin генерирует статический экземпляр внутри нашего класса, а не предоставляет их как статические свойства и методы.

Например, Repository.getNextGuestId() можно обратиться с помощью Repository.INSTANCE.getNextGuestId() , но есть способ лучше.

Мы можем заставить Kotlin генерировать статические методы и свойства, аннотируя открытые свойства и методы репозитория с помощью @JvmStatic :

object Repository {
   val BACKUP_PATH = "/backup/user.repo"

   private val _users = mutableListOf<User>()
   private var _nextGuestId = 1000

   @JvmStatic
   val users: List<User>
       get() = _users

   @JvmStatic
   val nextGuestId
       get() = _nextGuestId++

   init {
       _users.add(User(100, "josh", "Joshua Calvert", listOf("admin", "staff", "sys")))
       _users.add(User(101, "dahybi", "Dahybi Yadev", listOf("staff", "nodes")))
       _users.add(User(102, "sarha", "Sarha Mitcham", listOf("admin", "staff", "sys")))
       _users.add(User(103, "warlow", groups = listOf("staff", "inactive")))
   }

   @JvmStatic
   fun saveAs(path: String?):Boolean {
       val backupPath = path ?: return false

       val outputFile = File(backupPath)
       if (!outputFile.canWrite()) {
           throw FileNotFoundException("Could not write to file: $backupPath")
       }
       // Write data...
       return true
   }

   @JvmStatic
   fun addUser(user: User) {
       // Ensure the user isn't already in the collection.
       val existingUser = users.find { user.id == it.id }
       existingUser?.let { _users.remove(it) }
       // Add the user.
       _users.add(user)
   }
}

Добавьте аннотацию @JvmStatic в свой код, используя IDE.

Если вернуться к UseCase.java , свойства и методы Repository больше не будут вызывать ошибок, за исключением Repository.BACKUP_PATH . Мы вернёмся к этому позже.

А пока давайте исправим следующую ошибку в методе registerGuest() .

Рассмотрим следующий сценарий: у нас есть класс StringUtils с несколькими статическими функциями для работы со строками. При конвертации его в Kotlin мы преобразовали методы в функции расширения . В Java нет функций расширения, поэтому Kotlin компилирует эти методы как статические функции.

К сожалению, если мы посмотрим на метод registerGuest() внутри UseCase.java , то увидим, что что-то не так:

User guest = new User(Repository.getNextGuestId(), StringUtils.nameToLogin(name), name);

Причина в том, что Kotlin помещает эти функции «верхнего уровня» или уровня пакета в класс, имя которого основано на имени файла. В данном случае, поскольку файл называется StringUtils.kt, соответствующий класс называется StringUtilsKt .

Мы могли бы изменить все наши ссылки StringUtils на StringUtilsKt и исправить эту ошибку, но это неидеально, потому что:

  • В нашем коде может быть много мест, которые необходимо обновить.
  • Само название неудобное.

Поэтому вместо того, чтобы реорганизовать наш код Java, давайте обновим наш код Kotlin, чтобы использовать другое имя для этих методов.

Откройте StringUtils.Kt и найдите следующее объявление пакета:

package com.google.example.javafriendlykotlin

Мы можем указать Kotlin использовать другое имя для методов уровня пакета, используя аннотацию @file:JvmName . Давайте используем эту аннотацию для имени класса StringUtils .

@file:JvmName("StringUtils")

package com.google.example.javafriendlykotlin

Теперь, если мы вернемся к UseCase.java , то увидим, что ошибка для StringUtils.nameToLogin() была устранена.

К сожалению, эта ошибка была заменена новой, связанной с параметрами, передаваемыми в конструктор для User . Перейдём к следующему шагу и исправим эту последнюю ошибку в UseCase.registerGuest() .

Kotlin поддерживает значения по умолчанию для параметров . Мы можем увидеть, как они используются, заглянув в блок init файла Repository.kt .

Repository.kt:

_users.add(User(102, "sarha", "Sarha Mitcham", listOf("admin", "staff", "sys")))
_users.add(User(103, "warlow", groups = listOf("staff", "inactive")))

Мы видим, что для пользователя «warlow» мы можем пропустить указание значения displayName , поскольку для него в User.kt указано значение по умолчанию.

User.kt:

data class User(
   val id: Int,
   val username: String,
   val displayName: String = username.toTitleCase(),
   val groups: List<String> = listOf("guest")
)

К сожалению, при вызове метода из Java это работает не так.

UseCase.java:

User guest = new User(Repository.getNextGuestId(), StringUtils.nameToLogin(name), name);

Значения по умолчанию не поддерживаются в языке программирования Java. Чтобы исправить это, давайте заставим Kotlin генерировать перегрузки для нашего конструктора с помощью аннотации @JvmOverloads .

Во-первых, нам нужно сделать небольшое обновление в User.kt

Поскольку класс User имеет только один основной конструктор , и конструктор не содержит аннотаций, ключевое слово constructor было опущено. Однако теперь, когда мы хотим добавить аннотацию, ключевое слово constructor необходимо включить:

data class User constructor(
    val id: Int,
    val username: String,
    val displayName: String = username.toTitleCase(),
    val groups: List<String> = listOf("guest")
)

При наличии ключевого слова constructor мы можем добавить аннотацию @JvmOverloads :

data class User @JvmOverloads constructor(
    val id: Int,
    val username: String,
    val displayName: String = username.toTitleCase(),
    val groups: List<String> = listOf("guest")
)

Если вернуться к UseCase.java , то можно увидеть, что в функции registerGuest больше нет ошибок!

Следующий шаг — исправить неисправный вызов user.hasSystemAccess() в UseCase.getSystemUsers() . Перейдите к следующему шагу или продолжайте читать, чтобы подробнее узнать, как @JvmOverloads исправил ошибку.

@JvmOverloads

Чтобы лучше понять, что делает @JvmOverloads , давайте создадим тестовый метод в UseCase.java :

private void testJvmOverloads() {
   User syrinx = new User(1001, "syrinx");
   User ione = new User(1002, "ione", "Ione Saldana");

   List<String> groups = new ArrayList<>();
   groups.add("staff");
   User beaulieu = new User(1002, "beaulieu", groups);
}

Мы можем создать User , используя всего два параметра: id и username :

User syrinx = new User(1001, "syrinx");

Мы также можем создать User , включив третий параметр для displayName , при этом продолжая использовать значение по умолчанию для groups :

User ione = new User(1002, "ione", "Ione Saldana");

Но невозможно пропустить displayName и просто указать значение для groups без написания дополнительного кода:

Давайте удалим эту строку или добавим к ней префикс «//», чтобы закомментировать ее.

В Kotlin, если мы хотим объединить параметры по умолчанию и параметры, не являющиеся параметрами по умолчанию, нам нужно использовать именованные параметры.

// This doesn't work...
User(104, "warlow", listOf("staff", "inactive"))
// But using named parameters, it does...
User(104, "warlow", groups = listOf("staff", "inactive"))

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

Давайте вернемся к UseCase.java и рассмотрим нашу следующую проблему: вызов user.hasSystemAccess() в методе UseCase.getSystemUsers() :

public static List<User> getSystemUsers() {
   ArrayList<User> systemUsers = new ArrayList<>();
   for (User user : Repository.getUsers()) {
       if (user.hasSystemAccess()) {     // Now has an error!
           systemUsers.add(user);
       }
   }
   return systemUsers;
}

Это интересная ошибка! Если вы используете функцию автодополнения вашей IDE для класса User , вы заметите, что hasSystemAccess() был переименован в getHasSystemAccess() .

Чтобы решить эту проблему, нам нужно, чтобы Kotlin генерировал другое имя для свойства val hasSystemAccess . Для этого можно использовать аннотацию @JvmName . Вернёмся к User.kt и посмотрим, где её следует применить.

Существует два способа применения аннотации. Первый — применить её непосредственно к методу get() , например:

val hasSystemAccess
   @JvmName("hasSystemAccess")
   get() = "sys" in groups

Это сигнал для Kotlin о необходимости изменить сигнатуру явно определенного геттера на предоставленное имя.

В качестве альтернативы его можно применить к свойству, используя префикс get: следующим образом:

@get:JvmName("hasSystemAccess")
val hasSystemAccess
   get() = "sys" in groups

Альтернативный метод особенно полезен для свойств, использующих неявно заданный геттер по умолчанию. Например:

@get:JvmName("isActive")
val active: Boolean

Это позволяет изменять имя геттера без необходимости явного определения геттера.

Несмотря на это различие, вы можете использовать любой из них, который вам удобнее. В обоих случаях Kotlin создаст геттер с именем hasSystemAccess() .

Если мы вернемся к UseCase.java , то сможем убедиться, что getSystemUsers() теперь не имеет ошибок!

Следующая ошибка находится в formatUser() , но если вы хотите узнать больше о соглашении об именовании геттеров Kotlin, продолжите чтение здесь, прежде чем переходить к следующему шагу.

Именование геттеров и сеттеров

Когда мы пишем на Kotlin, легко забыть, что нужно писать такой код:

val myString = "Logged in as ${user.displayName}")

Фактически вызывает функцию для получения значения displayName . Мы можем убедиться в этом, перейдя в меню «Инструменты» > «Kotlin» > «Показать байт-код Kotlin» и нажав кнопку «Декомпилировать» :

String myString = "Logged in as " + user.getDisplayName();

Если мы хотим получить к ним доступ из Java, нам необходимо явно указать имя геттера.

В большинстве случаев имя геттера в Java для свойств Kotlin — это просто get + имя свойства, как мы видели на примере User.getHasSystemAccess() и User.getDisplayName() . Единственное исключение — свойства, имена которых начинаются с «is». В этом случае имя геттера в Java — это имя свойства Kotlin.

Например, свойство User такое:

val isAdmin get() = //...

Доступ из Java можно осуществить с помощью:

boolean userIsAnAdmin = user.isAdmin();

Используя аннотацию @JvmName , Kotlin генерирует байт-код с указанным именем, а не именем по умолчанию, для аннотируемого элемента.

Это работает так же и для сеттеров, чьи имена всегда генерируются set + property name. Например, возьмём следующий класс:

class Color {
   var red = 0f
   var green = 0f
   var blue = 0f
}

Предположим, мы хотим изменить имя сеттера с setRed() на updateRed() , оставив геттеры нетронутыми. Для этого можно использовать версию @set:JvmName :

class Color {
   @set:JvmName("updateRed")
   var red = 0f
   @set:JvmName("updateGreen")
   var green = 0f
   @set:JvmName("updateBlue")
   var blue = 0f
}

Тогда на Java мы сможем написать:

color.updateRed(0.8f);

UseCase.formatUser() использует прямой доступ к полю для получения значений свойств объекта User .

В Kotlin свойства обычно предоставляются через геттеры и сеттеры. Это касается и свойств val .

Это поведение можно изменить с помощью аннотации @JvmField . При применении её к свойству класса Kotlin пропускает генерацию методов getter (и setter для свойств var ), и к резервному полю можно обращаться напрямую.

Поскольку объекты User являются неизменяемыми, мы хотели бы представить каждое из их свойств как поле, поэтому мы аннотируем каждое из них с помощью @JvmField :

data class User @JvmOverloads constructor(
   @JvmField val id: Int,
   @JvmField val username: String,
   @JvmField val displayName: String = username.toTitleCase(),
   @JvmField val groups: List<String> = listOf("guest")
) {
   @get:JvmName("hasSystemAccess")
   val hasSystemAccess
       get() = "sys" in groups
}

Если мы сейчас еще раз посмотрим на UseCase.formatUser() то увидим, что ошибки были исправлены!

@JvmField или const

При этом в файле UseCase.java есть еще одна похожая ошибка:

Repository.saveAs(Repository.BACKUP_PATH);

Если мы здесь используем автозаполнение, то увидим, что есть Repository.getBACKUP_PATH() , и поэтому может возникнуть соблазн изменить аннотацию BACKUP_PATH с @JvmStatic на @JvmField .

Давайте попробуем это. Вернёмся к Repository.kt и обновим аннотацию:

object Repository {
   @JvmField
   val BACKUP_PATH = "/backup/user.repo"

Если мы сейчас посмотрим на UseCase.java , то увидим, что ошибка исчезла, но также есть примечание о BACKUP_PATH :

В Kotlin const могут быть только примитивные типы, такие как int , float и String . В данном случае, поскольку BACKUP_PATH — это строка, мы можем повысить производительность, используя const val вместо val с аннотацией @JvmField , сохраняя при этом возможность доступа к значению как к полю.

Давайте изменим это сейчас в Repository.kt:

object Repository {
   const val BACKUP_PATH = "/backup/user.repo"

Если мы еще раз посмотрим на UseCase.java , то увидим, что осталась только одна ошибка.

Последняя ошибка гласит: Exception: 'java.io.IOException' is never thrown in the corresponding try block.

Однако если мы посмотрим на код Repository.saveAs в Repository.kt , то увидим, что он выдаёт исключение. Что происходит?

В Java есть концепция «проверяемых исключений». Это исключения, которые можно восстановить, например, если пользователь допустил ошибку при вводе имени файла или сеть временно недоступна. После перехвата проверяемого исключения разработчик может предоставить пользователю обратную связь с инструкциями по устранению проблемы.

Поскольку проверяемые исключения проверяются во время компиляции, вы объявляете их в сигнатуре метода:

public void openFile(File file) throws FileNotFoundException {
   // ...
}

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

Решение состоит в том, чтобы попросить Kotlin добавить IOException , который потенциально выбрасывается в сигнатуру Repository.saveAs() , чтобы байт-код JVM включил его как проверяемое исключение.

Мы делаем это с помощью аннотации @Throws в Kotlin, которая способствует взаимодействию Java/Kotlin. В Kotlin исключения ведут себя аналогично Java, но, в отличие от Java, в Kotlin есть только непроверяемые исключения. Поэтому, если вы хотите сообщить своему коду Java, что функция Kotlin генерирует исключение, вам нужно использовать аннотацию @Throws в сигнатуре функции Kotlin. Перейдите в Repository.kt file и обновите saveAs() , включив новую аннотацию:

@JvmStatic
@Throws(IOException::class)
fun saveAs(path: String?) {
   val outputFile = File(path)
   if (!outputFile.canWrite()) {
       throw FileNotFoundException("Could not write to file: $path")
   }
   // Write data...
}

Благодаря аннотации @Throws мы видим, что все ошибки компилятора в UseCase.java исправлены! Ура!

Вы можете задаться вопросом, придется ли вам теперь использовать блоки try и catch при вызове saveAs() из Kotlin.

Нет! Помните, в Kotlin нет проверяемых исключений, и добавление @Throws к методу ничего не меняет:

fun saveFromKotlin(path: String) {
   Repository.saveAs(path)
}

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

В этой лабораторной работе мы рассмотрели основы написания кода на языке Kotlin, который также поддерживает написание идиоматического кода на языке Java.

Мы обсудили, как можно использовать аннотации для изменения способа генерации байт-кода JVM в Kotlin, например:

  • @JvmStatic для генерации статических членов и методов.
  • @JvmOverloads для генерации перегруженных методов для функций, имеющих значения по умолчанию.
  • @JvmName для изменения имени геттеров и сеттеров.
  • @JvmField для предоставления свойства непосредственно как поля, а не через геттеры и сеттеры.
  • @Throws для объявления проверяемых исключений.

Окончательное содержимое наших файлов:

Пользователь.кт

data class User @JvmOverloads constructor(
   @JvmField val id: Int,
   @JvmField val username: String,
   @JvmField val displayName: String = username.toTitleCase(),
   @JvmField val groups: List<String> = listOf("guest")
) {
   val hasSystemAccess
       @JvmName("hasSystemAccess")
       get() = "sys" in groups
}

Repository.kt

object Repository {
   const val BACKUP_PATH = "/backup/user.repo"

   private val _users = mutableListOf<User>()
   private var _nextGuestId = 1000

   @JvmStatic
   val users: List<User>
       get() = _users

   @JvmStatic
   val nextGuestId
       get() = _nextGuestId++

   init {
       _users.add(User(100, "josh", "Joshua Calvert", listOf("admin", "staff", "sys")))
       _users.add(User(101, "dahybi", "Dahybi Yadev", listOf("staff", "nodes")))
       _users.add(User(102, "sarha", "Sarha Mitcham", listOf("admin", "staff", "sys")))
       _users.add(User(103, "warlow", groups = listOf("staff", "inactive")))
   }

   @JvmStatic
   @Throws(IOException::class)
   fun saveAs(path: String?):Boolean {
       val backupPath = path ?: return false

       val outputFile = File(backupPath)
       if (!outputFile.canWrite()) {
           throw FileNotFoundException("Could not write to file: $backupPath")
       }
       // Write data...
       return true
   }

   @JvmStatic
   fun addUser(user: User) {
       // Ensure the user isn't already in the collection.
       val existingUser = users.find { user.id == it.id }
       existingUser?.let { _users.remove(it) }
       // Add the user.
       _users.add(user)
   }
}

StringUtils.kt

@file:JvmName("StringUtils")

package com.google.example.javafriendlykotlin

fun String.toTitleCase(): String {
   if (isNullOrBlank()) {
       return this
   }

   return split(" ").map { word ->
       word.foldIndexed("") { index, working, char ->
           val nextChar = if (index == 0) char.toUpperCase() else char.toLowerCase()
           "$working$nextChar"
       }
   }.reduceIndexed { index, working, word ->
       if (index > 0) "$working $word" else word
   }
}

fun String.nameToLogin(): String {
   if (isNullOrBlank()) {
       return this
   }
   var working = ""
   toCharArray().forEach { char ->
       if (char.isLetterOrDigit()) {
           working += char.toLowerCase()
       } else if (char.isWhitespace() and !working.endsWith(".")) {
           working += "."
       }
   }
   return working
}