Rechercher
 

La programmation orientée objets Java

Les instructions Les packages



Nous avons, dans le chapitre précédent, étudié l'ensemble des instructions de base du langage. Si vous avez bien regardé tous les exemples, vous avez pu remarquer qu'à chaque fois, le code était placé dans une structure introduite par le mot clé class. Il s'agissait d'une définition de classe. Celle-ci se place dans le cadre de la programmation orientée objet. En quelques mots, on peut dire qu'il s'agit d'une méthodologie permettant de décrire un problème de façon la plus proche possible de la spécification de ce dernier. En d'autres termes, l'approche programmation orientée objet confère au langage une expressivité accrue. Cette approche est aussi très payante en terme de génie logiciel.

Un peu de vocabulaire

Nous allons, dans les paragraphes qui suivent, tenter de définir quelques notions de vocabulaire liées à la programmation orientée objet. Ceux d'entre vous déjà experts en la matière peuvent, s'ils le désirent, passer directement à la section suivante.

L'unité de base d'un programme écrit dans un langage dit orienté objet, est l'objet. Cette unité de code contient notamment des données (souvent appelées attributs), et une série d'actions (traditionnellement nommée méthodes) réalisables sur ces données. On regroupe souvent les termes d'attributs et de méthodes sous celui de champs de l'objet.

