next up previous
Previous: No Title

Sous-sections

Les objets d'O'Caml : partie III




Objectifs :Présentation du noyau objet du langage O'CAML : relation de sous-typage, style fonctionnel, interfaces et modules, simulation d'héritage avec les modules

Sous-typage

Le sous-typage est la possibilité pour un objet d'un certain type d'être considéré et utilisé comme un objet d'au autre type.

exemple

Par exemple, en reprenant les définitions des classes point et point_colore, il est possible d'indiquer, EXPLICITEMENT, qu'une instance de point_colore doit être considérée comme point. La relation "est un sous-type de" se note :>. On note que point_colore est un sous-type de point de la manière suivante :
point_colore :> point
Si le membre gauche de la relation est omis, alors c'est le type de la valeur qui sera considéré comme membre gauche.

Soient les déclarations suivantes

# let p = new point (4,5);;
val p : point = <obj>
# let pc = new point_colore (4,5) "blanc";;
val pc : point_colore = <obj>
# let np = (pc :> point);;
val np : point = <obj>
# let np2 = (pc : point_colore :> point);;
val np2 : point = <obj>
L'invocation de la méthode print sur les différents points donnera :
#  p#print();;
( 4 , 5)- : unit = ()
#  pc#print();;
( 4 , 5) de couleur blanc- : unit = ()
#  np#print();;
( 4 , 5) de couleur blanc- : unit = ()
où l'envoi d'un message print sur np, valeur considérée de type point déclenche la méthode print de la classe point_colore.

Cela permet par exemple de construire une liste de points contenant en fait des instances de point_colore :

# let l = [p;np];;
val l : point list = [<obj>; <obj>]
# List.map (fun x -> x#print()) l;;
( 4 , 5)( 4 , 5) de couleur blanc- : unit list = [(); ()]

