xvw.lol

OCaml, modules et schémas d'importation

Le langage de modules de OCaml peut être intimidant, et il implique généralement l'utilisation de beaucoup de mots-clés, par exemple open et include qui permettent d'importer des définitions dans un module. Depuis la version 4.08 du langage, la primitive open a été généralisée pour permettre l'ouverture d'expression de module arbitraire. Dans cet article, nous allons observer comment utiliser cette généralisation pour reproduire une pratique commune dans d'autres langages, que j'appelle, un peu pompeusement, des stratégies d'importation, décrivant, par exemple, ce genre de d'importation import {a, b as c} from K sans dépendre d'un langage dédié à l'importation.

La généralisation des ouvertures a été documentée dans le papier "Extending OCaml's open", présenté au OCaml Workshp 2017, et implémenté — pour la version 4.08 de OCaml — via les soumissions #1506 et #2147 (adjoint, probablement, de quelques correctifs suivant la fusion de l'implémentation). Cette généralisation augmente grandement la flexibilité du terme open, rendant possible la mise en place de quelques astuces pour contrôler finement l'importation de composants de modules dans un autre module.

Certaines des astuces présentées sont reprises telles qu'elles du papier qui, en plus de présenter les stratégies d'implémentations, présente quelques cas d'usages (dont certains n'étant pas pertinents dans le cadre de cet article car ils ne concernent pas les stratégies d'importations).

Il est probable qu'une grande partie des astuces présentées ne deviennent pas idiomatiques dans les bases de code OCaml. Selon moi, leur présentation met essentiellement en lumière l'accroissement de flexibilité de la primitive open sans devoir se reposer sur une extension gramaticale spécifique dédiée à l'importation de composants, tout en présentant quelques encodages un peu capilotractés ... pour le plaisir de la démonstration.

Importation de composants de modules

Quand on décrit un programme OCaml, on construit des collections de modules qui exportent des composants (des types, des sous-modules, des exceptions et des fonctions). Il est donc crucial de contrôler finement leurs accessibilités depuis d'autres modules, pour cela, on dispose de deux primitives — open et include — dont la différence est subtile. Pour décrire correctement les différences entre les deux primitive, nous allons nous baser sur ce module (un peu artificiel) que nous utiliserons dans les exemples qui suivent :

module A = struct
  type a = T_a
  let a_a = 1
  let a_b = T_a
  module B = struct
    type b = T_b
    let b_a = T_b
    let b_b = T_a
  end
end
module A : sig
  type a = T_a
  val a_a : int
  val a_b : a
  module B : sig
    type b = T_b
    val b_a : b
    val b_b : a
  end
end

Comme vous pouvez le remarquer, l'implémentation — et de facto, la signature — du module n'est pas très intéressant, il ne servira qu'a illustrer mon propos. Ce que l'on voudra, c'est utiliser, dans un fichier c.ml (qui dénotera le module C), les composants décrits dans A.

La première approche, et la plus évidente, est d'utiliser leurs noms complets (un appel totalement qualifié) en utilisant le chemin du module. Par exemple, créons un couple de int et de A.a :

let c_a = (A.a_a, A.B.b_b)

Par contre, il arrive que le fait d'utilisé les chemins complets puisse être rébarbatif (voire totalement illisible). C'est pour ça que nous allons voir comment importer les composants du module A dans le module C. Cependant, comme le but de l'article n'est pas d'être un didacticiel sur l'ouverture et l'inclusion, mais d'explorer les ouvertures généralisées pour décrire des schémas d'importations, nous ne nous étendrons pas sur ces deux fonctionnalités qui sont déjà fortemment décrites dans le manuel du langage — dans les sections dédiées aux modules, à la surcharge liée aux ouvertures et aux ouvertures généralisées.

Ouverture de modules

La primitive open permet d'importer les composants d'un module dans un autre module, sans re-exporter ces dernières dans le module courant. Par exemple, utilisons open pour réécrire la fonction c_a :

