Cours 6

Htmligure
Version & licenses
Creative Commons License

Les foncteurs.

Guyslain
Thursday, 21 July 2016

Retour au sommaire.

Justification.

Les interfaces de Java permettent à un objet de prendre en argument un autre objet, de n'importe quelle classe, du moment que l'objet en argument possède certaines méthodes. C'est donc une forme de polymorphisme, les arguments peuvent avoir un type quelconque, mais avec la possibilité de mettre des contraintes additionnelles sur le type.

En OCaml, nous avons vu que les variables polymorphes peuvent avoir tous les types. Nous n'avons pas de façon de contraindre une variable à avoir n'importe quel type mais avec une fonction associée. En un sens, nous n'en avons pas vraiment besoin : si la fonction prenant cette variable en argument a besoin de faire une opération propre à cet argument $x$, c'est-à-dire qu'elle doit appeler une fonction $f$propre au type de $x$, il lui suffit de prendre aussi $f$ en argument.

Prenons l'exemple des files de priorités. Les files de priorités sont définies génériquement sur le type des éléments contenus dans les files. Mais une contrainte est de pouvoir comparer les éléments, puisque la raison d'être des files de priorité est de récupérer les éléments dans un ordre particulier. En Java, on pourrait donc définir une classe générique et imposer que le type des clés étendent la classe Comparable.

  1. public class Heap<Key extends Comparable<? super Key>> { ... }

Il n'y a pas de mécanisme équivalent en OCaml. Au passage, la solution Java est loin d'être parfaite, et d'ailleurs la librairie standard de Java ne fait pas comme cela. La raison est qu'avec cette solution, pour tout classe peut exister qu'une seule sorte de file de priorité : l'ordre est fixé par la classe. On veut plutôt séparer la classe des clés de l'ordre sur les clés: ce sont deux choses distinctes. Comme ci-dessus, il est donc préférable de donner la fonction de comparaison (un objet d'interface Comparator) au constructeur de la file de priorité : on en revient donc à la solution fonctionnelle.

Néanmoins, OCaml propose un mécanisme alternatif, les foncteurs (à ne pas confondre avec l'interface foncteur). Le principe est de générer des définitions en fonction des valeurs définies par un module. Par exemple, définir un module de file de priorité en fonction d'un module d'interface Set.OrderedType.

Les foncteurs.

Un foncteur est une sorte de fonction prenant en argument un module, et ayant pour résultat un module. Par exemple, pour créer un dictionnaire dont les clés sont les chaînes de caractères, on peut écrire :

  1. module StringMap = Map.Make(String)

Ici, le module Make du module Map est un foncteur, et il est appliqué au module String, son argument. Le résultat est le module StringMap. Regardons le type de Map.Make :

  1. module Map.Make : functor (K : Set.OrderedType) -> Map.S

Map.S est l'interface des dictionnaires. On retrouve la flèche -> des fonctions, précédée de l'argument et suivi du résultat. Techniquement les foncteurs ne sont pas des fonctions : les fonctions vivent au niveau des valeurs, alors que les foncteurs vivent uniquement au niveau des modules. On ne peut pas produire un module à partir d'une valeur, ou une valeur à partir d'un module (en tout cas, pas comme cela).

L'argument K est annoté par le type de module Set.OrderedType. Cela signifie que pour appliquer Map.Make à un module, il faut que ce dernier implémente l'interface Set.OrderedType : avoir un type t et une fonction compare du bon type. On peut donc en déduire comment réaliser un dictionnaire dont les clés sont les entiers :

  1. module Int = struct
  2. type t = int
  3. let compare a b = a - b
  4. end
  5. module IntMap = Map.Make(Int)

Il n'est par contre pas possible de faire un dictionnaire sur les listes contenant des éléments de type arbitraire, puisque le type t de OrderedType doit ne pas avoir de paramêtre. On peut néanmoins faire des dictionnaires de listes d'entiers :

  1. module IntList = struct
  2. type t = int list
  3. let compare = compare
  4. end
  5. module IntListMap = Map.Make(IntList)

La librairie standard contient des foncteurs pour créer des structures d'ensembles (Set.Make), de dictionnaires (Map.Make) et de tables de hachage (Hashtbl.Make).

En dehors des structures de données, les principaux cas d'utilisation des foncteurs sont en général soit pour étendre automatiquement un module implémentant une interface minimaliste en lui ajoutant des fonctions supplémentaires, soit pour créer des niveaux d'abstraction dans une implémentation. Un exemple classique est la librairie Cohttp qui permet l'implémentation haut-niveau de clients et de serveurs http. Cohttp fonctionne sur plusieurs architectures, et il est facile d'en ajouter de nouvelles. Pourquoi ? les couches haut-niveaux de cohttp sont générés par des foncteurs, prenant en argument une implémentation des couches bas-niveaux (du module IO spécifiquement). Une autre application des foncteurs est le paramétrage d'un programme : cela permet de pouvoir ensuite facilement changer les paramètres, et même de tester différents paramêtrages avec un seul programme.

Créer un foncteur.

Pour créer un foncteur, il est préférable de commencer par créer une interface pour l'argument et une autre pour le résultat (si elles n'existent pas). Ce n'est pas strictement nécessaire, mais il sera obligatoire d'annoter le type du module en argument, donc autant le nommer. Nommer l'interface résultat est aussi une bonne pratique pour rendre le résultat lisible.

