Modules de première classe
g progmod Programmation modulaire submod Modules et sous-modules progmod->submod foncteurs Foncteurs et réutilisabilité submod->foncteurs fstclassmod Modules de première classe submod->fstclassmod
OCaml ≥ 3.12
Table des matières

Jusqu'à maintenant, les façons que nous avons vues de construire des modules étaient relativement statiques: par exemple nous ne pouvions pas utiliser un module ou un autre selon une option de configuration avec quelque chose comme if ... then Mod1 else Mod2.

Les modules de première classe sont une extension apportée à OCaml à partir de la version 3.12. Cela consiste à pouvoir empaqueter des modules dans des valeurs pour les utiliser comme n'importe quelle autre expression. De telles valeurs peuvent être dépaquetées pour retrouver le module empaqueté de façon à accéder aux composants du module.

Cette possibilité impacte trois aspects du langage: les expressions de type (pour pouvoir décrire le type des valeurs contenant des modules, ou modules de première classe), les expressions (pour empaqueter un module et filtrer les valeurs contenant des modules), et la construction de modules (pour récupérer le module contenu dans une valeur). Nous allons voir ces différents aspects ainsi qu'un exemple.

1. Typage

Le type d'une valeur contenant un module de type T est noté (module T). On peut ajouter des contraintes comme par exemple (module T with type t = int).

Ainsi, une fonction prenant en paramètre un module de ce type et renvoyant un entier aura le type suivant:

(module T with type t = int) -> int

On peut évidemment utiliser cette forme de type dans les définitions de type:

# type 'a mod_fun =
  | Blabla
  | Mod of ( (module T with type t = 'a) -> 'a );;
type 'a mod_fun = Blabla | Mod of ((module T with type t = 'a) -> 'a)
2. Empaquetage (pack)

L'empaquetage (pack) consiste à faire d'un module un module de première classe, c'est-à-dire une valeur comme les autres, mais évidemment possédant un type reflétant sa nature.

L'expression (module M : T) crée un module de première classe à partir d'un module M de type T:

# module Int_ord = struct
  type t = int
  let compare (x:int) y = Stdlib.compare x y
end;;
module Int_ord : sig type t = int val compare : int -> int -> int end
# let x = (module Int_ord : Map.OrderedType);;
val x : (module Map.OrderedType) = <module>

L'annotation de type : T est inutile si le type de M peut être déterminé par le compilateur. Dans l'exemple ci-dessous, comme les listes ne peuvent contenir que des éléments de même type, il est inutile de mettre une annotation de type dans (module M):

# module M = struct type t = int let compare = Stdlib.compare end;;
module M : sig type t = int val compare : 'a -> 'a -> int end
# let list = [ x ; (module M) ];;
val list : (module Map.OrderedType) list = [<module>; <module>]

Pour utiliser des modules de première classe, on définira souvent des fonctions retournant des modules. Ces modules peuvent être créés localement puis renvoyés empaquetés dans des valeurs:

# module type Int = sig val v : int end;;
module type Int = sig val v : int end
# let make_int x =
  let module M = struct let v = x end in
  (module M : Int);;
val make_int : int -> (module Int) = <fun>

Il est possible d'introduire localement (pour le contenu de la fonction) un ou plusieurs types abstraits, avec la notation (type a), et d'utiliser ces types dans la définition d'un module local. S'il n'y a pas de contrainte sur ces types locaux, la fonction sera polymorphe sur ces types, donc le module local qui les utilise le sera aussi.

# module type Value = sig type t val value : t end;;
module type Value = sig type t val value : t end
# let make_value (type a) v =
  let module M = struct type t = a let value = v end in
  (module M : Value);;
val make_value : 'a -> (module Value) = <fun>
# let v_int = make_value 3 ;;
val v_int : (module Value) = <module>
# let v_float = make_value 12. ;;
val v_float : (module Value) = <module>

Pour faire apparaître le lien entre le type du paramètre et le type de t dans le module retourné, on pourra ajouter une contrainte with type t = a:

# let make_value (type a) v =
  let module M = struct type t = a let value = v end in
  (module M : Value with type t = a);;
