xvw.lol

Effective ML, au travers de la commande 'destruct'

Cet article est une réinterprétation francophone d'un article publié le blog de Tarides, en Anglais, qui présente l'utilisation de la commande destruct pour générer des motifs manquants dans du filtrage par motif.

Les serveurs Merlin et OCaml LSP, deux serveurs de langage OCaml étroitement liés (en effet, OCaml LSP utilise des bibliothèques décrites dans Merlin), améliorent la productivité grâce à des fonctionnalités telles que l'auto-complétion et l'inférence de types. Leur commande destruct, moins connue mais très utile, simplifie l'utilisation du filtrage par motif en générant des instructions de correspondance exhaustives, comme nous le montrerons dans cet article. La commande a récemment bénéficié de quelques améliorations, la rendant plus utilisable, et nous profitons de cette mise à jour pour la présenter et illustrer quelques cas d'utilisation.

Un bon IDE pour un langage de programmation doit fournir des informations contextuelles, telles que des suggestions de complétion, des détails sur les expressions comme les types, et des retours d'erreurs en temps réel. Cependant, dans un monde idéal, il devrait également servir d'assistant à l'écriture de code, capable de générer du code selon les besoins. Et bien qu'il existe indéniablement des points communs entre un large éventail de langages de programmation, permettant la "généralisation" des interactions avec un éditeur de code via un protocole (tel que LSP), certains langages possèdent des fonctionnalités rares ou même uniques qui nécessitent un traitement spécial. Heureusement, il est possible de développer des fonctionnalités adaptées à ces particularités. Celles-ci peuvent être invoquées dans LSP via des requêtes personnalisées pour récupérer des informations arbitraires et des actions de code pour transformer un document selon les besoins. Splendide ! Cependant, une telle fonctionnalité peut être plus difficile à découvrir, car elle dénormalise quelque peu l'expérience utilisateur de l'IDE. C'est le cas de la commande destruct, qui est immensément utile et fait gagner beaucoup de temps.

Dans cet article, nous allons tenter de comprendre l'utilité de cette commande et son application à l'aide d'exemples quelque peu simplistes. Ensuite, nous approfondirons avec quelques exemples moins artificiels que j'utilise dans mon codage quotidien. J'espère que cet article sera utile et divertissant tant pour les personnes qui connaissent déjà destruct que pour celles qui ne le connaissent pas.

destruct, dans les grandes lignes

OCaml permet l'expression de types de données algébriques qui, associés au filtrage par motif, peuvent être utilisés pour décrire des structures de données et effectuer une analyse de cas. Dans le cas où un filtrage par motif n'est pas exhaustif, l'avertissement 8, connu sous le nom de partial-match, sera levé lors de la phase de compilation. Il est donc conseillé de maintenir des blocs de filtrage exhaustifs.

Pour ceux qui ne connaissent pas le terme destruct, le filtrage par motif est une analyse de cas, et l'expression de la forme (un ensemble de motifs) sur laquelle vous effectuez le filtrage est appelée déstructuration, car vous déballez des valeurs à partir de données structurées. C'est la même terminologie utilisée en JavaScript.

La commande destruct aide à atteindre cette exhaustivité. Lorsqu'elle est appliquée à un motif (via M-x merlin-destruct dans Emacs, :MerlinDestruct dans Vim, et Alt + d dans Visual Studio Code), elle génère des motifs. La commande se comporte différemment selon le contexte du curseur :

  • Lorsqu'elle est appelée sur une expression, elle la remplace par un filtrage par motif sur ses constructeurs.

  • Lorsqu'elle est appelée sur un motif d'un filtrage non exhaustif, elle rend le filtrage exhaustif en ajoutant les cas manquants.

  • Lorsqu'elle est appelée sur un motif générique (wildcard), elle le précisera si possible.

Examinons chacun de ces scénarios à l'aide d'exemples. J'utilise Doom Emacs, cependant, l'ensemble des exemples devraient être parfaitement adaptables dans votre éditeur favori !

Déstructuration sur une expression

