Vous reprendez bien un peu de curry avec votre programme?

Introduction

La programmation fonctionnelle, comme son nom l’indique, considère les fonctions comme des éléments de premier ordre: on les traite parfois de first class citizen pour signifier leur importance dans les langages permettant ce paradigme de programmation.

Cela implique notamment que les fonctions peuvent être traitées de la même manière que des entiers ou des chaînes de caractères dans d’autres langages: on peut les placer dans des variables, les retourner comme résultats d’une fonction et même les passer en paramètres d’autres fonctions.

La curryfication est une autre fonctionnalité classique des langues de programmation fonctionnelle. Elle consiste à transformer une fonction prenant plusieurs arguments en une séquence de fonctions prenant un seul argument chacune. Au risque de vous décevoir, le nom provient du mathématicien Haskell Curry, qui a longtemps travaillé sur le lambda-calcul, et n’a donc rien de gastronomique.

Considérons un exemple simple pour commencer: la fonction add prend deux entiers et les additionne.

def add(a: Int, b: Int): Int = a + b
val c = add(3,4) // c = 7

Nous pouvons curryfier la fonction en séparant chacun de ses paramètres:

def add(a: Int)(b: Int): Int = a + b

Fondamentalement, le code n’a pas changé. La fonction peut d’ailleurs s’utiliser presque comme précédemment, bien qu’un peu moins élégamment:

val c = add(3)(4) // c = 7

Les choses deviennent un peu plus intéressantes lorsqu’on fixe le premier paramètre, a, créant ainsi une fonction à un seul argument:

val add3: Int => Int = add(3)(_)

Comme son type l’indique, add3 est une fonction recevant en paramètre un unique entier et retournant un unique entier. On précise par ailleurs que add3 se comporte comme la fonction add, dont le premier paramètre est l’entier 3, et le second est à définir plus tard. Il est alors possible d’exploiter cette fonction comme n’importe quelle autre:

val c = add3(4)

Utilisation de la curryfication en architecture logicielle

À quoi la curryfication peut-elle bien servir, concrètement?

De manière générale, elle permet de spécialiser des fonctions génériques. On peut ainsi commencer par écrire des fonctions très génériques, reposant sur plusieurs paramètres, puis en proposer des versions particulières supposément plus simples d’utilisation. Dans notre exemple sur l’addition, nous proposions un cas particulier de l’addition où le premier terme était fixé à 3.

Facilitation de la composition de fonctions

Je suis intimement persuadé que la clef de l’évolutivité logicielle vient de la composition de ses entités. Dans le cadre du développement d’un backend pour un site Web, nous pouvons avoir deux fonctions essentielles: une pour déterminer si un utilisateur a le droit de réaliser une action, et une autre pour réaliser l’action proprement dite.

def userHasRole(role: String, userId: Int): Boolean = ???
def doSomething(): Result = ???

Créons une fonction qui n’exécute le traitement de la requête que si l’utilisateur est administrateur:

def withPermission(condition: => Boolean)(processing: => Result) = 
   if(condition) processing(request)
   else Forbidden

La fonction peut s’utiliser en fournissant ses deux arguments, et donc sans bénéficier de la curryfication:

withPermission(userHasRole("admin", userId))(doSomething)

Curryfions un peu cet appel, en définissant le comportement à adopter sous la forme d’une fonction créée lors de l’appel à withPermission:

withPermission(userHasRole("admin", userId)) { 
   // do something
   Ok("It works")
}

Curryfions à présent userHasRole:

def userHasRole(role: String)(userId: int): Boolean = ???

La fonction peut continuer à être utilisée comme précédemment, bien que ça ne soit, à nouveau, pas très utile:

withPermission(userHasRole("admin")(userId)) { 
   // do something
   Ok("It works")
}

Par contre, nous pouvons à présent créer une version spécialisée de userHasRole qui vérifie directement si un utilisateur est administrateur. Cela nous évitera à avoir à préciser « admin » chaque fois qu’une telle vérification est nécessaire.

def userIsAdmin(userId: Int): Boolean = userHasRole("admin")(userId)

withPermission(userIsAdmin(userId)) { 
   // do something
   Ok("It works")
}

Poussons notre démarche un pas plus loin. Jusqu’à présent, nous considérions que l’identifiant de l’utilisateur était déjà connu. En réalité, lorsque le backend commence à traiter une requête, ce n’est généralement pas le cas: cet identifiant doit être récupéré d’une manière ou d’une autre, par exemple depuis un jeton JWT. Imaginons qu’il existe de telles fonctions réalisant ce travail d’extraction:

def extractUserIdFromJWT(): Int = ???

On peut exploiter ces fonctions de manière générique en les combinant avec le reste du traitement de la requête (ce qu’il faut faire une fois l’identifiant d’utilisateur obtenu) en créant une nouvelle fonction curryfiée qui lie les deux de manière fort flexible:

withUserId(userIdProvider: => Int)(process: Int => Result) = {
  val userId: Int = userIdProvider()
  process(userId)
}

Comme précédemment, le traitement peut être défini au moment de l’invocation de withUserId:

withUserId(extractUserIdFromJWT()) { userId =>
  withPermission(userIsAdmin(userId)) { 
    // do something
    Ok("It works")
  }
}