Il faut bien comprendre que chaque attribut, ce à un moment donné, a une valeur (d'un certain type). L'ensemble des valeurs des attributs de l'objet détermine l'état courant de l'objet. Cet état ne doit changer que si une méthode de l'objet est invoquée. Ce n'est pas une obligation, mais cela est fortement conseillé (nous y reviendrons lorsque nous parlerons de la programmation orientée composant).

En Java, on ne définit pas directement l'objet, on en définit la nature. Le concept permettant cela est celui de classe. Pour mieux comprendre les choses, revenons un peu en arrière et reprenons la notion de type. Un type définit donc la nature d'une variable qui pourra se voir affectée de importe quelle valeur de ce type. Parallèlement, une classe définie une ensemble de valeurs (d'objets) utilisables. On pourra par conséquent définir une variable qui aura comme type une classe. Un objet se créé donc à partir d'une définition de classe. On dit que l'objet est instance de sa classe. L'exemple suivant vous montre comment instancier un objet à partir d'une classe (ici nommé Circle), nous y reviendrons.

Circle monCercle=new Circle();

Il est clair que pour deux objets instances d'une même classe, la définition des méthodes reste la même (elles sont "en quelque sorte" des constantes de la classe). Par contre, les attributs, peuvent changer. La valeur des attributs définit donc, à un instant donné, l'état courant de l'objet.

Définition d'une classe

Comme nous l'avons donc déjà dit, on introduit une définition de classe par le mot clé class. Celui ci doit être suivi du nom de la classe puis d'une description de ses champs (attributs et méthodes) mise entre parenthèses. De l'information supplémentaire peut être fournie, nous y reviendrons dans les sections suivantes.

Fichier "Circle.java"
class Circle {
    // définition des champs
    // ...
}

Les attributs de classe

Dans les chapitres précédents, nous avons déjà vu comment définir des variables à l'intérieur d'une méthode (rappelez vous les exemples avec les méthodes main). Pour définir un attribut de classe, on réalise la même démarche. En effet, un attribut de classe peut quand même être vu comme une variable, au même titre que les variables. La différence essentielle réside dans le fait que le domaine de visibilité est plus grand dans le cas des attributs de classe. Par domaine de visibilité, j'entends l'étendue des parties du code du programme sur lesquelles l'attribut peut être accédé (ne serait-ce déjà que par toutes les méthodes de la classe). Une variable, elle, ne peut être utilisée uniquement qu'au niveau du bloc d'instructions dans lequel elle est définie (éventuellement une méthode). La classe suivante donne la description d'une classe Circle associée à la notion de cercle : elle devra donc contenir un point pour le centre (de type Point2D) et un rayon (valeur flottante, double précision). La classe Point2D, elle, contiendra deux valeurs (flottantes, double précision) permettant de définir les coordonnées d'un point dans le plan.

Fichier "Circle.java"
class Circle {
    Point2D centre;
    double rayon;
}
Fichier "Point2D.java"
class Point2D {
    double x,y;
}

Les méthodes

Maintenant, nous désirons définir des comportement liés à cette classe graphique qu'est le cercle. Nous devons donc définir des méthodes à cet effet. Supposons que dans le cadre du programme que nous sommes en train d'écrire nous ayons besoin de déplacer un cercle selon un vecteur donné : il nous faut donc écrire une méthode qui prend un vecteur en paramètre. Nous devons donc, au passage, définir une classe Vector2D (cette classe possèdera deux attributs de type flottant double précision). Une petite remarque, au stade actuel de l'écriture de ce petit programme, on pourrait penser qu'il soit judicieux de ne pas créer la classe Vector2D et d'utiliser à la place la classe Point2D contenant la même information. Ceci est une mauvaise idée, car il est clair que la sémantique d'un point du plan n'est pas la même que celle d'un vecteur. Mathématiquement, ils représentent deux concepts différents avec lesquels ont obtient des traitement différentes. Il est donc très fortement conseillé des séparer les choses en codes distincts, dès lors que l'on parle de deux concepts différents. Si vous faite ainsi, il sera très facile de rajouter un comportement (une méthode) si vous l'avez oublié (autrement, cela ne serait peut-être pas si simple). Nous obtenons donc les classes suivantes :

Fichier "Circle.java"
class Circle {
    Point2D centre;
    double rayon;

    void Move(Vector2D vecteur) {
        centre.x += vecteur.x;
        centre.y += vecteur.y;
    }
}
Fichier "Point2D.java"
class Point2D {
    double x,y;
}
Fichier "Vector2D.java"
class Vector2D {
    double x,y;
}

Vous aurez sans doute remarqué l'utilisation de mot clé void. Celui-ci sert à définir un type de retour nul (sans valeur) pour la méthode. Si une méthode doit retourner une valeur (non nulle) alors il faut spécifier son type à cet endroit précis de la définition de la méthode.

La surcharge de méthodes

Si maintenant, vous voulez définir un déplacement de cercle, non pas en spécifiant un vecteur, mais bien une valeur en x et une seconde en y, il vous faut écrire une autre méthode. L'exemple suivant vous montre comment faire.

Fichier "Circle.java"
class Circle {
    Point2D centre;
    double rayon;

    void Move(Vector2D vecteur) {
        centre.x += vecteur.x;
        centre.y += vecteur.y;
    }
  
    void Move(double x,double y) {
        centre.x += x;
        centre.y += y;
    }
}

Vous aurez certainement remarqué que l'on a utilisé le même nom pour les deux méthodes : cette possibilité s'appelle la surcharge. Cela fonctionne car, lorsque vous utiliserez une des deux méthodes, vous spécifierez soit un argument de type Point2D, soit deux arguments de type double. Plus formellement, deux méthodes ne doivent pas avoir le même prototype (nous entendons par là le type de paramètres et celui de retour de la méthode). Donc, si nous voulons définir deux méthodes de déplacement, une horizontalement et une verticalement, nous ne pouvons pas les nommer de la même manière, sans quoi on ne pourrait savoir laquelle choisir lors d'un appel de méthode. Voici donc un programme possible, agrémenté d'une méthode d'affichage texte.

Fichier "Circle.java"
class Circle {
    Point2D centre;
    double rayon;

    void Move(Vector2D vecteur) {
        centre.x += vecteur.x;
        centre.y += vecteur.y;
    }

    void Move(double x,double y) {
        centre.x += x;
        centre.y += y;
    }
  
    void MoveH(double x) { centre.x += x; }
    void MoveV(double y) { centre.y += y; }

    void println(){
        System.out.print("Objet Circle :\n\tcentre : ");
        centre.print();
        System.out.println("\n\trayon : " + rayon);
    }
}
Fichier "Point2D.java"
class Point2D {
    double x,y;

    void print(){
        System.out.print("[" + x + ", " + y + "]");
    }
}

N.B. : les méthodes System.out.print et System.out.println affichent toutes les deux un message passé en paramètre, la différence résidant dans le fait que la seconde rajoute en plus un retour à la ligne suivante. Second détail, a priori, il aurait été plus judicieux de fournir à la place des fonctions d'affichages, des fonctions retournant la description textuelle de l'objet (si possible nommée toString). Mais cela aurait soulevé des problèmes qui seront traités un peu plus tard.

Instanciation d'objet

Nous avons, jusqu'à maintenant, vu comment définir une classe d'objets. Nous allons, à présent, voir comment définir des objets à partir d'une classe. Cette création d'objet, à partir d'une classe, est appelée instanciation. On instancie donc un objet en appliquant l'opérateur new sur un constructeur de classe. Précisons un peu les choses.

L'allocation de mémoire

Pour qu'un objet puisse réellement exister au sein de la machine, il faut qu'il puisse stocker son état dans une zone de la mémoire. Or deux objets définis à partir de deux classes différentes n'ont, à priori, pas forcément besoin de la même taille d'espace mémoire (car n'ayant pas les mêmes définitions d'attributs). L'opérateur new est donc là dans le but de nous simplifier la vie. Par l'intermédiaire d'une méthode un peu particulière (un constructeur, que nous allons étudier dans la section suivante) d'une classe données, cet opérateur déterminera sans problème la taille de l'espace mémoire requis.

Les constructeurs

De manière basique, on peut dire qu'un constructeur est une méthode d'une classe donnée, servant à créer des objets. Remarque importante à retenir : un constructeur n'a pas de type de retour, contrairement aux méthodes et se nomme toujours de la même manière que sa classe. De même que les méthodes acceptent la surcharge, les constructeurs l'admettent aussi. L'exemple suivant propose donc quelques constructeurs pour nos classes déjà étudiées.

fichier "Circle.java"
 
class Circle {
    Point2D centre;
    double rayon;

    Circle(){ centre=new Point2D();   rayon=1; }
    Circle(Point2D c,double r){ centre=c; rayon=r; }

    void Move(Vector2D vecteur) {
        centre.x += vecteur.x;   centre.y += vecteur.y;
    }

    void Move(double x,double y) {
        centre.x += x;   centre.y += y;
    }

    void MoveH(double x) { centre.x += x; }

    void MoveV(double y) { centre.y += y; }

    void println(){
        System.out.print("Objet Circle :\n\tcentre : ");
        centre.print();
        System.out.println("\n\trayon : " + rayon);
    }
}
fichier "Point2D.java"
 
class Point2D {
    double 

     x,y;Point2D(){x=y=0; }
    Point2D(double i, double j){ x=i;   y=j; }

    void print(){ System.out.print("[" + x + ", " + y + "]"); }
}
fichier "Vector2D.java"
 
class Vector2D {
    double 

     x,y;Vector2D(){x=y=0; }
    Vector2D(double i, double j){ x=i;   y=j; }
}

On remarque deux choses en regardant ce programme. Un constructeur peut créer des objets qu'il utilise. Second point, on s'aperçoit que les constructeurs servent principalement à définir l'état initial des objets instanciés. Sans un tel mécanisme il serait difficile de connaître l'état initial des objets et donc quasi impossible de déterminer l'évolution du programme.

Petit exercice : déterminez l'affichage résultant du programme suivant (solution).

fichier "Start.java"
 
class Start {
    public static void main(String args[]){
        Circle c1=new Circle();
	Circle c2=new Circle(new Point2D(5,4),3);

        c1.println();   c2.println();
    } 
}

Quelques règles sur les constructeurs :

Si aucun constructeur n'est spécifié, dans la définition de la classe, un constructeur par défaut vous est obligatoirement fourni, celui-ci n'admettant aucun paramètre.

Si vous en définissez au moins un, le constructeur par défaut (qui n'admet pas de paramètres) n'est plus fourni. Si vous en avez l'utilité il vous faudra alors le définir explicitement.

Les destructeurs

Nous venons donc de voir que des constructeurs pouvaient être fournis pour permettre la création d'objets. Parallèlement, un destructeur (et un seul) peut être défini pour être utilisé lors de la destruction de l'objet. Celui-ci doit forcément se nommer finalize, il ne prend aucun paramètre et ne renvoie aucun type (void). Cette méthode doit, de plus, être qualifiée de public, sans quoi le compilateur vous rappellera à l'ordre. Voici un petit exemple :

 
class Point2D {
    //...

    public void finalize() { 
        System.out.println("Objet Point2D détruit");
    }
    //...
}

Je sens arriver la question fatidique ... Mais comment un objet est-il détruit ? Le ramasse-miettes (garbage collector pour les anglophones) est là pour ça. Etudions donc son principe de plus près.

Le ramasse-miettes

Un programme Java a besoin de mémoire pour pouvoir s'exécuter (en règle général, plus il en a, mieux c'est). Comme on l'a déjà vu, l'opérateur new se charge d'allouer (de réquisitionner) de la mémoire à la demande. Une conséquence évidente est que, si l'on ne libère pas la mémoire des objets devenus inutiles, on peut rapidement en manquer. Le ramasse-miettes (ou GC [Garbage Collector]) se charge de repérer ces objets inutiles et de libérer cette mémoire inaccessible. Il opère de façon totalement automatisé, et dans la quasi totalité des cas, vous n'avez pas à vous en soucier. N'oubliez pas que vous avez la possibilité de définir, par l'intermédiaire des destructeurs, les actions à effectuer en cas de destructions d'objet.

Pour les plus curieux, sachez qu'en fait le GC fonctionne en permanence dans un thread de faible priorité.

Méthodes et attributs statiques

En quelques mots, on peut résumer ce qui vient d'être vu ainsi : on définit des classes, à partir desquelles on peut instancier des objets qui eux sont manipulables. Or si l'on revient au premier exemple de ce cours, on s'apercevra que tout fonctionne sans qu'il y ait eut d'instanciation. La seule différence avec ce que l'on a déjà vu, réside dans la présence du mot clé static. Nous allons donc étudier quelques utilisations possibles de ce mot clé.

main : point d'entrée du programme

Comme nous l'avons déjà dit, on lance l'exécution d'un programme Java en démarrant une machine virtuelle Java (la JVM) avec, en paramètre, le nom de la classe de démarrage, laquelle doit forcément; contenir une méthode main. Une fois que la JVM c'est mise en place, elle lance le programme proprement dit, par la première instruction de la méthode main, et ce, sans instancier d'objet à partir de la classe de démarrage. Cela fonctionne, car la méthode est déclarée static : c'est à dire qu'elle existe sans qu'il y ait eut instanciation. La tâche principale de cette méthode est alors d'instancier des objets sur différentes classes afin que le programme puisse travailler.

Une chose importante doit bien être comprise : comme la méthode main existe indépendamment de toute classe, si elle doit utiliser des attributs ou des méthode de la classe, il faut alors que ces champs soient eux aussi déclarés static, sans quoi, ils n'existent pas. Plus formellement, les méthodes déclarées statiques, sur une classe, ne peuvent en manipuler que des champs statiques.

Notons au passage que la méthode main admet en paramètre un tableau de chaînes de caractères ("String args[]"). Celui-ci contient les éventuelles options spécifiées sur la ligne de commande, lors du lancement de la JVM. Pour connaître la taille du tableau, il suffit bien entendu de faire "args.length". A titre d'information, le nom du paramètre peut-être n'importe quel nom, mais il est de bon ton d'utiliser args.

fichier "Start.java"
 
class Start {
    static int a = 3;

    static public void main(String args[]){
        a += 5;
        System.out.println("a^2 = " + Square(a));
    }

    static int Square(int value){
        return value*value;
    }
}

Partage d'informations

Une conséquence logique d'une définition statique est la suivante : les champs statique d'une classe sont partagés par toutes les instances de cette classe: En effet, comme tout champs statique existe indépendamment de toute instanciation d'objet, il existe aussi après une quelconque instanciation. L'exemple suivant reprend ce qui vient d'être dit : attention tout de même, car il peut dérouter quelques novices en Java. Des explications suivront.

fichier "Start.java"
 
class Start {
    static int a = 3;

    static public void main(String args[]){
        Start s1=new Start(), s2=new Start();
        s1.a++;   s2.a++;
        System.out.println("a = " + Start.a);
    }
}
 
>java Start
5
>
      

Pour ceux qui n'auraient pas suivit, reprenons les choses calmement. Le programme se lance en exécutant la méthode main statique. Celle-ci instancie deux objet à partir de la classe Start elle-même (ça marche parfaitement). On pourrait, dans le main, si on le voulait, faire "s1.main()" qui instancirait encore deux objets, mais on aurait très rapidement un souci de mémoire (qu'en pensez vous ?). Ensuite, sur chacun des deux objets, on incrémente la variable a. Mais comme elle est partagée, on incrémente finalement la variable de deux unités. Finalement, on affiche le résultat : 5. Notez qu'on accède ici à la variable en utilisant le nom de la classe. Ceci est permit car cela permet d'accéder des champs statiques d'un classe à partir d'une autre.

N'ayant rien de plus à ajouter sur les champs statiques, je vous propose donc, dans la section suivante, d'étudier un des concepts clés de la programmation orientée objet : l'héritage.

L'héritage

Afin de vous amener simplement, mais clairement, à l'assimilation de ce concept, nous allons tout d'abord considérer une extension du programme manipulant nos fameux cercles sur un espace à deux dimension. Il en résultera une simplification phénoménale du problème.

Ce qu'il ne faut surtout pas faire

L'extension va consister en l'introduction de classes liées aux autres notions de figures dans le plan. En effet, nous aimerions, maintenant, pouvoir manipuler, outre des cercles, des carrés, des rectangles, des triangles, ou toutes autres figures géométriques de votre choix. L'idée la plus basique se résume en la définition d'une classe par type de figures. Voici donc un autre exemple de classe.

fichier "Square.java"
 
class Square {
    Point2D centre;
    double longueur;

    Square(){ centre=new Point2D();   longueur=1; }
    Square(Point2D c,double l){ centre=c; longueur=l; }

    void Move(Vector2D vecteur) {
      centre.x += vecteur.x;   centre.y += vecteur.y;
    }

    void Move(double x,double y) {
        centre.x += x;   centre.y += y;
    }

    void MoveH(double x) { centre.x += x; }
    void MoveV(double y) { centre.y += y; }

    void println(){
        System.out.print("Objet Square :\n\tcentre : ");
        centre.print();
        System.out.println("\n\tlongueur : " + longueur);
    }
}

Jusque là, rien de bien nouveau, et l'ensemble de classes fonctionne correctement. Malgré cela, ce n'est pas franchement ce que l'on peut faire de mieux. En effet, si l'on regarde le code de la classe Circle et celui de la classe Square, c'est la même chose, aux noms des variables près. Si l'on avait un moyen de regrouper les parties de code identiques (on dit factoriser le code), cela serait bien mieux. C'est, en outre, ce que propose le concept d'héritage.

Ce qu'il faut faire

L'idée principale consiste à définir une classe à partir d'une autre. La classe définie à partie d'une autre sera nommée classe fille. Celle qui sert à définir un classe fille sera nommée classe mère. On dit alors que la classe fille hérite (ou dérive) de la classe mère. Une remarque importante peut déjà être faites : une classe fille dérive d'une unique classe mère, l'héritage multiple n'étant pas supporté par le langage Java (nous verrons par la suite un moyen de simuler l'héritage multiple, ce avec le concept d'interface). Une fois que l'héritage est spécifié, la classe fille possède aussi l'ensemble des attributs et des méthodes de sa classe mère.

La principale difficulté, avec l'héritage, est de définir ce qui est propre à la classe mère et ce qui l'est pour sa classe héritière. Dans tous les cas, cela est fortement lié au problème considéré. Revenons donc à nos classes Circle et Square. On remarque alors que dans les deux cas, on a besoin de connaître la position du centre de la figure. De même, on définit dans les deux cas, les mêmes méthodes, liées au déplacement de la figure. En réfléchissant encore un peu, on peut alors pressentir qu'il en sera de même pour toutes les classes associées à la notion de figures géométriques. Il pourrait donc être judicieux de définir une classe Shape de laquelle toutes les classes, associées à une figure géométrique, pourraient hériter.

Au niveau de la syntaxe à utiliser pour définir un lien d'héritage, c'est très simple. Il suffit d'ajouter le mot clé extends suivit du nom de la classe mère, de suite après le nom de la classe fille, ce dans la définition de cette dernière.

class Circle extends Shape { ... }

Un point important et souvent source d'erreur est à éclaircir. On n'hérite en aucun cas des constructeurs. Si vous ne spécifiez pas explicitement un constructeur particulier, vous ne pourrez l'utiliser, ce même s'il en existe un défini dans la classe mère. Par contre, des règles existent sur l'utilisation des constructeurs de la classe mère dans les constructeurs d'une classe fille quelconque.

Avant de voir ces règles en détail, quelques précisions sont à apporter sur deux mots clés du langage : super et this. Le premier sert à accéder les définitions de classe au niveau de la classe parente de la classe considérée (ces définitions pouvant être soit des méthodes ou des constructeurs). Le second sert à accéder à la classe courante.

Règle 1 : si vous invoquez super(), cela signifie que le constructeur en cours d'éxécution passe la main au constructeur de la classe parente pour commencer à initialiser les attributs définis dans cette dernière. Ensuite il continura le premier constructeur continuera son exécution.

Règle 2 : un appel de constructeur de la classe mère peut uniquement se faire qu'en première instruction d'une définition de constructeur. Une conséquence évidente est qu'on ne peut utiliser qu'un seul appel au constructeur de la classe mère.

Règle 3 : si la première instruction d'un constructeur ne commence pas par le mot clé super le constructeur par défaut de la classe mère est appelé. Dit autrement, l'appel à super() est implicite. Dans ce cas, faites bien attention à ce qu'un contructeur à zéro paramètre soit définit au sein de la classe parente.

Règle 4 : si vous invoquez this(), le constructeur considéré passe la main à un autre constructeur de la classe considérée. Encore une fois, cela doit être la première instruction du bloc.

Afin de mieux comprendre les choses regardez bien attentivement les petits exempels qui suivent : il permettent, notamment, de valider les règles que nous venons de présenter.

fichier "Classe1.java" fichier "Classe2.java"
 
class Classe1 {
    Classe1(){
        System.out.println("Classe1");
    }

    Classe1(int val){
        this();
        System.out.println(val);
    }
}
 
class Classe2 extends Classe1 {
    Classe2(){
        super(5);
        System.out.println("Classe2");
    }

    Classe2(int val){
        System.out.println(val);
    }
}
exemple de création d'objet résultats
new Classe1(); Classe1
new Classe1(3); Classe1
3
new Classe2(); Classe1
5
Classe2
new Classe2(2); Classe1
2

Maintenant que ces quelques points vous ont été présenté, nous pouvons revenir à notre exemple initial, celui des figures géométriques. Notez bien que ce petit exemple reprend, en grande partie, tout ce qui vous a déjà été dévoilé dans ce chapitre.

fichier "Shape.java"
 
class Shape {
    Point2D centre;

    Shape(){ centre=new Point2D(); }
    Shape(Point2D c){ centre=c; }

    void Move(Vector2D vecteur) {
        centre.x += vecteur.x;   centre.y += vecteur.y;
    }

    void Move(double x, double y) {
      centre.x += x;   centre.y += y;
    }

    void MoveH(double x) { centre.x += x; }
    void MoveV(double y) { centre.y += y; }

    void print(String nature){
        System.out.print("Objet "+nature" :\n\tcentre : ");  centre.print();
    }

    void println(){ print("Shape");   System.out.println(""); }
}
fichier "Square.java"
 
class Square extends Shape {
    double longueur;

    Square(){ longueur=1; }
    Square(Point2D c,double l){ super(c);   longueur=l; }

    void println(){
        super.print("Square");
        System.out.println("\n\tlongueur : " + longueur);
    }
}
fichier "Circle.java"
class Circle extends Shape {
  double rayon;

  Square(){ rayon=1; }
  Square(Point2D c,double r){ super(c);   rayon=r; }

  void println(){
    super.print("Circle");
    System.out.println("\n\trayon : "+rayon);
  }
}

J'espère que ce petit exemple vous aura convaincu de l'intérêt de l'héritage. Si ce n'est pas le cas, la section suivante est faîtes pour vous : elle tente de vous présenter, un par un, les atouts majeurs qu'apporte l'héritage.

L'intérêt

Le premier point important qu'il faut absolument bien assimiler, est que l'héritage supprime, en grande partie, les redondances dans le code. En effet, une fois la hiérarchie de classes bien établie, on localise en un point unique les sections de code (celles-ci restantes à tous moments accessibles grâce au mot clé super).

Seconde chose importante, et ce à condition que la hiérarchie de classes est été bien pensée, on peut très facilement rajouter, après coup, une classe, et ce à moindre coup, étant donné que l'on peut réutiliser le code des classes parentes.

Dernier point, si vous n'aviez pas encore modélisé un comportement dans une classe donnée, et que vous vouliez maintenant le rajouter, une fois l'opération terminée, ce comportement sera alors directement utilisable dans l'ensemble des sous-classes de celle considérée.

Le polymorphisme

Définition

Un langage orienté objet est dit polymorphique, s'il offre la possibilité de pouvoir percevoir un objet en tant qu'instance de classes variées, selon les besoins. Le langage Java est polymorphique.

Pour mieux comprendre, reconsidérons notre hiérarchie de classes associée aux figures géométriques. Au niveau des figures, il est bien clair qu'un carré est une figure géométrique. Au point de vue du programme Java, il en va de même : un objet instancié sur une classe donnée peut être utilisé en temps qu'instances de toutes les classes parentes de la classe considéré. On y arrive de deux façons - par le polymorphisme - par utilisation du casting (équivalent à la première manière). Le petit exemple suivant explicite mieux les choses.

fichier "A.java" fichier "B.java"
 
class A {
  ...
}
 
class B extends A {
  ...
}
B b=new B();

A a=b;          // Utilisation du polymorphisme
(A) b;          // On utilise le casting

Une petite remarque qui sera utile pour plus tard : en Java, si vous ne spécifiez pas de lien d'héritage, la classe en cours de définition hérite alors de la class Object. Cette classe, fournie par la JVM (Java Virtual Machine), est la classe de plus haut niveau de laquelle toute les autres dérivent (directement ou indirectement). On peut donc, et ce pour tout objet Java, écrire "Object o=objetJava;". Nous verrons plus tard que la classe Object fournie un certain nombre de fonctionnalités qui sont donc applicables sur n'importe quel objet Java.

Mais attention : tout n'est pas aussi facile qu'il y parait. Pour vous en convaincre, essayez de répondre au petit exercice suivant (solution). Des explications seront fournies dans la section suivante.

fichier "A.java" fichier "B.java"
 
class A {
  void m(){
    System.out.println("Mother");
  }
}
 
class B extends A {
  void m(){
    System.out.println("Son");
  }
}
public class Polym {
    public static void main(String args[]){
        B b=new B();
        A a=b;

        a.m();       // Utilisation du polymorphisme
    }
}

La liaison dynamique (dynamic binding)

Peut-être avez vous été fort étonné en visualisant la solution de l'exercice précédent. Pourtant, il en va ainsi : on appelle cela la liaison dynamique (en anglais "dynamic binding" ou "late binding" ou parfois même "run-time binding"). En fait, il n'y a aucune difficulté. Statiquement (lors de la compilation), l'objet peut être perçu comme étant d'une sur-classe de celle de sa création, mais perçu seulement. Lors de l'exécution, c'est la méthode la plus spécifique que est utilisée : celle de la classe de création si elle existe. L'exemple qui suit vous donne le code des classes de figures géométriques, avec un petit exemple d'utilisation.

fichier "Shape.java"
 
class Shape {
    Point2D centre;

    Shape(){ centre=new Point2D(); }
    Shape(Point2D c){ centre=c; }

    void Move(Vector2D vecteur) {
        centre.x += vecteur.x;   centre.y += vecteur.y;
    }

    void Move(double x,double y) {
        centre.x += x;   centre.y += y;
    }

    void MoveH(double x) { centre.x += x; }
    void MoveV(double y) { centre.y += y; }

    void print(String nature){
        System.out.print("Objet "+nature" :\n\tcentre : ");
        centre.print();
    }

    double area(){ return 0; }

    void println(){ print("Shape");   System.out.println(""); }
}
fichier "Square.java"
class Square extends Shape {
    double longueur;

    Square(){ longueur=1; }
    Square(Point2D c,double l){ super(c);   longueur=l; }

    double area(){ return longueur*longueur; }

    void println(){
        super.print("Square");
        System.out.println("\n\tlongueur : " + longueur);
    }
}
fichier "Circle.java"
 
class Circle extends Shape {
    double rayon;

    Circle(){ rayon=1; }
    Circle(Point2D c,double r){ super(c);   rayon=r; }

    double area(){ return 2*Math.PI*rayon*rayon; }

    void println(){
        super.print("Circle");
        System.out.println("\n\trayon : " + rayon);
    }
}
fichier "Start.java"
 
class Start {
    static double surfaceur(Shape s){ return s.area(); }

    static public void main(String args[]){
        Shape s1=new Shape();
        Square s2=new Square(new Point2D(),2);

        System.out.println(surfaceur(s1));
        System.out.println(surfaceur(s2));
    }
}
Résultat

Ce petit programme a encore un petit défaut! A priori, une figure, sans autre précision sur sa nature n'a pas de surface. Or une méthode double area() de la classe Shape est nécessaire pour pouvoir utiliser le polymorphisme. Le concept d'abstraction permet alors de résoudre ce genre de problème. Etudions donc ce nouveau concept.

Faire usage de l'abstraction

Il peut donc, dans certains cas, être utile de définir une méthode sans en donner le code. Les seules informations données sont donc le nom de la méthodes, les types des paramètres et le type de retour de cette dernière : on nomme cela le prototype de la méthode. En Java, on dit qu'on définit une méthode abstraite. C'est la même chose. Pour réaliser une telle définition, il suffit de mettre en tête de la définition du prototype le mot clé abstract. Dans ce cas, on ne définit plus le corps de la méthode et on supprime les accolades que l'on remplace pas un point-virgule. Voici un exemple de définition de méthode abstraite.

abstract double area();

Mais attention : on ne peut pas définir une méthode abstraite n'importe ou. Cela ne se peut que dans une définition de classe abstraite.

Définir une classe abstraite

Une classe abstraite est une classe qui peut contenir des méthodes abstraites. Une chose importante est a signaler d'hors et déjà : comme une classe abstraite, possédant des méthodes dont le code est inconnu, est incomplète, on ne peut en aucun cas instancier d'objet de cette classe. Il faut impérativement définir des classes filles, lesquelles fournirons les définitions manquantes (soit quoi elles seraient aussi abstraites). Ces classes filles peuvent, par contre, servir à instancier des objets. Dans le cadre de nos classes de figures, cela se traduit pas le fait que si la classe Shape est abstraite, on ne peut plus instancier d'objet de cette nature (plus de "new Shape()"). Le polymorphisme peut encore être utilisé ("Shape s=new Circle()").

Du point de vue de la syntaxe, on définit une classe abstraite en rajoutant devant le mot clé abstract. Le code de la classe Shape devient donc le suivant.

fichier "Shape.java"
 
abstract class Shape {
    Point2D centre;

    Shape(){ centre=new Point2D(); }
    Shape(Point2D c){ centre=c; }

    void Move(Vector2D vecteur) {
        centre.x += vecteur.x;   centre.y += vecteur.y;
    }

    void Move(double x,double y) {
        centre.x += x;   centre.y += y;
    }

    void MoveH(double x) { centre.x += x; }
    void MoveV(double y) { centre.y += y; }

    void print(String nature){
        System.out.print("Objet "+nature+" :\n\tcentre : ");
        centre.print();
    }

    abstract double area();

    void println(){ print("Shape");   System.out.println(""); }
}

Certains d'entre vous sont peut-être gênés par le fait que l'on ne puisse plus créer d'objet de classe Shape. Mais physiquement, il n'y a pas de figures, mais des cercles et des carrés, et d'autres formes, que l'on regroupe sous le terme générique de figures. Donc, notre modèle est conforme à la spécification (implicite) de notre problème.

Il existe une autre technique pour introduire de l'abstraction dans un programme, par le biais des interfaces. Voyons cela de plus près.

Définir une interface

En fait, ce mécanisme n'est qu'une généralisation du concept de classe abstraite. Plus précisément, une interface est une classe dont toutes les méthodes sont abstraites. On n'a donc plus besoin de spécifier que les méthodes sont abstraites (signalées par le mot clé abstract), car elle doivent forcément l'être.

Au niveau de la syntaxe, on introduit une interface non plus par le mot clé class mais par le mot interface (comme vous vous en doutiez très certainement). Petite subtilité au passage, on n'hérite pas d'une interface, mais on l'implémente. En d'autres termes, on doit forcément fournir le code de toutes les méthodes de l'interface utilisée (sauf dans le cas d'une classe abstraite qui implémente un interface, ou bien d'une interface dérivée d'une autre). Le fait d'implémenter une interface se réalise grâce au mot clé implements. Voici quelques exemples.

 
interface I1 {
    void m();
}

