Appeler du code Kotlin à partir de Java

Dans cet atelier de programmation, vous allez apprendre à écrire ou à adapter votre code Kotlin afin de l'appeler facilement à partir d'un code Java.

Points abordés

  • Comment utiliser @JvmField, @JvmStatic et d'autres annotations.
  • Limitations liées à l'accès à certaines fonctionnalités du langage Kotlin à partir du code Java.

Ce que vous devez déjà savoir

Cet atelier de programmation est destiné aux programmeurs et suppose des connaissances de base en Java et Kotlin.

Cet atelier de programmation simule la migration d'une partie d'un projet plus vaste écrit avec le langage de programmation Java, pour y intégrer un nouveau code Kotlin.

Pour simplifier les choses, nous aurons un seul fichier .java appelé UseCase.java, qui représentera la base de code existante.

Imaginons que nous venons de remplacer une fonctionnalité initialement écrite en Java par une nouvelle version écrite en Kotlin, et que nous devons terminer de l'intégrer.

Importer le projet

Le code du projet peut être cloné à partir du projet GitHub ici : GitHub

Vous pouvez également télécharger et extraire le projet à partir d'une archive ZIP disponible ici :

Télécharger le fichier ZIP

Si vous utilisez IntelliJ IDEA, sélectionnez "Import Project" (Importer le projet).

Si vous utilisez Android Studio, sélectionnez "Import project (Gradle, Eclipse ADT, etc.)" (Importer un projet (Gradle, Eclipse ADT, etc.)).

Ouvrons UseCase.java et commençons à résoudre les erreurs que nous voyons.

La première fonction qui pose problème est registerGuest :

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

Les erreurs pour Repository.getNextGuestId() et Repository.addUser(...) sont les mêmes : "Non-static cannot be accessed from a static context." (Non-static ne peut pas être consulté à partir d'un contexte statique.)

Examinons maintenant l'un des fichiers Kotlin. Ouvrez le fichier Repository.kt.

Nous constatons que notre dépôt est un singleton déclaré à l'aide du mot clé "object". Le problème est que Kotlin génère une instance statique à l'intérieur de notre classe, au lieu de les exposer en tant que propriétés et méthodes statiques.

Par exemple, Repository.getNextGuestId() peut être référencé à l'aide de Repository.INSTANCE.getNextGuestId(), mais il existe une meilleure solution.

Nous pouvons demander à Kotlin de générer des méthodes et des propriétés statiques en annotant les propriétés et méthodes publiques du dépôt avec @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)
   }
}

Ajoutez l'annotation @JvmStatic à votre code à l'aide de votre IDE.

Si nous revenons à UseCase.java, les propriétés et les méthodes de Repository ne provoquent plus d'erreurs, à l'exception de Repository.BACKUP_PATH. Nous y reviendrons plus tard.

Pour l'instant, corrigeons l'erreur suivante dans la méthode registerGuest().

Prenons l'exemple suivant : nous avions une classe StringUtils avec plusieurs fonctions statiques pour les opérations sur les chaînes. Lorsque nous l'avons converti en Kotlin, nous avons converti les méthodes en fonctions d'extension. Java ne possède pas de fonctions d'extension. Kotlin compile donc ces méthodes en tant que fonctions statiques.

Malheureusement, si nous examinons la méthode registerGuest() dans UseCase.java, nous constatons que quelque chose ne va pas :

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

En effet, Kotlin place ces fonctions "de premier niveau" ou de niveau package dans une classe dont le nom est basé sur le nom de fichier. Dans ce cas, comme le fichier est nommé StringUtils.kt, la classe correspondante est nommée StringUtilsKt.

Nous pourrions remplacer toutes les références à StringUtils par StringUtilsKt et corriger cette erreur, mais ce n'est pas l'idéal, car :

  • Il peut y avoir de nombreux endroits dans notre code qui devront être mis à jour.
  • Le nom lui-même est maladroit.

Plutôt que de refactoriser notre code Java, mettons à jour notre code Kotlin pour utiliser un autre nom pour ces méthodes.

Ouvrez StringUtils.Kt, puis recherchez la déclaration de package suivante :

package com.google.example.javafriendlykotlin

Nous pouvons demander à Kotlin d'utiliser un nom différent pour les méthodes au niveau du package en utilisant l'annotation @file:JvmName. Utilisons cette annotation pour nommer la classe StringUtils.

@file:JvmName("StringUtils")

package com.google.example.javafriendlykotlin

Maintenant, si nous revenons à UseCase.java, nous pouvons voir que l'erreur pour StringUtils.nameToLogin() a été résolue.

Malheureusement, cette erreur a été remplacée par une nouvelle concernant les paramètres transmis au constructeur pour User. Passons à l'étape suivante et corrigeons cette dernière erreur dans UseCase.registerGuest().

Kotlin accepte les valeurs par défaut pour les paramètres. Pour voir comment ils sont utilisés, examinons le bloc init de Repository.kt.

Repository.kt:

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

Nous pouvons constater que pour l'utilisateur"warlow", nous pouvons omettre la valeur de displayName, car une valeur par défaut est spécifiée pour cette variable dans User.kt.

