Précédent Index Suivant

Comparaison modules et objets

La principale différence entre programmation modulaire et programmation objet en Objective CAML provient du système de types. En effet la programmation par modules reste dans le système de types à la ML, i.e. du polymorphisme paramétrique (même code exécuté pour différents types de paramètres), alors que la programmation par objets entraîne un polymorphisme ad hoc (où l'envoi d'un message à un objet déclenche l'application de codes différents). Cela est particulièrement clair avec le sous-typage. Cette extension du système de types à la ML ne peut être simulée en ML pur. Il sera toujours impossible de construire des listes hétérogènes sans casser le système de types.

La programmation modulaire et la programmation par objets sont deux réponses sûres (grâce au typage) à l'organisation logique d'un programme, permettent la réutilisabilité et la modifiabilité de composants logiciels. La programmation par objets en Objective CAML autorise le polymorphisme paramétrique (classes paramétrées) et d'inclusion (envoi de messages) grâce à la liaison tardive et au sous-typage, avec des restrictions dues à l'égalité, ce qui facilite une programmation incrémentielle. La programmation modulaire permet de restreindre le polymorphisme paramétrique et utilise la liaison immédiate ce qui peut être utile pour conserver l'efficacité de l'exécution.

Le modèle de la programmation modulaire permet de facilement étendre les fonctionnalités sur des types de données récursifs non extensibles. Si l'on désire ajouter un cas dans un type somme, il sera nécessaire de modifier une bonne partie des sources.
Le modèle de la programmation objet définit un ensemble récursif de données à partir de classes. On prend une classe par cas de données.

Efficacité d'exécution

La liaison retardée correspond à une indirection dans le tableau des méthodes (voir page ??). Comme l'accès, de l'extérieur de la classe, à une variable d'instance passe aussi par un envoi de message, cette accumulation d'indirections peut se révéler coûteuse.

Pour montrer cette perte d'efficacité, nous construisons la hiérarchie de classe suivante :

# class virtual test () =
object
method virtual somme : unit -> int
method virtual somme2 : unit -> int
end;;
# class a x =
object(self)
inherit test ()
val a = x
method a = a
method somme () = a
method somme2 () = self#a
end;;
# class b x y =
object(self)
inherit a x as super
val b = y
method b = b
method somme () = b + a
method somme2 () = self#b + super#somme2()
end;;


On compare ensuite les temps d'exécution entre d'une part l'envoi des messages somme somme2 sur une instance de la classe b et d'autre part l'appel à la fonction f suivante.

# let f a b = a + b ;;
# let iter g a n = for i = 1 to n do ignore(g a) done ; g a ;;
# let go i j = match i with
1 -> iter (fun x -> x#somme()) (new b 1 2) j
| 2 -> iter (fun x -> x#somme2()) (new b 1 2) j
| 3 -> iter (fun x -> f 1 x) 2 j ;;

# go (int_of_string (Sys.argv.(1))) (int_of_string (Sys.argv.(2))) ;;


Pour 10 millions d'itérations, on obtient les résultats suivants :
  code-octet natif
cas 1 07,5 s 0,6 s
cas 2 15,0 s 2,3 s
cas 3 06,0 s 0,3 s
Cet exemple a été construit dans le but de montrer que la liaison retardée a un coût par rapport à la liaison statique habituelle. Ce coût dépend de la quantité de calcul par rapport au nombre d'envois de messages dans une méthode. L'utilisation du compilateur natif diminue la partie calcul sans changer la partie indirection du test. On s'aperçoit dans le cas 2 que les multiples indirections à l'envoi du message somme2 ont un coût incompressible.

Exemple : interface graphique

La bibliothèque graphique UPI (voir page ??) a été conçue en utilisant le noyau fonctionnel/impératif du langage. Elle s'adapte très facilement sous forme de modules. Chaque composant devient un module indépendant, permettant ainsi une harmonisation des noms des fonctions. Pour ajouter un composant, il est nécessaire de connaître le type concret des composants. Au nouveau module de modifier les champs nécessaires pour décrire son affichage et ses comportements.

Elle peut aussi se réécrire en objet. Pour cela on construit la hiérarchie de classes de la figure 16.1.



Figure 16.1 : hiérarchie de classes pour Upi


Il est plus aisé d'ajouter de nouveaux composants grâce à l'héritage qu'en utilisant les modules, Néanmoins, l'absence de surcharge nécessite toujours un codage des options en tant que paramètres des méthodes. L'utilisation de la relation de sous-typage permet de facilement construire la liste des composants d'un conteneur. La liaison retardée sélectionne les méthodes propres au composant. L'intérêt de l'objet provient aussi de la possibilité d'étendre ou de modifier le contexte graphique, et les autres types utilisés, toujours grâce à l'héritage. C'est pour cela que les principales bibliothèques graphiques sont organisées selon le modèle objet.

Traduction de modules en classes

Un module simple ne déclarant qu'un seul type et ne possédant pas de fonction polymorphe indépendante du type déclaré peut se traduire en une classe. Selon la nature du type utilisé (type enregistrement ou type somme) on traduit le module en classe de manière différente.

Déclarations de types

Type enregistrement
Un type enregistrement s'écrit directement sous forme d'une classe où chaque champ de l'enregistrement devient une variable d'instance.

Type somme
Un type somme se traduit en plusieurs classes en utilisant le modèle de conception <<composite>>. Une classe abstraite décrit les opérations (les fonctions) sur ce type. Chaque branche du type somme devient alors une sous-classe de la classe abstraite et implante les méthodes abstraites pour sa branche. Il n'y a plus alors de <<filtrage de motif>> mais choix de la méthode spécifique à la branche.

Types paramétrés
Les types paramétrés s'implantent par des classes paramétrées.

Types abstraits
On peut considérer une classe comme un type abstrait. À aucun moment l'état interne de la classe n'est visible à l'extérieur de sa hiérarchie. Néanmoins rien n'empêche de définir une sous-classe pour accéder aux variables d'instances d'une classe.

Types mutuellement récursifs
Les déclarations de types mutuellement récursifs se traduisent par des déclarations de classes mutuellement récursives.

Déclarations de fonctions

Les fonctions dont un paramètre dépend du type t du module se traduisent en méthodes. Les fonctions où n'apparaît pas le type t peuvent être déclarées private dans la mesure où leur appartenance au module n'est pas directement liée au type t. Cela a de plus l'avantage de ne pas poser problème si des variables de types apparaissent dans le type des paramètres. Se pose alors le problème des fonctions dont un paramètre est du type t et un autre est du type 'a. Ces fonctions sont très rares dans les modules de la bibliothèque standard. On distingue les modules <<particuliers>> comme Marshal ou Printf qui ont un typage non standard, des modules sur les structures linéaires comme List. Pour ce dernier la fonction fold_left, de type ('a -> 'b -> 'a) -> 'a -> 'b list -> 'a, est difficilement traduisible telle quelle dans une méthode de la classe ['b] liste car la variable de type 'a est libre et ne peut apparaître dans le type de la méthode. Plutôt que d'ajouter un paramètre de type à la classe liste, il est préférable de sortir ces fonctions dans de nouvelles classes, paramétrées par 2 variables de types et possédant un champ liste.

Méthodes binaires
Les méthodes binaires en dehors du sous-typage ne posent pas de difficulté.

Autres déclarations
Déclarations de valeurs non fonctionnelles. On peut accepter de déclarer les valeurs non fonctionnelles en dehors des classes. Il en est de même pour les exceptions.

Exemple : listes avec itérateur
On cherche à traduire en objet un module ayant la signature LISTE suivante :

# module type LISTE = sig
type 'a liste = C0 | C1 of 'a * 'a liste
val add : 'a liste -> 'a -> 'a liste
val length : 'a liste -> int
val hd : 'a liste -> 'a
val tl : 'a liste -> 'a liste
val append : 'a liste -> 'a liste -> 'a liste
val fold_left : ('a -> 'b -> 'a) -> 'a -> 'b liste -> 'a
end ;;


On déclare tout d'abord la classe abstraite 'a liste correspondant à la définition du type.

# class virtual ['a] liste () =
object (self : 'b)
method virtual add : 'a -> 'a liste
method virtual empty : unit -> bool
method virtual hd : 'a
method virtual tl : 'a liste
method virtual length : unit -> int
method virtual append : 'a liste -> 'a liste
end ;;


On définit ensuite les deux sous-classes c1_liste et c0_liste pour chaque composant du type somme. Chacune de ces classes doit définir les méthodes de la classe abstraite ancêtre.

# class ['a] c1_liste (t, q) =
object (self )
inherit ['a] liste () as super
val t = t
val q = q
method add x = new c1_liste (x, (self : 'a #liste :> 'a liste))
method empty () = false
method length () = 1 + q#length()
method hd = t
method tl = q
method append l = new c1_liste (t,q#append l)
end ;;
# class ['a] c0_liste () =
object (self)
inherit ['a] liste () as super
method add x = new c1_liste (x, (self : 'a #liste :> 'a liste))
method empty () = true
method length () = 0
method hd = failwith "c0_liste : hd"
method tl = failwith "c0_liste : tl"
method append l = l
end ;;
# let l = new c1_liste (4, new c1_liste (7, new c0_liste ())) ;;
val l : int liste = <obj>


La fonction LISTE.fold_left n'a pas été incorporée dans la classe liste pour éviter d'introduire un nouveau paramètre de type. On préfère définir la classe fold_left pour implanter ce traitement. Pour cela, on utilise une variable d'instance fonctionnelle (f).

# class virtual ['a,'b] fold_left () =
object(self)
method virtual f : 'a -> 'b -> 'a
method iter r (l : 'b liste) =
if l#empty() then r else self#iter (self#f r (l#hd)) (l#tl)
end ;;
# class ['a,'b] gen_fl f =
object
inherit ['a,'b] fold_left ()
method f = f
end ;;


On construit alors une instance de la classe gen_fl pour l'addition :

# let afl = new gen_fl (+) ;;
val afl : (int, int) gen_fl = <obj>
# afl#iter 0 l ;;
- : int = 11


Simulation d'héritage avec des modules

La relation d'héritage entre classes permet de récupérer dans une sous-classe l'ensemble des déclarations de variables et de méthodes de la classe ancêtre. On peut simuler cette relation en utilisant des modules. La sous-classe qui hérite se transforme en un module paramétré dont le paramètre est la classe ancêtre. L'héritage multiple augmente le nombre de paramètres du module. On reprend l'exemple classique sur les points et les points colorés décrit au chapitre 15 pour le traduire en modules.

La classe point devient le module Point de signature POINT suivante.

# module type POINT =
sig
type point
val new_point : (int * int) -> point
val get_x : point -> int
val get_y : point -> int
val moveto : point -> (int * int) -> unit
val rmoveto : point -> (int * int) -> unit
val display : point -> unit
val distance : point -> float
end ;;


La classe point_colore se transforme en un module paramétré PointColore dont le paramètre est de signature POINT.

# module PointColore = functor (P : POINT) ->
struct
type point_colore = {p:P.point;c:string}
let new_point_colore p c = {p=P.new_point p;c=c}
let get_c self = self.c
(* début *)
let get_x self = let super = self.p in P.get_x super
let get_y self = let super = self.p in P.get_y super
let moveto self = let super = self.p in P.moveto super
let rmoveto self = let super = self.p in P.rmoveto super
let display self = let super = self.p in P.display super
let distance self = let super = self.p in P.distance super
(* fin *)
let display self =
let super = self.p in P.display super; print_string ("de couleur "^ self.c)
end ;;


La lourdeur des déclarations <<héritées>> peut être allégée par une procédure automatique de traduction ou une extension du langage. Les déclarations récursives de méthodes pourraient s'écrire par un seul let rec ... and. L'héritage multiple entraîne des foncteurs à plusieurs paramètres. Le coût de la redéfinition n'est pas plus grand que la liaison tardive.

La liaison tardive n'est pas implantée dans cette simulation. Pour le faire, il faut définir un enregistrement où chaque champ correspond au type des fonctions/méthodes.

Limitations de chaque modèle

Le modèle fonctionnel/modulaire offre un cadre rassurant mais rigide pour la modifiabilité du code. Le modèle objet d'Objective CAML souffre de la double vision des classes : structuration et type, impliquant l'absence de surcharge et l'impossibilité d'effectuer des contraintes de type d'un type ancêtre vers un type descendant.

Modules

Les principales limitations du modèle fonctionnel/modulaire proviennent de la difficulté d'extensibilité des types. Bien que les types abstraits permettent de s'affranchir de la représentation concrète d'un type, leur utilisation dans les modules paramétrés nécessite d'indiquer à la main les égalités de types entre modules, compliquant l'écriture des signatures.

Dépendances récursives
Le graphe de dépendances des modules d'une application ne contient pas de cycle (DAG). Cela implique d'une part de ne pas avoir de types mutuellement récursifs entre deux modules et d'autre part d'empêcher les déclarations de valeurs mutuellement récursives.

Difficultés d'écriture des signatures
Un des intérêts de l'inférence de types est de ne pas nécessiter l'écriture des types des paramètres des fonctions. La description des signatures fait perdre cette pratique. Il devient nécessaire de spécifier les types des déclarations de la signature <<à la main>>. On peut utiliser l'option -i du compilateur ocamlc pour afficher le type de toutes les déclarations globales d'un fichier .ml et utiliser cette information pour construire la signature d'un module. Dans ce cas, on perd la démarche <<génie logiciel>> qui consiste à spécifier le module avant de l'implanter. De plus si la signature et le module subissent des changements importants, on doit revenir à l'édition de la signature. Les modules paramétrés ont besoin des signatures de leurs paramètres et celles-ci doivent aussi être décrites manuellement. Enfin si on associe une signature fonctionnelle à un module paramétré, il n'existe pas de possibilité pour récupérer la signature résultat de l'application du foncteur. Cela oblige à écrire principalement des signatures non fonctionnelles, quitte à les assembler par la suite pour construire une signature fonctionnelle.

Exportation et importation de modules
L'importation des déclarations d'un module simple s'effectue soit par la notation pointée (Module.nom), soit directement par le nom d'une déclaration (nom) si le module a été ouvert (open Module). Les déclarations de l'interface du module importé ne sont pas directement exportables au niveau du module en cours de définition. Celui-ci a accès à ces déclarations, mais elles ne sont pas considérées comme des déclarations du module. Pour ce faire il est nécessaire de déclarer, à la manière de la simulation d'héritage, les valeurs importées. Il en est de même pour les modules paramétrés. Les déclarations du module paramètre ne sont pas considérées comme des déclarations du module courant.

Objets

Les principales limitations du modèle objet d'Objective CAML proviennent du typage. Le point le plus déroutant quand on aborde l'extension objet d'Objective CAML provient de l'impossibilité de construire des méthodes contenant un type paramétré dont le paramètre de type est libre. La déclaration d'une classe peut être vue comme la définition d'un nouveau type et suit en cela la règle générale qui interdit la présence de variables de type libres dans une déclaration de type. C'est pour cela que les classes paramétrées sont indispensables dans le modèle objet d'Objective CAML car elles permettent de lier ces variables de type.

Absence de surcharge
Le modèle objet d'Objective CAML ne permet pas la surcharge de méthode. Comme le type d'un objet correspond aux types de ces méthodes, le fait de posséder plusieurs méthodes de même nom mais de types différents entraîne de nombreuses ambiguïtés, dues au polymorphisme paramétrique, que le système ne pourrait résoudre que dynamiquement, ce qui est contradictoire avec la vision totalement statique du typage. Prenons une classe exemple qui possède deux méthodes message possédant pour la première un paramètre entier et pour la deuxième un paramètre flottant. Soit e une instance de cette classe et soit la fonction f suivante :

# let f x a = x#message a ;;
Les appels f e 1 et f e 1.1 ne peuvent pas statiquement être résolus car il n'y a pas dans le code de la fonction f d'informations spécifiques à la classe exemple.

Une conséquence immédiate de cette absence est l'unicité des constructeurs d'instances. La déclaration d'une classe indique les paramètres à fournir à la fonction de création. Celle-ci est unique.

Initialisation
L'initialisation des variables d'instance déclarées dans une classe peut être problématique quand elle doit se calculer à partir des valeurs passées à la construction.

Égalité entre instances
La seule égalité qui fonctionne sur les objets est l'égalité physique. L'égalité structurelle retourne toujours false quand elle s'applique à deux objets physiquement différents. Cela peut surprendre dans la mesure où deux instances d'une même classe partagent la même table des méthodes. On pourrait imaginer un test physique sur la table des méthodes et un test structurel sur les valeurs (val) des objets. Ce sont des choix d'implantation à la manière du filtrage linéaire.

Hiérarchie de classes
On ne trouve aucune hiérarchie de classes dans la distribution du langage. En effet l'ensemble des bibliothèques sont fournies sous forme de modules simples ou paramétrés. Cela montre que l'extension objet du langage est encore en cours de stabilisation et milite peu en faveur de son emploi intensif.


Précédent Index Suivant