Cela vient de la liaison tardive (choix de la méthode à utiliser à l'exécution). La combinaison de liaison tardive et sous-typage autorise une nouvelle forme de polymorphisme, le polymorphisme d'inclusion.

sous-typage héritage

Le sous-typage est une notion différente de l'héritage. Il y a deux arguments principaux. Le premier est qu'il est possible de coercer un type classe dans un autre type classe sans que le premier corresponde à un descendant du deuxième (on peut faire du sous-typage sans héritage). En effet, on aurait pu définir la classe point_colore de manière indépendante de la classe point et forcer le type de l'une de ses instances en type classe point.

Deuxièmement il est aussi possible d'avoir un héritage de classes sans pouvoir faire du sous-typage entre instances de ces classes. On ajoute une méthode egalite à la classe point qui prend un argument de type de la classe en entrée. Dans la sous-classe point_colore on redéfinit la même méthode dont le type de l'argument est toujours le type de la classe en cours de définition. Si l'on essaie de faire du sous-typage entre deux instances de ces classes, alors il serait possible d'envoyer le message egalite à une instance de point_colore qui a été coercée en point avec un paramètre de type point. Dans ce cas la recherche d'un champs couleur dans l'argument de type point provoquerait une erreur à l'exécution.

class virtual egal ()  =
object(self : 'a)
  method virtual eq : 'a -> bool
end;;

class point (x_init,y_init)  = 
object(self)
inherit egal ()

  val mutable x = x_init
  val mutable y = y_init

  method eq q = (self#get_x = q#get_x ) && (self#get_y = q#get_y)
  method get_x = x
  method get_y = y
  method moveto (a,b) = begin x <- a; y <- b end
  method rmoveto (dx,dy) = begin x <- x + dx; y <- y + dy end
  method print () =
    begin
      print_string "( ";
      print_int x;
      print_string " , ";
      print_int y;
      print_string ")";
     end

  method distance () = sqrt(float(x*x + y*y))

end;;

class point_colore p c  =
object (self)
  inherit point p as super

  val c = c

  method eq q =  (self#get_x = q#get_x )
                 && (self#get_y = q#get_y)
                 && (self#get_c = q#get_c)
  method get_c = c
  method print () =
  begin
      super#print();
      print_string (" de couleur "^ self#get_c);
  end
end;;
L'exécution du programme suivant entraîne un (long) message d'erreur :
# let p = new point (2,3);;
val p : point = <obj>
# let pc = new point_colore (2,3) "blanc";;
val pc : point_colore = <obj>
# let np = (pc :> point);;
This expression cannot be coerced to type
  point =
    < distance : unit -> float; eq : point -> bool; get_x : int; get_y : 
      int; moveto : int * int -> unit; print : unit -> unit;
      rmoveto : int * int -> unit >;
it has type
  point_colore =
    < distance : unit -> float; eq : point_colore -> bool; get_c : string;
      get_x : int; get_y : int; moveto : int * int -> unit;
      print : unit -> unit; rmoveto : int * int -> unit >
but is here used with type
  < distance : unit -> float; eq : point -> bool; get_c : string;
    get_x : int; get_y : int; moveto : int * int -> unit;
    print : unit -> unit; rmoveto : int * int -> unit >
Type
  point_colore =
    < distance : unit -> float; eq : point_colore -> bool; get_c : string;
      get_x : int; get_y : int; moveto : int * int -> unit;
      print : unit -> unit; rmoveto : int * int -> unit >
is not compatible with type
  point =
    < distance : unit -> float; eq : point -> bool; get_x : int; get_y : 
      int; moveto : int * int -> unit; print : unit -> unit;
      rmoveto : int * int -> unit >
Only the first object type has a method get_c

Formalisation

Cette partie est fortement inspirée du cours de Francois Barthélémy et maria-Virginia Aponte au CNAM.

sous-typage entre objets

Soient t=<m11; ...; mnn> et t'=<m11; ...; mnn; τ'> où τ' est une suite de méthodes,
on dit que t' est un sous-type de t dans C (contexte de typage), noté t' ≤t
si σi ≤τi pour i ∈{1,...,n} .

appel de fonction

Si f : σ→τdans C , a:σ' dans C et σ' ≤σ&thicksp; dans C
alors (f a) est bien typé dans C et a le type τ .

Une fonction f qui attend un argument de type σ peut recevoir sans danger un argument d'un sous-type de σ .

sous-typage de types fonctionnels

Si on définit les classes suivantes :

class a =
 ...
  method f : t1 -> t2
 ...
end;;

class b =
 ...
  method f : t3 -> t4
 ...
end;;

Si on veut montrer que b ≤a alors il faut vérifier (t3 →t4) ≤(t1 →t2) . Pour distinguer les deux méthodes f on les nomme : fa et fb .

Soient t1 →t2 et t3 →t4 deux types fonctionnels, ils sont en relation de sous-typage :
(t3 →t4) ≤(t1 →t2)
si et seulement si :

justifications
Soient les 2 fonctions suivantes bien typées :

let g (p : t2) = ...
let h ((o:a),(x:t1)) = g(o#f(x));;
avec
g : t2 -> nt
h : ( a * t1) -> nt

1.
co-variance : la fonction g attend un argument de type t2 ou d'un de ses sous-types. Comme cet argument est dans le corps de h résultat de l'envoi du message f(x) , il peut être résultat de l'appel de fb , donc :

type_res(fb) ≤type_res(fa) ⇒t4 ≤t2

2.
contra-variance : En appliquant f à une instance de b (notée ob on obtient : Le type de x est t1 (type des arguments de fa , mais il doit pouvoir être passé comme argument de fb (de type t3 , donc

(type_arg(fa) = type(x) = t1) ≤type_arg(fb) ⇒t1 ≤t3

La relation t3≤t_1 est impossible car alors fb ne pourrait recevoir un argument de type t1 et l'appel h(ob,r) avec r de type t1 serait alors incorrect.

exemple

En reprenant l'exemple sur les point et point_colore précédent, on obtient :

eqpoint : point →bool
eqpoint_colore : point_colore →bool

et on s'aperçoit alors que pour que

point_colore ≤ point

il faudrait que

(point_colore →bool) ≤(point →bool)

c'est à dire, avec la relation de contra-variance des types fonctionnels

point ≤point_colore

ce qui est faux. Donc point_colore n'est pas un sous-type de point !!!

sous-typage et classes paramétrées

Les contraintes de type sur les paramètres de types d'une classe paramétrées. Cela permet de restreindre le polymorphisme paramétrique par le polymorphisme d'inclusion. Dans le but de construire des listes de graphical_object il est possible de préciser la contrainte de types printable par graphical_object. Ainsi pour toutes les classes pouvant être sous-typées en graphical_object, leurs instances respectives pourront être éléments de telles listes.

Style fonctionnel

Le style de la programmation objet est le plus souvent impératif. Un message est envoyé à un objet qui modifie physiquement son état interne (ses champs de données). Néanmoins il est aussi possible d'aborder la programmation objet par le style fonctionnel. L'envoi d'un message à un objet retourne un nouvel objet.

copie d'objets

On utilise pour cela l annotation {< ... >} qui retourne une copie de l'objet (self) dans lequel les valeurs certains champs de données sont changées.

class point (x_init,y_init) =
object 
  val x = x_init
  val y = y_init
  method moveto (a,b) = {<x=a; y=b>}
  method rmoveto (dx,dy) = {<x=x+dx;y=y+dy>}
  method affiche () =
    begin
      print_string "( ";
      print_int x;
      print_string " , ";
      print_int y;
      print_string ")";
     end
end;;

qui possède l'interface suivante :

class point :
  int * int ->
  object ('a)
    val x : int
    val y : int
    method affiche : unit -> unit
    method moveto : int * int -> 'a
    method rmoveto : int * int -> 'a
  end

Les méthodes moveto et rmoveto retourne un objet. On peut donc envoyer à leur résultat un message, comme dans l'exemple suivant :

# let p = new point (2,3);;
val p : point = <obj>
# (p#rmoveto(10,10))#affiche();;
( 12 , 13)- : unit = ()
# p#affiche();;
( 2 , 3)- : unit = ()

La fonctionOo.copy retourne une copie d'un objet. Son type est le suivant :

(< .. > as 'a) -> 'a

avec les classes paramétrées

Le style fonctionnel peut utiliser les classes paramétrées avec la difficulté supplémentaire, due au sous-typage, de ne pas faire apparaître le type de self comme type résultat d'une méthode. C'est à dire, le type de self ne doit pas sortir de la classe.

On redéfinit la classe abstraite ['a] liste de la manière suivante :

class virtual ['a] liste () =
object
  method virtual empty : unit -> bool
  method virtual cons  : 'a -> 'a liste
  method virtual head :  'a
  method virtual tail : 'a liste
  method virtual display : unit -> unit
end;;
où la méthode cons retourne une nouvelle liste.

La classe cons doit alors coercer le type de self dans la méthode cons.

class  ['a] cons (v ,l)  =
object (self)
  inherit ['a] liste ()
  constraint 'a = #printable
  val car = v
  val cdr = l
  method empty () = false
  method cons x = new cons (x, (self  : 'a #liste :> 'a liste))
  method head = car
  method tail = cdr
  method display () =  
      begin
        car#print();
        print_string "  ::";
        self#tail#display()
    end
end;;

La classe nil utilisant le constructeur cons ne subit pas de modification sur les contraintes de types.

exception ListeVide;;
class  ['a] nil () =
object (self)
  inherit ['a] liste ()
  val nil = ()
  method empty () = true
  method cons x = new cons (x, new nil())
  method head = raise ListeVide
  method tail = raise ListeVide
  method display () = print_string "[]"
end;;

Interface

Les interfaces de classes sont en général inférées par le typechecker d'O'Caml, mais peuvent aussi être définies par une déclaration de types.

L'interface interf_point est déclarée de la manière suivante :

class type interf_point  = 
object 
  method get_x : int
  method get_y : int
  method moveto : (int * int ) -> unit
  method rmoveto : (int * int )  -> unit
  method print : unit -> unit
  method distance : unit -> float
end;;

class type interf_point =
  object
    method distance : unit -> float
    method get_x : int
    method get_y : int
    method moveto : int * int -> unit
    method print : unit -> unit
    method rmoveto : int * int -> unit
  end

Celle-ci peut dont être utilisée pour coercer le type d'une définition de classe.

# let f (x:interf_point) = x;;
val f : interf_point -> interf_point = <fun>

Ces interfaces ne peuvent masquer que les champs de variables d'instance et les méthodes privées. Elles ne peuvent en aucun cas masquer des méthodes abstraites ou des méthodes publiques. C'est là que leur limitaion apparaît. Néanmoins, elles utilisent aussi la relation d'héritage (entre interfaces).

En fait l'intérêt principal de pouvoir construire des interfaces de classes sans avoir à définir la classe, provient de l'utilisation des modules. Ainsi il sera possible de construire la signature d'un module, utilisant des types objets, uniquement en donnant la description des interfaces de ces classes.

Modules et objets

La programmation par modules et la programmation par objets ont des buts similaires : organisation logique d'un programme et compilation séparée. Elles permettent de faire de l'encapsulation de données, pour la réutilisation et la modification de composants, et d'étendre les fonctionnalités des composants. Il est à noter qu'avec les modules paramétrés il est possible de simuler les propriétés de l'héritage.

En effet soit le module Point de signature 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 affiche : point -> unit
    val distance : point -> float
  end;;

On peut construire un module Point_colore de la manière suivante :

module Point_colore = 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
  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 distance self = let super = self.p in P.distance super
  let affiche self =
    let super = self.p in 
    begin
      P.affiche super;
      print_string ("de couleur "^ self.c)
    end
end;;
La lourdeur des déclarations "héritées" peut être alégée par une procédure automatique de déclaration. Les déclarations récursives de méthodes pourraient s'écrirent 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 principale différence entre programmation modulaire et programmation objet en O'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 et la liaison tardive 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 se simuler en ML pur. Il sera toujours impossible de construire des listes hétérogènes sans casser le système de types.

En conclusion la programmation modulaire et la programmation par objets sont deux réponses sûres (grace au typage) à l'organisation logique d'un programme, permettent la réutilisabilité et la modifiabilité de composants logiciels. La programmation objet en O'Caml permet le polymorphisme paramétrique (classes paramétrées) et d'inclusion (envoi de messages) grace à la liaison tardive et au sous-typage, avec des restrictions dues à l'égalité, ce qui facilite une programmation incrémentale. La programmation modulaire reste dans le domaine du polymorphisme paramétrique sans restriction avec liaison immédiate ce qui peut être utile pour l'efficacité de l'exécution.

Autres lectures

Ce cours s'inspire des mêmes documents que le cours précédent :


next up previous
Previous: No Title
Emmanuel CHAILLOUX
1998-11-15