VIII. Programmation modulaire▲
Jusqu'ici nous avons programmé en Caml en considérant que nous disposions d'un ensemble de fonctions et de types prédéfinis chargés à l'ouverture d'une session interactive et constituant l'environnement initial de la session, puis que nous définissions au fur et à mesure des besoins un certain nombre de noms de valeurs, de types ou d'exceptions. Cette façon de faire est impraticable pour développer de véritables logiciels. Il faut pouvoir découper un programme en plusieurs parties qui puissent être sauvegardées et bien entendu réutilisées.
Caml offre différents outils pour cela. Nous étudierons tout d'abord une technique simple qui est l' inclusion de fichiers . Elle permet d'enregistrer un programme Caml dans un ou plusieurs fichiers et de charger ces fichiers en mémoire au fur et à mesure des besoins. Cette technique n'est pas suffisante pour deux raisons : (i) les programmes doivent être recompilés à chaque chargement, (ii) ces fichiers ne peuvent pas être développés indépendamment puisqu'il faut s'assurer que les ensembles de noms définis dans chacun soient disjoints. Pour pallier à cette insuffisance, Caml permet de découper une application en modules compilables séparément. Les modules offrant un degré suffisant de généralité peuvent être insérés dans une bibliothèque partageable par plusieurs applications.
Dans ce chapitre nous appellerons :
- entité : un type, une exception, un constructeur ou une valeur ;
- programme Caml : une suite de phrases qui sont soit des expressions, soit des définitions de valeurs, de type ou d'exception, soit des directives.
VIII-A. Inclusion de fichiers▲
Un programme Caml peut être enregistré dans un fichier qui doit avoir l'extension .ml. Pour charger un programme Caml en mémoire, il suffit d'appliquer la fonction prédéfinie include au nom du fichier qui le contient. Cette application a pour effet de lire chaque phrase du programme et de l'évaluer. Tout se passe comme si ces phrases avaient été entrées au clavier.
Supposons, par exemple, que le fichier "un_deux_trois.ml" du répertoire courant ait le contenu :
let un = 1;;
let deux = un + 1;;
let trois = deux + 1;;
son inclusion se déroulera de la façon suivante :
#include "un_deux_trois.ml";;
un : int = 1
deux : int = 2
trois : int = 3
- : unit = ()
Si le fichier n'appartient pas au répertoire courant il faut préfixer le nom de ce fichier par le nom du répertoire auquel il appartient. Si l'extension .ml est omise elle est automatiquement rajoutée. On aurait donc pu écrire include "un_deux_trois" au lieu de include "un_deux_trois.ml".
VIII-B. Modules▲
Un module est un morceau de programme Caml qui forme un tout et qui est susceptible d'être réutilisé. Le texte d'un module est enregistré dans un fichier appelé fichier source du module, qui a le même nom que celui du module et l'extension .ml. Par exemple, le module "cercle" de texte :
type cercle = {Rayon: float};;
let pi = 3.14;;
let périmètre {Rayon = r} = 2. *. pi *. r;;
let surface {Rayon = r} = pi *. r *. r;;
enregistré dans le fichier "cercle.ml".
On distingue trois types de modules :
- les modules de la bibliothèque standard de Caml qui contiennent notamment les valeurs et les types prédéfinis. Par exemple, le module io consacré aux entrées-sorties (open_in, input_line, …) ou bien le module int consacré aux opérations sur les entiers (prefix +, minus, …) ;
- les modules écrits par le programmeur ;
- le module top qui est un peu particulier. Il est composé de toutes les phrases entrées au clavier ou incluses à partir d'un fichier au cours d'une session interactive. Il n'a donc pas de fichier source à proprement parler.
VIII-B-1. Qualification des noms d'un module▲
Les expressions contenues dans un module peuvent faire appel soit à des noms internes, définis dans ce module, soit à des noms externes définis dans d'autres modules. Rien n'interdisant à des modules différents de contenir des entités de même nom, une entité ne peut être identifiée que par un couple « nom du module, nom interne au module ». En Caml, un tel couple est appelé nom complet (ou nom qualifié ) et est noté m __ n où m est le nom du module et n est le nom interne au module appelé nom abrégé (ou nom non qualifié ). Par exemple, int__minus désigne le nom minus du module int qui est un module de la bibliothèque de base de Caml.
On pourrait imposer que seuls les noms complets soient employés dans les programmes, mais ce serait très lourd. Pour l'éviter Caml offre un mécanisme d'ouverture de module qui permet le plus souvent de n'employer que les noms abrégés, leur complétion étant automatiquement effectuée par Caml. Ce mécanisme est le suivant :
- pour utiliser le nom abrégé d'une entité dans le texte source d'un module il faut au préalable ouvrir le module qui contient cette définition.
- un module est explicitement ouvert par la directive #open m où m est le nom du module et fermé par la directive #close m . Pour faciliter la tâche du programmeur, Caml ouvre automatiquement avant l'analyse d'un module, le module à analyser ainsi que les modules de la bibliothèque qui sont le plus utilisés.
- lorsque Caml rencontre un nom abrégé, il le complète par le nom du premier module ouvert qui définit ce nom, en parcourant ces modules dans l'ordre suivant : le module en cours d'analyse, les modules explicitement ouverts dans l'ordre de leur ouverture, les modules de bibliothèque ouverts dans un ordre défini lors de l'installation de Caml.
Les règles d'emploi des noms abrégés dans le texte d'un module, impliquées par ce mécanisme sont les suivantes :
- toutes les entités du module en cours d'écriture peuvent être désignées par leur nom abrégé. Cette règle est tout à fait naturelle. Elle a été appliquée implicitement, dans tous les chapitres précédents, pour le module top ;
- toutes les entités d'un module ouvert peuvent être désignées par leur nom abrégé, s'il n'existe pas d'entité de même nom dans un module explicitement ouvert préalablement. Il y a donc intérêt à ouvrir en premier le module le plus utilisé ;
- toutes les entités d'un module de bibliothèque peuvent être désignées sous leur nom abrégé, s'il n'existe pas d'entité de même nom dans les modules explicitement ouverts ou bien dans les modules de bibliothèque ouverts préalablement. Une conséquence de cette règle est la possibilité de redéfinir les primitives de la bibliothèque tout en conservant leur désignation. La fonction prédéfinie min, par exemple, choisit le plus petit de deux entiers. On peut la redéfinir dans le module top pour qu'elle choisisse le plus petit entier d'une liste non vide d'entiers :
#min;;
- : int -> int -> int = <fun>
#min 2 1;;
- : int = 1
#let rec min = function
| [] -> failwith "min est inapplicable"
| [n] -> n
| n::l -> int__min n (min l);;
min : int list -> int = <fun>
#min [3; 1; 2];;
- : int = 1
- Dans les autres cas, les définitions devront être désignées par leur nom complet. C'est le cas de int__min dans le programme précédent.
VIII-B-2. Interface de module▲
Les entités définies dans un module peuvent être classées en deux catégories : les entités publiques qui sont destinées à être utilisées par d'autres modules et les entités privées qui servent uniquement à définir les premières et qui sont cachées aux autres modules. Considérons, par exemple, un module contenant les formules permettant de calculer le volume de divers objets géométriques (sphères, cylindres, prismes, etc.). Pour définir ces formules il peut être nécessaire d'utiliser des constantes (π par exemple) ou des définitions intermédiaires (la surface de base d'un cylindre, par exemple) qui ne doivent pas être visibles dans les autres modules. =
Pour distinguer entre entités publiques et entités privées, Caml décompose un module en deux parties :
- l' interface , qui est formée par les signatures des noms de valeurs et par les définitions des types et des exceptions publiques. La signature d'un nom de valeur est une phrase de la forme :
value n : t;;
- l' implémentation , qui est formée par les définitions des noms de valeur et celles des types et des exceptions privées.
Le fichier contenant l'implémentation d'un module de nom m doit avoir pour nom m .ml et celui contenant son interface m .mli.
Par exemple, dans le module cercle on peut vouloir cacher la valeur pi et exporter le type cercle et les fonctions périmètre et surface. En ce cas l'interface, enregistrée dans le fichier cercle.mli contiendra les phrases :
type cercle = {Rayon: float};;
value périmètre cercle -> float;;
value surface cercle -> float;;
et l'implémentation, enregistrée dans le fichier cercle.ml, les phrases :
let pi = 3.14;;
let périmètre {Rayon = r} = 2. *. pi *. r;;
let surface {Rayon = r} = pi *. r *. r;;
L'interface par défaut d'un module est constituée de toutes les définitions contenues dans ce module.
VIII-B-3. Compilation d'un module▲
Interface et implémentation d'un module sont destinés à être compilés. Il faut pour cela appliquer la fonction prédéfinie compile au nom du fichier correspondant. Par exemple :
#compile "cercle.mli";;
- : unit = ()
#compile "cercle.ml";;
- : unit = ()
La compilation de l'interface d'un module m produit le fichier m .zi, dit fichier d ' interface compilée et celle de l'implémentation produit le fichier m .zo, dit fichier de code objet . Par exemple cercle.zi et cercle.zo pour le module cercle.
Le fichier d'interface compilée contient les informations sur les types des entités déclarées dans le fichier d'interface. Ce fichier est indispensable pour compiler les modules qui font appel aux entités publiques de m . Quant au fichier de code objet, il contient le code compilé des valeurs définies dans le fichier d'implémentation.
VIII-B-4. Chargement d'un module▲
Pour charger en mémoire un module non compilé et l'exécuter il faut appliquer la fonction prédéfinie load au nom de son fichier source. Par exemple :
#load "un_deux_trois";;
un : int = 1
deux : int = 2
trois : int = 3
- : unit = ()
Pour faire de même avec un module compilé, il faut appliquer la fonction prédéfinie load_object au nom de son fichier de code objet. Par exemple :
#load_object "cercle.zo";;
- : unit = ()
(si l'extension .zo est omise elle automatiquement rajoutée).
Attention ! pour que les définitions des modules chargés soient visibles il faut au préalable ouvrir le module :
#un;;
Toplevel input:
>un;;
>^^
The value identifier un is unbound.
##open "un_deux_trois";;
#un;;
- : int = 1
Travailler en mode interactif est équivalent à charger de façon incrémentale le module top qui, comme nous l'avons vu, contient toutes les définitions entrées au clavier ou par une inclusion de fichier (include).
Au démarrage d'une session interactive, Caml charge automatiquement tous les modules de la bibliothèque standard. Il ouvre aussi un certain nombre de modules comme nous l'avons expliqué au §VII.B.1.
VIII-C. Exemple complet▲
Cet exemple est consacré à la manipulation de volumes géométriques : sphères, cylindres, prismes, etc. Il s'agit de faire des calculs sur leurs surfaces et sur leurs volumes. Dans un premier temps nous constituons une bibliothèque d'objets bi et tri-dimensionnels : cercles, carrés, cubes, sphères, etc. A chaque type d'objet est associé un module.
Nous donnons ci-dessous les modules relatifs aux cercles, aux rectangles et aux cylindres, avec pour chacun son interface et son implémentation :
- module "cercle" :
- Interface
type cercle = {Rayon: float};;
value périmètre : cercle -> float;;
value surface : cercle -> float;;
- Implémentation
let pi = 3.14;;
let périmètre {Rayon = r} = 2. *. pi *. r;;
let surface {Rayon = r} = pi *. r *. r;;
- module "rectangle" :
- Interface
type rectangle = {Longueur: float; Largeur: float};;
value périmètre : rectangle -> float;;
value surface : rectangle -> float;;
- Implémentation
let périmètre {Longueur = x; Largeur = y} = 2. *. (x +. y);;
let surface {Longueur = x; Largeur = y} = x *. y;;
- module "cylindre" :
- Interface
type cylindre = {Rayon: float; Hauteur: float};;
value surface : cylindre -> float;;
value volume : cylindre -> float;;
- Implémentation
#open "cercle";;
#open "rectangle";;
let périmètre_base {Rayon = r} =
cercle__périmètre {cercle__Rayon = r};;
let surface_base {Rayon = r} =
cercle__surface {cercle__Rayon = r};;
let surface_droite {Rayon = r; Hauteur = h} =
rectangle__surface
{Longueur = périmètre_base {Rayon = r; Hauteur = h};
Largeur = h};;
let surface c =
(2. *. surface_base c) +. surface_droite c;;
let volume ({Hauteur = h} as c) =
(surface_base c) *. h;;
Remarquons, dans la définition de la fonction périmètre_base du module "cylindre", l'utilisation du nom complet cercle__Rayon pour désigner le champ Rayon d'un cercle et ne pas le confondre avec le champ Rayon d'un cylindre. De même le nom complet rectangle__surface est utilisé pour distinguer la fonction surface d'un cylindre de celle d'un rectangle.
Voici maintenant une session de travail sur les cylindres. Cette session se déroule de la fàçon suivante :
-
On se place dans le répertoire contenant les modules "rectangle", "cercle" et "cylindre" :
Sélectionnez
cd "…";
-
On compile ces modules :
Sélectionnez
#compile "rectangle.mli";; - : unit = () #compile "rectangle.ml";; - : unit = () #compile "cercle.mli";; - : unit = () #compile "cercle.ml";; - : unit = () #compile "cylindre.mli";; - : unit = () #compile "cylindre.ml";; File "cylindre.ml" Warning: useless #open on module "cercle". - : unit = ()
-
Le message « Warning: useless #open on module "cercle". » indique qu'il n'était pas indispensable d'ouvrir le module cercle puisque toutes les entités de ce module sont désignées par leur nom complet. Remarquons de plus que l'on a compilé les interfaces des modules "rectangle" et "cercle" avant de compiler le module "cylindre" qui fait appel à ces deux modules. Si l'on avait commencé par compiler le module "cylindre" Caml aurait envoyé le message suivant :
indiquant qu'il n'a pas trouvé l'interface compilée du module "cercle" dans lequel il aurait recherché la définition du constructeur cercle__Rayon.
Sélectionnez#compile "cylindre.ml";; Cannot find the compiled interface file cercle.zi.
-
On charge les codes objets :
Sélectionnez
#load_object "rectangle.zo";; - : unit = () #load_object "cercle.zo";; - : unit = () #load_object "cylindre.zo";; - : unit = ()
-
On ouvre le module "cylindre" :
Sélectionnez
##open "cylindre";;
- On définit un cylindre de rayon 10 et de hauteur 20, puis on en calcule sa surface et son volume :
#let mon_cylindre = {Rayon = 10.; Hauteur = 20.};;
mon_cylindre : cylindre = {Rayon = 10.0; Hauteur = 20.0}
#surface mon_cylindre;;
- : float = 1884.0
#volume mon_cylindre;;
- : float = 6280.0