juillet
2009
Le billet d’aujourd’hui a pour but de désamorcer une difficulté courante et propre à entraver l’usage des classes en Objective-Caml.
Le style de programmation encouragé par Objective-Caml tend à marginaliser le recours à une POO d’encapsulation où les classes auraient pour objectif essentiel de limiter la propagation des effets hors d’une certaine portée. En présence de valeurs immutables cet usage des classes comme un moyen de componentisation, si essentiel en POO impérative, perd tout ou partie de son attrait. Il devient alors tentant d’utiliser les classes, non pas comme une enveloppe pour camoufler son contenu, mais comme un type produit particulier, un type produit ouvert.
Dans un programme suffisamment expressif un tel type produit n’est pas isolé, il va de pair avec un type somme.
Le but étant souvent de construire un type algébrique où la somme est fermée mais où les produits restent ouverts.
Remarque hors sujet:
Vu d’une certaine façon, le catalogue des design patterns n’est qu’une longue énumération des inconvénients à penser dans le cadre d’un paradigme plutôt qu’à utiliser le type qui convient pour l’usage qui convient. Les gourous de la POO ont remarqué depuis longtemps l’inadéquation des types produits ouverts à implanter ce qui par nature est essentiellement un type somme. Plutôt que de nous infliger avec fatalisme leur catalogue de solutions imparfaites, ils devraient en tirer la conclusion qui s’impose d’elle-même. Que leur langage favori n’est pas assez riche en types. Que la POO n’est pas une meilleure implantation du vieux type union (même s’il était typé avec les pieds) mais un type de nature différente qui n’élimine pas le besoin d’un véritable type somme dans les langages impératifs.
Revenons à notre type somme fermée de produits ouverts.
Pour l’illustrer nous allons prendre l’exemple du type des arbres rouge-noir.
Chaque noeud d’un arbre rouge-noir est :
- soit vide (Empty)
- soit rouge (Red)
- soit noir (Black)
Evidemment il faudra distinguer le cas d’un noeud vide comme un cas à part (un noeud vide n’a pas de fils), ce qu’un type produit ne peut pas faire. De plus il n’y a aucune raison d’ajouter des cas supplémentaires. C’est pourquoi le type node_color doit être un type somme fermée.
Il en va différemment des données attachées aux noeuds, on peut vouloir ne stocker qu’une clé (conteneur ensembliste) ou bien on peut vouloir stocker un article attaché à une clé (conteneur associatif). On peut aussi vouloir stocker des renseignements structurels complémentaires, par exemple son nombre de noeuds (en vue d’accéder rapidement à l’élément de rang n). Un paramètre de type nous permet d’abstraire toutes ces possibilités :
type 'a node_color = | Empty | Red of 'a node_data | Black of 'a node_data and 'a node_data = {left: 'a node_color; right: 'a node_color; data: 'a}
Un paramètre de type est raisonnable ici parce que nous n’avons qu’un seul type produit. Si nous avions un type algébrique plus expressif et que nous souhaitions l’ouvrir en extension pour chacune de ses alternatives il serait déraisonnable d’ajouter un paramètre de type pour chacun des cas. On ne pourrait plus simuler en paramétrant le type somme, il nous faudrait un véritable type produit ouvert.
Le code d’une première tentative d’utiliser une classe ressemblerait alors à ceci :
type node_color = | Empty | Red of node_data | Black of node_data class node_data l r = object method left: node_color = l method right: node_color = r end
Cependant ce code ne compile pas parce que le type node_data n’est pas défini dans node_color.
Bien sûr, si on interverti l’ordre des déclarations, c’est à son tour le type node_color qui n’est pas défini dans node_data.
Curieusement, il n’existe pas de syntaxe pour rendre mutuellement récursifs le type node_color et la classe node_data. En voici l’explication rationnelle: une classe n’est pas vraiment un type, c’est surtout une valeur fonctionnelle (celle du constructeur new). Une classe est avant tout une fonction constructeur pour un type produit. Par conséquent une classe n’a rien à faire dans une déclaration de type.
Pour spécifier un type produit comme extensible il suffit en fait de spécifier ce type comme on le ferait pour un type enregistrement puis de remplacer la paire encadrante {…} par une paire <…>. Par la suite une classe servira de constructeur pour ce type.
type node_color = | Empty | Red of node_data | Black of node_data and node_data = <left: color_node; right: color_node> class node_data l r = object method left: node_color = l method right: node_color = r end
Voilà, j’espère que ce billet vous aura aidé à faire le point sur les différentes sortes de types à votre disposition, ainsi qu’à les faire cohabiter dans un cadre fonctionnel, hors des domaines à quoi leurs origines les prédestinait.
Ici j’utilise un objet pour avoir des champs nommés, ce qui augmente la lisibilité quand tu as plus de 2/3 « champs » (au détriment de la concision, effectivement).
Effectivement, si tu pensais au sous-typage, il n’est pas utile dans ce cas : pour en profiter et avoir un type extensible, il faudrait rajouter des variantes polymophes et un type dérecursifié.
constraint 'a = < e : 'e; b : 'b; .. >;; <br />
<br />
# type 'a tree = ('a, 'a tree) _tree;; <br />
(* -rectypes, sinon constructeur supplémentaire *) <br />
<br />
# type ('a, 'n) _rbtree = [('a, 'n) _tree | `Red of 'r * 'n] <br />
constraint 'a = < r : 'r; .. >;; <br />
(* on n'a pas besoin de rementionner les autres champs dans la contrainte *) <br />
<br />
# type 'a rbtree = ('a, 'a rbtree) _rbtree;; <br />
<br />
# let test = `Black (1, `Empty ());; <br />
val test : [> `Black of int * [> `Empty of unit ] ] = `Black (1, `Empty ()) <br />
<br />
# (test : _ tree);; <br />
- : < b : int; e : unit; .. > tree = `Black (1, `Empty ()) <br />
<br />
# (test : _ rbtree);; <br />
- : < b : int; e : unit; r : 'a; .. > rbtree = `Black (1, `Empty ())
constraint ouvre des perspectives intéressantes, par exemple la composition au niveau des types :
type ('v,'u) compose = <br />
'a -> 'c <br />
constraint 'u = 'a -> 'b <br />
constraint 'v = 'b -> 'c <br />
Il y a un intérêt spécifique à utiliser un type objet alors que ça marche aussi bien avec un type tuple ?
type 'a node = <br />
| Empty of 'e <br />
| Red of 'r * 'a node <br />
| Black of 'b * 'a node <br />
constraint 'a = 'e * 'r * 'b <br />
C’est un peu moins verbeux :
# Black (1,Empty ());; <br />
- : (unit * 'a * int) node = Black (1, Empty ()) <br />
Oui c’est bof l’absence de prévisualisation sur les commentaires.
Bon, que dire, sinon que tu m’étonne toujours par ton expertise sur des aspects d’ocaml peu documentés.
Un exemple d’instanciation du type ‘a node :
type int_node = <br />
< empty : unit; red : int; black : int > node <br />
Qu’on pourrait dérouler comme ceci :
type int_node = <br />
| Empty of unit <br />
| Red of int * int_node <br />
| Black of int * int_node <br />
Inconvénient perceptible: les types inférés sont plus verbeux que si on avait juste plusieurs paramètres.
# Red (1,Empty ());; <br />
- : < black : 'a; empty : unit; red : int > node = Red (1, Empty ()) <br />
Il y a en fait une méthode raisonnablement jolie pour passer un grand nombre de paramètres de types : utiliser un type objet comme type-level record.
| Empty of 'e <br />
| Red of 'r * 'a node <br />
| Black of 'b * 'a node <br />
constraint 'a = < empty : 'e; red : 'r; black : 'b >
PS : pas de prévisualisation sur les commentaires ? C’est bof.