User.kt:

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

Malheureusement, cela ne fonctionne pas de la même manière lorsque vous appelez la méthode depuis Java.

UseCase.java:

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

Les valeurs par défaut ne sont pas acceptées dans le langage de programmation Java. Pour résoudre ce problème, demandons à Kotlin de générer des surcharges pour notre constructeur à l'aide de l'annotation @JvmOverloads.

Nous devons d'abord apporter une petite modification à User.kt.

Étant donné que la classe User ne comporte qu'un seul constructeur principal et que celui-ci n'inclut aucune annotation, le mot clé constructor a été omis. Cependant, maintenant que nous souhaitons l'annoter, le mot clé constructor doit être inclus :

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

Avec le mot clé constructor, nous pouvons ajouter l'annotation @JvmOverloads :

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

Si nous revenons à UseCase.java, nous pouvons voir qu'il n'y a plus d'erreurs dans la fonction registerGuest.

L'étape suivante consiste à corriger l'appel cassé à user.hasSystemAccess() dans UseCase.getSystemUsers(). Passez à l'étape suivante pour en savoir plus ou continuez à lire cet article pour découvrir plus en détail ce que @JvmOverloads a fait pour corriger l'erreur.

@JvmOverloads

Pour mieux comprendre ce que fait @JvmOverloads, créons une méthode de test dans 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);
}

Nous pouvons construire un User avec seulement deux paramètres, id et username :

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

Nous pouvons également construire un User en incluant un troisième paramètre pour displayName tout en utilisant toujours la valeur par défaut pour groups :

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

Toutefois, il n'est pas possible d'ignorer displayName et de fournir uniquement une valeur pour groups sans écrire de code supplémentaire :

Supprimons donc cette ligne ou ajoutons "//" devant pour la mettre en commentaire.

En Kotlin, si nous voulons combiner des paramètres par défaut et non par défaut, nous devons utiliser des paramètres nommés.

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

En effet, Kotlin génère des surcharges pour les fonctions, y compris les constructeurs, mais ne crée qu'une seule surcharge par paramètre avec une valeur par défaut.

Revenons à UseCase.java et abordons notre prochain problème : l'appel à user.hasSystemAccess() dans la méthode 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;
}

Voilà une erreur intéressante ! Si vous utilisez la fonctionnalité de saisie semi-automatique de votre IDE sur la classe User, vous remarquerez que hasSystemAccess() a été renommé getHasSystemAccess().

Pour résoudre le problème, nous aimerions que Kotlin génère un autre nom pour la propriété val hasSystemAccess. Pour ce faire, nous pouvons utiliser l'annotation @JvmName. Revenons à User.kt et voyons où l'appliquer.

Nous pouvons appliquer l'annotation de deux façons. La première consiste à l'appliquer directement à la méthode get(), comme ceci :

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

Cela indique à Kotlin de modifier la signature du getter défini de manière explicite en fonction du nom fourni.

Vous pouvez également l'appliquer à la propriété en utilisant un préfixe get: comme suit :

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

La méthode alternative est particulièrement utile pour les propriétés qui utilisent un getter par défaut défini de manière implicite. Exemple :

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

Cela permet de modifier le nom du getter sans avoir à le définir explicitement.

Malgré cette distinction, vous pouvez utiliser celle qui vous convient le mieux. Dans les deux cas, Kotlin créera un getter nommé hasSystemAccess().

Si nous revenons à UseCase.java, nous pouvons vérifier que getSystemUsers() ne comporte plus d'erreurs.

L'erreur suivante se trouve dans formatUser(), mais si vous souhaitez en savoir plus sur la convention de dénomination des getters Kotlin, continuez votre lecture avant de passer à l'étape suivante.

Nommage des getters et des setters

Lorsque nous écrivons du code Kotlin, il est facile d'oublier que l'écriture de code tel que :

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

appelle en fait une fonction pour obtenir la valeur de displayName. Pour le vérifier, accédez à Tools > Kotlin > Show Kotlin Bytecode (Outils > Kotlin > Afficher le bytecode Kotlin) dans le menu, puis cliquez sur le bouton Decompile (Décompiler) :

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

Lorsque nous souhaitons y accéder depuis Java, nous devons écrire explicitement le nom du getter.

Dans la plupart des cas, le nom Java des getters pour les propriétés Kotlin est simplement get + le nom de la propriété, comme nous l'avons vu avec User.getHasSystemAccess() et User.getDisplayName(). La seule exception concerne les propriétés dont le nom commence par "is". Dans ce cas, le nom Java du getter est le nom de la propriété Kotlin.

Par exemple, une propriété sur User telle que :

val isAdmin get() = //...

L'accès depuis Java se ferait avec :

boolean userIsAnAdmin = user.isAdmin();

En utilisant l'annotation @JvmName, Kotlin génère un bytecode qui porte le nom spécifié, plutôt que le nom par défaut, pour l'élément annoté.

Il en va de même pour les setters, dont les noms générés sont toujours set + nom de la propriété. Prenons l'exemple de la classe suivante :

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