On commence à apercevoir une structure qui se dégage de ce code: des fonctions curryfiées s’enchaînent les unes aux autres. La première fonction fournit un élément de contexte spécialisé qui est exploité par la fonction suivante pour poursuivre le travail sans se soucier de la manière dont ce contexte est apparu. Il s’agit d’une manière d’exploiter la composition de fonctions afin de développer du code généraliste est réutilisable, tout en l’exploitant de manière spécifique afin qu’il soit plus clair.

Injection de dépendances

La curryfication de fonction peut être utilisée pour injecter des dépendances au sein du code. Par exemple, une fonction générale pour récupérer les données relatives à un utilisateur dans une base de données pourrait s’écrire comme suit:

trait Database {
  def run(query: Query): Result
}

def getUser(db: Database)(userId: Int): Option[User] = {
   db.run(users.filter(_.id === userId).result).map(_.headOption)
}

La base de données est typiquement une dépendance dont on souhaite se débarrasser pour ne pas avoir à constamment la manipuler. Utilisons une version simplifiée de la fonction:

val localDatabase: Database = ???
def localGetUser(userId: Int): Option[User] = getUser(localDatabase)(_)

Il n’y alors plus à se soucier de la base de données dans le reste du code:

val user: Option[User] = localGetUser(42)

La fonction prémâchée peut également être fournie à d’autres fonctions, qui n’ont même plus conscience du fait que les utilisateurs proviennent d’une base de données: avoir une fonction fournissant des utilisateurs à la demande leur suffit:

def userProcessing(userProvider: Int => Option[User]) = {
   val userId = 42
   val user: Option[User] = userProvider(userId)
   // do something with user
}

La curryfication de la fonction getUser est également utile pour la tester, car il n’est pas nécessaire de fournir une véritable base de données dans le cadre du test: un mock suffirait.

val mockDatabase = new Database {
  def run(query: Query): Result = Result(s"Mocked result for query $query") 
}

val fetchUser = getUser(mockDatabase)(_)
println(fetchUser(42))

Gestion de la configuration

Dans le cadre d’applications complexes, la curryfication peut aider à gérer une partie de la complexité en distinguant les différents éléments qui la constituent. On peut par exemple passer progressivement les éléments d’une configuration. Par exemple, une fonction générant une ligne de log pourrait vous être fournie comme suit:

def createLogger(level: String)(message: String): String = 
  s"[$level] $message"

En pratique, cependant, seuls quelques niveaux de journalisation sont réellement utilisés (information, erreur, etc.). Vous auriez donc préférer travailler avec des fonctions toutes faites pour ces niveaux. Qu’à cela ne tienne: spécialisons la fonction originale.

val errorLogger = createLogger("ERROR")
println(errorLogger("Something went wrong")) // [ERROR] Something went wrong

Support des paramètres implicites

En Scala, la curryfication permet également l’usage de paramètres implicites, dont nous avons déjà discuté précédemment.

Les paramètres implicites doivent être les derniers d’une fonction curryfiée. Comme ils sont implicites, la fonction peut être invoquée sans qu’ils soient explicitement fournis. Cela peut simplifier l’usage de cette fonction.

def present(amount: Int)(implicit currency: String) = s"$amount $currency"

implicit val defaultCurrency: String = "euro"

println(present(42)) // 42 euro
println(present(24)("yen")) // 24 yen

Le paramètre implicite peut lui-même être une fonction:

def present(amount: Int)(implicit renderCurrency: Int => String) = renderCurrency(amount)

implicit def renderEuro(amount: Int): String = s"${amount} €"

def renderDollar(amount: Int): String = s"$$ ${amount}"

println(present(42)) // 42 €
println(present(24)(renderDollar)) // $ 24

Conclusion

La curryfication, bien qu’à l’origine un concept théorique en mathématiques et en informatique, s’avère être un outil puissant dans l’architecture logicielle. Elle permet de décomposer les fonctions complexes en étapes plus simples et modulaires, facilitant ainsi leur compréhension, leur réutilisation et leur extension. En rendant les paramètres indépendants les uns des autres, elle encourage une conception centrée sur la séparation des préoccupations, une qualité fondamentale pour construire des systèmes robustes et évolutifs.

En pratique, la curryfication offre une flexibilité accrue dans la manière dont les fonctions sont appliquées et combinées. Que ce soit pour configurer des dépendances, créer des fonctions spécialisées ou structurer des pipelines de transformation de données, elle aide à réduire la duplication de code et à améliorer la lisibilité. Les développeurs peuvent ainsi pré-appliquer des arguments constants, ce qui non seulement simplifie le code mais le rend également plus testable et maintenable.

Enfin, la curryfication s’intègre harmonieusement aux paradigmes modernes de programmation fonctionnelle, en particulier dans des langages comme Scala. En facilitant la composition, l’injection de dépendances et l’utilisation de contextes implicites, elle encourage une architecture fluide et modulaire. Adopter la curryfication, c’est donc non seulement enrichir ses pratiques de programmation, mais aussi poser les bases d’un code plus clair, plus efficace et plus élégant.

Une réflexion sur “Vous reprendez bien un peu de curry avec votre programme?

  1. Pingback: Loaner pattern en Scala | Le compas de l'architecte

Laisser un commentaire