open A

let c_a = (a_a, B.b_b)
val c_a : int * A.a

Comme on peut le voir dans la signature (inférée), n'exporte pas les composants du module A et référence le type a par son chemin complet. Il serait aussi possible d'ouvrir A.B en utilisant open A.B ou encore open B (car A est déjà ouvert).

De la même manière qu'il est possible d'ouvrir des modules dans l'implémentation, il est aussi possible d'ouvrir des modules dans la signature, permettant de réduire le chemin pour décrire des types (ou des modules).

Ouverture locale de modules

Les cas d'ouvertures que nous avons observés précédemment étaient globales au module dans lequelle elles étaient invoquées, ce qui peut être un peu contraignant quand on veut ouvrir plusieurs modules — exposant, par exemple, des opérateurs arithmétiques. Heureusement, il est possible d'ouvrir au niveau de l'expression, de deux manières différentes :

  • let open Module in ... où dans l'expression suivant ce bloc — donc lexicalement borné — Module sera ouvert localement. Ce qui est très utile pour n'ouvrir un module que dans une fonction ;

  • Module.(...)Module ne sera ouvert — lexicalement borné aussi — uniquement entre les parenthèses. Ce qui est très utile pour n'ouvrir un module que dans une expression, par exemple, imaginons que le module Float expose un module Infix, exposant les opérateurs arithmétiques usuels pour l'arithmétique, il serait possible de décrire une opération entre des flottants de cette manière :
    let x = Float.Infix.(1.2 + 3.14 + 1.68).

L'absence d'ouverture locale peut être très handicapant. Par exemple, le langage F# ne permet que des ouvertures globales ce qui rend la définition d'opérateurs dans un module dédié laborieuse, préconisant l'utilisation de surcharge d'opérateurs (ou encore de Statically Resolved Type Parameters) engendrant parfois beaucoup de complexité.

Inclusion de modules

La primitive include ressemble très fort à la primitive open sauf qu'elle — comme son nom l'indique — inclus le contenu du module ciblé dans le corps du module où elle est appelée. Par exemple, si nous avions utilisé include au lieu de open dans notre exemple précédent, observons l'incidence sur la signature inférée :

include A

let c_a = (a_a, B.b_b)
type a = A.a = T_a
val a_a : int
val a_b : a
module B = A.B
val c_a : int * A.a

Même si la signature varie légèrement de celle que nous avions définie en introduction — il y a quelques subtilités concernant les propagations d'égalités de types et de modules, décrites dans la section "Recovering the type of a module", liées au renforcement — on peut observer que le contenu du module A a été inclus, ajouté au module C. Au contraire de l'ouverture, il n'est pas possible de faire de l'inclusion locale, ce qui est parfaitement logique parce que de l'inclusion au niveau local aurait exactement le même effet qu'une ouverture.

De mon expérience personnelle, je retire généralement deux cas d'usages spécfiques à l'utilisation de include :

  • l'extension d'un module existant (par exemple, ajouter une fonction au module List dans le cadre de mon projet, ou pour faire une extension à la bibliothèque standard) ;

  • l'inclusion de sous-modules dans un module parent. Par exemple, il est assez courant que dans un module, on retrouve des opérateurs (où des opérateurs de liaison) qu'il est assez courant de confiner dans des sous-modules dédiés (généralement Infix et Syntax). Pour des raisons d'API, les re-exporter au niveau du module mère peut être une bonne idée. C'est d'ailleurs intensivement utilisé dans Preface.

Les inclusions sont un outils puissant d'extension, mais aussi du mutualisation de code et il y a beaucoup à dire car ça peut souvent impliquer la présence de substitution, substitution déstructive ou de renforcement, ce qui impliquerait la rédaction d'un article dédié !

Ouverture VS inclusion avant OCaml 4.08

