liksi logo

Communiquez vos intentions avec les fonctions d'extension Kotlin

Noé Maillard - Publié le 13/04/2023
Communiquez vos intentions avec les fonctions d'extension Kotlin

Les fonctions d’extension Kotlin sont une des fonctionnalités les plus puissantes de Kotlin. Qui n’a jamais voulu rajouter une fonctionnalité à une librairie, se débarrasser de toutes ses classes Util, améliorer la lisibilité du code dans un traitement complexe ? Les fonctions d’extension nous permettent de faire tout ça et plus. Mais alors attention, l’oncle Ben nous le disait il y a bien longtemps déjà :

With great Power comes great Responsibility

Eh oui, on peut faire plein de mauvaises choses : pollution du scope, complexité accidentelle, surcharge de méthodes

Explorons ensemble les différentes manières de se servir des fonctions d’extension, en examinant divers cas d’usage, afin de déterminer si elles constituent un outil adapté pour résoudre un problème donné.

Présentation

Commencons par un peu de théorie, pour nous permettre de comprendre comment écrire une fonction d’extension, l’utiliser et comment elle est interprétée par le compilateur

fun String.extension(param: String) = this + param

On vient de déclarer une fonction d’extension de la classe String, qui s’appelle `extension`. On l’utilise comme un méthode normale :

val li = "Li"
val liksi = "Li".extension("ksi")
println(liksi) // "Liksi"

On notera au passage que c’est un très bon exemple de ce qu’il ne faut pas faire, on a ajouté une complexité qui n’est pas utile.

A la compilation, le compilateur créé une méthode statique dans une nouvelle classe dédiée

public final class ExtensionKt {
    static final String extension(String $this$extension, String param) {
      CharSequence var1 = (CharSequence)$this$extension;
      return var1 + param;
    }
}

On peut remarquer que la compilation a converti le this en premier paramètre du code compilé. On appelle ce paramètre le receiver : il reçoit une extension sur son type. La fonction d’extension a uniquement accès aux propriétés et méthodes publiques d’un type. Même si dans la définition on a accès au this, on est obligé de respecter la visibilité des champs.

Et ça nous permet de faire une remarque très importante : Les fonctions d’extension ne servent pas à communiquer avec la machine, c’est du sucre syntaxique. Elles servent à communiquer entre nous, les développeurs, à transmettre l’intention du code de l’auteur au lecteur. C’est pour cette raison qu’il faut faire très attention quand on utilise ce genre de fonction, puisque la communication entre humains est très libre et donc soumise à une mauvaise interprétation.

Alors c’est parti pour un tour de ce qu’on peut faire et à quoi faire attention quand on utilise les fonctions d’extension !

Mappers

Quand on développe des fonctionnalités, on a souvent besoin de mapper des données d’un format à un autre, par exemple :

data class Order(val orderId: Int, val customer: Customer, val items: List<Item>)
data class Customer(val name: String, val address: String)
data class Item(val name: String, val price: Double)

fun Customer.toCustomerDto() = CustomerDto(name)
fun Item.toItemDto() = ItemDto(name, price)
fun Order.toOrderDto(): OrderDto {
    val itemDtos = items.map { it.toItemDto() }
    return OrderDto(orderId, customer.toCustomerDto(), itemDtos)
}

data class OrderDto(val orderId: Int, val customer: CustomerDto, val items: List<ItemDto>)
data class CustomerDto(val name: String)
data class ItemDto(val name: String, val price: Double)

// utilisation
val order = orderService.getOrder(12)

val orderDTO = order.toOrderDto()

Là on a 3 mappers, réutilisables et faciles à comprendre. Il y a plusieurs avantages à utiliser des fonctions d’extension au lieu de définir les mappers en tant que méthodes de classes:

  • c’est le contexte “consommateur” qui définit le mapper, autorisant plusieurs contextes, plusieurs mappers sans que ceux ci soient définis au même endroit dans le code
  • Cela ouvre la possibilité de définir des mappers sur des classes venant de librairies externes

Raccourcis

Il arrive qu’on utilise des librairies qui sont bien faites pour des cas d’usages généraux, mais on aimerait qu’elles correspondent mieux au cas d’usage spécifique qu’on rencontre. Pour illustrer ce cas d’usage, on va s’aider de TestContainers, qui nous monte un conteneur SFTP pour des tests. On veut venir vérifier que les fichiers sont au bon endroit.

fun SftpContainer.ls(path: String) = execInContainer("ls", "/home/user/$path").stdout.split("\n")
val container = SftpContainer(config)


// before
val files = container.execInContainer("ls", "/home/user/path/to/file").stdout.split("\n")

// after
val files = container.ls("path/to/file")

