VII. Polymorphisme▲
Le polymorphisme est la troisième caractéristique essentielle d'un langage de programmation orienté objet, après l'abstraction et l'héritage.
Le polymorphisme fournit une autre dimension séparant la partie interface de l'implémentation qui permet de découpler le quoi du comment. Le polymorphisme améliore l'organisation du code et sa lisibilité de même qu'il permet la création de programmes extensibles qui peuvent évoluer non seulement pendant la création initiale du projet, mais également quand des fonctions nouvelles sont désirées.
L'encapsulation crée de nouveaux types de données en combinant les caractéristiques et les comportements. Cacher la mise en œuvre permet de séparer l'interface de l'implémentation en mettant les détails private. Cette sorte d'organisation mécanique est bien comprise par ceux qui viennent de la programmation procédurale. Mais le polymorphisme s'occupe de découpler au niveau des types. Dans le chapitre précédent, nous avons vu comment l'héritage permet le traitement d'un objet comme son propre type ou son type de base. Cette capacité est critique, car elle permet à beaucoup de types (dérivés d'un même type de base) d'être traités comme s'ils n'étaient qu'un type, et permet a un seul morceau de code de traiter sans distinction tous ces types différents. L'appel de méthode polymorphe permet à un type d'exprimer sa distinction par rapport à un autre, de type semblable, tant qu'ils dérivent tous les deux d'un même type de base. Cette distinction est exprimée à travers des différences de comportement des méthodes que vous pouvez appeler par la classe de base.
Dans ce chapitre, vous allez comprendre le polymorphisme (également appelé en anglais dynamic binding ou late binding ou encore run-time binding) en commençant par les notions de base, avec des exemples simples qui enlèvent progressivement tout ce qui ne concerne pas le comportement polymorphe du programme.
VII-A. Upcasting revisité▲
Dans le chapitre 6 nous avons vu qu'un objet peut être manipulé avec son propre type ou bien comme un objet de son type de base. Prendre la référence d'un objet et l'utiliser comme une référence sur le type de base est appelé upcasting (transtypage ascendant) en raison du mode de représentation des arbres d'héritages avec la classe de base en haut.
On avait vu le problème repris ci-dessous apparaître à propos des instruments musicaux. Puisque plusieurs exemples jouent des Notes, nous devrions créer une classe Note séparée, dans le package :
//: c07:music:Note.java
// Notes pour jouer des instruments de musique
package
c07.music;
import
com.bruceeckel.simpletest.*;
public
class
Note {
private
String noteName;
private
Note
(
String noteName) {
this
.noteName =
noteName;
}
public
String toString
(
) {
return
noteName; }
public
static
final
Note
MIDDLE_C =
new
Note
(
"Middle C"
),
C_SHARP =
new
Note
(
"C Sharp"
),
B_FLAT =
new
Note
(
"B Flat"
);
// Etc.
}
///:~
C'est une classe d'« énumération », qui fixe le nombre d'objets constants que l'on peut choisir. Vous ne pouvez pas créer d'objets additionnels, car le constructeur est privé.
Dans l'exemple suivant, Wind est un type d'Instrument, par conséquent Wind hérite d'Instrument :
//: c07:music:Music.java
// Héritage & upcasting.
package
c07.music;
import
com.bruceeckel.simpletest.*;
public
class
Music {
private
static
Test monitor =
new
Test
(
);
public
static
void
tune
(
Instrument i) {
// ...
i.play
(
Note.MIDDLE_C);
}
public
static
void
main
(
String[] args) {
Wind flute =
new
Wind
(
);
tune
(
flute); // Upcasting
monitor.expect
(
new
String[] {
"Wind.play() Middle C"
}
);
}
}
///:~
//: c07:music:Wind.java
package
c07.music;
// Les objets Wind sont des instruments
//, car ils ont la même interface :
public
class
Wind extends
Instrument {
// Redéfinition de la méthode de l'interface :
public
void
play
(
Note n) {
System.out.println
(
"Wind.play() "
+
n);
}
}
///:~
//: c07:music:Music.java
// Inheritance & upcasting.
package
c07.music;
import
com.bruceeckel.simpletest.*;
public
class
Music {
private
static
Test monitor =
new
Test
(
);
public
static
void
tune
(
Instrument i) {
// ...
i.play
(
Note.MIDDLE_C);
}
public
static
void
main
(
String[] args) {
Wind flute =
new
Wind
(
);
tune
(
flute); // Upcasting
monitor.expect
(
new
String[] {
"Wind.play() Middle C"
}
);
}
}
///:~
La méthode Music.tune( ) accepte une référence sur un Instrument, mais également sur tout ce qui dérive d'Instrument. Dans le main( ), ceci se matérialise par une référence sur un objet Wind qui est passée à tune( ), sans qu'un transtypage (cast) soit nécessaire. Ceci est correct, l'interface dans Instrument doit exister dans Wind, car Wind hérite d'Instrument. Utliser l'« upcasting » de Wind vers Instrument peut « rétrécir » cette interface, mais elle ne peut pas être plus petite que l'interface entière d'Instrument.
VII-A-1. Oubliant le type d'objet▲
Music.java pourrait vous sembler étrange. Pourquoi donc oublier intentionnellement le type d'un objet ? C'est ce qui arrive quand on fait un transtypage ascendant, et il semble beaucoup plus naturel que tune( ) prenne tout simplement une référence sur Wind comme argument. Ceci introduit un point essentiel : en faisant ça, il faudrait écrire une nouvelle méthode tune( ) pour chaque type de Instrument du système. Supposons que l'on suive ce raisonnement et que l'on ajoute les instruments Stringed (à cordes) et Brass (cuivres) :
//: c07:music:Music2.java
// Surcharger plutôt que d'utiliser le transtypage ascendant.
package
c07.music;
import
com.bruceeckel.simpletest.*;
class
Stringed extends
Instrument {
public
void
play
(
Note n) {
System.out.println
(
"Stringed.play() "
+
n);
}
}
class
Brass extends
Instrument {
public
void
play
(
Note n) {
System.out.println
(
"Brass.play() "
+
n);
}
}
public
class
Music2 {
private
static
Test monitor =
new
Test
(
);
public
static
void
tune
(
Wind i) {
i.play
(
Note.MIDDLE_C);
}
public
static
void
tune
(
Stringed i) {
i.play
(
Note.MIDDLE_C);
}
public
static
void
tune
(
Brass i) {
i.play
(
Note.MIDDLE_C);
}
public
static
void
main
(
String[] args) {
Wind flute =
new
Wind
(
);
Stringed violin =
new
Stringed
(
);
Brass frenchHorn =
new
Brass
(
);
tune
(
flute); // Pas de transtypage ascendant
tune
(
violin);
tune
(
frenchHorn);
monitor.expect
(
new
String[] {
"Wind.play() Middle C"
,
"Stringed.play() Middle C"
,
"Brass.play() Middle C"
}
);
}
}
///:~
Ceci fonctionne, mais avec un inconvénient majeur: il vous faut écrire des méthodes spécifiques à chaque ajout d'une classe Instrument. Ceci implique davantage de programmation dans un premier temps, mais également beaucoup de travail à fournir si l'on désire ajouter une nouvelle méthode comme tune( ) ou un nouveau type d'Instrument. Ajouter le fait que le compilateur ne donnera aucun message d'erreur si vous oubliez la surcharge de l'une de vos méthodes, et toute cette construction utilisant les types devient ingérable.
Ne serait-il pas plus commode d'écrire une seule méthode qui prenne la classe de base en argument, plutôt que toutes les classes dérivées spécifiques ? Ou encore, ne serait-il pas agréable d'oublier qu'il y a des classes dérivées et d'écrire votre code en ne s'adressant qu'à la classe de base?
C'est exactement ce que le polymorphisme vous permet de faire. Souvent, ceux qui viennent de la programmation procédurale sont déroutés par le mode de fonctionnement du polymorphisme.
VII-B. Le twist▲
Le problème avec Music.java peut être visualisé en exécutant le programme. La sortie est Wind.play( ). C'est bien sûr le résultat attendu, mais il n'est pas évident de comprendre le fonctionnement. Examinons la méthode tune( ) :
public
static
void
tune
(
Instrument i) {
// ...
i.play
(
Note.MIDDLE_C);
}
Elle prend en argument une référence sur un Instrument. Comment le compilateur peut-il donc deviner que cette référence sur un Instrument pointe dans le cas présent sur un Wind et non pas un Brass ou un Stringed? Hé bien il ne peut pas. Pour bien comprendre la question soulevée, mieux vaut examiner le mécanisme d'association (binding).
VII-B-1. Liaison entre l'appel de méthode et la méthode▲
Raccorder un appel de méthode avec le corps de cette méthode est appelé liaison (binding). Quand cette liaison est réalisée avant l'exécution du programme (par le compilateur et l'éditeur de lien, s'il y en a un), on parle de liaison statique (early binding). Vous ne devriez pas avoir déjà entendu ce terme auparavant, car avec les langages procéduraux c'est imposé. Les compilateurs C n'ont qu'une sorte d'appel de méthode, la liaison statique.
Ce qui déroute dans le programme précédent tourne autour de la liaison statique, car le compilateur ne peut pas connaître la bonne méthode à appeler lorsqu'il ne dispose que d'une référence sur Instrument.
La solution s'appelle la liaison tardive (late binding), qui signifie que la liaison est effectuée à l'exécution, en se basant sur le type de l'objet. La liaison tardive est également appelée liaison dynamique (dynamic binding ou run-time binding). Quand un langage implémente la liaison dynamique, un mécanisme doit être prévu pour déterminer le type de l'objet lors de l'exécution et pour appeler ainsi la méthode appropriée. Ce qui veut dire que le compilateur ne connaît toujours pas le type de l'objet, mais le mécanisme d'appel de méthode trouve et effectue l'appel vers le bon corps de méthode. Les mécanismes de liaison dynamique varient selon les langages, mais vous pouvez deviner que des informations relatives au type doivent être implantées dans les objets.
Toutes les associations de méthodes en Java utilisent la liaison dynamique à moins que l'on ait déclaré la méthode static ou final (les méthodes private sont implicitement final). Cela signifie que d'habitude vous n'avez pas à vous préoccuper du déclenchement de la liaison dynamique, cela se fait automatiquement.
Pourquoi déclarer une méthode final ? On a vu dans le chapitre précédant que cela empêche quelqu'un de redéfinir cette méthode. Peut-être plus important, cela « coupe » effectivement la liaison dynamique, ou plutôt cela indique au compilateur que la liaison dynamique n'est pas nécessaire. Le compilateur génère donc du code légèrement plus efficace pour les appels de méthodes spécifiées final. Cependant, dans la plupart des cas, cela ne changera pas la performance globale de votre programme ; mieux vaut donc utiliser final seulement à la suite d'une décision de conception, et non pas comme tentative d'amélioration des performances.
VII-B-2. Produire le bon comportement▲
Une fois que vous savez que toutes les méthodes associées en Java se créent grâce au polymorphisme via l'association tardive, vous pouvez écrire votre code pour appeler la classe de base et vous savez que toutes les classes dérivées utiliseront le même code. D'une autre façon, vous pouvez « envoyer un message à un objet et le laisser adopter le bon comportement ».
L'exemple classique en POO (programmation orientée objet) est celui des « Formes ». Il est couramment utilisé, car il permet de visualiser facilement le concept. Malheureusement, il peut dérouter les programmeurs novices en leur faisant croire que la POO est simplement de la programmation graphique, ce qui bien sûr n'est pas le cas.
L'exemple des « Formes » a une classe de base appelée Shape et diverses classes dérivées : Circle, Square, Triangle, etc. La raison pour laquelle cet exemple fonctionne si bien est qu'il est facile de dire « un cercle est un type de forme » et que c'est compréhensible. Le diagramme de classe montre les différentes relations :
Le surclassement (upcasting) peut se traduire par une simple déclaration comme :
Shape s =
new
Circle
(
);
Ici, un objet Circle est créé, et sa référence est immédiatement assignée à une Shape, qui paraîtrait être une erreur (assignement d'un type à un autre) ; et pourtant c'est correct, car un objet Circle est une Shape par héritage. Donc le compilateur accepte cette déclaration et ne renvoie pas de message d'erreur.
Supposez que vous appelez une méthode de la classe de base (qui a été surchargée dans la classe dérivée):
s.draw
(
);
Encore une fois, vous pourriez supposer que la méthode draw( ) de Shape est appelée, car, après tout, c'est un objet Shape donc comment le compilateur pourrait faire autre chose? Et pourtant la méthode Circle.draw( ) est appelée grâce à l'association tardive (polymorphisme).
L'exemple suivant le prouve d'une façon légèrement différente :
//: c07:Shapes.java
// Polymorphisme en Java.
import
com.bruceeckel.simpletest.*;
import
java.util.*;
class
Shape {
void
draw
(
) {}
void
erase
(
) {}
}
class
Circle extends
Shape {
void
draw
(
) {
System.out.println
(
"Circle.draw()"
);
}
void
erase
(
) {
System.out.println
(
"Circle.erase()"
);
}
}
class
Square extends
Shape {
void
draw
(
) {
System.out.println
(
"Square.draw()"
);
}
void
erase
(
) {
System.out.println
(
"Square.erase()"
);
}
}
class
Triangle extends
Shape {
void
draw
(
) {
System.out.println
(
"Triangle.draw()"
);
}
void
erase
(
) {
System.out.println
(
"Triangle.erase()"
);
}
}
// Une "fabrique" qui crée des formes aléatoirement :
class
RandomShapeGenerator {
private
Random rand =
new
Random
(
);
public
Shape next
(
) {
switch
(
rand.nextInt
(
3
)) {
default
:
case
0
: return
new
Circle
(
);
case
1
: return
new
Square
(
);
case
2
: return
new
Triangle
(
);
}
}
}
public
class
Shapes {
private
static
Test monitor =
new
Test
(
);
private
static
RandomShapeGenerator gen =
new
RandomShapeGenerator
(
);
public
static
void
main
(
String[] args) {
Shape[] s =
new
Shape[9
];
// Remplit le tableau avec des formes :
for
(
int
i =
0
; i <
s.length; i++
)
s[i] =
gen.next
(
);
// Fait des appels de méthodes polymorphes :
for
(
int
i =
0
; i <
s.length; i++
)
s[i].draw
(
);
monitor.expect
(
new
Object[] {
new
TestExpression
(
"%% (Circle|Square|Triangle)"
+
"
\\
.draw
\\
(
\\
)"
, s.length)
}
);
}
}
///:~
La classe de base Shape établit l'interface commune à tout ce qui hérite de Shape : toutes les formes peuvent être dessinées et effacées. Les classes dérivées surchargent ces définitions pour fournir un comportement unique propre à chaque type de forme.
RandomShapeGenerator est une sorte de « fabrique » qui crée une instance de Shape de type aléatoire chaque fois que vous appelez sa méthode next( ). Notez que le surclassement se fait dans l'instruction de retour, tous les objets faisant référence à un Circle, Square, ou Triangle sont renvoyés de la méthode next( ) en tant que Shape. Donc même si vous appelez next( ), vous ne pourrez jamais voir de quel type spécifique il s'agit, puisque vous obtiendrez toujours une référence de Shape.
main( ) contient un tableau de Shape rempli par l'appel à RandomShapeGenerator.next( ). À cet endroit, vous savez que vous avez des Shapes, mais rien de plus (et le compilateur non plus). Cependant, quand vous parcourez ce tableau et appelez draw( ) pour chaque objet, le comportement correct spécifique à chaque type se produit, comme vous pouvez le voir dans la sortie à l'exécution du programme.
L'objectif de la partie traitant le choix aléatoire des formes est de faire comprendre que le compilateur n'a pas de connaissance particulière qui lui permet d'appeler la bonne méthode au moment de la compilation. Tous les appels à draw( ) doivent être fait par des associations dynamiques.
VII-B-3. Extensibilité▲
Revenons maintenant à l'exemple sur l'instrument de musique. En raison du polymorphisme, vous pouvez ajouter autant de nouveaux types que vous voulez dans le système sans changer la méthode tune( ). Dans un programme orienté objet bien conçu, la plupart ou même toutes vos méthodes suivront le modèle de tune( ) et communiqueront seulement avec l'interface de la classe de base. Un tel programme est extensible parce que vous pouvez ajouter de nouvelles fonctionnalités en héritant de nouveaux types de données de la classe de base commune. Les méthodes qui utilisent l'interface de la classe de base n'auront pas besoin d'être retouchées pour intégrer de nouvelles classes.
Regardez ce qui se produit dans l'exemple de l'instrument si vous ajoutez des méthodes dans la classe de base et un certain nombre de nouvelles classes. Voici le schéma :
Toutes ces nouvelles classes fonctionnent correctement avec la vieille méthode tune( ), sans modification. Même si tune( ) est dans un fichier séparé et que de nouvelles méthodes sont ajoutées à l'interface de Instrument, tune( ) fonctionnera toujours correctement, même sans recompilation. Voici l'implémentation du diagramme présenté ci-dessus :
//: c07:music3:Music3.java
// Un programme extensible.
package
c07.music3;
import
com.bruceeckel.simpletest.*;
import
c07.music.Note;
class
Instrument {
void
play
(
Note n) {
System.out.println
(
"Instrument.play() "
+
n);
}
String what
(
) {
return
"Instrument"
; }
void
adjust
(
) {}
}
class
Wind extends
Instrument {
void
play
(
Note n) {
System.out.println
(
"Wind.play() "
+
n);
}
String what
(
) {
return
"Wind"
; }
void
adjust
(
) {}
}
class
Percussion extends
Instrument {
void
play
(
Note n) {
System.out.println
(
"Percussion.play() "
+
n);
}
String what
(
) {
return
"Percussion"
; }
void
adjust
(
) {}
}
class
Stringed extends
Instrument {
void
play
(
Note n) {
System.out.println
(
"Stringed.play() "
+
n);
}
String what
(
) {
return
"Stringed"
; }
void
adjust
(
) {}
}
class
Brass extends
Wind {
void
play
(
Note n) {
System.out.println
(
"Brass.play() "
+
n);
}
void
adjust
(
) {
System.out.println
(
"Brass.adjust()"
);
}
}
class
Woodwind extends
Wind {
void
play
(
Note n) {
System.out.println
(
"Woodwind.play() "
+
n);
}
String what
(
) {
return
"Woodwind"
; }
}
public
class
Music3 {
private
static
Test monitor =
new
Test
(
);
// Indépendants des types, ainsi les nouveaux types
// ajoutés au système marchent toujours bien :
public
static
void
tune
(
Instrument i) {
// ...
i.play
(
Note.MIDDLE_C);
}
public
static
void
tuneAll
(
Instrument[] e) {
for
(
int
i =
0
; i <
e.length; i++
)
tune
(
e[i]);
}
public
static
void
main
(
String[] args) {
// Transtypage ascendant pendant l'ajout au tableau :
Instrument[] orchestra =
{
new
Wind
(
),
new
Percussion
(
),
new
Stringed
(
),
new
Brass
(
),
new
Woodwind
(
)
}
;
tuneAll
(
orchestra);
monitor.expect
(
new
String[] {
"Wind.play() Middle C"
,
"Percussion.play() Middle C"
,
"Stringed.play() Middle C"
,
"Brass.play() Middle C"
,
"Woodwind.play() Middle C"
}
);
}
}
///:~
Les nouvelles méthodes sont what( ), qui retourne une référence String avec une description de la classe, et adjust( ), qui fournit un moyen d'accorder chaque instrument.
Dans main( ), quand on met quelque chose dans le tableau orchestra, on fait automatiquement un transtypage ascendant en Instrument.
Vous pouvez constater que la méthode tune( ) ignore parfaitement tous les changements qui sont intervenus autour d'elle, et pourtant cela marche correctement. C'est exactement ce que le polymorphisme est censé fournir. Vos modifications ne peuvent abîmer les parties du programme qui ne devraient pas être affectées. Dit autrement, le polymorphisme est une des techniques majeures permettant au programmeur de « séparer les choses qui changent des choses qui restent les mêmes. »
VII-B-4. Piège: redéfinition de méthodes privées▲
Voici quelque chose que vous pourriez innocemment essayer de faire:
//: c07:PrivateOverride.java
// Abstract classes and methods.
import
com.bruceeckel.simpletest.*;
public
class
PrivateOverride {
private
static
Test monitor =
new
Test
(
);
private
void
f
(
) {
System.out.println
(
"private f()"
);
}
public
static
void
main
(
String[] args) {
PrivateOverride po =
new
Derived
(
);
po.f
(
);
monitor.expect
(
new
String[] {
"private f()"
}
);
}
}
class
Derived extends
PrivateOverride {
public
void
f
(
) {
System.out.println
(
"public f()"
);
}
}
///:~
Vous pourriez raisonnablement supposer trouver en sortie « public f( ) », mais une méthode privée est automatiquement finale, et est également cachée aux classes filles. Donc la fonction f( ) de la classe Derived est dans ce cas une nouvelle méthode ; ce n'est même pas une surcharge puisque la version de la classe parent n'est même pas visible dans Derived.
Le résultat de ceci est que seules les méthodes non privées peuvent être redéfinies, mais vous devriez inspecter l'apparence des méthodes privées surchargées, lesquelles ne génèrent pas d'avertissements lors de la compilation, mais ne font pas ce que vous attendiez. Pour être clair, vous devriez utiliser un nom différent pour ces méthodes privées de la classe parent dans votre classe fille.
VII-C. Classes et méthodes abstraites▲
Dans tout les exemples d'Instrument, les méthodes dans la classe de base Instrument sont toujours des méthodes « factices ». Si ces méthodes sont appelées une fois, vous avez fait quelque chose de mauvais. En effet le but d'Instrument est de créer une interface commune pour toutes les classes qui en dérivent.
La seule raison d'établir cette interface commune est qu'elle puisse être exprimée différemment par chaque sous-type différent. Cela établit une base, vous pouvez ainsi dire ce qui est commun à toutes les classes qui en dérivent. Une autre manière de dire cela est d'appeler Instrument une classe de base abstraite (ou simplement une classe abstraite) Vous créez une classe abstraite quand vous avez besoin de manipuler un ensemble de classes à partir de cette interface commune. Toutes les méthodes des classes dérivées qui correspondent à la signature d'une déclaration de la classe de base seront appelées en utilisant le mécanisme de « binding » (lien) dynamique. (Cependant, comme vu dans la section précédente, si le nom de la méthode est le même que celui de la classe de base, mais que les arguments sont différents, vous aurez de la surcharge de méthode, ce qui n'est probablement pas ce que vous recherchez).
Si vous avez une classe abstraite comme Instrument, les instances de cette classe n'ont presque jamais de sens. C'est normal, car Instrument a pour but d'exprimer uniquement l'interface, et non une implémentation particulière, donc créer un objet Instrument n'a aucun sens, et vous voudrez probablement empêcher l'utilisateur de le faire. Ceci peut être accompli en faisant afficher à toutes les méthodes de la classe Instrument des messages d'erreurs, mais ceci ne se verra qu'à l'exécution de la classe et nécessite donc de se baser sur des tests exhaustifs de la partie utilisateur. Ce serait beaucoup mieux de pouvoir détecter ces problèmes à la compilation.
Java fournit un mécanisme pour faire ceci, appelé les méthodes abstraites. (32) C'est une méthode qui est incomplète, elle a uniquement une déclaration, mais pas de corps. Voici la syntaxe pour la déclaration d'une méthode abstraite :
abstract
void
f
(
);
Une classe qui contient des méthodes abstraites est appelée une classe abstraite. Si une classe contient une ou plusieurs méthodes abstraites, la classe elle-même doit être qualifiée d'abstraite avec le mot clé abstract. (Sinon le compilateur vous donnera un message d'erreur).
Quand une classe abstraite est incomplète, qu'est supposé faire le compilateur quand quelqu'un essaie de faire un objet de cette classe ? Il ne peut, sans danger, créer un objet d'une classe abstraite, donc vous aurez un message d'erreur du compilateur. Grâce à ça, le compilateur assure la pureté d'une classe abstraite, et vous n'avez pas à vous soucier de sa mauvaise utilisation possible.
Si vous héritez d'une classe abstraite et que vous voulez créer des objets de ce nouveau type, vous devez fournir le corps de toutes les méthodes abstraites de la classe de base. Si vous ne le faites pas (et vous pourriez choisir de ne pas le faire), alors la classe fille est aussi abstraite, et le compilateur vous forcera à définir cette classe avec le mot clé abstract
Il est possible de créer une classe abstract sans inclure des méthodes abstract. C'est utile quand vous avez une classe dans laquelle avoir des méthodes abstract n'a aucun sens, mais dont vous voulez éviter l'instanciation.
La classe Instrument peut facilement être transformée en classe abstract. Seules certaines de ses méthodes vont être abstract, comme marquer une classe abstraite ne vous force pas à définir toutes les méthodes comme abstract. Voici à quoi cela ressemble :
Voici l'exemple de l'orchestre modifié pour utiliser des classes et des méthodes abstract:
//: c07:music4:Music4.java
// Classes et méthodes abstraites.
package
c07.music4;
import
com.bruceeckel.simpletest.*;
import
java.util.*;
import
c07.music.Note;
abstract
class
Instrument {
private
int
i; // Allocation faite pour chaque instance
public
abstract
void
play
(
Note n);
public
String what
(
) {
return
"Instrument"
;
}
public
abstract
void
adjust
(
);
}
class
Wind extends
Instrument {
public
void
play
(
Note n) {
System.out.println
(
"Wind.play() "
+
n);
}
public
String what
(
) {
return
"Wind"
; }
public
void
adjust
(
) {}
}
class
Percussion extends
Instrument {
public
void
play
(
Note n) {
System.out.println
(
"Percussion.play() "
+
n);
}
public
String what
(
) {
return
"Percussion"
; }
public
void
adjust
(
) {}
}
class
Stringed extends
Instrument {
public
void
play
(
Note n) {
System.out.println
(
"Stringed.play() "
+
n);
}
public
String what
(
) {
return
"Stringed"
; }
public
void
adjust
(
) {}
}
class
Brass extends
Wind {
public
void
play
(
Note n) {
System.out.println
(
"Brass.play() "
+
n);
}
public
void
adjust
(
) {
System.out.println
(
"Brass.adjust()"
);
}
}
class
Woodwind extends
Wind {
public
void
play
(
Note n) {
System.out.println
(
"Woodwind.play() "
+
n);
}
public
String what
(
) {
return
"Woodwind"
; }
}
public
class
Music4 {
private
static
Test monitor =
new
Test
(
);
// Ne tient pas compte du type, donc les nouveaux types
// ajoutés au système vont toujours fonctionner correctement.
static
void
tune
(
Instrument i) {
// ...
i.play
(
Note.MIDDLE_C);
}
static
void
tuneAll
(
Instrument[] e) {
for
(
int
i =
0
; i <
e.length; i++
)
tune
(
e[i]);
}
public
static
void
main
(
String[] args) {
// Upcasting pendant l'ajout dans le tableau:
Instrument[] orchestra =
{
new
Wind
(
),
new
Percussion
(
),
new
Stringed
(
),
new
Brass
(
),
new
Woodwind
(
)
}
;
tuneAll
(
orchestra);
monitor.expect
(
new
String[] {
"Wind.play() Middle C"
,
"Percussion.play() Middle C"
,
"Stringed.play() Middle C"
,
"Brass.play() Middle C"
,
"Woodwind.play() Middle C"
}
);
}
}
///:~
Vous pouvez voir qu'il n'y a réellement aucun changement excepté dans la classe de base.
Il est utile de créer des classes et des méthodes abstract car elles rendent explicites le caractère abstrait d'une classe, et elles disent à l'utilisateur et au compilateur la façon dont ces classes doivent être utilisées.
VII-D. Constructeurs et polymorphisme▲
Comme d'habitude, les constructeurs sa comportent différemment des autres sortes de méthodes. C'est aussi vrai pour le polymorphisme. Même si les constructeurs ne sont pas polymorphes (ils sont en fait des méthodes static, mais la déclaration static est implicite), il est important de comprendre comment les constructeurs se comportent dans des hiérarchies complexes combinées avec le polymorphisme. Cette compréhension vous aidera à éviter des enchevêtrements désagréables.
VII-D-1. Ordre d'appel des constructeurs▲
L'ordre d'appel des constructeurs a été brièvement abordé dans le chapitre 4 et également dans le chapitre 6, mais c'était avant que le polymorphisme ne soit introduit.
Un constructeur de la classe de base est toujours appelé pendant le processus de construction d'une classe dérivée, en remontant la hiérarchie d'héritage de sorte qu'un constructeur pour chaque classe de base est appelé. Ceci semble normal, car le rôle du constructeur est précisément de construire correctement l'objet. Une classe dérivée a seulement accès à ses propres membres, et pas à ceux de la classe de base (dont les membres sont typiquement private). Seul le constructeur de la classe de base a la connaissance et l'accès appropriés pour initialiser ses propres éléments. Par conséquent, il est essentiel que tous les constructeurs soient appelés, sinon l'objet ne serait pas entièrement construit. C'est pourquoi le compilateur impose un appel de constructeur pour chaque partie d'une classe dérivée. Il appellera silencieusement le constructeur par défaut si vous n'appelez pas explicitement un constructeur de la classe de base dans le corps du constructeur de la classe dérivée. S'il n'y a aucun constructeur par défaut, le compilateur le réclamera (dans le cas où une classe n'a aucun constructeur, le compilateur générera automatiquement un constructeur par défaut).
Prenons un exemple qui montre les effets de la composition, de l'héritage, et du polymorphisme sur l'ordre de construction :
//: c07:Sandwich.java
// Ordre d'appel des constructeurs.
package
c07;
import
com.bruceeckel.simpletest.*;
class
Meal {
Meal
(
) {
System.out.println
(
"Meal()"
); }
}
class
Bread {
Bread
(
) {
System.out.println
(
"Bread()"
); }
}
class
Cheese {
Cheese
(
) {
System.out.println
(
"Cheese()"
); }
}
class
Lettuce {
Lettuce
(
) {
System.out.println
(
"Lettuce()"
); }
}
class
Lunch extends
Meal {
Lunch
(
) {
System.out.println
(
"Lunch()"
); }
}
class
PortableLunch extends
Lunch {
PortableLunch
(
) {
System.out.println
(
"PortableLunch()"
);}
}
public
class
Sandwich extends
PortableLunch {
private
static
Test monitor =
new
Test
(
);
private
Bread b =
new
Bread
(
);
private
Cheese c =
new
Cheese
(
);
private
Lettuce l =
new
Lettuce
(
);
public
Sandwich
(
) {
System.out.println
(
"Sandwich()"
);
}
public
static
void
main
(
String[] args) {
new
Sandwich
(
);
monitor.expect
(
new
String[] {
"Meal()"
,
"Lunch()"
,
"PortableLunch()"
,
"Bread()"
,
"Cheese()"
,
"Lettuce()"
,
"Sandwich()"
}
);
}
}
///:~
Cet exemple crée une classe complexe et d'autres classes, chaque classe a un constructeur qui s'annonce lui-même. La classe importante est Sandwich, qui est au troisième niveau d'héritage (quatrième, si vous comptez l'héritage implicite de Object) et qui a trois objets membres. On peut voir la sortie quand un objet Sandwich est créé dans main( ). Ceci signifie que l'ordre d'appel des constructeurs pour un objet complexe est le suivant :
- Le constructeur de la classe de base est appelé. Cette étape est répétée récursivement jusqu'à ce que la racine de la hiérarchie soit construite d'abord, suivie par la classe dérivée suivante, etc., jusqu'à atteindre la classe la plus dérivée.
- Les initialiseurs des membres sont appelés dans l'ordre de déclaration.
- Le corps du constructeur de la classe dérivée est appelé.
L'ordre d'appel des constructeurs est important. Quand vous héritez, vous savez tout au sujet de la classe de base et pouvez accéder à tous les membres public et protected de la classe de base. Ceci signifie que vous devez pouvoir présumer que tous les membres de la classe de base sont valides quand vous êtes dans la classe dérivée. Dans une méthode normale, la construction a déjà eu lieu, ainsi tous les membres de toutes les parties de l'objet ont été construits. Dans le constructeur, cependant, vous devez pouvoir supposer que tous les membres que vous utilisez ont été construits. La seule manière de le garantir est d'appeler d'abord le constructeur de la classe de base. Ainsi, quand vous êtes dans le constructeur de la classe dérivée, tous les membres auxquels vous pouvez accéder dans la classe de base ont été initialisés. Savoir que tous les membres sont valides à l'intérieur du constructeur est également la raison pour laquelle, autant que possible, vous devriez initialiser tous les objets membres (c'est-à-dire les objets mis dans la classe par composition) à leur point de définition dans la classe (par exemple, b, c et l dans l'exemple précédent). Si vous suivez cette recommandation, vous contribuerez à vous assurer que tous les membres de la classe de base et les objets membres de l'objet actuel aient été initialisés. Malheureusement, cela ne couvre pas tous les cas, comme vous allez le voir dans la section suivante.
VII-D-2. Héritage et nettoyage▲
Lorsque vous utilisez la composition ou l'héritage pour créer une nouvelle classe, la plupart du temps vous n'avez pas à vous soucier du nettoyage; les « sous-objets » peuvent, en général, être abandonnés au ramasse-miettes. Si vous avez à traiter la question du nettoyage, vous devez le faire avec soin en créant une méthode dispose( ) (le nom que j'ai choisi d'utiliser ici ; vous pouvez trouver quelque chose de mieux) pour votre nouvelle classe. Avec l'héritage, vous devez surcharger dispose( ) dans la classe dérivée si vous avez un nettoyage spécial qui doit intervenir dans le ramasse-miettes. Quand vous surchargez dispose( ) dans une classe héritée, il est important de se souvenir d'appeler la version de dispose( ) de la classe de base, autrement le nettoyage de la classe de base n'aura pas lieu. L'exemple suivant le montre :
//: c07:Frog.java
// Nettoyage et héritage.
import
com.bruceeckel.simpletest.*;
class
Characteristic {
private
String s;
Characteristic
(
String s) {
this
.s =
s;
System.out.println
(
"Creating Characteristic "
+
s);
}
protected
void
dispose
(
) {
System.out.println
(
"finalizing Characteristic "
+
s);
}
}
class
Description {
private
String s;
Description
(
String s) {
this
.s =
s;
System.out.println
(
"Creating Description "
+
s);
}
protected
void
dispose
(
) {
System.out.println
(
"finalizing Description "
+
s);
}
}
class
LivingCreature {
private
Characteristic p =
new
Characteristic
(
"is alive"
);
private
Description t =
new
Description
(
"Basic Living Creature"
);
LivingCreature
(
) {
System.out.println
(
"LivingCreature()"
);
}
protected
void
dispose
(
) {
System.out.println
(
"LivingCreature dispose"
);
t.dispose
(
);
p.dispose
(
);
}
}
class
Animal extends
LivingCreature {
private
Characteristic p=
new
Characteristic
(
"has heart"
);
private
Description t =
new
Description
(
"Animal not Vegetable"
);
Animal
(
) {
System.out.println
(
"Animal()"
);
}
protected
void
dispose
(
) {
System.out.println
(
"Animal dispose"
);
t.dispose
(
);
p.dispose
(
);
super
.dispose
(
);
}
}
class
Amphibian extends
Animal {
private
Characteristic p =
new
Characteristic
(
"can live in water"
);
private
Description t =
new
Description
(
"Both water and land"
);
Amphibian
(
) {
System.out.println
(
"Amphibian()"
);
}
protected
void
dispose
(
) {
System.out.println
(
"Amphibian dispose"
);
t.dispose
(
);
p.dispose
(
);
super
.dispose
(
);
}
}
public
class
Frog extends
Amphibian {
private
static
Test monitor =
new
Test
(
);
private
Characteristic p =
new
Characteristic
(
"Croaks"
);
private
Description t =
new
Description
(
"Eats Bugs"
);
public
Frog
(
) {
System.out.println
(
"Frog()"
);
}
protected
void
dispose
(
) {
System.out.println
(
"Frog dispose"
);
t.dispose
(
);
p.dispose
(
);
super
.dispose
(
);
}
public
static
void
main
(
String[] args) {
Frog frog =
new
Frog
(
);
System.out.println
(
"Bye!"
);
frog.dispose
(
);
monitor.expect
(
new
String[] {
"Creating Characteristic is alive"
,
"Creating Description Basic Living Creature"
,
"LivingCreature()"
,
"Creating Characteristic has heart"
,
"Creating Description Animal not Vegetable"
,
"Animal()"
,
"Creating Characteristic can live in water"
,
"Creating Description Both water and land"
,
"Amphibian()"
,
"Creating Characteristic Croaks"
,
"Creating Description Eats Bugs"
,
"Frog()"
,
"Bye!"
,
"Frog dispose"
,
"finalizing Description Eats Bugs"
,
"finalizing Characteristic Croaks"
,
"Amphibian dispose"
,
"finalizing Description Both water and land"
,
"finalizing Characteristic can live in water"
,
"Animal dispose"
,
"finalizing Description Animal not Vegetable"
,
"finalizing Characteristic has heart"
,
"LivingCreature dispose"
,
"finalizing Description Basic Living Creature"
,
"finalizing Characteristic is alive"
}
);
}
}
///:~
Chaque classe dans la hiérarchie contient également un membre objet de type Characteristic et un autre de type Description, qui doivent aussi être éliminés. L'ordre d'élimination doit être inverse de l'ordre d'initialisation, au cas où un sous-objet dépendrait d'un autre. Pour les champs, cela signifie l'inverse de l'ordre de déclaration (puisque les champs sont initialisés dans l'ordre de déclaration). Pour les classes de base (suivant l'usage en C++ pour les destructeurs), vous devez tout d'abord effectuer le nettoyage de la classe dérivée, puis le nettoyage de la classe de base. Ceci parce que le nettoyage de la classe dérivée pourrait appeler des méthodes de la classe de base qui nécessitent que les composants de la classe de base existent, ainsi vous ne devez pas les détruire prématurément. Le résultat montre que toutes les parties de l'objet Frog sont détruites dans l'ordre inverse de leur création.
A partir de cet exemple, vous pouvez voir que, bien qu'il ne soit pas toujours nécessaire d'effectuer le nettoyage, si tel est le cas, le processus demande soin et application .
VII-D-3. Comportement des méthodes polymorphes dans les constructeurs▲
La hiérarchie d'appel des constructeurs pose un dilemme intéressant. Qu'arrive-t-il si à l'intérieur d'un constructeur vous appelez une méthode dynamiquement attachée de l'objet en cours de construction ? À l'intérieur d'une méthode ordinaire, vous pouvez imaginer ce qui arrivera : l'appel dynamiquement attaché est résolu à l'exécution, parce que l'objet ne peut pas savoir s'il appartient à la classe dans laquelle se trouve la méthode ou bien dans une classe dérivée de cette classe. Par souci de cohérence, vous pourriez penser que c'est ce qui doit arriver dans les constructeurs.
Ce n'est pas exactement le cas. Si vous appelez une méthode dynamiquement attachée à l'intérieur d'un constructeur, la définition redéfinie de cette méthode est appelée. Cependant, l'effet peut être plutôt surprenant et peut cacher des bogues difficiles à trouver.
Le travail du constructeur est conceptuellement d'amener l'objet à l'existence (ce qui n'est guère une simple prouesse). À l'intérieur de n'importe quel constructeur, l'objet intégral pourrait n'être que partiellement formé - vous pouvez seulement savoir que les objets de la classe de base ont été initialisés, mais vous ne pouvez pas savoir quelles sont les classes filles qui héritent de vous. Cependant, un appel de méthode dynamiquement attaché, atteint « extérieurement » la hiérarchie d'héritage. Il appelle une méthode dans une classe dérivée. Si vous faites ça à l'intérieur d'un constructeur, vous appelez une méthode qui pourrait manipuler des membres non encore initialisés - c'est le meilleur moyen de s'attirer de gros ennuis.
Vous pouvez voir le problème dans l'exemple suivant :
//: c07:PolyConstructors.java
// Constructeurs et polymorphisme ne conduisent
// pas ce à quoi que vous pourriez vous attendre.
import
com.bruceeckel.simpletest.*;
abstract
class
Glyph {
abstract
void
draw
(
);
Glyph
(
) {
System.out.println
(
"Glyph() before draw()"
);
draw
(
);
System.out.println
(
"Glyph() after draw()"
);
}
}
class
RoundGlyph extends
Glyph {
private
int
radius =
1
;
RoundGlyph
(
int
r) {
radius =
r;
System.out.println
(
"RoundGlyph.RoundGlyph(), radius = "
+
radius);
}
void
draw
(
) {
System.out.println
(
"RoundGlyph.draw(), radius = "
+
radius);
}
}
public
class
PolyConstructors {
private
static
Test monitor =
new
Test
(
);
public
static
void
main
(
String[] args) {
new
RoundGlyph
(
5
);
monitor.expect
(
new
String[] {
"Glyph() before draw()"
,
"RoundGlyph.draw(), radius = 0"
,
"Glyph() after draw()"
,
"RoundGlyph.RoundGlyph(), radius = 5"
}
);
}
}
///:~
Dans Glyph, la méthode draw( ) est abstraite (abstract), elle a donc été conçue pour être redéfinie. En effet, vous êtes forcés de la redéfinir dans RoundGlyph. Mais le constructeur de Glyph appelle cette méthode, et l'appel aboutit à RoundGlyph.draw( ), ce qui semble être l'intention. Mais si on regarde la sortie, on peut voir que lorsque le constructeur de Glyph appelle draw( ), la variable radius n'a même pas encore la valeur initiale de 1, elle vaut zéro. Le résultat serait probablement réduit à l'affichage d'un point ou même à rien du tout, et vous seriez là à fixer l'écran et à essayer de comprendre pourquoi le programme ne marche pas.
L'ordre de l'initialisation décrit dans la section précédente n'est pas complètement exhaustif, et c'est la clé qui va résoudre le mystère. La procédure d'initialisation est la suivante :
- La zone allouée à l'objet est initialisée à zéro binaire avant tout.
- Les constructeurs des classes de base sont appelés comme décrit précédemment. À ce stade, la méthode draw( ) redéfinie est appelée (et oui, avant l'appel du constructeur de RoundGlyph) ; elle trouve une valeur de radius qui vaut zéro à cause de la première étape.
- Les initialiseurs des membres sont appelés dans l'ordre de déclaration.
- Le corps du constructeur de la classe dérivée est appelé.
Le bon côté de ceci est que tout est au moins initialisé à zéro (selon la signification de zéro pour un type de donnée particulier) et non laissé avec n'importe quelle valeur. Cela inclut les références d'objet qui sont incorporées à l'intérieur d'une classe par composition, et qui passent à null. Ainsi, si vous oubliez d'initialiser une référence vous obtiendrez une exception à l'exécution. Tout le reste est à zéro, qui est habituellement une valeur que l'on repère en examinant la sortie.
D'un autre côté, vous devez être passablement horrifiés du résultat de ce programme. Vous avez fait une chose parfaitement logique, et pourtant le comportement est mystérieusement incorrect, sans aucune manifestation du compilateur (C ++ a un comportement plus rationnel dans la même situation). Les bogues de ce genre peuvent facilement être enterrés et nécessiter pas mal de temps pour être découverts.
Il en résulte la recommandation suivante pour les constructeurs : « Faire le minimum pour mettre l'objet dans un état correct, et, si possible, ne pas appeler de méthodes. » Les seules méthodes qui peuvent être appelées en toute sécurité à l'intérieur d'un constructeur sont celles qui sont final dans la classe de base (même chose pour les méthodes private, qui sont automatiquement final). Celles-ci ne peuvent être redéfinies et ne réservent donc pas de surprise.
VII-E. Concevoir avec l'héritage▲
Après avoir vu le polymorphisme, c'est un instrument tellement astucieux qu'on dirait que tout doit être hérité. Ceci peut alourdir votre conception ; en fait si vous choisissez d'utiliser d'abord l'héritage lorsque vous créez une nouvelle classe à partir d'une classe existante, cela peut devenir inutilement compliqué.
Une meilleure approche est de choisir d'abord la composition, surtout quand il ne vous semble pas évident de choisir entre les deux. La composition n'oblige pas à concevoir une hiérarchie d'héritage. Mais elle est également plus flexible, car il est alors possible de choisir dynamiquement un type (et son comportement), alors que l'héritage requiert un type exact au moment de la compilation. L'exemple suivant l'illustre :
//: c07:Transmogrify.java
// Changer dynamiquement le comportement
// d'un objet par la composition (la conception "State pattern").
import
com.bruceeckel.simpletest.*;
abstract
class
Actor {
public
abstract
void
act
(
);
}
class
HappyActor extends
Actor {
public
void
act
(
) {
System.out.println
(
"HappyActor"
);
}
}
class
SadActor extends
Actor {
public
void
act
(
) {
System.out.println
(
"SadActor"
);
}
}
class
Stage {
private
Actor actor =
new
HappyActor
(
);
public
void
change
(
) {
actor =
new
SadActor
(
); }
public
void
performPlay
(
) {
actor.act
(
); }
}
public
class
Transmogrify {
private
static
Test monitor =
new
Test
(
);
public
static
void
main
(
String[] args) {
Stage stage =
new
Stage
(
);
stage.performPlay
(
);
stage.change
(
);
stage.performPlay
(
);
monitor.expect
(
new
String[] {
"HappyActor"
,
"SadActor"
}
);
}
}
///:~
Un objet Stage contient une référence vers un Actor, qui est initialisé avec un objet HappyActor. Cela signifie que performPlay( ) produit un comportement particulier. Mais puisqu'une référence peut être reliée à un objet différent à l'exécution, une référence à un objet SadActor peut être substituée dans actor, et alors le comportement produit par performPlay( ) change. Ainsi vous gagnez en flexibilité dynamique à l'exécution (cela est également appelé le State Pattern. Voir Thinking in Patterns (with Java) sur www.BruceEckel.com). Par contre, vous ne pouvez pas décider d'hériter différemment à l'exécution ; cela doit être complètement déterminé à la compilation.
Voici une recommandation générale : « Utilisez l'héritage pour exprimer les différences de comportement, et les champs pour exprimer les variations d'état ». Dans l'exemple précédent, les deux sont utilisés ; deux classes différentes héritent pour exprimer la différence dans la méthode act( ), et Stage utilise la composition pour permettre à son état d'être changé. Dans ce cas, ce changement d'état provoque un changement de comportement.
VII-E-1. Héritage pur vs. extension▲
Lorsque l'on étudie l'héritage, il semblerait que la façon la plus propre de créer une hiérarchie d'héritage soit de suivre l'approche « pure ». À savoir que seules les méthodes qui ont été établies dans la classe de base ou l'interface sont surchargeables dans la classe dérivée, comme le montre ce diagramme :
Ceci peut se nommer une relation « est-un » pure, car l'interface d'une classe établit ce qu'elle est. L'héritage garantit que toute classe dérivée aura l'interface de la classe de base et rien de moins. Si vous suivez ce diagramme, les classes dérivées n'auront également pas plus que l'interface de la classe de base.
Ceci peut être considéré comme une substitution pure, car les objets de classe dérivée peuvent être parfaitement substitués par la classe de base, et vous n'avez jamais besoin de connaître d'informations supplémentaires sur les sous-classes quand vous les utilisez :
Cela étant, la classe de base peut recevoir tout message que vous pouvez envoyer à la classe dérivée, car les deux ont exactement la même interface. Tout ce que vous avez besoin de faire est de faire un transtypage ascendant (upcast) à partir de la classe dérivée et de ne jamais regarder en arrière pour voir quel type exact d'objet vous manipulez. Tout est géré à travers le polymorphisme.
En la considérant de cette manière, une relation pure « est-un » semble la seule façon sensée de pratiquer, et toute autre conception dénote une réflexion embrouillée et est par définition erronée. Ceci aussi est un piège. Dès que vous commencez à penser de cette manière, vous allez tourner en rond et découvrir qu'étendre l'interface (ce que, malencontreusement, le mot clé extends semble encouragé) est la solution parfaite à un problème particulier. Ceci pourrait être qualifié de relation « est-comme-un », car la classe dérivée est comme la classe de base (elle a la même interface fondamentale), mais elle a d'autres éléments qui nécessitent d'implémenter des méthodes additionnelles :
Mais si cette approche est aussi utile et sensée (selon la situation) elle a un inconvénient. La partie étendue de l'interface de la classe dérivée n'est pas accessible à partir de la classe de base, donc une fois que vous avez utilisé le transtypage ascendant (upcast), vous ne pouvez pas appeler les nouvelles méthodes :
Si vous ne faites pas de transtypage ascendant dans ce cas, cela ne va pas vous incommoder, mais vous serez souvent dans une situation où vous aurez besoin de retrouver le type exact de l'objet afin de pouvoir accéder aux méthodes étendues de ce type. La section suivante montre comment cela se passe.
VII-E-2. Transtypage descendant et identification du type à l'exécution▲
Puisque vous avez perdu l'information du type spécifique par un transtypage ascendant (upcast) (en remontant la hiérarchie d'héritage), il est logique de retrouver le type en redescendant la hiérarchie d'héritage par un transtypage descendant (downcast). Cependant, vous savez qu'un transtypage ascendant est toujours sûr, la classe de base ne pouvant pas avoir une interface plus grande que la classe dérivée. Ainsi, chaque message que vous envoyez par le biais de l'interface de la classe de base a la garantie d'être accepté. Mais avec un transtypage descendant, vous ne savez pas vraiment qu'une forme (par exemple) est en réalité un cercle. Cela pourrait plutôt être un triangle ou un carré ou quelque chose d'un autre type.
Pour résoudre ce problème, il doit y avoir un moyen de garantir qu'un transtypage descendant est correct, ainsi vous n'allez pas effectuer un transtypage accidentel vers le mauvais type et ensuite envoyer un message que l'objet ne pourrait accepter. Ce serait assez imprudent.
Dans certains langages (comme C++) vous devez effectuer une opération spéciale afin d'avoir un transtypage ascendant sûr, mais en Java, chaque transtypage est vérifié ! Donc même s'il semble que vous faites juste un transtypage explicite ordinaire, lors de l'exécution ce transtypage est vérifié pour assurer qu'en fait il s'agit bien du type auquel vous vous attendez. Si il ne l'est pas, vous récupérez une ClassCastException. Cette action de vérifier les types au moment de l'exécution est appelée identification du type à l'exécution (Run-Time Type Identification : RTTI). L'exemple suivant montre le comportement de la RTTI:
//: c07:RTTI.java
// Transtypage descendant & identification du type à l'exécution (RTTI).
// {ThrowsException}
class
Useful {
public
void
f
(
) {}
public
void
g
(
) {}
}
class
MoreUseful extends
Useful {
public
void
f
(
) {}
public
void
g
(
) {}
public
void
u
(
) {}
public
void
v
(
) {}
public
void
w
(
) {}
}
public
class
RTTI {
public
static
void
main
(
String[] args) {
Useful[] x =
{
new
Useful
(
),
new
MoreUseful
(
)
}
;
x[0
].f
(
);
x[1
].g
(
);
// Compilation : méthode non trouvée dans Useful :
//! x[1].u();
((
MoreUseful)x[1
]).u
(
); // Transtypage descendant/RTTI
((
MoreUseful)x[0
]).u
(
); // Exception levée
}
}
///:~
Comme dans le diagramme, MoreUseful étend l'interface de Useful. Mais puisqu'il en a hérité, on peut faire un transtypage ascendant vers un Useful. Vous pouvez voir ceci se produire dans l'initialisation du tableau x dans main( ). Comme les deux objets du tableau sont de la classe Useful, vous pouvez envoyer les méthodes f( ) et g( ) aux deux, et si vous essayez d'invoquer u( ) (qui existe seulement dans MoreUseful), vous aurez un message d'erreur à la compilation.
Si vous voulez accéder à l'interface étendue d'un objet MoreUseful, vous pouvez essayer un transtypage descendant. Si c'est le type correct, cela fonctionnera. Autrement, vous recevrez une ClassCastException. Vous n'avez pas besoin d'écrire un code spécial pour cette exception, car elle indique une erreur du programmeur qui pourrait arriver n'importe où dans un programme.
La RTTI est plus riche qu'un simple transtypage. Par exemple, il y a une façon de connaître le type que vous manipulez avant d'essayer de faire un transtypage descendant. Tout le chapitre 10 est consacré à l'étude de différents aspects de l'identification du type à l'exécution en Java (RTTI).
VII-F. Résumé▲
Polymorphisme signifie « différentes formes ». En programmation orientée objet, vous avez la même physionomie (l'interface commune dans la classe de base) et différentes formes qui utilisent cette physionomie : les différentes versions des méthodes dynamiquement attachées.
Vous avez vu dans ce chapitre qu'il est impossible de comprendre, ou même créer, un exemple de polymorphisme sans utiliser l'abstraction et l'héritage. Le polymorphisme est une notion qui ne peut pas être présentée séparément (comme on peut le faire par exemple avec l'instruction switch), mais qui fonctionne plutôt en conjonction avec le schéma global des relations entre classes. Les gens sont souvent troublés par d'autres dispositifs non-orientés objet de Java, comme la surcharge de méthode, qui sont parfois présentés comme étant orientés objet. Ne soyez pas dupe : si ce n'est pas de la liaison dynamique, ce n'est pas du polymorphisme.
Pour utiliser le polymorphisme (et par conséquent les techniques orientées objet) pertinemment dans vos programmes, vous devez élargir votre vision de la programmation pour y inclure non seulement les membres et les messages d'une classe individuelle, mais également ce qui est partagé entre les classes et les rapports entre elles. Bien que ceci exige un effort significatif, cela en vaut vraiment le coup, car il en résulte un développement plus rapide, un code mieux organisé, des programmes extensibles et une maintenance plus facile.
VII-G. Exercices▲
Les solutions aux exercices sélectionnés peuvent être trouvées dans le document électronique The Thinking in Java Annotated Solution Guide, disponible pour un faible coût sur www.BruceEckel.com.
- Ajouter une nouvelle méthode à la classe de base de Shapes.java qui affiche un message, mais sans la redéfinir dans les classes dérivées. Expliquer ce qu'il se passe. Maintenant, la redéfinir dans une des classes dérivées, mais pas dans les autres, et voir ce qu'il se passe. Finalement, la redéfinir dans toutes les classes dérivées.
- Ajouter un nouveau type de Shape à Shapes.java et vérifier dans main( ) que le polymorphisme fonctionne pour votre nouveau type comme il le fait pour les anciens types.
- Changer Music3.java pour que what( ) devienne une méthode toString( ) de la classe racine Object. Essayer d'afficher les objets Instrument en utilisant System.out.println( ) (sans aucun transtypage).
- Ajouter un nouveau type d'Instrument à Music3.java et vérifier que le polymorphisme fonctionne pour votre nouveau type.
- Modifier Music3.java pour qu'il crée de manière aléatoire des objets Instrument de la même façon que Shapes.java le fait.
- Créer une hiérarchie d'héritage de Rongeur : Souris, Gerbille, Hamster, etc. Dans la classe de base, fournir des méthodes qui sont communes à tous les Rongeurs, et les redéfinir dans les classes dérivées pour obtenir un comportement différent pour chaque type de Rongeur. Créer un tableau de Rongeurs, le remplir avec différents types spécifiques de Rongeurs, et appeler vos méthodes de la classe de base pour voir ce qui arrive.
- Modifier l'Exercice 6 pour que Rongeur soit une classe abstract. Rendre les méthodes de Rongeur abstraites dès que possible.
- Créer une classe comme étant abstract sans inclure aucune méthode abstract et vérifier que vous ne pouvez créer aucune instance de cette classe.
- Ajouter la classe Pickle à Sandwich.java.
- Modifier l'Exercice 6 afin qu'il démontre l'ordre des initialisations des classes de base et des classes dérivées. Maintenant, ajouter des objets membres à la fois à la classe de base et aux classes dérivées, et montrer dans quel ordre leur initialisation se produit durant la construction.
- Créer une classe de base avec deux méthodes. Dans la première méthode, appeler la seconde méthode. Faire hériter une classe et redéfinir la seconde méthode. Créer un objet de la classe dérivée, faire un transtypage ascendant vers le type de base, et appeler la première méthode. Expliquer ce qui se passe.
- Créer une classe de base avec une méthode abstract print( ) qui est redéfinie dans une classe dérivée. La version redéfinie de la méthode affiche la valeur d'une variable int définie dans la classe dérivée. Au point de définition de cette variable, lui donner une valeur non nulle. Dans le constructeur de la classe de base, appeler cette méthode. Dans main( ), créer un objet du type dérivé, et ensuite appeler sa méthode print( ). Expliquer les résultats.
- Suivant l'exemple de Transmogrify.java, créer une classe Starship contenant une référence AlertStatus qui peut indiquer trois états différents. Inclure des méthodes pour changer les états.
- Créer une classe abstract sans méthodes. Dériver une classe et ajouter une méthode. Créer une méthode static qui prend une référence vers la classe de base, effectue un transtypage descendant vers la classe dérivée, et appelle la méthode. Dans main( ), démontrer que cela fonctionne. Maintenant, mettre la déclaration abstract pour la méthode dans la classe de base, éliminant ainsi le besoin du transtypage descendant.