Avant la fusion de la proposition de la généralisation des ouvertures, il existait une différence sensible dans l'usage des open contre include : le paramètre que prenait les deux primitives.

  • open prenait un chemin de module, par exemple : A ou encore A.B.C

  • include prenait une expression de module, par exemple : des chemins comme A ou A.B.C, mais aussi des applications de foncteurs comme F(X), des modules contraints par des signatures : (M : S) ou directement le corps d'un module : struct ... end.

Cette différence de flexibilité impliquait l'utilisation de détours assez génants pour atteindre le même niveau d'expressivité pour les ouvertures en comparaison aux inclusions. En effet, pour permettre aux ouvertures de composer avec des applications de foncteurs ou des contraintes, il fallait passer par des modules intermédiaires.

Dans le cas de l'utilisation d'un chemin, les deux appels sont — en terme d'expressivité — identiques, parce qu'un chemin peut aussi être une expression de module :

include A.B.C
open A.B.C

Par contre, dès que l'on essaie d'ouvrir des cas un peu plus complexes, nativement supportés par include, on devait rapidement introduire des modules intermédiaires :

include F(X)
include (M : S)
include struct
  let x = 10
end
module A = F(X)
open A

module B : S = M
open B

module C = struct
  let x = 10
end
open C

Même si, aux premiers abords, ça peut ne pas sembler dramatique, l'introduction de modules intermédiaires impose de ne pas les exporter dans la signature du module qui les ouvres (dans son mli ou sa signature). En complément, alors que open et include sont souvent présentés symétriquement, on ne pouvait que déplorer leur absence de symétrie dans les paramètres qu'ils prenaient. Heureusement, depuis 4.08 (qui a tout de même été libérée en Juin 2019), grâce à la généralisation des ouvertures, open prend maintenant une expression de module arbitraire, exactement comme include, nous permettant de l'utiliser pour mimer ces fameux schémas d'importation, évoqués en introduction de cet article.

Un premier bénéfice

Le fait que la primitive open puisse prendre des expressions de modules arbitraires offre un premier bénéfice — probablement futile quand on aime écrire les signatures de ses modules : la définition d'expressions locales. En effet, l'ouverture d'un module n'exporte pas son contenu, il est donc possible de décrire très facilement des valeurs top-level non-exportée en les définissant dans une expression open struct ... end. Par exemple :

open struct
  let x = 10
  let y = 20
end
let z = x + y
val z : int

Les fonctions x et y sont confinées dans une ouverture, elles ne sont donc pas exportées, ce qui peut être très utiles quand on veut définir des valeurs (des types ou des modules) localement. De plus comme une structure peut être contrainte par une signature, il est aussi, par exemple, possible d'encapsuler un état mutable partagé dans l'ouverture locale, ne s'échappant pas du périmètre de son ouverture. Voici deux exemples dans lesquels il est impossible de modifier la cellule de référence sans passer par les combinateurs exportés, le premier utilisant une contrainte, le second en utilisant des ouvertures locales imbriquées :

open (
  struct
    let cell = ref 0
    let incr () = cell := !cell + 1
    let decr () = cell := !cell - 1
  end :
    sig
      val incr : unit -> unit
      val decr : unit -> unit
    end)
open struct
  open struct 
    let cell = ref 0 
  end
  let incr () = cell := !cell + 1
  let decr () = cell := !cell - 1
end

Même si l'approche classique utilisée par un développeur OCaml est d'utiliser des signatures pour des problématiques d'encpasulation, utilisée comme telle, l'ouverture généralisée de modules permet de cacher, de manière relativement élégante, certains éléments de plomberies nécéssaires à la construction d'un module (qui lui, devrait exposer une API publique au moyen d'une signature).

Maintenant que nous avons observé des exemples d'utilisation de l'ouverture généralisée, voyons de quelle manière elle rend la présence d'un langage dédié aux schémas d'importations discutablement utiles.