La déstructuration d'une expression fonctionne de manière assez évidente. Si le vérificateur de types connaît le type de l'expression (dans notre exemple, il le connaît par inférence), l'expression sera remplacée par un filtrage sur tous les cas énumérables.

exemple de destructuration sur une expression

Déstructuration sur une correspondance de motifs non-exhaustive

Le second comportement est, à mon avis, le plus pratique. Bien que j'aie rarement besoin de remplacer une expression par un filtrage par motif, je veux souvent effectuer une analyse de cas sur tous les constructeurs d'un type somme. En implémentant juste un motif unique, comme Foo, mon expression de filtrage n'est pas exhaustive, et si je fais une destruct sur celui-ci, elle générera tous les cas manquants.

exemple de destructuration sur une correspondance non-exhaustive

Déstructuration sur un motif générique

Le comportement final est très similaire au précédent ; lorsque vous appliquez destruct à un motif générique (ou à un motif produisant un générique, par exemple, une déclaration de variable), la commande générera toutes les branches manquantes.

exemple de destructuration sur un motif wildcarant

Déstructuration en présence de nesting

Lorsqu'elle est utilisée de manière interactive, il est possible de déstructurer des motifs imbriqués pour atteindre rapidement l'exhaustivité. Par exemple, imaginons que notre variable x soit de type t option :

  • Nous commençons par déstructurer notre motif générique (_), ce qui produira deux branches, None et Some _.

  • Ensuite, nous pouvons appliquer destruct sur le motif générique associé de Some _, ce qui produira tous les cas concevables pour le type t.

exemple de destructuration sur des motifs nestés

Dans le cas de produits (plutôt que de sommes)

Dans les exemples précédents, nous avons toujours traité des cas dont les domaines sont parfaitement définis, ne déstructurant que des cas de branches de types somme simples. Cependant, la commande destruct peut également agir sur des produits. Considérons un exemple très ambitieux où nous effectuerons un filtrage par motif exhaustif sur une valeur de type t * t option, générant tous les cas possibles en utilisant uniquement destruct :

exemple de destructuration sur des produits

Il est possible de constater que lorsqu'il est utilisé de manière interactive, la commande permet de gagner beaucoup de temps, et associée aux retours en temps réel de Merlin concernant les erreurs, on peut rapidement déterminer si notre filtrage par motif est exhaustif. Dans un sens, c'est un peu comme un "dérivateur" manuel.

La commande destruct peut agir sur n'importe quel motif, elle fonctionne donc également dans les arguments de fonction (bien que leur représentation ait légèrement changé pour la version 5.2.0), et en plus de déstructurer les tuples, il est également possible de déstructurer des enregistrements, ce qui peut être très utile pour notre quête d'exhaustivité !

exemple de destructuration sur des records

Quand l'ensemble des constructeurs est non-fini

Parfois, les types ne sont pas énumérables de manière finie. Par exemple, comment pouvons-nous gérer les chaînes de caractères ou même les entiers ? Dans de telles situations, destruct tentera de trouver un exemple. Pour les entiers, ce sera 0, et pour les chaînes de caractères, ce sera la chaîne vide.

exemple de destructuration sur set non-finis

Excellent ! Nous avons couvert une grande partie des comportements de la commande destruct, qui sont très pertinents dans leur contexte. Il y en a d'autres (comme les cas de destruction en présence de GADTs qui ne génèrent que des sous-ensembles de motifs), mais il est temps de passer à un exemple du monde réel !

La quête de l'exhaustivité: Effective ML