Imaginons que nous voulions remplacer le nom du setter setRed() par updateRed(), tout en laissant les getters tels quels. Pour ce faire, nous pouvons utiliser la version @set:JvmName :

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

Depuis Java, nous pourrions alors écrire :

color.updateRed(0.8f);

UseCase.formatUser() utilise l'accès direct aux champs pour obtenir les valeurs des propriétés d'un objet User.

En Kotlin, les propriétés sont normalement exposées par le biais de getters et de setters. Cela inclut les propriétés val.

Il est possible de modifier ce comportement à l'aide de l'annotation @JvmField. Lorsque ce modificateur est appliqué à une propriété d'une classe, Kotlin ne génère pas de méthodes getter (ni setter pour les propriétés var), et le champ de stockage est directement accessible.

Étant donné que les objets User sont immuables, nous souhaitons exposer chacune de leurs propriétés en tant que champs. Nous allons donc annoter chacun d'eux avec @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
}

Si nous revenons à UseCase.formatUser(), nous pouvons voir que les erreurs ont été corrigées.

@JvmField ou const

Une autre erreur semblable s'affiche dans le fichier UseCase.java :

Repository.saveAs(Repository.BACKUP_PATH);

Si nous utilisons l'autocomplétion ici, nous pouvons voir qu'il y a un Repository.getBACKUP_PATH(). Il peut donc être tentant de modifier l'annotation sur BACKUP_PATH de @JvmStatic à @JvmField.

Essayons ceci. Revenez à Repository.kt et mettez à jour l'annotation :

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

Si nous examinons UseCase.java maintenant, nous constatons que l'erreur a disparu, mais qu'il existe également une note sur BACKUP_PATH :

En langage Kotlin, les seuls types qui peuvent être const sont les types primitifs, tels que int, float et String. Dans ce cas, comme BACKUP_PATH est une chaîne, nous pouvons obtenir de meilleures performances en utilisant const val plutôt qu'un val annoté avec @JvmField, tout en conservant la possibilité d'accéder à la valeur en tant que champ.

Modifions cela maintenant dans Repository.kt :

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

Si nous revenons à UseCase.java, nous pouvons voir qu'il ne reste qu'une seule erreur.

Le message d'erreur final indique Exception: 'java.io.IOException' is never thrown in the corresponding try block.

Si nous examinons le code de Repository.saveAs dans Repository.kt, nous constatons qu'il génère une exception. Que se passe-t-il ?

Java utilise le concept d'"exception vérifiée". Il s'agit d'exceptions qui peuvent être récupérées, par exemple lorsque l'utilisateur a mal saisi un nom de fichier ou que le réseau est temporairement indisponible. Une fois l'exception vérifiée interceptée, le développeur peut fournir à l'utilisateur des informations sur la façon de résoudre le problème.

Étant donné que les exceptions vérifiées sont vérifiées au moment de la compilation, vous devez les déclarer dans la signature de la méthode :

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

Kotlin, en revanche, n'a pas d'exceptions vérifiées, ce qui est à l'origine du problème ici.

La solution consiste à demander à Kotlin d'ajouter l'IOException potentiellement générée à la signature de Repository.saveAs(), afin que le bytecode JVM l'inclue en tant qu'exception vérifiée.

Pour ce faire, nous utilisons l'annotation Kotlin @Throws, qui facilite l'interopérabilité Java/Kotlin. En Kotlin, les exceptions se comportent de la même manière qu'en Java, mais contrairement à Java, Kotlin ne comporte que des exceptions non vérifiées. Par conséquent, si vous souhaitez informer votre code Java qu'une fonction Kotlin lève une exception, vous devez utiliser l'annotation @Throws pour la signature de la fonction Kotlin. Passez à Repository.kt file et mettez à jour saveAs() pour inclure la nouvelle annotation :

@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...
}

Avec l'annotation @Throws en place, nous pouvons voir que toutes les erreurs de compilation dans UseCase.java sont corrigées. Parfait !

Vous vous demandez peut-être si vous devrez désormais utiliser des blocs try et catch lorsque vous appellerez saveAs() depuis Kotlin.

Non. N'oubliez pas que Kotlin n'a pas d'exceptions vérifiées et que l'ajout de @Throws à une méthode ne change rien :

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

Il est toujours utile d'intercepter les exceptions lorsqu'elles peuvent être gérées, mais Kotlin ne vous oblige pas à les gérer.

Dans cet atelier de programmation, nous avons abordé les bases de l'écriture de code Kotlin qui prend également en charge l'écriture de code Java idiomatique.

Nous avons vu comment utiliser les annotations pour modifier la façon dont Kotlin génère son bytecode JVM, par exemple :

  • @JvmStatic pour générer des membres et des méthodes statiques.
  • @JvmOverloads pour générer des méthodes surchargées pour les fonctions ayant des valeurs par défaut.
  • @JvmName pour modifier le nom des getters et des setters.
  • @JvmField pour exposer une propriété directement en tant que champ, plutôt que via des getters et des setters.
  • @Throws pour déclarer les exceptions vérifiées.

Le contenu final de nos fichiers est le suivant :

User.kt

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
}