Schémas d'importation

Depuis que la modularité est devenue un des cheveaux de bataille de la conception de logiciels — JavaScript a d'ailleurs empilé les propositions en faisant dépendre la stratégie de modularisation, et d'importation, du cadriciel ou du système de construction utilisé — les langages (comme Python ou encore Haskell) ont proposé des fonctionnalités similaires à la primitive open de OCaml pour importer des composants dans le module courant. Généralement, ces directives d'importation sont un tout petit langage à part entière, régit par ses propres règles et sa propre grammaire. Depuis que open est généralisé, on peut encoder une grande partie des constructions d'importation usuelles — même si nous verrons que certaines, proposées par Haskell, peuvent être un peu verbeuse à encoder.

Pour l'exemple, nous utiliserons le module suivant comme cible d'importation :

module Foo : sig
  val x : int
  val y : string
  val z : char
end

Il existe cependant une nuance essentielle sur la notion d'importation qualifiée, en effet, en Haskell, pour être utilisé, un module doit être importé, alors qu'en OCaml, c'est la description du schéma de compilation qui indique la présence ou non d'un module. Dans nos différents exemples, nous supposerons que le module Foo, que nous avons décrit précédemment, est présent dans notre schéma de compilation. De ce fait, pour les importations qualifiée — où les termes sont toujours préfixées par leur cheminm de module, il n y a aucune cérémonie aditionnelle nécéssaire. Il est importer de garder à l'esprit que les astuces présentées ci-dessous peuvent se composer pour construit des schémas d'importations très spécifiques (et probablement irréalistes), démontrant que, au coup d'un peu de verbosité, l'approche langage permet encore plus de flexibilité qu'un DSL dédié à l'importation rigide.

Importation non-qualifiée

La première directive consiste simplement à importer les définition de Foo dans le module courant, les fonctions x, y et z :

import * from Foo
open Foo

Il n y a pas de subtilité, importer toutes les termes exposés par Foo consiste simplement à l'ouvrir. Il n y a pas grand chose à dire de plus, on ne tire ici partit d'aucune subtilité du langage.

Qualification renommée

Une autre directive commune consiste à renommer le module, par exemple, importer Foo sous le nom Bar en rendant accessible dans le module Bar.x, Bar.y et Bar.z, pour cela, on peut utiliser alias de module type-level.

import Foo as Bar
open struct module Bar = Foo end

On utilise la construction open struct ... end pour ne pas échapper notre alias dans l'API publique de notre module — même si, en présence d'une signature, cette coquetterie n'est pas nécéssaire car il suffit de ne pas exporter le module Bar dans la signature.

Présence du module renommé

Le fait d'utiliser un alias de module laisse le module Foo accessible, et dans certains cas, on voudrait le rendre inaccessible. La solution la plus simple est de simplement vider le module et pour clarifier l'erreur lié à son utilisation non-désirée, on peut ajouter une alerte indiquant que le module a été supprimé :

open struct
  module Bar = Foo
  module Foo = struct end [@@alert erased]
end

Rendant l'exploitation du module Foo, dans le module courant, impossible. Cependant, comme il est d'usage de fournir des signatures de modules — et donc d'en contrôler son API publique, on trouvera plus régulièrement des renommages de cette forme : module Bar = Foo. De plus, je ne suis pas convaincu que l'interdiction d'accès au module original soit réellement un problème.

Renommage imbriqué

On pourrait imaginer ce genre de renommage : import Foo as Bar.Baz, mais OCaml ne permet pas de décrire des chemins complets de cette forme module Bar.Baz = Foo, il faut donc décrire la hiérarchie d'imbrication des modules, de cette manière, rendant les fonctions Bar.Baz.x, Bar.Baz.y et Bar.Baz.z disponibles dans le module courant :

open struct 
  module Bar = struct 
    module Baz = Foo 
  end 
end

