Héritage et polymorphisme

Chapitres traités   


Héritage

L'héritage est un mécanisme qui permet à une classe d'hériter de l'ensemble du comportement et des attributs d'une autre classe.

Grâce à l'héritage, une classe peut disposer immédiatement de toutes les fonctionnalités d'une classe existante. De ce fait, pour créer la nouvelle classe, il suffit d'indiquer dans quelle mesure elle diffère de la classe existante.

Une classe qui hérite d'une autre classe est appelée sous-classe ou classe dérivée, et la classe qui offre son héritage à une autre est appelée super-classe, ou classe de base.

le sens de la flèche indique la super-classe.
.

Chaque classe ne peut posséder qu'une super-classe, mais elle peut avoir un nombre illimité de sous-classes. Les sous-classes héritent de tous les attributs et de l'ensemble du comportement de leur super-classe.

Dans l'exemple ci-contre, deux sous-classes sont créées, à savoir Chien et Oiseau. Elles héritent toutes les deux de la super-classe Animal et à ce titre elles récupèrent tous les attributs et tout le comportement de cette dernière. Par exemple Chien et Oiseau sont tous les deux capables de Manger. Par contre Chien n'est pas capable de Voler, ce comportement n'est valable que pour Oiseau.

Dans la pratique, cela signifie que si la super-classe possède un comportement et des attributs dont vous avez besoin, il ne sera pas nécessaire de la redéfinir ou d'en copier le code pour disposer du même comportement et des mêmes attributs dans votre classe. Votre classe recevra, en effet, automatiquement ces éléments de sa super-classe, la super-classe les recevra de sa propre super-classe, et ainsi de suite, jusqu'au sommet de la hiérarchie. Votre classe deviendra une combinaison de toutes les caractéristiques de classes situées au-dessus d'elle dans la hiérarchie, ainsi que de ses propres caractéristiques.

La partie supérieure de la hiérarchie de classes est occupée par la classe Object ; toutes les classes héritent des attributs et du comportement de cette super-classe. La classe Object est la classe la plus générale de la hiérarchie. Cette classe est implicite, elle existe systématiquement. Elle définit le comportement et les attributs dont héritent toutes les classes de la bibliothèque de classes de Java. Au fur et à mesure que l'on descend dans la hiérarchie, chaque classe devient plus adaptée à une tâche précise. Une hiérarchie de classes définit des concepts abstraits en haut de la hiérarchie. Ces concepts deviennent de plus en plus concrets et spécialisés au fur et à mesure que l'on descend dans la hiérarchie des sous-classes.

Choix du chapitre La notion d'héritage

Nous allons voir comment mettre en oeuvre l'héritage en Java, à partir d'un exemple simple de classe ne comportant pas encore de constructeur. Supposez que nous disposions de la classe Point suivante (pour l'instant, peu importe qu'elle ait été déclarée publique ou non) :

Imaginons que nous ayons besoin d'une classe Pointcol, destinée à manipuler des points colorés d'un plan. Une telle classe peut manifestement disposer des mêmes fonctionnalités que la classe Point, auxquelles on pourrait adjoindre, par exemple, une méthode nommée colore, chargée de définir la couleur. Dans ces conditions, nous pouvons chercher à définir la classe Pointcol comme dérivée de la classe Point. Si nous prévoyons, outre la méthode colore, un membre nommé couleur, de type byte, destiné à représenter la couleur d'un point, voici comment pourrait se présenter la définition de la classe Pointcol (ici encore, peu importe qu'elle soit publique ou non) :

La mention extends Point précise au compilateur que la classe Pointcol est une classe dérivée de Point. extends signifie que nous allons étendre les possibilités de la classe de base.

Disposant de cette classe, nous pouvons déclarer des variables de type Pointcol et créer des objets de ce type de manière usuelle, par exemple :

Un objet de type Pointcol peut alors faire appel :

  1. aux méthodes publiques de Pointcol, ici colore ;
  2. mais aussi aux méthodes publiques de Point: initialise, déplace et affiche.

D'une manière générale, un objet d'une classe dérivée accède aux membres publics de sa classe de base, exactement comme s'ils étaient définis dans la classe dérivée elle-même.

Voici un petit programme complet illustrant ces possibilités (pour l'instant, la classe Pointcol est très rudimentaire ; nous verrons plus loin comment la doter d'autres fonctionnalités indispensables). Ici, nous créons à la fois un objet de type Pointcol et un objet de type Point.

Choix du chapitre Accès d'une classe dérivée aux membres de sa classe de base

En introduction, nous avons dit qu'une classe dérivée hérite des champs et méthodes de sa classe de base. Mais nous n'avons pas précisé l'usage qu'elle peut en faire. Voyons précisément ce qu'il en est en distinguant les membres privés des membres publics.

Une classe dérivée n'accède pas aux membres privés

Dans l'exemple précédent, nous avons vu comment les membres publics de la classe de base restent des membres publics de la classe dérivée. C'est ainsi que nous avons pu appliquer la méthode initialise à un objet de type Pointcol.

En revanche, nous n'avons rien dit de la façon dont une méthode de la classe dérivée peut accéder aux membres de la classe de base. En fait, une classe dérivée n'a pas plus de droits d'accès à sa classe de base que n'importe quelle autre classe :

Une méthode d'une classe dérivée n'a pas accès aux membres privés de sa classe de base.
.

Cette règle peut paraître restrictive. Mais en son absence, il suffirait de créer une classe dérivée pour violer le principe d'encapsulation.

