Nous avons vu avec le premier exemple que les programmes en OCaml sont organisés en modules, et que chaque module est une liste de types, de valeurs, de modules et de types de modules. En particulier, nous avons mentionné que chaque fichier est un module, portant le même nom que le fichier. Ceci explique que les noms des fichiers de source OCaml doivent être formés de caractères alphanumériques et du caractère _.
Il est aussi possible de définir des modules internes à un fichier. Plus généralement, tout module peut contenir un autre module. On utilise pour cela la syntaxe de définition d'un module :
Le nom d'un module doit être formé de caractères alphanumériques et du caractère _, et doit impérativement commencé par une majuscule.
Les modules sont eux aussi typés. Le type d'un module, qui est appelé interface du module, est une liste de déclarations comprenant :
L'interface d'un module peut ignorer une partie des définitions présentes dans le module : ces définitions omises ne seront pas accessibles depuis l'extérieur du module : c'est exactement comme si elles n'existaient pas.
En ce qui concerne les noms des types, chaque type apparaissant dans l'interface peut apparaître sous trois formes : avec, avec ou sans la définition associée. Les trois syntaxes sont :
Dans le premier cas, seul le nom du type sera connue à l'extérieur du module. Les valeurs de ce type ne pourront donc être manipulées que par les fonctions définies par le module : il n'y a pas d'accès possible à la représentation des valeurs de ce type.
Dans le troisième cas, l'expression du type est exportée, donc les modules utilisant ce type peuvent le manipuler en connaissant sa représentation, donc sans limite.
Le deuxième cas est intermédiaire, il permet d'exporter la représentation, mais d'interdire la création de valeur de ce type à l'extérieur du module. Par rapport au premier cas, l'intérêt est d'autoriser l'observation des valeurs de ce type (de la même façon que nous avons vu comment observer un couple). Nous en reparlerons donc lorsque nous étudierons les types en détails.
En ce qui concerne les valeurs, la syntaxe utilisée est :
L'expression de type ne peut utiliser que des types connus à l'extérieur du module. Si l'expression de type utilise des types définis par le module, ceux-ci doivent être déclarés dans l'interface.
En ce qui concerne les modules, chaque module $I$ définie à l'intérieur d'un module $O$ peut être déclaré dans l'interface de $O$, en indiquant le nom de $I$ et son interface. Il est possible de ne pas donner d'interface, mais c'est rarement utile puisque cela rend le module inutilisable (donc peut-être ne fallait-il pas mentionner ce module dans l'interface de $O$). Les syntaxes possibles sont :
Il est possible de nommer une interface, en utilisant le mot-clé module type. Prenons cet exemple, avec l'équivalent de l'interface Comparable de Java, et un module implémentant cette interface :
De fait, Comparable existe dans la librairie standard d'OCaml, mais s'appelle Set.OrderedType. Notons ici que IntComparable est déclaré avec une interface nommée, en l'occurence Comparable : on peut utiliser les interfaces nommées à la place d'interfaces explicites (celles données avec sig ... end).
Toute valeur, ou tout type défini par un module et déclaré dans son interface peut être utilisé sans restriction (sauf en présence du mot-clé private) à l'extérieur de ce module. Pour faire référence à un identifiant (de valeur, de type, de module ou d'interface) d'un autre module, il suffit d'utiliser la syntaxe :
Par exemple, pour définir un cercle à l'extérieur du module Geometry du premier exemple, on peut écrire :
On peut éviter de devoir redonner systématiquement le nom d'un module en ouvrant ce module :
Il ne faut cependant pas en abuser, car les noms des modules participent à la documentation, donc à la lisibilité du code. On préfère plutôt ouvrir les modules localement à une expression, en utilisant l'une des deux syntaxes :
ou
Pour utiliser un module externe dans l'interpréteur, s'il ne s'agit pas d'un module du noyau de la librairie standard, il faut le charger explicitement. L'interpréteur accepte des directives pour cela. Les directives sont des mot-clés propres à l'interpréteur (ne faisant pas partie du langage OCaml) commençant par le caractère #, et permettant de paramétrer l'interpréteur.
Pour charger un module, la façon basique est d'utiliser #load "filename.cmo";; avec filename.cmo un fichier compilé. Il est nécessaire d'avoir le répertoire contenant le fichier dans la liste des répertoires recherchés par le toplevel. Pour ajouter un répertoire, il suffit de faire #directory "/directory_path";;. Si l'opération échoue, le toplevel affiche un message d'erreur.
Si le module à charger fait partie d'une librairie installée, il existe une méthode plus simple :
Pour compiler un module faisant référence à d'autres modules, il faut donner la liste de ces modules en arguments. C'est bien sûr fastidieux, ce qui explique l'usage d'outils de compilation, comme make. Nous utiliserons ocamlbuild, qui est relativement simple à utiliser pour les projets de taille moyenne, donc suffisant pour nos besoins, et ocamlfind qui est un utilitaire pour trouver les emplacements d'installation des librairies dans le système.
Ocamlbuild gère les modules grâce à un fichier de configuration, contenant les informations concernant les fichiers sources du projet, et les librairies utilisées par chaque fichier source. Nous apprendrons à l'utiliser au fur et à mesure de nos besoins. Ocamlbuild s'utilise avec la ligne de commande :
Par exemple, pour compiler un fichier ml et son interface, en un module compilé, on veut obtenir un fichier d'extension cmo :
Ceci crée un fichier filename.cmo dans un sous-répertoire _build. Si le fichier en question comporte des dépendances à d'autres modules, il faudra créer un fichier de configuration _tags pour ocamlbuild. L'utilisation de ces outils sera vu en TP.