Ce qui est, je vous l'accorde, un peu verbeux, mais si pour des raisons obscures, vous voudriez renommer un module existant avec un chemin composé, vous pouvez, en déclarant la hiérarchie des modules.

Importation sélective

Il arrive parfois que l'importation complète du module soit excessive et que l'on ne voudrait que quelques composants exposés par ce dernier. C'est pour ça qu'il est possible de n'importer qu'une partie d'un module. Dans cet exemple, ne nous voudrions n'importer que x et y :

import {x, y} from Foo
open struct
  let x = Foo.x
  let y = Foo.y
end

Même si cette approche est, elle aussi, un peu verbeuse, elle n'importe, dans le module courant, que les fonctions x et y. Il est possible de simplifier cette écriture en utilisant des n-uplet et des ouvertures locales :

open struct
  let (x, y) = Foo.(x, y)
end

Sinon, il est aussi possible de contraindre l'ouverture au moyen d'une signature, ce qui implique de devoir le type des fonctions à exporter :

open (Foo : sig
  val x : int
  val y : string end)

Plusieurs propositions (#10013 et #11558) ont été faites pour permettre d'utiliser du let-punning rendant la syntaxe moins lourde, cependant dans la première a été délestée du punning pour les membres de modules et la seconde est toujours au stade de l'issue.

Importation sélective avec renommage

Comme les deux premières propositions laissent à l'utilisateurs un contrôle sur le nom (ce n'est que de la redéfinition de fonction), on peut trivialement intégrer le renommage. Dans cet exemple, on expose x et new_y_name, qui appelle Foo.y :

import {x, y as new_y_name} from Foo
open struct
  let (x, new_y_name) = Foo.(x, y)
end

Sans surprise, le renommage est assez simple, par contre, si l'on voulait utiliser l'approche par signature, il faudrait utiliser un peu plus d'astuce en couplant une ouverture et une inclusion :

open (struct
    include Foo
    let new_y_name = y
  end : sig
    val x : int
    val new_y_name : string end)

Cependant, cette dernière proposition est tellement verbeuse qu'elle en devient un peu irrationnelle — surtout en comparaison avec la précédente — que j'imagine que c'est le genre de code que l'on ne verra jamais — ou du moins, que l'on ne voudrait jamais voir — dans une base de code régulière. En revanche, même si la proposition est lourde, je trouve qu'elle montre tout de même assez explicitement comment il est possible de composer les différentes constructions et outils vu précédemment.

Importation par exclusion

Haskell possède un modificateur d'importation un peu particulier dont j'ai longuement hésité à parler parce que je n'avais aucune idée de comment l'implémenter, mais c'était sans compter, une fois de plus, sur l'inestimable aide de @octachron et de @xhtmlboi qui m'ont tout deux donné, approximativement, la même solution . Ce modificateur permet d'importer tout un module, excepté une liste de composant. Dans cet exemple, x et y seront importé parce que l'on importe tout le module Foo, excepté la fonction z.

import Foo hidding (z)

OCaml ne dipose pas, nativement, la possibilité de construire des intersections ou des différences de modules. La solution proposée par @octachron et @xhtmlboi repose sur de la réecriture de fonction, adjoint à l'utilisation d'une alerte, d'une manière un peu similaire à ce que nous avions fait pour évincer un module renommé. Par contre, avant d'observer la solution proposée, faisons un petit détour par le variant vide.

Le type somme vide

En OCaml, il est possible de définir un type somme qui ne contient aucun constructeur, en utilisant le variant vide et qui permet, dans les grandes lignes, de décrire des valeurs non représentables. Pour le définir, il suffit de construire une somme avec une branche vide (qui, attention, n'est pas le type bottom, noté ) :

type empty = |

Pour se convaincre que le compilateur peut réfuter les cas contenant une valeur de type empty, on peut très facilement l'expérimenter avec de la correspondance de motifs, dans l'exemple qui suit, le compilateur ne lève aucun avertissement car les motifs sont exhaustifs. Comme il n'est pas possible de construire une valeur de type empty (sauf en trichant, en utilisant, par exemple de la sorcellerie comme l'inénarrable fonction Obj.magic), on peut réfuter le traitement du cas d'erreur :