Si l'on considère la classe Pointcol précédente, elle ne dispose pour l'instant que d'une méthode affiche, héritée de Point qui, bien entendu, ne fournit pas la couleur. On peut chercher à la doter d'une nouvelle méthode nommée par exemple affichec, fournissant à la fois les coordonnées du point coloré et sa couleur. Il ne sera pas possible de procéder ainsi :

En effet, la méthode affichec de Pointcol n'a pas accès aux champs privés x et y de sa classe de base.

Choix du chapitre Elle accède aux membres publics

Comme on peut s'y attendre :

Une méthode d'une classe dérivée a accès aux membres publics de sa classe de base.
.

Ainsi, pour écrire la méthode affichec, nous pouvons nous appuyer sur la méthode affiche de Point en procédant ainsi :

On notera que l'appel affiche() dans la méthode affichec est en fait équivalent à this.affiche(); Par héritage, la méthode affiche fait partie de la sous-classe.

Nous pouvons procéder de même pour définir dans Pointcol une nouvelle méthode d'initialisation nommée initialisec, chargée d'attribuer les coordonnées et la couleur à un point coloré :

Choix du chapitre Elle accède aux membres protégés

Pour une classe dérivée, le fait de ne pas pouvoir accéder aux attributs privés peut sembler embarrassant. Il est alors possible de déclarer les attributs de la classe de base comme protégés. Dès lors, avec la déclaration suivante dans la classe Point :

permet d'utiliser de nouveau l'écriture suivante dans la classe Pointcol :

Ce système paraît séduisant puisque la classe de base et ses enfants peuvent accéder aux attributs protégés sans qu'il soit possible d'y accéder de l'extérieur. Toutefois, il existe un gros problème, c'est que toutes les classes du même paquetage peuvent également accéder à ces attributs, et pourtant sans aucun lien de parenté. Il faudra alors avoir beaucoup de réserve quant à cette approche, puisqu'elle va contre la notion d'encapsulation.

Une méthode d'une classe dérivée a accès aux membres protégés de sa classe de base.
.

Attention : Toutes les classes du même paquetage on accès également aux membres protégés, même si il n'y a aucun lien de parenté.


Choix des chapitres Construction et initialisation des objets dérivés

Dans les exemples précédents, nous avons volontairement choisi des classes sans constructeurs. En pratique, la plupart des classes en disposeront. Nous allons examiner ici les différentes situations possibles (présence ou absence de constructeur dans la classe de base et dans la classe dérivée). Puis nous préciserons, comme nous l'avons fait pour les classes simples (non dérivées), la chronologie des différentes opérations d'initialisation (implicites ou explicites) et d'appel des constructeurs.

Appels des constructeurs

Rappelons que dans le cas d'une classe simple (non dérivée), la création d'un objet par new entraîne l'appel d'un constructeur ayant la signature voulue (nombre et type des arguments). Si aucun constructeur ne convient, on obtient une erreur de compilation sauf si la classe ne dispose d'aucun constructeur et que l'appel de new s'est fait sans argument. On a affaire alors à un pseudo-constructeur par défaut (qui ne fait rien). Voyons ce que deviennent ces règles avec une classe dérivée.

Exemple introductif

Examinons d'abord un exemple simple dans lequel la classe de base (Point) et la classe dérivée (Pointcol) disposent toutes les deux d'un constructeur (ce qui correspond à la situation la plus courante en pratique). Pour fixer les idées, supposons que nous utilisions le schéma suivant, dans lequel la classe Point dispose d'un constructeur à deux arguments et la classe Pointcol d'un constructeur à trois arguments :

Tout d'abord, il faut savoir que :

En Java, le constructeur de la classe dérivée doit prendre en charge l'intégralité de la construction de l'objet.
.

S'il est nécessaire d'initialiser certains champs de la classe de base et qu'ils sont convenablement encapsulés, il faudra disposer de fonctions d'altération ou bien recourir à un constructeur de la classe de base. Ainsi, le constructeur de Pointcol pourrait :

  1. initialiser le champ couleur (accessible, car membre de Pointcol),
  2. appeler le constructeur de Point pour initialiser les champs x et y.

Pour ce faire, il est toutefois impératif de respecter une règle imposée par Java :

Si un constructeur d'une classe dérivée appelle un constructeur d'une classe de base, il doit obligatoirement s'agir de la première instruction du constructeur et ce dernier est désigné par le mot clé super.

Dans notre cas, voici ce que pourrait être cette instruction :

super (x, y) ; appel d'un constructeur de la classe de base, auquel
on fournit en arguments les valeurs de x et de y

D'où notre constructeur de Pointcol :

Voici un petit programme complet illustrant cette possibilité. I1 s'agit d'une adaptation du programme du paragraphe précédent, dans lequel nous avons remplacé les méthodes d'initialisation des classes Point et Pointcol par des constructeurs.

Résultat :

Je suis en 3 5
Je suis en 3 5
et ma couleur est : 3
Je suis en 5 8
et ma couleur est : 2
Je suis en 6 5
et ma couleur est : 2


Remarques :

  1. Nous avons déjà signalé qu'il est possible d'appeler dans un constructeur un autre constructeur de la même classe, en utilisant le mot clé this comme nom de méthode. Comme celui effectué par super, cet appel doit correspondre à la première instruction du constructeur. Dans ces conditions, on voit qu'il n'est pas possible d'exploiter les deux possibilités en même temps.
  2. Nous montrerons plus loin qu'une classe peut dériver d'une classe qui dérive elle-même d'une autre. L'appel par super ne concerne que le constructeur de la classe de base du niveau immédiatement supérieur.
  3. Le mot clé super possède d'autres utilisations que nous examinerons ultérieurement.