On voit bien ici que l’intention du code écrit est bien plus simple à lire que la méthode originale. Ces raccourcis sont très pratiques si on les utilise souvent dans un projet, et s’ils sont bien documentés. Attention à ne pas faire des raccourcis pour tout, ça peut complexifier plus que simplifier le code.

Extension Privées

Un des plus gros danger des fonctions d’extension, c’est l’extension des types primitifs. Ça vient polluer le scope et on se retrouve avec des suggestions IntelliJ dans tous les sens !

Ça ne veut pas pour autant dire qu’on ne peut pas en faire, il faut juste les faire en privé. Par exemple, dans une fonction qui normalise les numéros de téléphone :

class PhoneService(private val caller: Caller) {
    private fun String.normalize() = replace("""^(\+)?(0|33)""".toRegex(), "+33")
    
    fun makePhoneCall(number: String) = caller.dial(number.normalize())
}

"John Doe".normalize() // Erreur de compilation, normalize n'est pas accessible ici

Ici, pas de soucis, la fonction normalize() n’est accessible que depuis la classe dans laquelle elle est utilisée et ne vient pas polluer la classe String dans le reste de la base de code.

Extension anonymes

Les fonctions d’extension peuvent être anonymes et passées en paramètres de fonction, ce qui donne la possibilité de faire des builders avec des DSL ! Ici, un petit exemple avec un builder (simplifié) pour construire une URI

data class URL(
    val host: String,
    var port: Int? = null,
    var protocol: String = "http",
    val queries: MutableList<Query> = mutableListOf()
) {
    data class Query(
        var key: String,
        var value: String
    )

    override fun toString() = "$protocol://$host${port?.let { ":$it" } ?: ""}${queries.joinToString("&", "?") { "${it.key}=${it.value}" }}"
    
}

fun URL.query(key: String, value: String) {
    queries.add(URL.Query(key, value))
}


fun url(host: String, init: URL.() -> Unit): URL {
    val url = URL(host)
    url.init()
    return url
}

val liksiURL = url("liksi.fr") {
    protocol = "https"
    port = 443
    query("q", "kotlin")
    query("lang", "fr")
}

println(liksiURL)
// https://liksi.fr:443?q=kotlin&lang=fr

La fonction d’extension `init` est bien cachée, ce qui est une bonne chose, ça nous permet d’initialiser le builder et de le configurer de manière très naturelle. en bonus, la fonction d’extension appelle une autre fonction d’extension qui renseigne les `queries`

Réification

Dans un projet Kotlin sur la JVM, on utilise beaucoup de librairies Java, avec lesquelles il y a une interopérabilité. Seulement en Kotlin, dans le cas des paramètres de type Class, il existe un principe qui s’appelle la réification:

Wikipedia : La réification (du latin res, rei « chose ») consiste à réifier, c’est-à-dire à donner les caractéristiques ou transformer en chose ce qui ne l’est pas, tel que considérer une personne comme un objet ou bien une idée abstraite comme un élément concret, ou à leur donner un caractère statique ou figé

Dans notre cas, la réification signifie pouvoir utiliser la valeur d’un type au runtime (sur la JVM, le typage générique est effacé à la compilation).

Imaginons une librairie Java qui permet de requêter une source de données sur des types génériques :

interface Client {
    <T> T getEntity(String query, Class<T> responseType);
}

Client client = ClientImpl()
User user = client.getEntity<User>("query", User.class)

C’est un peu lourd d’avoir à déclarer 2 fois le type. Grâce à une fonction d’extension, on peut rendre ça plus agréable à lire :

inline fun <reified T> Client.getEntity(query: String) = getEntity(query, T::class.java)

val client = ClientImpl()
val user = client.getEntity<User>("query")

Les fonctions réifiées ont une petite particularité elles n’existent pas dans la JVM, elle est copiée collée avant la compilation. Il faut donc être attentif aux deux points suivants :

  • si vous développez une librairie destinée à être consommée par un projet en Java :

Kotlin se vante d’etre un language 100 % compatible avec Java, on pourrait donc se demander comment on peut appeler une fonction qui n’existe pas dans la JVM depuis Java. La réponse est simple, on ne peux pas. c’est un des rares cas de non compatibilité, le compilateur ne génère pas la méthode.

  • si elle est appelée à beaucoup d’endroits dans le code, et/ou qu’elle est longue, son utilisation entraînera une grosse empreinte du bytecode, puisqu’elle est “copiée collée” avant la compilation en bytecode

Conclusion

Nous pouvons tirer quelques enseignements de ces exemples. Les fonctions d’extension sont souvent de courts extraits de code qui expriment l’intention du développeur. Elles doivent être utilisées avec prudence et de manière sélective. Toutefois, lorsqu’elles sont employées correctement, elles peuvent transformer un code complexe et difficile à comprendre en quelque chose de très clair et agréable à lire.

Derniers articles