let f : ('a, empty) result -> 'a = function
  | Ok x -> x

Mais dans notre cas d'usage, ce n'est pas la réfutation qui va nous intéresser mais plutôt le fait qu'il n'est pas possible de décrire une valeur de type empty pour permettre l'éviction de fonctions.

Suppression de fonctions

La solution qui m'a été proposée consiste à rendre les fonctions que l'on veut expulser du module impossible à appeler. Pour ça, nous allons d'abord créer une fonction placeholder que nous utiliserons pour écraser une fonction existante :

type empty = |
let expelled : empty -> unit = fun _ -> ()

A priori, notre fonction expelled est impossible à appeler car on ne peut la provisionner d'une valeur de type empty, cette dernière étant impossible à produire. Nous allons donc pouvoir inclure le module que l'on veut refiner et ensuite substituer les fonctions que l'on veut exclure avec notre fonction expelled, et nous les associerons à une alerte pour clarifier l'erreur que l'utilisation d'une fonction évincée produira :

open struct
  include Foo
  let (z [@alert expelled]) = expelled
end

Et voila, on peut être à peu près sûr que l'utilisation de z provoquera une erreur de compilation, et la compilation d'un module qui l'exploite soulèvera un avertissement. En revanche, la solution est loin d'être parfait car elle n'élimine pas le composant du module. Pour être très honnête, j'ai très rarement eu l'occasion de regretter l'absence de cette fonctionnalité, nativement. De mon point de vue, l'importation sélèctive suffit généralement largement.

Ancrages de types

Avant de conclure cet article, @octachron m'a pointé du doigt l'asymétrie partielle entre open et include en présence de modules anonymes (donc d'expression de modules struct ... end), c'est un problème auquel j'avais déjà été confronté théoriquement car j'avais assisté à l'événement de Mai 2023 du OCaml Users in ParisClément Blaudeau, dans sa présentation Retrofitting OCaml Modules (qui était une synthèse de son papier OCaml modules: formalization, insights and improvements).

Comme l'ouverture n'exporte pas les composants ouverts, sans l'association à une signature explicite, certains termes ne peuvent pas être typés. Par exemple :

open struct type t = A end
let x = A

Dans cet exemple, le type t (et son constructeur A) est présent dans le scope courant, cependant, comme il n'est pas exporté, il est impossible de typer correctement x. Si le module disposait d'une signature, on pourrait facilement se rendre compte qu'il n'existe pas de type acceptable pour xet qu'il faudrait soit changer la directive d'ouverture, soit ne pas exporter x. C'est un problème que l'on appelle l'ancrage des types qui est décrit expansivement dans le papier cité en début de section.

Conclusion

Je pense sincèrement que, dans une utilisation quotidienne de OCaml, nous sommes très rarement confronté à ce genre de besoins. L'objectif de l'article était, essentiellement, de montrer comment utiliser certaines primitives liées au langage de modules en complément de la généralisation des ouvertures pour démontrer qu'avoir des primitives expressives et composables permet de reproduire, parfois trivialement (et parfois moins trivialement), des schémas d'importations classiques, présents dans d'autres langages de programmation. Il existe probablement d'autres encodages rigolos — probablement à base de foncteurs — et n'hésitez pas à me les faire parvenirs pour que je puisse compléter cet article !

Pour terminer, j'ajouterai que même si j'ai fièrement fanfaronné en prétendant que ce n'était pas commun de programmer de cette manière en OCaml, la présence de paquet comme ppx_import ou ppx_open indique que quelques allègement syntaxique ne serait pas de trop, notamment pour les importation sélectives.