Objectifs :Présentation du noyau objet du langage O'CAML : classes, héritage, classes et méthodes abstraites
L'extension objet d'O'Caml s'intègre au noyau fonctionnel et au noyau
impératif du langage, et aussi à son système de types. C'est ce
dernier point qui en fait son originalité. On obtient ainsi un
langage objet, typé statiquement, avec inférence de types. Cette
extension permet de définir des classes et des instances, autorise
l'héritage entre classes y compris multiple, accepte les classes
paramétrées et
les classes abstraites. Les interfaces de classes sont engendrées par
leur définition mais peuvent être précisées par une signature de
modules.
On définit l'inévitable
classe point
qui contient deux champs de données (ou variables d'instance),
et six champs de méthodes (ou méthodes d'instance) : deux méthodes d'accès aux champs de données,
deux procédures de déplacement
absolu et relatif d'un point, un affichage et une fonction de calcul de distance par rapport
à l'origine. Cette définition de la classe point possède des
méthodes effectuant des modifications physiques des champs de
données.
class point (x_init,y_init) = object val mutable x = x_init val mutable y = y_init 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;;
Le système infère l'interface de la classe :
class point : int * int -> object val mutable x : int val mutable y : int 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
# let p1 = new point (0,0);; val p1 : point = <obj> # let p2 = new point (3,4);; val p2 : point = <obj>
Le type inféré pour les instances p1 et p2 est le type
objet (<obj>
point). C'est une abréviation du type
objet long suivant :
point = < distance : unit -> float; get_x : int; get_y : int; moveto : int * int -> unit; print : unit -> unit; rmoveto : int * int -> unit >contenant les méthodes et leur type.
L'envoi d'un message à un objet s'effectue par la notation # (la notation point étant déjà utilisée pour les records et les modules). Ce message correspond à une méthode définie dans la classe de l'objet. L'exemple suivant montre différentes requêtes effectuées sur des objets de la classe point.
# p1#get_x;; - : int = 0 # p2#get_y;; - : int = 4 # p1#affiche();; ( 0 , 0)- : unit = () # p2#print();; ( 3 , 4)- : unit = () # if (p1#distance()) = (p2#distance()) then print_string ("c'est le hasard\n") else print_string ("on pouvait parier\n");; on pouvait parier - : unit = ()
Du point de vue des types, les objets de type point peuvent être manipulés par les fonctions polymorphes d'O'Caml :
# p1 = p1 ;; - : bool = true # p1 == p1;; - : bool = true # p1 = p2;; - : bool = false # let p3 = new point (0,0);; val p3 : point = <obj> # p1 = p3;; - : bool = true # p1 == p3;; - : bool = false
comme n'importe quelle valeur du langage.
Une classe peut contenir des champs de données (ou variables d'instance)
appartenant à d'autres classes. C'est la relation "Has-a" (ou "A-un") entre
deux classes. Elle est notée par une simple flèche entre les 2 classes
dans le sens "C1 Has-a C2" : C1 --> C2
. Si C1 a de à champs de C2 on notera : C1 <>---> C2
la relation.
L'exemple suivant définit une classe picture contenant un tableau de point
class picture n = object val mutable ind = 0 val tab = Array.create n (new point(0,0)) method add p = if (ind < n -1) then begin tab.(ind)<-p; ind <- ind + 1 end else failwith ("picture.add : ind = "^(string_of_int ind)) method remove () = if (ind > 0) then ind <-ind-1 method print () = for i=0 to ind do tab.(i)#print() done end;;
Le système infère l'interface de la classe :
class picture : int -> object val mutable ind : int val tab : point array method add : point -> unit method print : unit -> unit method remove : unit -> unit end
Le champs tab possède le type point array correspondant à un tableau de points.
C'est l'avantage majeur de la programmation objet que de pouvoir étendre
le comportement d'une classe existante tout en continuant à utiliser
le code écrit par la classe originale. Quand on étend une classe,
la nouvelle classe hérite de tous les champs, de données et de méthodes,
de la classe qu'elle étend.
C'est la relation "Is-a" (ou "Est-un") entre 2 classes. Elle est notée
par une flèche remplie entre la sous-classe et la classe ancêtre.
point
qui hérite des coordonnées
et des déplacements de point et du calcul de distance : class point_colore p c = object inherit point p val c = c method get_color = c method print () = begin print_string "( "; print_int x; print_string " , "; print_int y; print_string (") de couleur "^c); end end;;
Le système retourne l'interface de classe suivante :
class point_colore : int * int -> string -> object val c : string val mutable x : int val mutable y : int method distance : unit -> float method get_color : string method get_x : int method get_y : int method moveto : int * int -> unit method print : unit -> unit method rmoveto : int * int -> unit end
Toutes les méthodes de l'interface peuvent être utilisée :
# let pc = new point_colore (2,3) "blanc";; val pc : point_colore = <obj> # pc#get_color;; - : string = "blanc" # pc#get_x;; - : int = 2 # pc#display();; ( 2 , 3) de couleur blanc- : unit = () # pc#get_x;; - : int = 2
La classe point_colore redéfinit
la méthode print
pour tenir compte
du champ couleur. On dit que la méthode print
redéfinit
celle de son ancêtre. Elle doit avoir le même type.
Cela ne suffit pas pour rendre les types point et point_colore compatibles :
# p1 = pc;; This expression has type point_colore = < distance : unit -> float; get_color : string; get_x : int; get_y : int; moveto : int * int -> unit; print : unit -> unit; rmoveto : int * int -> unit > but is here used with type point = < distance : unit -> float; 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_color
Il est pratique, dans la définition d'une méthode d'une classe, de pouvoir invoquer une autre méthode de la classe sur soi-même ou de pouvoir invoquer une méthode de la classe ancêtre. Pour cela O'Caml autorise de nommer l'objet soi-même ou la classe ancêtre. On redéfinit la classe point_colore :
class point_colore p c = object(self) inherit point p as super val c = c method get_color = c method print () = begin super#print(); print_string (" de couleur "^ self#get_color); end end;;
Il est possible de donner des noms quelconques pour la classe ancêtre et sa sous-classe, mais autant utiliser la terminologie objet (self ou this pour soi-même et super pour l'ancêtre). Cela est utile dans le cas de l'héritage multiple pour différencier les ancêtres.
On appelle "liaison retardée" (ou liaisons "dynamique") la détermination à l'exécution de la méthode à utiliser lors de l'envoi d'un message. La liaison "précoce" (ou liaison "statique") effectue cette résolution à la compilation
Quand un programme s'exécute, il doit établir la valeur associée à chaque identificateur rencontré. Cette liaison entre un identificateur et sa valeur peut s'effectuer soit à la compilation (liaison statique ou précoce), soit à l'exécution (liaison dynamique ou retardée). Ce problème se posait avant la programmation par objet (par exemple la majorité des dialectes Lisp interprétés possèdent une liaison dynamique, et les dialectes ML compilés une liaison statique).
En programmation objet les liaisons concernent aussi les méthodes. Les langages à objets utilisent la liaison retardée pour implanter le polymorphisme ad hoc où le même envoi de message peut déclencher différents codes à s'exécuter selon l'objet receveur. C'est l'objet lui-même qui saura le code à exécuter. On appelle surcharge d'une méthode le fait de conserver plusieurs liaisons pour cette méthode (à ne pas confondre avec la redéfinition qui masque une ancienne définition qui n'est plus accessible).
L'exemple de la classe abstraite expr_ar
illustre ce propos. La fonction
evaluateur de type #expr_ar -> unit
prend une expression arithmétique et lui envoie le message eval
. C'est l'objet lui-même qui pourra
déterminer la méthode à employer selon sa nature (constante ou
opération binaire). Il est presque impossible au compilateur de le déterminer. En
effet le type de la fonction étant le type d'une classe abstraite sans le corps des méthodes (donc sans code) la liaison ne peut qu'être retardée. Cette détermination pourrait être effectuées pour les classes concrètes, mais limiterait l'intérêt du sous-typage.
On suppose que l'on avait écrit la méthode rmoveto de la classe point en appelant la méthode get_y sur self. Dans la classe point_colore on redéfinit seulement la méthode get_y qui retourne la valeur du champs y fois . C'est un cas d'école, mais cela permet de comprendre le mécanisme.
class point (x_init,y_init) = object(self) ... method rmoveto (dx,dy) = begin x <- x + dx; y <- self#get_y + dy end ... end;; class point_colore p c = object inherit point p val c = c method get_y = y*100 method get_color = c ... end;;
Le programme suivant construit un point et un point_colore, les affiche, les déplace et les réaffiche "
# let p = new point (1,1);; val p : point = <obj> # p#print();; ( 1 , 1)- : unit = () # p#rmoveto(3,4);; - : unit = () # p#print();; ( 4 , 5)- : unit = () # let pc = new point_colore(1,1) "blanc";; val pc : point_colore = <obj> # pc#print();; ( 1 , 1) de couleur blanc- : unit = () # pc#rmoveto(3,4);; - : unit = () # pc#print();; ( 4 , 104) de couleur blanc- : unit = ()
La méthode rmoveto qui n'a pas été redéfinie a son comportement modifiée par la redéfinition de la méthode get_y. En effet la liaison, c'est à dire le choix de la méthode get_y dans le corps de rmoveto n'est pas déterminé à la compilation de la classe point. Ce choix est effectué en regardant dans la liste des méthodes d'une instance de la classe point ou point_colore. Pour une instance de point l'envoi du message rmoveto déclenchera la méthode get_y définie dans point. Par contre le même envoi de message sur une instance de point_colore, appellera la méthode rmoveto héritée de point et déclenchera la méthode get_y redéfinie dans point_colore.
Il est possible d'indiquer dans la définition de la classe, une méthode initializer déclenchée immédiatement après la construction de l'objet. Cet "initialisateur" peut faire n'importe quel calcul et a accès aux champs de l'instance (car celle-ci vient d'être créée). On reprend l'exemple des classe point et point_colore en ajoutant à chaque classe un initialisateur.
class point (x_init,y_init) = object ... initializer print_string "Creation d'un point"; print_newline(); flush stdout end;; class point_colore p c = object inherit point p ... initializer print_string "Creation d'un point colore"; print_newline(); flush stdout end;;
L'exécution suivante permet de suivre l'ordre de déclenchement de la construction des objets et de leur initialisateur :
# let p = new point;; val p : int * int -> point = <fun> # let p = new point (3,4);; Creation d'un point val p : point = <obj> # let pc = new point_colore (3,4) "blanc";; Creation d'un point Creation d'un point colore val pc : point_colore = <obj>
Une méthode peut être déclarée private. Elle n'apparaîtra pas dans l'interface de la classe et donc dans le type de l'objet. Par contre les méthodes privées sont héritées et pourront donc être utilisée dans la hiérarchie. L'exemple suivant reprend la déclaration de la classe point en rendant private la méthode rmoveto :
class point (x_init,y_init) = ... method private rmoveto (dx,dy) = begin x <- x + dx; y <- self#get_y + dy end method step1 = self#rmoveto(1,1) ... end;;
L'interface ne contient donc pas la méthode rmoveto comme le montre l'exemple suivant :
# let p = new point (2,3);; Creation d'un point val p : point = <obj> # p#print();; ( 2 , 3)- : unit = () # p#step1;; - : unit = () # p#print();; ( 3 , 4)- : unit = () # p#rmoveto(1,1);; This expression has type point It has no method rmoveto
Les classes abstraites sont des classes dont certaines méthodes sont déclarées mais ne possèdent pas de corps. Ces méthodes sont dites alors abstraites. Il n'est pas possible d'instancier une classe abstraite (new est interdit). On utilise le mot clé virtual pour le préciser.
Si une sous-classe, d'une classe abstraite, redéfinit toutes les méthodes abstraite de l'ancêtre, alors elle devient concrète, sinon elle reste abstraite.
Dans cet exemple on construit une classe abstraite graphical_object qui ne contient qu'une seule méthode abstraite print.
class virtual graphical_object () = object method virtual print : unit -> unit end;;L'interface calculée est la suivante :
class virtual graphical_object : unit -> object method virtual print : unit -> unit endLa sous-classe rectangle suivante hérite de graphical_object et définit de manière concrète toutes les méthodes abstraites de graphical_object.
class rectangle (p1,p2) = object inherit graphical_object () val mutable llc = (p1 : point) val mutable ruc = (p2 : point) method print () = begin print_string "(";p1#print(); print_string ",";p2#print(); print_string ")" end end;;Le système retourne l'interface de classe suivante :
class rectangle : point * point -> object val mutable llc : point val mutable ruc : point method print : unit -> unit end
Ce cours s'inspire des documents suivants :