Les scope functions en Kotlin : let, run, apply, also, with
1. Aperçu
Kotlin a cinq fonctions qui se ressemblent beaucoup : let, run, with, apply et also. Elles exécutent toutes un bloc de code sur un objet, mais elles diffèrent par la façon dont vous accédez à cet objet et par la valeur que la fonction retourne. Beaucoup choisissent au hasard une fonction, et le code s’exécute quand même, mais il est plus difficile à lire.
Cet article explique les deux critères pour distinguer les scope functions et illustre chaque fonction avec des exemples concrets. Ensuite, vous allez écrire des tests pour vérifier leur comportement et voir les pièges courants. Après la lecture, vous saurez choisir la bonne fonction pour chaque situation.
2. Deux critères pour distinguer
Chaque scope function reçoit une lambda et l’appelle sur un context object. Vous devez simplement répondre à deux questions :
- Dans la lambda, accédez-vous à l’objet via
this(receiver) ou viait(paramètre) ? - La fonction retourne-t-elle le résultat de la lambda ou l’objet lui-même ?
| Fonction | Context object | Valeur retournée | Est une extension? |
|---|---|---|---|
let |
it |
Résultat de la lambda | Oui |
run |
this |
Résultat de la lambda | Oui (et version sans extension) |
with |
this |
Résultat de la lambda | Non, objet en paramètre |
apply |
this |
L’objet lui-même | Oui |
also |
it |
L’objet lui-même | Oui |
Vous pouvez vous en souvenir rapidement par paires :
applyetalsoretournent l’objet, donc elles conviennent pour configurer un objet ou insérer un side effect au milieu d’une chaîne d’appels.let,runetwithretournent le résultat, donc elles conviennent pour transformer l’objet en une autre valeur.
Les exemples ci-dessous utilisent quelques classes simples :
data class Customer(val name: String, val email: String?)
class Order(val id: String) {
val items = mutableListOf<Double>()
var discountPercent = 0
}
class Invoice {
var number: String = ""
var amount: Double = 0.0
var paid: Boolean = false
}
3. let : transformer et traiter les valeurs nullable
let passe l’objet dans la lambda sous le nom it et retourne le résultat de la lambda. Le cas d’usage le plus courant est de le combiner avec l’appel sécurisé ?. pour exécuter du code seulement si la valeur n’est pas null :
fun sendWelcome(customer: Customer) {
customer.email?.let { email ->
println("Envoyer un email de bienvenue à $email")
}
}
fun main() {
sendWelcome(Customer("An", "an@example.com"))
sendWelcome(Customer("Bình", null))
}
Envoyer un email de bienvenue à an@example.com
Avec le client Bình, email est null. L’expression ?. s’arrête là et la lambda ne s’exécute pas. Dans la lambda, email est de type String, pas String?, vous pouvez donc l’utiliser sans vérification supplémentaire. Ici, nous nommions le paramètre email au lieu d’it pour que le code soit plus lisible.
let est aussi utile quand vous voulez transformer une valeur sans créer de variable intermédiaire :
val domain = Customer("An", "an@example.com").email
?.let { it.substringAfter("@") }
?: "inconnu"
println(domain) // example.com
4. run : calculer en fonction de l’objet
run ressemble à let, sauf que l’objet est accédé via this. Puisque this peut être implicite, vous appelez directement les propriétés et méthodes de l’objet :
val order = Order("ORD-42").apply {
items += listOf(100_000.0, 50_000.0)
discountPercent = 10
}
val total = order.run {
val subtotal = items.sum()
subtotal - subtotal * discountPercent / 100
}
println(total) // 135000.0
Dans le bloc run, items et discountPercent sont des propriétés de order. La valeur retournée est la dernière expression, c’est-à-dire le montant après réduction.
run a aussi une version qui n’est pas une extension. Vous l’utilisez pour regrouper quelques instructions qui créent une valeur, sans que les variables temporaires ne fuient dans la scope externe :
val connectionUrl = run {
val host = System.getenv("DB_HOST") ?: "localhost"
val port = System.getenv("DB_PORT") ?: "5432"
"jdbc:postgresql://$host:$port/shop"
}
Après ce bloc, host et port n’existent plus. Seul connectionUrl est conservé.
5. with : regrouper plusieurs appels sur le même objet
with fonctionne comme run, mais ce n’est pas une extension. L’objet est passé en tant que premier paramètre :
val summary = with(order) {
"Commande $id : ${items.size} produits, réduction $discountPercent%"
}
println(summary)
Commande ORD-42 : 2 produits, réduction 10%
Sémantiquement, with(order) { ... } se lit comme « avec cette commande, faites… ». Cette fonction convient quand l’objet est certainement non-null et que vous avez besoin d’appeler plusieurs de ses membres. Si l’objet peut être null, vous devriez utiliser order?.run { ... }, car with ne peut pas être combinée avec ?..
6. apply : configurer un objet
apply accède à l’objet via this et retourne l’objet lui-même. C’est la façon idiomatique d’initialiser et de configurer un objet dans une seule expression :
val invoice = Invoice().apply {
number = "INV-001"
amount = 250_000.0
paid = false
}
println("${invoice.number} - ${invoice.amount}") // INV-001 - 250000.0
Le résultat de la lambda est ignoré et apply retourne toujours l’Invoice qui vient d’être créée. Vous rencontrerez souvent apply quand vous travaillez avec des classes Java utilisant des setters, par exemple lors de la configuration d’un bean dans Spring :
@Bean
fun restTemplate(): RestTemplate = RestTemplate().apply {
requestFactory = SimpleClientHttpRequestFactory().apply {
setConnectTimeout(2_000)
setReadTimeout(5_000)
}
}
7. also : ajouter un side effect sans briser la chaîne d’appels
also accède à l’objet via it et retourne aussi l’objet lui-même. Cette fonction convient aux actions « au passage » comme enregistrer, valider ou appeler des métriques, quand ces actions ne modifient pas le flux principal :
fun createOrder(id: String): Order =
Order(id).also { println("Commande créée ${it.id}") }
fun main() {
val order = createOrder("ORD-7")
.also { it.items += 99_000.0 }
.also { println("Nombre de produits : ${it.items.size}") }
}
Commande créée ORD-7
Nombre de produits : 1
Comme l’objet est accédé via it et non via this, vous pouvez toujours utiliser this de la classe externe sans confusion. C’est aussi pour cette raison que also est plus lisible que apply quand la lambda utilise principalement l’objet au lieu de le configurer.
Il y a une petite astuce pour utiliser also pour échanger deux variables :
var a = 1
var b = 2
a = b.also { b = a }
println("$a $b") // 2 1
L’expression b.also { ... } retourne l’ancienne valeur de b (2), tandis que la lambda assigne b = a (1) avant que l’assignation à a se produise.
8. Choisir la bonne fonction
Vous pouvez utiliser les règles suivantes :
- Pour exécuter du code si la valeur n’est pas
null, ou pour transformer une valeur : utilisez?.let. - Pour calculer une valeur à partir de plusieurs membres de l’objet : utilisez
run, ouwithsi l’objet est certainement non-null. - Pour regrouper quelques instructions en une expression : utilisez
run { }sans receiver. - Pour initialiser ou configurer un objet : utilisez
apply. - Pour enregistrer, valider ou ajouter un side effect tout en conservant l’objet : utilisez
also.
9. Tests du comportement
Pour vous assurer que vous comprenez correctement les valeurs retournées, écrivez quelques tests. D’abord, ajoutez les dépendances à pom.xml :
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>2.0.21</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
</dependencies>
Si votre projet est Spring Boot 3.x, spring-boot-starter-test inclut déjà JUnit 5 et AssertJ, vous n’avez donc pas besoin de déclarer ces deux bibliothèques séparément.
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class ScopeFunctionsTest {
@Test
fun `apply et also retournent l'objet lui-même`() {
val invoice = Invoice()
val fromApply = invoice.apply { amount = 100.0 }
val fromAlso = invoice.also { it.paid = true }
assertThat(fromApply).isSameAs(invoice)
assertThat(fromAlso).isSameAs(invoice)
assertThat(invoice.amount).isEqualTo(100.0)
assertThat(invoice.paid).isTrue()
}
@Test
fun `let run with retournent le résultat de la lambda`() {
val order = Order("ORD-1").apply { items += listOf(10.0, 20.0) }
val count: Int = order.let { it.items.size }
val sum: Double = order.run { items.sum() }
val label: String = with(order) { "Commande $id" }
assertThat(count).isEqualTo(2)
assertThat(sum).isEqualTo(30.0)
assertThat(label).isEqualTo("Commande ORD-1")
}
@Test
fun `let ne s'exécute pas quand la valeur est null`() {
var called = false
Customer("Bình", null).email?.let { called = true }
assertThat(called).isFalse()
}
}
Le premier test utilise isSameAs pour vérifier qu’apply et also retournent la bonne référence, pas une copie. Le deuxième test déclare les types explicitement (Int, Double, String) pour montrer clairement ce que chaque fonction retourne. Le troisième test confirme que ?.let ignore une valeur null.
10. Pièges courants
10.1. Le this est masqué dans apply
À l’intérieur d’apply, le receiver le plus proche est prioritaire. Par conséquent, une propriété du même nom dans la classe externe sera masquée :
class InvoiceFactory(private val amount: Double) {
fun create() = Invoice().apply {
amount = amount // assigne Invoice.amount à lui-même, reste 0.0
}
}
Vous avez deux façons de le corriger. La première est d’être explicite avec this@InvoiceFactory.amount. La deuxième est de basculer vers also :
fun create() = Invoice().also { it.amount = amount }
10.2. Le piège de ?.let { } ?: ...
Beaucoup écrivent x?.let { ... } ?: fallback comme s’il était équivalent à if/else. Ce n’est pas équivalent. Si la lambda retourne null, la branche ?: s’exécute aussi :
val cache = mapOf<String, String>()
val email: String? = "an@example.com"
email?.let { cache[it] } ?: println("Pas d'email")
Pas d'email
Ici, email n’est pas null, mais cache[it] retourne null, donc le message s’affiche quand même, bien que ce ne soit pas correct. Quand vous avez besoin de branchement explicite, utilisez if (email != null) ... else ....
10.3. Trop d’imbrication
Quand vous imbriquez les scope functions, it et this pointent vers différents objets et le lecteur doit retracer lequel est lequel :
// Difficile à lire
order.let { o -> o.items.firstOrNull()?.let { price -> price.run { this * 2 } } }
Remarque : Vous ne devriez pas imbriquer plus d’un niveau. Si vous devez le faire, nommez explicitement le paramètre au lieu d’utiliser
it. Mieux encore, extrayez-le en variable locale ou fonction séparée.
10.4. Utiliser une scope function juste parce qu’on peut
val name = customer.let { it.name } n’apporte rien de plus que val name = customer.name. Les scope functions sont utiles quand elles vous aident à éviter des variables temporaires, gérer null ou regrouper du code de configuration. Si elles ne font pas cela, ne les utilisez pas.
11. Conclusion
Les cinq scope functions de Kotlin ne diffèrent que sur deux points : l’objet est accédé via this ou it, et la fonction retourne l’objet ou le résultat de la lambda. De là, vous avez les cas d’usage correspondants : apply pour configurer, also pour ajouter un side effect, let pour gérer les valeurs nullable et transformer, tandis que run et with pour calculer en fonction de l’objet. Vous devez également être vigilant face au masquage de this, au piège de ?.let ?:, et à l’imbrication excessive. Enfin, souvenez-vous que l’objectif des scope functions est de rendre le code plus lisible, pas de le raccourcir à tout prix.