abstract class C1 {
    void g();
}

class C2 extends C1 implements I1{
    void m(){
        // Le code de m
    }

    void g(){
        // Le code de g
    }
}

interface I2 extends I1 {
    void n();
}

abstract C3 implements I2 {
    void m(){ 
        // Le code de m();
    }
}

La différence essentielle entre une classe abstraite (dont toute les méthodes seraient abstraites) et une interface réside dans le fait que l'on ne peut hériter que d'une seule classe (héritage simple), alors que l'on peut implémenter plusieurs interfaces. C'est une solution pour simuler l'héritage multiple.

 
class MyClass extends MotherClass implements Interface1, Interface2 { ... }

Attributs et méthodes constants

Toujours dans le but d'augmenter le pouvoir d'expression du langage, les concepteur du langage Java y ont introduit le mot clé final. Bien que sa sémantique diffère selon les cas d'utilisation, on peut dire que, globalement, il permet d'introduire de l'invariance dans vos programmes, soit dans un but lié à la modélisation des problèmes, soit dans le cadre d'optimisations. Afin de mieux percevoir cela, étudions chacun des cas d'utilisation de ce mot clé.

Attributs constants

Il vous est possible de préfixer une déclaration de variable (ou d'attribut) par le mot clé final. Dans ce cas, la variable est dès lors considéré comme constante : sa valeur ne pourra plus être modifiée. Ce qui implique que l'on est obligé d'initialiser une variable constante lors de sa définition : ne l'oubliez pas. Voici un petit exemple.

 
class Math {
    final double PI = 3.141592654;    // Attribut de classe
    void exemple(){
        final int value = 2;          // Variable
    }
}

Une petite entorse à la règle précédente : dans le cas d'un attribut de classe, il est possible de ne pas l'initialiser lors de la déclaration, mais il est alors obligatoire de le faire dans les constructeurs de la classe. Cette possibilité, introduite dans le langage à partir de la version 1.1 du JDK, permet de pouvoir avoir différents objets avec leurs propres valeurs constantes. Sans cette possibilité on aurait eut des valeurs constantes identiques pour tout les objets de la classe considérée.

 
class Math {
    final double PI ;
    Math(double delta) {
        PI = 3.141592654 + delta;
    }
}

Attention tout de même : il y a un très gros piège. Dans le cas ou une variable constante (ou un attribut) contient un objet (ou un tableau, c'est aussi un objet), ce qui est constant n'est pas l'état de cet objet (ses attributs) mais sa référence (son pointeur). Autrement dit, c'est la zone mémoire qui reste invariante, mais son contenue peut changer. Ceci est normal, car malgré que le langage masque la gestions des références, elles restent bien présentes : en Java, quand vous définissez une variable contenant un objet, vous définissez en réalité un pointeur. Pour ce qui ne saurait pas ce qu'est un pointeur (un référence) on peut dire qu'il s'agit d'un identificateur de zone de la mémoire, permettant de situer en ensemble de donnée (ici un objet).

 
class Math {
    double PI = 3.141592654;
    static public void main(String args[]){
        final Math m=new Math();

        m=new Math();   // n'est plus autorisé
        m.PI = 6.28;    // est autorisé
    }
}

Méthodes finales

De manière analogue, il est possible de déclarer une méthode en la préfixant de mot clé final. Mais la sémantique d'une telle déclaration n'est plus la même que précédemment : en effet, une méthode ne change pas au cours du temps. Par contre, elle peut changer selon le niveau dans lequel on se trouve sur une hiérarchie de classes donnée. C'est justement ce changement que l'on interdit en utilisant ce mot clé.

Autrement dit, dès lors que vous déclarez, dans une classe donnée, une méthode finale, il vous sera alors impossible de la redéfinir dans les sous-classes. On trouve deux intérêts à ce genre de manipulation : cela peut être plus réaliste, au niveau de la modélisation d'un problème, de figer un comportement à un niveau de l'arborescence de classe - on gagne en terme de rapidité d'exécution. Le premier point ne demande pas plus de commentaires. Le second, lui, peut être plus difficile à comprendre : nous allons donc nous attarder un peu dessus.

On a vu qu'avec la liaison dynamique et le polymorphisme, la JVM, lors d'un appel de méthode, se charge de lancer la méthode la plus spécifique dans une hiérarchie de classe, ce en fonction du type réel (pas forcément celui de la variable) de l'objet sur lequel l'appel est réalisé. Pour ce faire, l'interprète (la JVM) récupère la classe de l'objet (liaison dynamique) et y recherche la méthode. Si il ne la trouve pas, il prends alors la classe mère (polymorphisme) et continu sa recherche, et ainsi de suite. Dès que la méthode est trouvée (elle existe forcément car le compilateur à validé le code), son corps est lancé. Il est clair que cette recherche coûte du temps. Si une méthode est déclarée finale, tout ceci n'a plus lieu : le compilateur, sait déterminer que si on appelle une méthode sur un objet d'une classe donnée, et que cette méthode est déclaré finale dans une des classe parentes, alors c'est elle qui doit être exécutée. Dans ce cas, la localisation de la méthode est écrite directement dans le code machine (byte code) produit par la JVM. Ne pouvant visualiser une optimisation, je ne donnerais pas d'exemple de code.

Paramètres constants

Une dernière possibilité d'utilisation du mot clé final permet de rendre un paramètre d'une méthode constant : il ne pourra donc jamais changer de valeur durant l'exécution du corps de la méthode. Un petit exemple étant plus parlant qu'un grand discours, en voici un.

 
class ClasseQueconque {
    void methodeQuelconque(final int x){
        // On affiche la valeur du paramètre
        System.out.print(x);

        // Ceci marche très bien
        int y = 2 * x;

        // Ceci ne marche pas du tout !!!
        x += 2;
    }
}

Il ne nous reste plus qu'à étudier la notion de classe intérieures pour en finir de ce chapitre.

Les classes intérieures

Nous allons dans cette section introduire la notion de classes intérieures (inner classes en anglais). Cette possibilité de pouvoir définir une classe à l'intérieur d'une autre, a été définit à partir de la version 1.1 du JDK. L'exemple suivant montre d'emblée comment créer une classe intérieure.

 
class TopLevel {

  class Internal {
    int attribut=0;
  }

  static public void main(String args[]){
    TopLevel tl=new TopLevel();
    Internal i=tl.new Internal();
  }
}

On peut tout de suite remarquer que l'on ne peut créer un objet instancié sur une classe intérieure, qu'à partir d'un objet instancié sur la classe englobante. Si l'on ne mentionne pas d'objet, pour appliquer l'opérateur new, c'est la classe courante qui est prise en considération, ce qui permet d'écrire des programmes similaires au suivant.

 
class TopLevel {
    class Internal {
        int attribut=0;
    }

    Internal internal(){ return new Internal(); }

    static public void main(String args[]){
        TopLevel tl=new TopLevel();
        Internal i=tl.internal();
    }
}

Nous en avons donc finit avec ce chapitre consacré à la programmation orientée objet. En fait, des choses, liées à cette méthode de programmation, manquent dans ce chapitre, mais elles sont déplacée dans les autres chapitres de ce cours, ce pour des raisons diverses. Je citerais, par exemple, les mécanismes de gestion de visibilité des champs, qui ont été déplacé dans le chapitre suivant. Je vous propose donc de poursuivre sans plus attendre l'étude de ce langage avec le mécanisme de paquetage de classes.



Les instructions Les packages