Cas général

L'exemple précédent correspond à la situation la plus usuelle, dans laquelle la classe de base et la classe dérivée disposent toutes les deux d'au moins un constructeur public (les constructeurs peuvent être surdéfinis sans qu'aucun problème particulier ne se pose). Dans ce cas, nous avons vu que le constructeur concerné de la classe dérivée doit prendre en charge l'intégralité de l'objet dérivé, quitte à s'appuyer sur un appel explicite du constructeur de la classe de base. Examinons les autres cas possibles, en distinguant deux situations

  1. la classe de base ne possède aucun constructeur,
  2. la classe dérivée ne possède aucun constructeur.

La classe de base ne possède aucun constructeur

II reste possible d'appeler le constructeur par défaut dans la classe dérivée, comme dans :

L'appel super() est ici superflu, mais il ne nuit pas. En fait, cette possibilité s'avère pratique lorsque l'on définit une classe dérivée sans connaître les détails de la classe de base. On peut ainsi s'assurer qu'un constructeur sans argument de la classe de base sera toujours appelé et, si cette dernière est bien conçue, que la partie de B héritée de A sera donc convenablement initialisée.

La classe dérivée ne possède aucun constructeur

Si la classe dérivée ne possède pas de constructeur, il n'est bien sûr plus question de prévoir un appel explicite (par super) d'un quelconque constructeur de la classe de base. On sait déjà que, dans ce cas, tout se passe comme s'il y avait appel d'un constructeur par défaut sans argument. Dans le cas d'une classe simple, ce constructeur par défaut ne faisait rien (nous l'avons d'ailleurs qualifié de "pseudo-constructeur"). Dans le cas d'une classe dérivée, il est prévu qu'il appelle un constructeur sans argument de la classe de base. On va retrouver ici les règles correspondant à la création d'un objet sans argument, ce qui signifie que la classe de base devra

  1. soit posséder un constructeur public sans argument "constructeur par défaut", lequel sera alors appelé,
  2. soit ne posséder aucun constructeur ; il y aura appel du pseudo-constructeur par défaut.

Voici quelques exemples.

Exemple 1

La construction d'un objet de type B entraîne l'appel du constructeur sans argument de A.

Exemple 2

Ici, on obtient une erreur de compilation car le constructeur par défaut de B cherche à appeler un constructeur sans argument de A. Comme cette dernière dispose d'au moins un constructeur, il n'est plus question d'utiliser le constructeur par défaut de A.

Exemple 3

Cet exemple ressemble au précédent, avec cette différence que A ne possède plus de constructeur. Aucun problème ne se pose plus. La création d'un objet de type B entraîne l'appel du constructeur par défaut de B, qui appelle le constructeur par défaut de A.

Choix du chapitre Initialisation d'un objet dérivé

Jusqu'ici, nous n'avons considéré que les constructeurs impliqués dans la création d'un objet dérivé. Mais comme nous l'avons déjà signalé précédemment pour les classes simples, la création d'un objet fait intervenir plusieurs phases :

  1. allocation mémoire,
  2. initialisation par défaut des champs,
  3. initialisation explicite des champs,
  4. exécution des instructions du constructeur.

La généralisation à un objet d'une classe dérivée est assez intuitive. Supposons que :

class B extends A { ... }

La création d'un objet de type B se déroule en 6 étapes.

  1. Allocation mémoire pour un objet de type B ; il s'agit bien de l'intégralité de la mémoire nécessaire pour un tel objet, et pas seulement pour les champs propres à B (c'est-à-dire non hérités de A).
  2. Initialisation par défaut de tous les champs de B (aussi bien ceux hérités de A, que ceux propres à B) aux valeurs "nulles" habituelles.
  3. Initialisation explicite, s'il y a lieu, des champs hérités de A ; éventuellement, exécution des blocs d'initialisation de A.
  4. Exécution du corps du constructeur de A.
  5. Initialisation explicite, s'il y a lieu, des champs propres à B ; éventuellement, exécution des blocs d'initialisation de B.
  6. Exécution du corps du constructeur de B.

Choix du chapitre Dérivations successives

Jusqu'ici, nous n'avons raisonné que sur deux classes à la fois et nous parlions généralement de classe de base et de classe dérivée. En fait, comme on peut s'y attendre :

  1. d'une même classe peuvent être dérivées plusieurs classes différentes,
  2. les notions de classe de base et de classe dérivée sont relatives puisqu'une classe dérivée peut, à son tour, servir de classe de base pour une autre.

Autrement dit, on peut très bien rencontrer des situations telles que celle représentée par l'arborescence suivante :


Choix des chapitres Redéfinition et surdéfinition de membres

Introduction

Nous avons déjà étudié la notion de surdéfinition de méthode à l'intérieur d'une même classe. Nous avons vu qu'elle correspondait à des méthodes de même nom, mais de signatures différentes. Nous montrerons ici comment cette notion se généralise dans le cadre de l'héritage : une classe dérivée pourra à son tour surdéfinir une méthode d'une classe ascendante.

Mais auparavant, nous vous présenterons la notion fondamentale de redéfinition d'une méthode. Une classe dérivée peut en effet fournir une nouvelle définition d'une méthode d'une classe ascendante. Cette fois, il s'agira non seulement de méthodes de même nom (comme pour la surdéfinition), mais aussi de même signature et de même type de valeur de retour. Alors que la surdéfinition permet de cumuler plusieurs méthodes de même nom, la redéfinition substitue une méthode à une autre.

La notion de redéfinition de méthode

Nous avons vu qu'un objet d'une classe dérivée peut accéder à toutes les méthodes publiques de sa classe de base. Considérons :

L'appel p.affiche() fournit tout naturellement les coordonnées de l'objet p de type Point. L'appel pc.affiche() fournit également les coordonnées de l'objet pc de type Pointcol, mais bien entendu, il n'a aucune raison d'en fournir la couleur.

C'est la raison pour laquelle, dans l'exemple du paragraphe précédent, nous avions introduit dans la classe Pointcol une méthode affichec affichant à la fois les coordonnées et la couleur d'un objet de type Pointcol.

Or, manifestement, ces deux méthodes affiche et affichec font un travail semblable elles affichent les valeurs des données d'un objet de leur classe. Dans ces conditions, il paraît logique de chercher à leur attribuer le même nom.

Cette possibilité existe en Java ; elle se nomme redéfinition de méthode. Elle permet à une classe dérivée de redéfinir une méthode de sa classe de base, en en proposant une nouvelle définition. Encore faut-il respecter la signature de la méthode (type des arguments), ainsi que le type de la valeur de retour. C'est alors cette nouvelle méthode qui sera appelée sur tout objet de la classe dérivée, masquant en quelque sorte la méthode de la classe de base.

Nous pouvons donc définir dans Pointcol une méthode affiche reprenant la définition actuelle de affichec. Si les coordonnées de la classe Point sont encapsulées et si cette dernière ne dispose pas de méthodes d'accès, nous devrons utiliser dans cette méthode affiche de Pointcol la méthode affiche de Point. Dans ce cas, un petit problème se pose ; en effet, nous pourrions être tentés d'écrire ainsi notre nouvelle méthode (en changeant simplement l'en-tête affichec en affiche)

Or, l'appel affiche() provoquerait un appel récursif de la méthode affiche de Pointcol. Il faut donc préciser qu'on souhaite appeler non pas la méthode affiche de la classe Pointcol, mais la méthode affiche de sa classe de base. Il suffit pour cela d'utiliser le mot clé super, de cette façon :

Dans ces conditions, l'appel pc.affiche() entraînera bien l'appel de affiche de Pointcol, laquelle, comme nous l'espérons, appellera affiche de Point, avant d'afficher la couleur.

Voici un exemple complet de programme illustrant cette possibilité :

Résultat :

Je suis en 3 5
et ma couleur est : 3
Je suis en 4 2
et ma couleur est : 3



Remarque

Même si cela est fréquent, une redéfinition de méthode n'entraîne pas nécessairement comme ici l'appel par super de la méthode correspondante de la classe de base.

Choix du chapitre Redéfinition de méthode et dérivations successives

On peut rencontrer des arborescences d'héritage aussi complexes qu'on le veut. Comme on peut s'y attendre, la redéfinition d'une méthode s'applique à une classe et à toutes ses descendantes jusqu'à ce que éventuellement l'une d'entre elles redéfinisse à nouveau la méthode. Considérons par exemple l'arborescence suivante, dans laquelle la présence d'un astérisque (*) signale la définition ou la redéfinition d'une méthode f :

Dans ces conditions, l'appel de la méthode f conduira, pour chaque classe, à l'appel de la méthode indiquée en regard :

classe A : méthode f de A,
classe B : méthode f de A,
classe C : méthode f de C,
classe D : méthode f de D,
classe E : méthode f de A,
classe F : méthode f de C.

Choix du chapitre Surdéfinition et héritage

Jusqu'à maintenant, nous n'avions considéré la surdéfinition qu'au sein d'une même classe. En Java, une classe dérivée peut surdéfinir une méthode d'une classe de base (ou, plus généralement, d'une classe ascendante). En voici un exemple :

Bien entendu, là encore, la recherche d'une méthode acceptable ne se fait qu'en remontant la hiérarchie d'héritage, jamais en la descendant... C'est pourquoi l'appel a.f(x) ne peut être satisfait, malgré la présence dans B d'une fonction f qui conviendrait.

Choix du chapitre Utilisation simultanée de surdéfinition et de redéfinition

Surdéfinition et redéfinition peuvent cohabiter. Voyez cet exemple :

Remarque

La richesse des possibilités de cohabitation entre surdéfinition et redéfinition peut conduire à des situations complexes qu'il est généralement préférable d'éviter en soignant la conception des classes.

Choix du chapitre Contraintes portant sur la redéfinition

Valeur de retour

Lorsqu'on surdéfinit une méthode, on n'est pas obligé de respecter le type de la valeur de retour. Cet exemple est légal :

En revanche, en cas de redéfinition, Java impose non seulement l'identité des signatures, mais aussi celle du type de la valeur de retour :

Ici, on n'a pas affaire à une surdéfinition de f puisque la signature de la méthode est la même dans A et dans B. Il devrait donc s'agir d'une redéfinition, mais comme les types de retour sont différents, on aboutit à une erreur de compilation.

Les droits d'accès

Considérons cet exemple :

I1 est rejeté par le compilateur. S'il était accepté, un objet de classe A aurait accès à la méthode f, alors qu'un objet de classe dérivée B n'y aurait plus accès. La classe dérivée romprait en quelque sorte le contrat établi par la classe A. C'est pourquoi,

Attention : La redéfinition d'une méthode ne doit pas diminuer les droits d'accès à cette méthode.

En revanche, elle peut les augmenter, comme dans cet exemple :

Ici, on redéfinit f dans la classe dérivée et, en plus, on la rend accessible à l'extérieur de la classe. On pourrait penser qu'on viole ainsi l'encapsulation des données. En fait, il n'en est rien puisque la méthode f de B n'a pas accès aux membres privés de A. Simplement, tout se passe comme si la classe B était dotée d'une fonctionnalité supplémentaire par rapport à A.

Cette redéfinition de méthodes est un outil extrêmement important. C'est la base même de la programmation orientée objet qui traite de la notion de polymorphisme.


Choix des chapitres Le polymorphisme

Quand vous créez un nouvel objet, Java se souvient de chacune des variables qui ont été définies pour cet objet, et de chaque variable définie pour chaque super-classe de l'objet. Avec ce système, toutes les classes se combinent les unes aux autres pour former un modèle pour l'objet en cours, et chaque objet fournit les informations adaptées à sa situation.

Les méthodes fonctionnent suivant le même principe. Les nouveaux objets peuvent accéder à tous les noms de méthode de leur classe et de leur super-classe. Les noms de méthode accessibles sont déterminés de manière dynamique quand une méthode est utilisée dans un programme en cours d'exécution. Quand vous appelez une méthode d'un objet particulier, l'interpréteur Java contrôle d'abord la classe de l'objet pour déterminer si elle contient cette méthode. Si tel n'est pas le cas, l'interpréteur recherche la méthode dans la super-classe de cette classe, et ainsi de suite, jusqu'à ce qu'il trouve la définition de la méthode.

Les choses se compliquent un peu quand une sous-classe définit une méthode qui porte le même nom, retourne le même type de valeur, et possède les mêmes arguments qu'une méthode définie dans une super-classe. Dans ce cas, c'est la définition de méthode trouvée en premier (en partant du bas de la hiérarchie et en remontant) qui est utilisée. De ce fait, vous pouvez créer dans une sous-classe une méthode qui empêche une méthode définie dans une super-classe d'être utilisée. Pour cela, attribuez à la méthode de la sous-classe le même nom, le même type de valeur de retour et les mêmes arguments que ceux de la super-classe. Dans ce cas, on parle de redéfinition de méthodes et de Polymorphisme.

Le terme polymorphisme décrit la caractéristique d'un élément qui peut prendre plusieurs formes, comme l'eau qui se trouve à l'état solide, liquide ou gazeux. En programmation Objet, le polymorphisme signifie que la même opération peut se comporter différemment sur différentes classes de la hiérarchie.

Dans l'exemple ci-contre, nous avons une classe abstraite Forme. A chaque fois que l'on aura à faire à une forme quelconque, elle devra être bien positionnée, être capable de s'afficher, de s'effacer et de se déplacer. Vous remarquez que le déplacement consiste, en fait, à effacer l'ancienne version, et d'afficher la version actuelle à la nouvelle position. La notion d'afficher et d'effacer dépend de la forme à tracer, donc ces opérations particulières ne sont pas définies au niveau de la classe Forme. A partir de cette classe nous héritons (étendons) de deux nouvelles classes, à savoir la classe Cercle et la classe Rectangle. En plus de la position, chacune de ces classes rajoute de nouveaux attributs, le rayon pour Cercle, la largeur et la hauteur pour Rectangle. La forme de ces classes étant connues, on peut maintenant redéfinir la façon de s'afficher et de s'effacer. C'est pour cela que nous retrouvons les opérations Effacer() et Afficher() dans les sous-classes. Par contre, il n'est pas nécessaire de réécrire Déplacer(), d'une part, parce que cette opération est connue par l'héritage, et d'autre part, quelque soit la forme, la façon de procéder reste identique.

Choix du chapitre Les bases du polymorphisme

Considérons cette situation dans laquelle les classes Point et Pointcol sont censées disposer chacune d'une méthode affiche, ainsi que des constructeurs habituels (respectivement à deux et trois arguments) :

Avec ces instructions :

Point p ;
p = new Point (3, 5) ;

on aboutit tout naturellement à cette situation :

Mais il se trouve que Java autorise ce genre d'affectation (p étant toujours de type Point)

p = new Pointcol (4, 8, (byte)2) ; // p de type Point contient la référence
à un objet de type Pointcol

La situation correspondante est la suivante :

D'une manière générale, Java permet d'affecter à une variable objet non seulement la référence à un objet du type correspondant, mais aussi une référence à un objet d'un type dérivé. On peut dire qu'on est en présence d'une conversion implicite (légale) d'une référence à un type classe T en une référence à un type ascendant de T ; on parle aussi de compatibilité par affectation entre un type classe et un type ascendant.

Considérons maintenant ces instructions :

Point p = new Point (3, 5) ;
p.affiche () ; appelle la méthode affiche de la classe Point
p = new Poincol (4, 8, 2) ;
p.affiche () ; appelle la méthode affiche de la classe Pointcol

Dans la dernière instruction, la variable p est de type Point, alors que l'objet référencé par p est de type Pointcol. L'instruction p.affiche() appelle alors la méthode affiche de la classe Pointcol. Autrement dit, elle se fonde, non pas sur le type de la variable p, mais bel et bien sur le type effectif de l'objet référencé par p au moment de l'appel (ce type pouvant évoluer au fil de l'exécution).

Exemple 1

Voici un premier exemple intégrant les situations exposées ci-dessus dans un programme complet :

Résultat :

Je suis en 3 5
Je suis en 4 8
et ma couleur est : 2
Je suis en 5 7


Exemple 2

Voici un second exemple de programme complet dans lequel nous exploitons les possibilités de polymorphisme pour créer un tableau "hétérogène" d'objets, c'est-à-dire dans lequel les éléments peuvent être de type différent.

Résultat :

Je suis en 0 2
Je suis en 1 5
et ma couleur est : 3
Je suis en 2 8
et ma couleur est : 9
Je suis en 1 2


Choix du chapitre Autre situation où l'on exploite le polymorphisme

Dans les exemples précédents, les méthodes affiche de Point et de Pointcol se contentaient d'afficher les valeurs des champs concernés, sans préciser la nature exacte de l'objet. Nous pourrions par exemple souhaiter que l'information se présente ainsi pour un objet de type Point :

Je suis un point
Mes coordonnees sont : 0 2

et ainsi pour un objet de type Pointcol :

Je suis un point colore de couleur 3
Mes coordonnees sont : 1 5

On peut considérer que l'information affichée par chaque classe se décompose en deux parties : une première partie spécifique à la classe dérivée (ici Pointcol), une seconde partie commune correspondant à la partie héritée de Point : les valeurs des coordonnées. D'une manière générale, ce point de vue pourrait s'appliquer à toute classe descendant de Point. Dans ces conditions, plutôt que de laisser chaque classe descendant de Point redéfinir la méthode affiche, on peut définir la méthode affiche de la classe Point de manière qu'elle :

  1. affiche les coordonnées (action commune à toutes les classes),
  2. fasse appel à une autre méthode (nommée par exemple identifie) ayant pour vocation d'afficher les informations spécifiques à chaque objet. Ce faisant, nous supposons que chaque descendante de Point redéfinira identifie de façon appropriée (mais elle n'aura plus à prendre en charge l'affichage des coordonnées).

Cette démarche nous conduit à définir la classe Point de la façon suivante :

Dérivons une classe Pointcol en redéfinissant comme voulu la méthode identifie :

Considérons alors ces instructions :

Pointcol pc = new Pointcol (8, 6, (byte)2) ;
pc.affiche () ;

L'instruction pc.affiche() entraîne l'appel de la méthode affiche de la classe Point (puisque cette méthode n'a pas été redéfinie dans Pointcol). Mais dans la méthode affiche de Point, l'instruction identifie() appelle la méthode identifie de la classe correspondant à l'objet effectivement concerné (autrement dit, celui de référence this). Comme ici, il s'agit d'un objet de type Pointcol, il y aura bien appel de la méthode identifie de Pointcol.

La même analyse s'appliquerait à la situation :

Point p ;
p = new Pointcol (8, 6, (byte)2) ;
p.affiche () ;

Là encore, c'est le type de l'objet référencé par p qui interviendra dans le choix de la méthode affiche.

Voici un programme complet reprenant les définitions des classes Point et Pointcol utilisant la même méthode main que l'exemple du paragraphe précédent pour gérer un tableau hétérogène :

Résultat :

Je suis un point
Mes coordonnees sont : 0 2
Je suis un point colore de couleur 3
Mes coordonnees sont : 1 5
Je suis un point colore de couleur 9
Mes coordonnees sont : 2 8
Je suis un point
Mes coordonnees sont : 1 2



Choix du chapitre Les conversions explicites de références

Nous avons largement insisté sur la compatibilité qui existe entre référence à un objet d'un type donné et référence à un objet d'un type ascendant. Comme on peut s'y attendre, la compatibilité n'a pas lieu dans le sens inverse. Considérons cet exemple, fondé sur nos classes Point et Pointcol habituelles :

class Point { ... }
class Pointcol extends Point { ... }
...
Pointcol pc ;
pc = new Point (...) ; erreur de compilation

Si l'affectation était légale, un simple appel tel que pc.colore(..) conduirait à attribuer une couleur à un objet de type Point, ce qui poserait quelques problèmes à l'exécution...

Mais considérons cette situation :

Point p ;
Pointcol pc1 = new Pointcol(...), pc2 ;
...
p = pc1 ; p contient la référence à un objet de type Pointcol
...
pc2 = p ; refusé en compilation

L'affectation pc2 = p est tout naturellement refusée. Cependant, nous sommes certains que p contient bien ici la référence à un objet de type Pointcol. En fait, nous pouvons forcer le compilateur à réaliser la conversion correspondante en utilisant l'opérateur de cast déjà rencontré pour les types primitifs. Ici, nous écrirons simplement :

pc2 =(Pointcol) p ; accepté en compilation

Toutefois, lors de l'exécution, Java s'assurera que p contient bien une référence à un objet de type Pointcol (ou dérivé) afin de ne pas compromettre la bonne exécution du programme. Dans le cas contraire, on obtiendra une exception ClassCastException qui, si elle n'est pas traitée (comme on apprendra à le faire ultérieurement), conduira à un arrêt de l'exécution.

Comme on peut s'y attendre, ce genre de conversion explicite n'est à utiliser qu'en toute connaissance de cause.


Je vous propose pour changer de faire un programme en mode graphique, toutefois la gestion d'une interface graphique sera expliquée de façon plus complète ultérieurement pour éviter de compliquer l'exercice. Je vous propose, toutefois, une explication sommaire uniquement pour comprendre le principe.

Pour créer cette application, il est nécessaire d'avoir l'écriture minimale suivante :

Le programme s'appelle Test et hérite du comportement minimum d'un cadre de fenêtre (JFrame) avec la possibilité de déplacement, de changement de taille, etc...

La méthode principale main permet de créer l'objet relatif à la classe Test et d'afficher la fenêtre correspondante.

Un constructeur a donc été mis en place, avec pour mission, de placer un titre à la fenêtre, de lui donner une taille et surtout de permettre de quitter l'application lorsqu'on clique sur l'icône "fermer".

Bien sûr, normalement votre fenêtre doit permettre l'affichage de votre application personnelle. Le cadre d'une fenêtre est composé de plusieurs parties comme, par exemple, la barre de titre, la zone de menu, la barre d'état, etc... Il existe une zone particulière pour l'affichage de l'application et qui s'appelle la "zone de contenu". Il est possible de récupérer cette zone grâce à la méthode getContentPane(). Pour avoir une gestion facile de l'affichage, il est souvent plus facile d'ajouter un panneau sur cette zone, lequel comportera toutes les méthodes graphiques pour pouvoir faire du dessin.

Pour avoir un panneau qui possède tous les outils d'affichage, il est bon de créer une classe personnelle, comme Panneau par exemple, qui hérite de JPanel. Il suffit de redéfinir la méthode paintComponent pour placer son tracé personnel. C'est le cas ici avec la méthode drawOval de la classe Graphics qui permet de tracer une ellipse à la position x=50, y=50 avec une largeur de 200 et une hauteur de 100.

Par héritage nous venons d'utiliser et de spécialiser deux classes très compétentes qui font parties de la Machine Virtuelle Java (JVM), savoir : JFrame et JPanel. Vous remarquez que ces deux classes commencent par la lettre "J", cela signifie qu'elles font parties du paquetage javax.swing et correspondent donc au Java 2 (ou ultérieur). Si, sur votre machine, vous ne désirez pas installer la JVM version 2, et que vous désirez simplement conserver la JVM native (version 1.1), vous avez la possibilité d'utiliser des classes équivalentes (mais beaucoup moins performantes) qui se trouvent dans le paquetage java.awt et qui se nomme respectivement Frame et Panel.

Tous les composants graphiques disposent d'une surface spéciale appelée conteneur (ou zone de contenu). JFrame est un cas à part, c'est un composant très sophistiqué et dispose de plusieurs zones, pour récupérer la "zone de contenu", il faut appeler la méthode getContentPane. Sur cette surface, il est possible de rajouter d'autres composants grâce à la méthode add. C'est le cas dans notre exemple avec la classe Panneau. La classe Panneau pourrait elle-même posséder d'autres composants (classes) comme des boutons (JButton), des étiquettes (JLabel), des boutons radio (JRadioButton), d'autres panneaux (JPanel), etc... En fait, JPanel est déjà un conteneur, Il suffit d'utiliser directement la méthode add pour placer tous les composants désirés. Cette notion de conteneur-contenu est très importante notamment pour l'affichage. Lorsqu'on demande au conteneur de s'afficher, il lance sa méthode d'affichage et ensuite il fait appel à tous les objets présents dans la "zone de contenu" pour qu'à leur tour, ils lancent leur propre méthode d'affichage. Le conteneur est au courant de tous ses objets attachés grâce à la méthode add.

Par ailleurs, toutes les classes graphiques comportent la même méthode pour l'affichage qui s'appelle paintComponant (pour le paquetage javax.swing sinon paint pour le paquetage java.awt) et qui possède un argument Graphics qui est une classe qui récupère toute la zone d'affichage pour que nous puissions faire un tracé personnalisé. Pour conclure, lorsque vous avez besoin de réaliser un tracé quelconque, il faudra toujours redéfinir la méthode paintComponent et presque systématiquement à l'intérieur de celle-ci, vous lancez l'affichage minimum du composant en appelant la même méthode de la classe de base grâce à l'instruction super.paintComponent.

L'affichage d'une fenêtre est sollicitée plusieurs fois ; au démarrage de l'application bien sûr, mais également lorsque vous avez une autre fenêtre qui est au dessus de la votre et que vous désirez que cette dernière soit replacée en premier plan. Le système se débrouille alors tout seul pour appeler les différents paintComponent. Il est possible de provoquer l'affichage d'un composant grâce à sa méthode repaint. Ce principe global est très intêressant et facile à mettre en oeuvre.

Pour connaître la classe JFrame, la classe JPanel, les méthodes de Graphics

  1. Le premier exercice consiste à créer une classe Cercle qui sera capable entre autre d'afficher un cercle de rayon 50 aux coordonnées fixées au moment de la création. De plus, cette classe doit être capable de connaître le nombre de cercles déjà créés et le numéro du cercle sera systématiquement afficher lors du tracé du cercle. Respecter l'encapsulation. Dans la méthode paintComponent de Panneau vous proposerez la création de deux cercles avec leur affichage respectif ayant pour coordonnées respectivement, (70, 70), (200, 100). Voici le résultat que vous devez obtenir :
  2. Placez une autre fenêtre au dessus de votre application, ensuite replacez votre application en premier plan. Que se passe-t-il ? d'après vous qu'elle est la solution pour remédier à ce problème ?
  3. Vous allez modifier la classe Cercle et la classe Panneau pour avoir les fonctionnements suivant : Nous ne nous préoccupons plus de la numérotation des objets déjà créés. Cette fois-ci le rayon d'un cercle n'est pas fixé spécialement à 50. Si les coordonnées ne sont pas précisées à la création d'un cercle, x prendra la valeur 100 et y la valeur 85. Dans la classe Panneau, vous allez créer deux tableaux de cinq cercles. Les cercles de chacun de ces tableaux disposent des mêmes coordonnées, à savoir : (100, 85) pour le premier tableau, (200, 85) pour le deuxième tableau. De plus, chacun des cercles intérieur dispose d'un rayon de 30, les suivants disposent un rayon de dix unités supérieur aux précédents. La progression est alors de : (30, 40, 50, 60, 70). Par soucis de performance, la création et l'affichage des tableaux de cercles sont séparés. En fait, les tableaux de cercles font partis intégrantes de la classe Panneau et sont donc des attributs. Lorqu'il sera nécessaire d'afficher les cercles, il suffira d'appeler les méthodes d'affichage des cercles dans la méthode prévue à cet effet, savoir paintComponent.
  4. Cette fois-ci, la classe Panneau dispose d'un seul cercle qui par défaut comporte un rayon de 10 et des coordonnées de (150, 85). Les coordonnées demeurent figées, par contre le rayon est variable. Il faut que lorsque le réaffichage se produit, le rayon du cercle augmente de cinq unités. Pour faire le test, vous placerez une fenêtre au dessus de votre application, et vous cliquerez plusieurs fois la barre de titre de chacune d'entre elles pour que, alternativement, l'une soit au dessus de l'autre et ainsi imposer un réaffichage du contenu.

 

  1. Cet exercice consiste à créer une classe Cercle qui sera capable entre autre d'afficher un cercle de diamètre 100 aux coordonnées fixées au moment de la création ainsi qu'une classe Carré qui sera également capable d'afficher un carré de 100 pixels de côté. Ces deux classes hériterons d'une classe Forme qui possède les attributs correspondant aux coordonnées centrale de chaque type de figure ainsi qu'une méthode d'affichage vide. Dans la classe Panneau, vous allez créer deux objets de type Cercle et de type Carré que vous appellerez respectivement cercle et carré. Dans la méthode paintComponent de Panneau vous proposerez l'affichage des deux objets avec les coordonnées respectives de (70, 70), (200, 100). Il serait également intéressant d'avoir le fond du panneau d'une couleur orange. Pour se faire, vous allez utiliser la méthode setBackground qui permet de choisir une couleur de fond en passant l'attribut Color.orange. Cette méthode sera sollicité au moment de l'initialisation de la classe Panneau. Voir le résultat ci-contre.
  2. Tout en voulant obtenir le même résultat, au lieu de créer deux objets séparés cercle et carré, vous allez créer à la place un tableau de plusieurs formes possibles, la première case du tableau correspondant au cercle et la deuxième case au carré. Lorsque vous proposerez l'affichage, il faudra lancer l'affichage d'une forme sans précisément connaître la forme réelle.
  3. Cette fois-ci le diamètre d'un cercle et le côté du carré ne sont pas fixés spécialement à 100. Si les coordonnées ne sont pas précisées à la création d'un cercle, x prendra la valeur 100 et y la valeur 85. De même, si les coordonnées ne sont pas précisées à la création d'un carré, x prendra la valeur 100 et y la valeur 85. Dans la classe Panneau, vous allez créer un tableau de cinq cercles et un tableau de cinq carrés. Les formes géométriques de chacun de ces tableaux disposent des mêmes coordonnées, à savoir : (100, 85) pour le premier tableau, (200, 85) pour le deuxième tableau. De plus, chacune des formes géométriques intérieures disposent d'un diamètre ou d'un côté de 60, les suivantes disposent d'un diamètre ou d'un côté de vingt unités supérieures aux précédentes. La progression est alors de : (60, 80, 100, 120, 140). Par soucis de performance, la création et l'affichage des tableaux de formes sont séparés.
  4. Tout en concervant la définition des différentes classes de forme, Vous allez, cette fois-ci, créer un tableau de dix formes, avec en alternance un cercle suivi d'un carré suivi d'un cercle suivi d'un carré, etc... Comme tout à l'heure, Vous commencerez par une dimension de 60 et vous progresserez de vingt unités.

  1. Reprenez l'exercice précédent, mais cette fois-ci, la classe Forme doit être une classe abstraite puisque la méthode dessine n'est pas définie dans à l'intérieur de celle-ci. Etablissez les changements nécessaires tout en conservant le même fonctionnement comme ci-dessus.
  1. (sujet sur les interfaces) Vous allez réaliser l'exemple ci-dessous en reprenant la base de travail de l'exercice 5.Par contre l'ordonnée du carré est la même que celle du cercle. Toutefois vous allez rajouter une classe que vous nommerez Texte qui aura la particularité de stocker une chaîne de caractères de votre choix à une position bien déterminée. Dans la classe Panneau, vous allez créer également un tableau mais cette fois-ci, il sera en plus capable de stocker un objet de la classe Texte. En fait, la première case du tableau correspond au cercle, la deuxième case au carré, et enfin la troisième au texte de bienvenue qui vous est proposé aux coordonnées (100, 150). Lorsque vous proposerez l'affichage, il faudra lancer l'affichage d'un élément sans le connaître au préalable. Etant donné que la classe Texte n'a absolument rien à voir avec toute la hiérarchie de la classe Forme, il est nécessaire de passer par une interface que vous appellerez Présentation, et la connexion se fera par la méthode dessine. Le cas échéant, faites toutes les modifications qui vous semblent nécessaires pour avoir une écriture cohérente.

  1. (sujet sur les classes anonymes) A partir de l'exercice précédent vous allez rajouter une classe anonyme issue de la classe abstraite Forme qui a l'apparence d'une ellipse avec les coordonnées (130, 150), dont la hauteur est de 50 et la largeur deux fois plus grande que la hauteur. Le résultat escompté est présenté dans la vue ci-contre.