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.(...)
où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 moduleFloat
expose un moduleInfix
, 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
etSyntax
). 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 encoreA.B.C
-
include
prenait une expression de module, par exemple : des chemins commeA
ouA.B.C
, mais aussi des applications de foncteurs commeF(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 Paris où Clé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 x
et
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.
Commentaires
Comme mon site web est hébergé sur un serveur de fichiers statiques (et ne dispose pas de base de données), les commentaires sont assurés par le Fediverse. Si vous possédez un compte, par exemple Mastodon, vous pouvez donc commenter cette page en répondant à ce fil de discussion!