En 2010, Yaron Minsky a donné une excellente présentation sur les raisons (et les avantages) d'utiliser OCaml chez Jane Street. En plus d'être très inspirante, elle fournit des insights spécifiques et des pièges à éviter pour utiliser OCaml efficacement dans un contexte industriel incroyablement sensible (d'où le nom "Effective ML"). C'est dans cette présentation que la maxime "Rendre les états illégaux non représentables" a été mentionnée publiquement pour la première fois, une phrase qui serait plus tard fréquemment utilisée pour promouvoir d'autres technologies (comme Elm). De plus, la présentation anticipe de nombreuses discussions sur la modélisation de domaine, chères à la communauté du Software Craftsmanship, en proposant des stratégies de réduction de domaine (plus tard largement développées dans le livre Domain Modeling Made Functional).

Parmi la liste des approches efficaces pour utiliser un langage ML, Yaron présente un scénario où l'on pourrait trop rapidement utiliser le générique dans une analyse de cas. L'exemple est étroitement lié à la finance, mais il est facile de le transposer dans un exemple plus simple. Nous allons implémenter une fonction equal pour un type très basique :

type t = 
  | Foo
  | Bar

La fonction equal peut être implémentée trivialement comme suit :

let equal a b = 
  match (a, b) with
  | Foo, Foo -> true
  | Bar, Bar -> true
  | _ -> false

Notre fonction fonctionne parfaitement et est exhaustive. Cependant, que se passe-t-il si nous ajoutons un constructeur à notre type t ?

  type t
    | Foo
    | Bar
+   | Baz

Notre fonction, dans le cas de equal Baz Baz, renverra false, ce qui n'est évidemment pas le comportement attendu. Comme le générique rend notre fonction exhaustive, le compilateur ne signalera aucune erreur. C'est pourquoi Yaron Minsky soutient que dans de nombreux cas avec une clause générique, c'est probablement une erreur. Si notre fonction avait été exhaustive, l'ajout d'un constructeur aurait levé un avertissement partial-match, nous obligeant à décider explicitement comment nous comporter en présence du nouveau constructeur ! Par conséquent, utiliser un wildcard dans ce contexte nous prive de la refactorisation sans crainte, qui est une force d'OCaml. C'est en effet un argument en faveur de l'utilisation d'un préprocesseur pour générer des fonctions d'égalité, en utilisant, par exemple, le dériveur standard eq ou le plus hygiénique Ppx_compare. Mais parfois, utiliser un préprocesseur n'est pas possible. Heureusement, la commande destruct peut nous aider à définir une fonction d'égalité exhaustive !

Nous allons procéder étape par étape, en séparant spécifiquement les différents cas et en utilisant un filtrage par motif imbriqué pour rendre les différents cas faciles à exprimer de manière récurrente :

exemple de destructuration pour une fonction d'égalité

Comme nous pouvons le voir, destruct nous permet de mettre en œuvre rapidement une fonction equal exhaustive sans utiliser de motifs génériques. Maintenant, nous pouvons ajouter notre constructeur Baz pour voir comment se déroule la refactoring ! En ajoutant un constructeur, nous détectons rapidement un motif récurrent où nous essayons de donner à la commande destruct autant de liberté que possible pour générer les motifs manquants !

exemple de destructuration pour une fonction d'égalité avec ajout de constructeur

Fantastique ! Nous avons pu mettre en œuvre rapidement une fonction equal. Ajouter un nouveau cas est trivial, laissant destruct gérer tout le travail !

Associée à des fonctionnalités modernes d'édition de texte (par exemple, l'utilisation de multi-curseurs), il est possible de gagner énormément de temps ! Un autre exemple de l'utilisation immodérée de destruct (mais trop long pour être détaillé dans cet article) était l'implémentation du module Mime dans YOCaml pour générer des flux RSS.

En conclusion

Associé à un formateur comme OCamlFormat (pour reformater proprement les fragments de code générés), destruct est un outil peu conventionnel dans le paysage des IDE. Il s'aligne avec les types algébriques et le filtrage par motif pour simplifier l'écriture du code et progresser vers un code plus facile à refactoriser et donc à maintenir ! Consciente de l'utilité de la commande, l'équipe de Merlin continue de la maintenir, en optimisant les dernières fonctionnalités d'OCaml pour rendre la commande aussi utilisable que possible dans le plus de contextes possible !

J'espère que cette collection d'exemples illustrés vous a motivé à utiliser la fonction destruct si vous n'en aviez pas déjà connaissance. N'hésitez pas à nous envoyer des idées d'améliorations, de correctifs et de cas d'utilisation amusants via X ou LinkedIn !

Bon hacking.