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) -> Int est une fonction qui prend deux Int et retourne un Int.
  • (String) -> Unit est une fonction qui ne retourne pas de valeur significative, similaire à void en Java.
  • () -> Long est 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
}
  • ::isVip référence une fonction au niveau du module.
  • Order::amount référence une propriété. Elle a le type (Order) -> Long.
  • taxService::withTax est une référence liée : la fonction est déjà liée à l’objet taxService.
[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 de when. À 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. Utilisez AtomicInteger dans 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 it est un non-local return. Il quitte complètement findFirstLarge.
  • return@forEach est un return avec label. Il termine seulement l’itération actuelle de la lambda, comme continue.

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 inline passe la lambda à un autre contexte, par exemple son exécution dans un autre thread, marquez le paramètre comme crossinline pour interdire les non-local return. Si vous voulez stocker la lambda dans une variable ou un champ, utilisez noinline.

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 it imbriqué. Si vous écrivez orders.map { it.items.filter { it.price > 0 } }, le it intérieur masque le it exté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. Un return simple dans forEach quitte toute la fonction englobante. C’est la source de nombreux bugs difficiles à détecter. Quand vous voulez simplement sauter un élément, utilisez return@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 Activity sur 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.

kotlinlambdafonction d'ordre supérieurprogrammation fonctionnelle