Fonctions d'ordre supérieur et lambda en Kotlin
1. Vue d’ensemble
En Kotlin, les fonctions sont des « citoyens de première classe ». Vous pouvez assigner une fonction à une variable, la passer comme paramètre, ou la retourner d’une autre fonction. Une fonction qui reçoit ou retourne une autre fonction s’appelle une fonction d’ordre supérieur. Le moyen le plus concis de créer une telle fonction est une lambda.
Cet article passe en revue les types de fonction, la syntaxe lambda, les références de fonction, les closures, les lambdas with receiver et le mot-clé inline. Après la lecture, vous pourrez écrire vos propres fonctions d’ordre supérieur. Vous saurez aussi éviter quelques erreurs courantes.
2. Type de fonction
Chaque fonction en Kotlin a un type, appelé type de fonction. La syntaxe comprend une liste des types de paramètres entre parenthèses, une flèche, puis le type de retour :
fun main() {
val add: (Int, Int) -> Int = { a, b -> a + b }
val greet: (String) -> Unit = { name -> println("Bonjour, $name") }
val now: () -> Long = { System.currentTimeMillis() }
println(add(2, 3))
greet("Lan")
println(now() > 0)
}
Explication :
(Int, Int) -> Intest une fonction qui prend deuxIntet retourne unInt.(String) -> Unitest une fonction qui ne retourne pas de valeur significative, similaire àvoiden Java.() -> Longest une fonction sans paramètres.- Vous appelez une variable fonction comme une fonction ordinaire :
add(2, 3). C’est équivalent àadd.invoke(2, 3).
5
Bonjour, Lan
true
Un type de fonction peut aussi être nullable. Dans ce cas, vous devez mettre le type entier entre parenthèses : ((Order) -> Unit)?. Pour l’appeler en toute sécurité, utilisez callback?.invoke(order).
3. Écrire votre première fonction d’ordre supérieur
On utilise un modèle simple tout au long de l’article. Le montant utilise le type Long (en dong) pour éviter les erreurs d’arrondi en virgule flottante :
data class Order(val id: String, val customer: String, val amount: Long)
fun calculateTotal(orders: List<Order>, priceOf: (Order) -> Long): Long {
var total = 0L
for (order in orders) {
total += priceOf(order)
}
return total
}
fun main() {
val orders = listOf(
Order("A1", "Lan", 100_000),
Order("A2", "Minh", 250_000),
Order("A3", "Lan", 50_000),
)
val full = calculateTotal(orders) { it.amount }
val discounted = calculateTotal(orders) { it.amount * 90 / 100 }
println(full)
println(discounted)
}
calculateTotal est une fonction d’ordre supérieur car le paramètre priceOf a le type (Order) -> Long. Cette fonction ne sait pas comment calculer le prix de chaque commande. Elle délègue cela à l’appelant. Grâce à cela, la même boucle peut être utilisée pour le prix plein et le prix réduit.
400000
360000
4. Syntaxe lambda
4.1. Différentes formes d’écriture
Les lambdas sont toujours entre accolades {}. Les paramètres viennent avant ->, le corps après. L’expression finale est la valeur de retour, vous n’avez pas besoin d’écrire return :
val isLarge: (Order) -> Boolean = { order ->
val threshold = 200_000
order.amount > threshold // valeur de retour
}
val isLargeShort: (Order) -> Boolean = { it.amount > 200_000 }
val ignoreSecond: (Order, Int) -> String = { order, _ -> order.id }
- Quand une lambda n’a qu’un seul paramètre, vous pouvez omettre la déclaration et utiliser le nom implicite
it. - Les paramètres non utilisés doivent être nommés
_pour clarifier le code et éviter les avertissements du compilateur.
4.2. Lambda de fin
Si le dernier paramètre d’une fonction est un type de fonction, vous pouvez placer la lambda en dehors des parenthèses. Si c’est le seul paramètre, vous pouvez même omettre les () :
val orders = listOf(
Order("A1", "Lan", 100_000),
Order("A2", "Minh", 250_000),
)
val large = orders.filter { it.amount > 200_000 }
val total = calculateTotal(orders) { it.amount }
val sorted = orders.sortedByDescending { it.amount }.map { it.id }
println(large.map { it.id })
println(sorted)
[A2]
[A2, A1]
C’est pourquoi le code Kotlin avec l’API de collections se lit presque comme du langage naturel. Toutes les fonctions filter, map, sortedByDescending dans la bibliothèque standard sont des fonctions d’ordre supérieur.
5. Référence de fonction
Quand vous avez déjà une fonction avec la bonne signature, vous n’avez pas besoin de la wrapper dans une lambda. Utilisez l’opérateur :: :
fun isVip(order: Order): Boolean = order.amount >= 200_000
class TaxService(private val ratePercent: Long) {
fun withTax(order: Order): Long = order.amount * (100 + ratePercent) / 100
}
fun main() {
val orders = listOf(Order("A1", "Lan", 100_000), Order("A2", "Minh", 250_000))
val taxService = TaxService(10)
println(orders.filter(::isVip).map { it.id }) // fonction au niveau du module
println(orders.map(Order::amount)) // référence de propriété
println(calculateTotal(orders, taxService::withTax)) // référence liée
}
::isVipréférence une fonction au niveau du module.Order::amountréférence une propriété. Elle a le type(Order) -> Long.taxService::withTaxest une référence liée : la fonction est déjà liée à l’objettaxService.
[A2]
[100000, 250000]
385000
Les références de fonction sont généralement plus lisibles que les lambdas quand le nom de la fonction exprime clairement l’intention.
6. Retourner une fonction et les closures
6.1. Une fonction qui retourne une fonction
Une fonction d’ordre supérieur peut aussi retourner une fonction. C’est utile quand vous voulez créer un comportement basé sur une configuration :
fun discountFor(level: String): (Long) -> Long {
val percent = when (level) {
"GOLD" -> 80
"SILVER" -> 90
else -> 100
}
return { amount -> amount * percent / 100 }
}
fun main() {
val goldPrice = discountFor("GOLD")
val normalPrice = discountFor("NONE")
println(goldPrice(500_000))
println(normalPrice(500_000))
}
400000
500000
La lambda retournée se « souvient » toujours de la variable percent, bien que discountFor soit terminée. Ce mécanisme s’appelle une closure.
Remarque : N’écrivez pas
"GOLD" -> { amount -> amount * 80 / 100 }à l’intérieur dewhen. À cet endroit,{}est interprété comme un bloc, pas une lambda, donc le code ne compilera pas. Calculez la valeur d’abord, puis retournez la lambda comme dans l’exemple ci-dessus. Vous pouvez aussi envelopper la lambda entre parenthèses :({ amount -> ... }).
6.2. Les closures peuvent modifier les variables
Contrairement aux lambdas en Java, les lambdas Kotlin peuvent modifier les variables var qu’elles capturent :
fun orderIdGenerator(prefix: String): () -> String {
var next = 1
return { "$prefix-${next++}" }
}
fun main() {
val nextId = orderIdGenerator("ORD")
println(nextId())
println(nextId())
println(nextId())
}
ORD-1
ORD-2
ORD-3
Le compilateur enveloppe next dans un objet de référence, appelé Ref, de sorte que la lambda et la fonction englobante partagent une valeur.
Remarque : Les closures modifiant une variable partagée ne sont pas thread-safe. Si plusieurs threads appellent
nextId()simultanément, vous pourriez obtenir des ID dupliqués. UtilisezAtomicIntegerdans ce cas.
7. Lambda with receiver
Kotlin a une forme spéciale de type de fonction : T.() -> R. À l’intérieur de cette lambda, this est l’objet de type T. Vous accédez directement à ses membres sans préfixe. C’est la base des DSL comme buildString, apply, ou le Gradle Kotlin DSL :
class OrderBuilder {
var id: String = ""
var customer: String = ""
var amount: Long = 0
fun build(): Order = Order(id, customer, amount)
}
fun order(block: OrderBuilder.() -> Unit): Order =
OrderBuilder().apply(block).build()
fun main() {
val o = order {
id = "B1"
customer = "Hoa"
amount = 120_000
}
println(o)
}
Order(id=B1, customer=Hoa, amount=120000)
block a le type OrderBuilder.() -> Unit. Par conséquent, dans la lambda, id = "B1" est en réalité this.id = "B1". apply(block) appelle la lambda avec le receiver étant le builder fraîchement créé, puis retourne ce builder lui-même.
8. inline et return dans les lambdas
8.1. Le coût des lambdas
Sur la JVM, chaque lambda est généralement compilée en un objet. Si la lambda capture des variables, chaque appel peut créer un nouvel objet. Pour les fonctions appelées très fréquemment, ce coût s’accumule.
Marquer une fonction comme inline résout ce problème. Le compilateur copie le corps de la fonction et le corps de la lambda directement au site d’appel, donc aucun objet n’est créé :
inline fun <T> measure(label: String, block: () -> T): T {
val start = System.nanoTime()
val result = block()
println("$label: ${(System.nanoTime() - start) / 1_000} µs")
return result
}
fun main() {
val orders = (1..1_000).map { Order("O$it", "C$it", it * 1_000L) }
val total = measure("Calculer le total") { calculateTotal(orders) { it.amount } }
println(total)
}
Calculer le total: 412 µs
500500000
Le temps mesuré varie d’une machine à l’autre. filter, map, forEach dans la bibliothèque standard sont tous inline.
8.2. Non-local return et label
Dans une lambda passée à une fonction inline, une instruction return simple quitte la fonction englobante, pas juste la lambda :
fun findFirstLarge(orders: List<Order>, threshold: Long): Order? {
orders.forEach {
if (it.amount > threshold) return it // quitte findFirstLarge
}
return null
}
fun printPaidOrders(orders: List<Order>) {
orders.forEach {
if (it.amount == 0L) return@forEach // saute simplement cet élément
println(it.id)
}
println("Terminé")
}
return itest un non-local return. Il quitte complètementfindFirstLarge.return@forEachest un return avec label. Il termine seulement l’itération actuelle de la lambda, commecontinue.
Avec une fonction non-inline, un return simple dans une lambda provoquera une erreur du compilateur. Vous êtes obligé d’utiliser un label.
Remarque : Si une fonction
inlinepasse la lambda à un autre contexte, par exemple son exécution dans un autre thread, marquez le paramètre commecrossinlinepour interdire les non-local return. Si vous voulez stocker la lambda dans une variable ou un champ, utiliseznoinline.
9. Tester les fonctions d’ordre supérieur
Ajoutez les dépendances de test à pom.xml. Le projet doit avoir kotlin-maven-plugin déjà configuré :
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
</dependencies>
Les fonctions d’ordre supérieur sont très faciles à tester. Vous n’avez qu’à passer une lambda simple et vérifier le résultat :
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class PricingTest {
private val orders = listOf(
Order("A1", "Lan", 100_000),
Order("A2", "Minh", 250_000),
Order("A3", "Lan", 50_000),
)
@Test
fun `calculateTotal applies the given price function`() {
assertThat(calculateTotal(orders) { it.amount }).isEqualTo(400_000L)
assertThat(calculateTotal(orders) { 1L }).isEqualTo(3L)
}
@Test
fun `discountFor returns function matching customer level`() {
assertThat(discountFor("GOLD")(500_000)).isEqualTo(400_000L)
assertThat(discountFor("SILVER")(500_000)).isEqualTo(450_000L)
assertThat(discountFor("UNKNOWN")(500_000)).isEqualTo(500_000L)
}
@Test
fun `generator keeps its own state per closure`() {
val first = orderIdGenerator("ORD")
val second = orderIdGenerator("INV")
assertThat(listOf(first(), first(), second()))
.containsExactly("ORD-1", "ORD-2", "INV-1")
}
}
Ce test confirme un point important : chaque appel à orderIdGenerator crée sa propre closure, avec sa propre variable next.
10. Les erreurs courantes et quand ne pas l’utiliser
-
Des lambdas trop longues. Si le corps de la lambda dépasse quelques lignes, extrayez-le en une fonction nommée et passez-la par
::. Le code sera plus lisible et plus testable. -
Abuser du
itimbriqué. Si vous écrivezorders.map { it.items.filter { it.price > 0 } }, leitintérieur masque leitextérieur. Donnez des noms de paramètres explicites. -
Inliner tout. Seules les petites fonctions avec des paramètres lambda doivent être inlinées. Inliner les grandes fonctions gonfle le bytecode. Les fonctions inline sans lambda n’apportent presque aucun bénéfice, et le compilateur vous l’indiquera.
-
Se tromper avec
return. Unreturnsimple dansforEachquitte toute la fonction englobante. C’est la source de nombreux bugs difficiles à détecter. Quand vous voulez simplement sauter un élément, utilisezreturn@forEach. -
Capturer des objets lourds. Les lambdas stockées longtemps, par exemple en tant qu’écouteurs, gardent les références de toutes les variables capturées. Cela peut causer des fuites mémoire, par exemple en gardant une
Activitysur Android. -
Pas besoin d’abstraction. S’il n’y a qu’une seule façon de traiter et qu’elle ne change pas, une fonction ordinaire suffit. N’ajoutez pas de paramètre lambda juste « au cas où ».
11. Conclusion
Dans cet article, vous avez vu comment les types de fonction décrivent la signature d’une fonction. Les lambdas et les références de fonction sont deux façons de créer des valeurs de fonction. Les fonctions d’ordre supérieur reçoivent ou retournent des fonctions pour séparer le « squelette » du « comportement ». Vous vous êtes aussi familiarisé avec les closures, les lambdas with receiver pour construire des DSL, et inline pour éliminer le coût des objets ainsi que le fonctionnement du non-local return.
Une fois ces concepts maîtrisés, vous pourrez lire la bibliothèque standard Kotlin bien plus facilement. Vous saurez aussi quand écrire des API de fonction d’ordre supérieur pour votre propre code.