val make_value : 'a -> (module Value with type t = 'a) = <fun>
# let v_int = make_value 3 ;;
val v_int : (module Value with type t = int) = <module>
# let v_float = make_value 12. ;;
val v_float : (module Value with type t = float) = <module>

L'ajout de cette contrainte n'est pas obligatoire. En son absence, le type du module renvoyé resterait abstrait, ce qui peut parfois être voulu.

3. Dépaquetage (unpack)

Le dépaquetage (unpack) consiste à récupérer le module empaqueté dans une valeur. Il y a deux façons de procéder, soit en utilisant (val expr : Type) dans une expression de module (à la place de struct .. end, d'un nom de module existant, ...), soit en utilisant le motif de filtrage (module Id : Type) pour lier Id au module empaqueté.

En continuant l'exemple de la section précédente, nous pouvons définir une fonction max_value prenant en paramètres deux modules de première classe de type Value et qui retournera le module avec la valeur de value la plus grande. La fonction de comparaison utilisée étant de type 'a -> 'a -> bool, les deux modules doivent avoir des types t identiques, ce que nous forçons par des annotations.

Voici une première façon de procéder, en créant dans la fonction deux modules en dépaquetant les deux modules en paramètres:

# let max_value (type a) v1 v2 =
  let module V1 = (val v1 : Value with type t = a) in
  let module V2 = (val v2 : Value with type t = a) in
  if V1.value > V2.value then v1 else v2;;
val max_value :
  (module Value with type t = 'a) ->
  (module Value with type t = 'a) -> (module Value with type t = 'a) = <fun>
# let (module M) = max_value (make_value 3) (make_value 4) in M.value;;
- : int = 4
# let (module M) =
  max_value (make_value "b") (make_value "a") in
  M.value;;
- : string = "b"

Une seconde façon de procéder utilise le filtrage par motif:

# let bin_op (type a) v1 v2 =
  let (module V1 : Value with type t = a) = v1 in
  let (module V2 : Value with type t = a) = v2 in
  if V1.value > V2.value then v1 else v2;;
val bin_op :
  (module Value with type t = 'a) ->
  (module Value with type t = 'a) -> (module Value with type t = 'a) = <fun>
# let (module M) = max_value (make_value 3) (make_value 4) in M.value;;
- : int = 4
# let (module M) =
  max_value (make_value "b") (make_value "a") in
  M.value;;
- : string = "b"
4. Exemple

Nous souhaitons afficher sur la sortie standard des messages normaux et des messages d'erreur1. Si la sortie standard est un terminal, nous voulons mettre les messages d'erreur en rouge, donc envoyer des caractères spéciaux pour l'affichage en couleur. Sinon, nous enverrons le message tel quel, sans ajout.

Nous voulons que cette fonctionnalité soit transparente dans le reste du programme, le module sera passé à la fonction de traitement treatment. Le module qui lui sera passé sera donc défini conditionnellement selon vers quoi est dirigée la sortie standard.

Nous commençons par définir un type de module, avec deux fonctions pour afficher les messages normaux ou d'erreur. Puis nous définissons deux modules de ce type, l'un pour le cas ou la sortie est un terminal, l'autre pour les autres cas. Enfin, nous définissons et appelons la fonctiontreatment en lui passant un module qui sera différent selon que Unix.stdout est un terminal ou non.

module type Output = sig
  val message : string -> unit
  val error : string -> unit
end

module Output_terminal = struct
    let message msg = print_endline msg
    let error msg = print_endline (Printf.sprintf "\027[91m%s\027[0m" msg)
end

module Output_other = struct
  let message = print_endline
  let error = print_endline
end

let choose_output () =
  if Unix.isatty Unix.stdout then
    (module Output_terminal : Output)
  else
    (module Output_other)

let treatment output =
  let (module O : Output) = output in
  O.message "Normal message";
  O.error "Error message"

let () = treatment (choose_output())

Le programme sera compilé sans oublier de le lier avec la bibliothèque unix.cmxa:

ocamlopt -o exemple unix.cmxa exemple.ml

1 Bien sûr, les messages d'erreur pourraient être envoyés sur la sortie d'erreur, mais il s'agit juste d'un exemple ici.