Prenons l'exemple d'un module de file de priorité basée sur des tas gauches. Le foncteur transformera un type ordonnée en un type des files de priorités. On définit :

  1. module type MinimalHeap = sig
  2. type key
  3. type t
  4. val empty : t
  5. val is_empty : t -> bool
  6. val add : key -> t -> t
  7. val top : t -> key option
  8. val observe : t -> (key * t) option
  9. end

Make est un nom classique pour un foncteur, comme toujours il apparait dans un autre module, par exemple son nom complet ici sera probablement Heap.Make, ce qui est parfaitement clair. On peut néanmoins choisir un nom arbitraire selon la syntaxe des noms de modules, autrement dit commençant par une majuscule. La syntaxe pour définir le foncteur est alors :

  1. module Make : functor (Key : Set.OrderedType) -> MinimalHeap
  2. = functor (Key : Set.OrderedType) ->
  3. struct
  4. type key = Key.t
  5. let (<) key1 key2 = Key.compare key1 key2 < 0

  6. type t =
  7. | Leaf
  8. | Node of t * key * int * t (* (left,root,rank,right) *)

  9. let empty = Leaf
  10. let is_empty h = h = Leaf

  11. let rank = function
  12. | Leaf -> -1
  13. | Node (_,_,r,_) -> r

  14. let join left root right =
  15. if rank left > rank right then Node (left, root, 1 + rank right, right)
  16. else Node (right, root, 1 + rank left, left)

  17. let rec merge heap1 heap2 =
  18. match heap1, heap2 with
  19. | Leaf, _ -> heap2
  20. | _, Leaf -> heap1
  21. | Node (left1,root1,_,right1), Node (_,root2,_,_) when root1 < root2 ->
  22. join left1 root1 (merge right1 heap2)
  23. | _, Node (left2, root2, _, right2) ->
  24. join left2 root2 (merge heap1 right2)

  25. let singleton elt = Node (Leaf, elt, 0, Leaf)

  26. let insert key heap = merge (singleton key) heap

  27. let top = function
  28. | Leaf -> None
  29. | Node (_,root,_,_) -> Some root

  30. let observe = function
  31. | Leaf -> None
  32. | Node (left, root, _, right) -> Some (root, merge left right)
  33. end

L'annotation de type pour Make est optionnelle, elle est en revanche obligatoire pour l'argument Key du foncteur. La suite du code est simple : on remarque simplement que les valeurs définies dans le foncteur peuvent utilisées les valeurs définies dans l'argument du foncteur. Par exemple le type key est définie à l'aide du type Key.t, et la fonction de comparaison (<) est définie à l'aide de Key.compare.

Une des difficultés principales dans l'utilisation des foncteurs est de réussir à s'assurer que les types sont correctement exportés. Par exemple l'implémentation ci-dessus est inutilisable : en effet, en imposant l'interface MinimalHeap, nous avons rendu abstraits les types t (ce qui est normal) et key (ce qui est génant). L'application du foncteur fournira donc un type des files de priorité dont les éléments font partis d'un type inconnu !

Une mauvaise solution serait de retirer l'annotation de type pour le module produit MinimalHeap. Mais il n'est pas négociable de perdre les possibilités d'abstraction que procurent les interfaces. Dans le cas des foncteurs, la solution consiste en l'ajout d'export explicite des types que l'on souhaite exporter. La syntaxe est alors la suivante :

  1. module Make :
  2. functor (Key : Set.OrderedType) ->
  3. MinimalHeap with type key = Key.t
  4. = functor (Key : Set.OrderedType) ->
  5. struct
  6. ...
  7. end

La syntaxe with type permet de rendre visible le fait que le type key défini par le foncteur est le même que le type Key.t de l'argument du foncteur. De même, il est possible de préciser des interfaces pour les modules définis par le foncteur : on aurait pu écrire :

  1. module Make :
  2. functor (K : Set.OrderedType) ->
  3. MinimalHeap with module Key = K
  4. = functor (K : Set.OrderedType) ->
  5. struct
  6. module Key = K
  7. type key = Key.t
  8. ...
  9. end

Il est possible d'exporter plusieurs déclarations de types et de modules en les séparant par le mot-clé and :

  1. module Make : functor (Foo : FooInterface) ->
  2. M with type t = Foo.t and type s = Foo.Bar.t and module M = Foo.Baz
  3. = ...

Retour au sommaire.