Introduction
Les design patterns sont des bonnes pratiques en développement logiciel issues de l’expérience des praticiens. On en parle généralement dans le cadre de la programmation orientée objet, domaine pour lequel il existe une littérature abondante, et notamment l’ouvrage de référence Design Patterns: Elements of Reusable Object-Oriented Software.
Cependant, les patrons de conception, comme on les appelle en français, existent également dans d’autres domaines, y compris les bases de données et la programmation fonctionnelle. Je vous propose d’aborder ici le patron de conception loaner, applicable en programmation fonctionnelle justement.
Usage
Un problème récurrent en programmation est la gestion de ressources telles que des connexions à une base de données. Une tâche basée sur une base de données consiste à créer la connexion, l’exploiter pour soumettre des requêtes à la base de données, et s’assurer que la connexion finisse toujours par être fermée — sans quoi les ressources qui y sont associées ne seraient pas libérées, ce qui pourrait mener à leur pénurie.
Certains langages répondent à cette problématique en proposant une structure ad hoc (comme le capitaine) à la gestion de la ressource. Par exemple, en Java, try-with-resources assure la bonne fermeture de la ressource, même si une exception vient interrompre l’exécution normale de l’application, pour peu qu’elle implémente l’interface Autoclosable:
try (Connection conn = new Connection(...)) {
conn.execute(...);
}
Sympathique, non? Ce genre d’approche a cependant quelques limitations. Bien que le try-with-resources soit utilisé à titre d’exemple, les mêmes limitations s’appliquent aux propositions similaires d’autres langages de programmation.
Limites de l’approche try-with-resources
Couplage entre la logique métier et la gestion des ressources
Avec try-with-resources, la logique métier, autrement dit le code réellement utile qu’on souhaite voir profiter de la ressource, est placée au sein du bloc défini par try-with-resources. Autrement dit, on lie de manière intrinsèque le traitement et la gestion de la ressource.
Cela limite la modularité du code, ce qui constitue un frein traditionnel à son test et à sa réutilisabilité. On préférerait pouvoir vérifier le bon comportement du traitement sans avoir à se soucier des problèmes de gestion des connexions. On aimerait aussi pouvoir appliquer le même traitement avec une gestion différente de ces connexions.
Difficulté à généraliser et à composer
Un bloc try peut difficilement être repris pour être intégré dans un ensemble plus vaste. Par exemple, imaginez que nous souhaitions enchaîner ce bloc avec d’autres au sein de Future. Ce n’est en réalité par l’entièreté du bloc qui devrait être composé, mais uniquement sa logique de traitement. Comme celle-ci est scellée au sein du bloc, l’en extraire n’est pas chose aisée.
Cette limitation rend l’approche try-with-resources peu adaptée aux environments asynchrones ou réactifs. En particulier, elle force la fermeture de la ressource à la fin du bloc, et donc potentiellement avant la fin du traitement. Ce qui va rapidement s’avérer problématique.
Pas de stratégie configurable
Un bloc try-with-resources définit de manière très rigide la manière dont la ressource est gérée: elle est créée au début et fermée à la fin. Point. Si cela peut convenir dans certains cas, dans d’autres, davantage de souplesse est préférable.
Par exemple, ou peut vouloir logger l’activité de gestion. On peut vouloir mettre en place un mécanisme de réessai, tant sur l’ouverture de la ressource que sur le traitement proprement dit. La fermeture de la ressource peut nécessiter un traitement complexe, qui dépend d’un contexte.
Plus encore que l’enrichissement du comportement du bloc, c’est sa paramétrisation qui est ici recherchée.
Un prêteur à la rescousse
Après avoir pointé du doigt les faiblesses de try-with-resources, il faut à présent que Loaner permette de les atténuer.
L’idée de ce design pattern est de ne pas reposer sur une fonctionnalité du langage, mais sur la capacité fondamentale des langages de programmation fonctionnelle à manipuler des fonctions d’ordres supérieures: il est possible de créer et de manipuler des fonctions qui prennent en argument d’autres fonctions, ou qui retournent d’autres fonctions.
On va alors distinguer d’une part la fonction responsable de la gestion d’une ressource, et d’autre part la fonction responsable de la logique métier. La seconde exploitant la ressource prêtée par la première.
Commençons par la fonction prêteuse:
def withConnection[T](f: Connection => T): T = {
val conn = Connection()
try {
f(conn)
} finally {
conn.close()
}
}
Créons ensuite une fonction de traitement qui nécessite une connexion:
def process(conn: Connection): Int =
conn.execute(...)
Utilisons conjointement les deux fonctions:
withConnection(conn => process(conn))
Cette dernière ligne peut s’écrire de manière plus concise:
withConnection(process(_))
Dans de nombreux cas (dont celui de cet exemple), il est un peu lourd d’écrire explicitement une fonction process, alors qu’elle ne sera utilisée qu’une seule fois. Allégeons un peu notre programme:
withConnection { conn =>
conn.execute(...)
}
Nous retrouvons ici une forme d’écriture déjà évoquée dans cadre de la capture de contexte (le contexte en question étant, dans notre cas, la connexion).
L’écriture n’est pas moins agréable si la gestion de la ressource nécessite des paramètres (ce qui est le cas, en pratique, pour la création d’une connexion à une base de données):
def withConnection[T](url: String, login: String, password: String)(f: Connection => T) = {
val conn = Connection(url, logic, password)
try {
f(conn)
} finally {
conn.close()
}
}
withConnection("path", "login", "password") { conn =>
conn.execute(...)
}
Ce que ça change
Avons-nous vraiment amélioré les choses?
Le premier changement de fond est que nous avons dû gérer d’éventuelles exceptions avec un try/finally. Nous avons également dû fermer la ressource avec un finally. Ces deux charges (et donc, en fin de compte, l’entièreté de la gestion de la ressource) sont devenues explicites, alors qu’elles étaient cachées par try-with-resources. Cela semble donc être une régression.
Une bonne nouvelle est que nous avons découplé la gestion de la logique métier de la gestion de la ressource. withConnection ne sait rien de l’usage qui est fait de la ressource qu’elle prête, et process ne sait rien de la manière dont la ressource est gérée. On peut donc assembler à l’envi des gestionnaires de ressources et des logiques métier. En particulier, un mock de connexion peut être utilisé pour tester le bon comportement du traitement. Inversement, on peut s’assurer que la connexion prêtée soit satisfaisante et bien gérée sans faire intervenir de véritable logique métier.
La composition est grandement facilitée par l’utilisation de fonctions d’ordre supérieur et leur curryfication. Si, par exemple, le mot de passe n’est pas fourni tel quel mais provient d’une ressource faisant office de coffre-fort à secrets, on peut utiliser les deux ressources conjointement:
trait Vault {
def getSecret(): String
}
def withVault[T](f: Vault => T) = {
val vault = Vault()
f(vault)
}
withVault{ vault =>
withConnexion("path", "login", vault.getSecret()) { conn =>
process(conn)
}
}
Nos trois fonctions withVault, withConnection et process ne savent rien les unes des autres, et pourtant elles se combinent sans soucis.
Notez que withVault a une gestion de sa ressource des plus minimalistes : pas de finally en vue pour forcer une fermeture, ce qui n’aurait pas de sens au vu de la définition que nous avons donnée de Vault. Chaque prêteur réalise la stratégie de gestion de ressource qui lui convient.
Vous travaillez dans un environnement asynchrone? Utilisez un prêteur de ressource capable d’en tenir compte:
def withConnection[T](f: Connection => Future[T]): Future[T] = {
val conn = Connection()
f(conn).andThen {
case _ => conn.close()
}
}
andThen est une fonction d’ordre supérieur (encore une!) qui réalise un traitement une fois le Future terminé. Dans notre cas, ce traitement assure la fermeture de la ressource au terme du traitement, quel que soit le moment où celui-ci survient.
Conclusion
Les langages impératifs modernes proposent un outillage pour la gestion des ressources, principalement pour s’assurer que celles-ci sont bien libérées au terme de leur utilisation. Si cet outillage est pratique dans les cas simples, c’est lorsqu’on s’intéresse à la testabilité, à la réutilisabilité et à la composition logicielles qu’on se rend compte de ses limites.
La programmation fonctionnelle propose une approche alternative, essentiellement basée sur l’utilisation de fonctions d’ordre supérieur, pour circonvenir ces limitations. Il en résulte un code qui n’est pas foncièrement plus complexe, le coût d’entrée se limitant à la séparation du traitement en deux fonctions: l’une pour la gestion de la ressource, l’autre pour son exploitation. Ces deux fonctions étant découplées, on acquiert la possibilité de les tester, de les échanger et de les combiner à volonté.
Une grande force de cette approche, selon moi, vient de cette capacité de combinaison. C’est elle qui permet un passage à l’échelle non pas (nécessairement) des performances, mais de la réutilisabilité logicielle.
Offrez des briques Lego, et laissez l’imagination des enfants construire des châteaux.