VI. Réutiliser les classes▲
Une des caractéristiques les plus excitantes de Java est la réutilisation du code. Mais pour être vraiment révolutionnaire, il faut faire plus que copier du code et le changer.
C'est l'approche utilisée dans les langages procéduraux comme C, et ça n'a pas très bien fonctionné. Comme tout en Java, la solution réside dans les classes. On réutilise du code en créant de nouvelles classes, mais au lieu de les créer depuis zéro, on utilise les classes que quelqu'un a construites et testées.
L'astuce est d'utiliser les classes sans détériorer le code existant. Dans ce chapitre nous verrons deux manières de faire. La première est plutôt directe : on crée simplement des objets de nos classes existantes à l'intérieur de la nouvelle classe. Cela s'appelle la composition, parce que la nouvelle classe se compose d'objets de classes existantes. On réutilise simplement les fonctionnalités du code et non sa forme.
La seconde approche est plus subtile. On crée une nouvelle classe comme un type d' une classe existante. On prend littéralement la forme d'une classe existante et on lui ajoute du code sans modifier la classe existante. Cet acte magique est appelé l'héritage, et le compilateur fait le plus gros du travail. L'héritage est une des pierres angulaires de la programmation orientée objet, et a bien d'autres implications qui seront explorées au chapitre 7.
Il s'avère que beaucoup de la syntaxe et du comportement sont identiques pour la composition et l'héritage (cela se comprend parce qu'ils sont tous deux des moyens de construire des nouveaux types à partir de types existants). Dans ce chapitre, vous apprendrez ces mécanismes de réutilisation de code.
VI-A. Syntaxe de la composition▲
Jusqu'à maintenant, la composition a été utilisée assez fréquemment. On utilise simplement des références sur des objets dans de nouvelles classes. Par exemple, supposons que l'on souhaite un objet qui contient plusieurs objets de type String, quelques types primitifs, et un objet d'une autre classe. Pour les objets non primitifs, on met des références à l'intérieur de notre nouvelle classe, mais on définit directement les types primitifs :
//: c06:SprinklerSystem.java
// La composition pour réutiliser du code.
import
com.bruceeckel.simpletest.*;
class
WaterSource {
private
String s;
WaterSource
(
) {
System.out.println
(
"WaterSource()"
);
s =
new
String
(
"Constructed"
);
}
public
String toString
(
) {
return
s; }
}
public
class
SprinklerSystem {
private
static
Test monitor =
new
Test
(
);
private
String valve1, valve2, valve3, valve4;
private
WaterSource source;
private
int
i;
private
float
f;
public
String toString
(
) {
return
"valve1 = "
+
valve1 +
"
\n
"
+
"valve2 = "
+
valve2 +
"
\n
"
+
"valve3 = "
+
valve3 +
"
\n
"
+
"valve4 = "
+
valve4 +
"
\n
"
+
"i = "
+
i +
"
\n
"
+
"f = "
+
f +
"
\n
"
+
"source = "
+
source;
}
public
static
void
main
(
String[] args) {
SprinklerSystem sprinklers =
new
SprinklerSystem
(
);
System.out.println
(
sprinklers);
monitor.expect
(
new
String[] {
"valve1 = null"
,
"valve2 = null"
,
"valve3 = null"
,
"valve4 = null"
,
"i = 0"
,
"f = 0.0"
,
"source = null"
}
);
}
}
///:~
Une des méthodes définies dans les deux classes est spéciale : toString( ). Vous apprendrez plus tard que chaque type non primitif a une méthode toString( ), et elle est appelée dans des situations spéciales lorsque le compilateur attend une String, mais qu'il a un objet. Ainsi dans SprinklerSystem.toString( ), dans l'expression :
"source = "
+
source;
le compilateur voit que vous essayez d'ajouter un objet String (« source = ») à un WaterSource. Parce que vous pouvez seulement « ajouter » une String à une autre String, il dit « Je vais convertir source en String en appelant toString( ) ! » Après avoir fait cela, il combine les deux Strings et passe la String résultante à System.out.println( ). Chaque fois que l'on veut permettre ce comportement avec une classe que l'on crée, il suffit de définir une méthode toString( ).
Les types primitifs qui sont des champs d'une classe sont automatiquement initialisés à zéro, comme précisé au chapitre 2. Mais les références objet sont initialisées à null, et si on essaye d'appeler des méthodes pour l'un d'entre eux, on obtient une exception. En fait il est bon (et utile) qu'on puisse les afficher sans lancer d'exception.
On comprend bien que le compilateur ne crée pas un objet par défaut pour chaque référence parce que cela induirait souvent une surcharge inutile. Si on veut initialiser les références, on peut faire :
- Au moment où les objets sont définis. Cela signifie qu'ils seront toujours initialisés avant que le constructeur ne soit appelé ;
- Dans le constructeur pour la classe ;
- Juste avant d'utiliser l'objet, ce qui est souvent appelé initialisation paresseuse. Cela peut réduire la surcharge dans les situations où la création d'un objet est coûteuse et l'objet n'a pas besoin d'être créé à chaque fois.
Les trois approches sont montrées ici:
//: c06:Bath.java
// Initialisation dans le constructeur avec composition
import
com.bruceeckel.simpletest.*;
class
Soap {
private
String s;
Soap
(
) {
System.out.println
(
"Soap()"
);
s =
new
String
(
"Constructed"
);
}
public
String toString
(
) {
return
s; }
}
public
class
Bath {
private
static
Test monitor =
new
Test
(
);
private
String // // Initialisation au moment de la définition:
s1 =
new
String
(
"Happy"
),
s2 =
"Happy"
,
s3, s4;
private
Soap castille;
private
int
i;
private
float
toy;
public
Bath
(
) {
System.out.println
(
"Inside Bath()"
);
s3 =
new
String
(
"Joy"
);
i =
47
;
toy =
3.14
f;
castille =
new
Soap
(
);
}
public
String toString
(
) {
if
(
s4 ==
null
) // Initialisation différée:
s4 =
new
String
(
"Joy"
);
return
"s1 = "
+
s1 +
"
\n
"
+
"s2 = "
+
s2 +
"
\n
"
+
"s3 = "
+
s3 +
"
\n
"
+
"s4 = "
+
s4 +
"
\n
"
+
"i = "
+
i +
"
\n
"
+
"toy = "
+
toy +
"
\n
"
+
"castille = "
+
castille;
}
public
static
void
main
(
String[] args) {
Bath b =
new
Bath
(
);
System.out.println
(
b);
monitor.expect
(
new
String[] {
"Inside Bath()"
,
"Soap()"
,
"s1 = Happy"
,
"s2 = Happy"
,
"s3 = Joy"
,
"s4 = Joy"
,
"i = 47"
,
"toy = 3.14"
,
"castille = Constructed"
}
);
}
}
///:~
Notez que dans le constructeur de Bath, une instruction est exécutée avant que toute initialisation ait lieu. Quand l'initialisation n'est pas effectuée au moment de la définition, il n'est pas encore garanti que l'on exécute une initialisation avant d'envoyer un message à un objet - sauf l'inévitable exception à l'exécution.
Quand toString( ) est appelé, il remplit s4 donc tous les champs sont proprement initialisés au moment où ils sont utilisés.
VI-B. La syntaxe de l'héritage▲
L'héritage est une partie primordiale de Java (et des langages de POO en général). Il s'avère que l'on utilise toujours l'héritage quand on veut créer une classe, parce qu'à moins d'hériter explicitement d'une autre classe, on hérite implicitement de la classe racine standard Java Object.
La syntaxe de composition est évidente, mais pour réaliser l'héritage il y a une forme clairement différente. Quand on hérite, on dit « Cette nouvelle classe est comme l'ancienne classe ». On stipule ceci dans le code en donnant le nom de la classe comme d'habitude, mais avant l'accolade ouvrante du corps de la classe, on met le mot clé extends suivi par le nom de la classe de base. Quand on fait cela, on récupère automatiquement tous les champs et méthodes de la classe de base. Voici un exemple :
//: c06:Detergent.java
// Syntaxe d'héritage & propriétés.
import
com.bruceeckel.simpletest.*;
class
Cleanser {
protected
static
Test monitor =
new
Test
(
);
private
String s =
new
String
(
"Cleanser"
);
public
void
append
(
String a) {
s +=
a; }
public
void
dilute
(
) {
append
(
" dilute()"
); }
public
void
apply
(
) {
append
(
" apply()"
); }
public
void
scrub
(
) {
append
(
" scrub()"
); }
public
String toString
(
) {
return
s; }
public
static
void
main
(
String[] args) {
Cleanser x =
new
Cleanser
(
);
x.dilute
(
); x.apply
(
); x.scrub
(
);
System.out.println
(
x);
monitor.expect
(
new
String[] {
"Cleanser dilute() apply() scrub()"
}
);
}
}
public
class
Detergent extends
Cleanser {
// Change une méthode :
public
void
scrub
(
) {
append
(
" Detergent.scrub()"
);
super
.scrub
(
); // Appel de la version de la classe de base
}
// Ajoute une méthode à l'interface :
public
void
foam
(
) {
append
(
" foam()"
); }
// Test de la nouvelle classe:
public
static
void
main
(
String[] args) {
Detergent x =
new
Detergent
(
);
x.dilute
(
);
x.apply
(
);
x.scrub
(
);
x.foam
(
);
System.out.println
(
x);
System.out.println
(
"Testing base class:"
);
monitor.expect
(
new
String[] {
"Cleanser dilute() apply() "
+
"Detergent.scrub() scrub() foam()"
,
"Testing base class:"
,
}
);
Cleanser.main
(
args);
}
}
///:~
Ceci montre un certain nombre de caractéristiques. Premièrement, dans Cleanser la méthode append( ), les Strings sont concaténés dans s en utilisant l'opérateur +=, qui est un des opérateurs (avec '+') que les concepteurs de Java « ont surchargés » pour travailler avec les Strings.
Deuxièmement, tant Cleanser que Detergent contiennent une méthode main( ). On peut créer un main( ) pour chacune de nos classes, et il est souvent recommandé de coder de cette manière afin de garder le code de test dans la classe. Même si on a beaucoup de classes dans un programme, seule la méthode main( ) pour une classe invoquée sur la ligne de commande sera appelée. (Aussi longtemps que main( ) est public, il importe peu que la classe dont elle fait partie soit public.) Donc dans ce cas, quand on écrit java Detergent, Detergent.main( ) sera appelée. Mais on peut également écrire java Cleanser pour invoquer Cleanser.main( ), bien que Cleanser ne soit pas une classe public. Cette technique de mettre un main( ) dans chaque classe permet de tester facilement chaque classe. Et l'on n'a pas besoin d'enlever la méthode main( ) quand on a fini de tester ; on peut la laisser pour des tests futurs.
Ici, on peut voir que Detergent.main( ) appelle Cleanser.main( ) explicitement, en passant les mêmes arguments depuis la ligne de commande (quoi qu'il en soit, on peut passer n'importe quel tableau de String).
Il est important que toutes les méthodes de Cleanser soient public. Il faut se souvenir que si on néglige toute spécification d'accès, le membre est par défaut en accès package, lequel permet d'accéder seulement aux membres du même package. Donc, au sein d'un même package, n'importe qui peut utiliser ces méthodes s'il n'y a pas de spécificateur d'accès. Detergent n'aurait aucun problème, par exemple. Quoi qu'il en soit, si une classe d'un autre package devait hériter de Cleanser, il pourrait accéder seulement aux membres public . Donc pour planifier l'héritage, en règle générale mettre tous les champs private et toutes les méthodes public.(Les membres protected permettent également d'accéder depuis une classe dérivée ; nous verrons cela plus tard). Bien sûr, dans des cas particuliers on devra faire des ajustements, mais cela est une règle utile.
Notez que Cleanser contient un ensemble de méthodes dans son interface : append( ), dilute( ), apply( ), scrub( ), et toString( ). Parce que Detergent est dérivé de Cleanser (à l'aide du mot-clé extends), il récupère automatiquement toutes les méthodes de son interface, même si elles ne sont pas toutes définies explicitement dans Detergent. On peut penser à l'héritage comme à une réutilisation de l'interface.
Comme vu dans scrub( ), il est possible de prendre une méthode qui a été définie dans la classe de base et de la modifier. Dans ce cas, on pourrait vouloir appeler la méthode de la classe de base dans la nouvelle version. Mais à l'intérieur de scrub( ),on ne peut pas simplement appeler scrub( ), puisque cela produirait un appel récursif, ce qui n'est pas ce que l'on veut. Pour résoudre ce problème, Java a le mot-clé super qui réfère à la « superclasse » donc la classe courante hérite. Ainsi l'expression super.scrub( ) appelle la version de la classe de base de la méthode scrub( ).
Quand on hérite, on n'est pas tenu de n'utiliser que les méthodes de la classe de base. On peut également ajouter de nouvelles méthodes à la classe dérivée exactement de la manière dont on met une méthode dans une classe : il suffit de la définir. La méthode foam( ) en est un exemple.
Dans Detergent.main( ) on peut voir que pour un objetDetergent, on peut appeler toutes les méthodes disponibles dans Cleanser aussi bien que dans Detergent (i.e., foam( )).
VI-B-1. Initialiser la classe de base▲
Puisqu'il y a deux classes concernées - la classe de base et la classe dérivée - au lieu d'une seule, il peut être un peu troublant d'essayer d'imaginer l'objet résultant produit par la classe dérivée. De l'extérieur, il semble que la nouvelle classe a la même interface que la classe de base et peut-être quelques méthodes et champs additionnels. Mais l'héritage ne se contente pas simplement de copier l'interface de la classe de base. Quand on crée un objet de la classe dérivée, il contient en lui un sous-objet de la classe de base. Ce sous-objet est le même que si on crée un objet de la classe de base elle-même. C'est simplement que, depuis l'extérieur, le sous-objet de la classe de base est enrobé au sein de l'objet de la classe dérivée.
Bien sûr, il est essentiel que le sous-objet de la classe de base soit correctement initialisé et il y a un seul moyen de garantir cela : exécuter l'initialisation dans le constructeur, en appelant le constructeur de la classe de base, lequel a toutes les connaissances et tous les privilèges appropriés pour exécuter l'initialisation de la classe de base. Java insère automatiquement les appels au constructeur de la classe de base au sein du constructeur de la classe dérivée. L'exemple suivant montre comment cela fonctionne avec 3 niveaux d'héritage :
//: c06:Cartoon.java
// Appels de constructeur durant l'initialisation
import
com.bruceeckel.simpletest.*;
class
Art {
Art
(
) {
System.out.println
(
"Art constructor"
);
}
}
class
Drawing extends
Art {
Drawing
(
) {
System.out.println
(
"Drawing constructor"
);
}
}
public
class
Cartoon extends
Drawing {
private
static
Test monitor =
new
Test
(
);
public
Cartoon
(
) {
System.out.println
(
"Cartoon constructor"
);
}
public
static
void
main
(
String[] args) {
Cartoon x =
new
Cartoon
(
);
monitor.expect
(
new
String[] {
"Art constructor"
,
"Drawing constructor"
,
"Cartoon constructor"
}
);
}
}
///:~
On peut voir que la construction commence par la classe la plus haute dans la hiérarchie, donc la classe de base est initialisée avant que les constructeurs de la classe dérivée puissent y accéder. Même si on ne crée pas de constructeur pour Cartoon( ), le compilateur fournira un constructeur par défaut qui appellera le constructeur de la classe de base.
VI-B-1-a. Constructeurs avec arguments▲
L'exemple ci-dessus a des constructeurs par défaut ; ils n'ont pas de paramètres. C'est facile pour le compilateur d'appeler ceux-ci parce qu'il n'y a pas de questions à se poser au sujet des arguments à passer. Si la classe n'a pas d'arguments par défaut, ou si on veut appeler le constructeur d'une classe de base avec arguments, on doit explicitement écrire les appels au constructeur de la classe de base en utilisant le mot clé super et la liste appropriée de paramètres :
//: c06:Chess.java
// Héritage, constructeurs et arguments.
import
com.bruceeckel.simpletest.*;
class
Game {
Game
(
int
i) {
System.out.println
(
"Game constructor"
);
}
}
class
BoardGame extends
Game {
BoardGame
(
int
i) {
super
(
i);
System.out.println
(
"BoardGame constructor"
);
}
}
public
class
Chess extends
BoardGame {
private
static
Test monitor =
new
Test
(
);
Chess
(
) {
super
(
11
);
System.out.println
(
"Chess constructor"
);
}
public
static
void
main
(
String[] args) {
Chess x =
new
Chess
(
);
monitor.expect
(
new
String[] {
"Game constructor"
,
"BoardGame constructor"
,
"Chess constructor"
}
);
}
}
///:~
Si on n'appelle pas le constructeur de la classe de base dans BoardGame( ), le compilateur va se plaindre qu'il ne peut pas trouver le constructeur de la forme Game( ). De plus, l'appel du constructeur de la classe de base doit être la première chose que l'on fait dans le constructeur de la classe dérivée. (Le compilateur le rappellera si on se trompe).
VI-B-1-b. Attraper les exceptions du constructeur de base▲
Comme nous venons de le préciser, le compilateur nous force à placer l'appel du constructeur de la classe de base en premier dans le constructeur de la classe dérivée. Cela signifie que rien ne peut être placé avant lui. Comme vous le verrez dans le chapitre 9, cela empêche également le constructeur de la classe dérivée d'attraper une exception qui provient de la classe de base. Ceci peut être un inconvénient de temps en temps.
VI-C. Combiner composition et héritage▲
Il est très classique d'utiliser ensemble la composition et l'héritage. L'exemple suivant montre la création d'une classe plus complexe, utilisant à la fois l'héritage et la composition, avec la nécessaire initialisation des constructeurs :
//: c06:PlaceSetting.java
// Mélanger composition & héritage.
import
com.bruceeckel.simpletest.*;
class
Plate {
Plate
(
int
i) {
System.out.println
(
"Plate constructor"
);
}
}
class
DinnerPlate extends
Plate {
DinnerPlate
(
int
i) {
super
(
i);
System.out.println
(
"DinnerPlate constructor"
);
}
}
class
Utensil {
Utensil
(
int
i) {
System.out.println
(
"Utensil constructor"
);
}
}
class
Spoon extends
Utensil {
Spoon
(
int
i) {
super
(
i);
System.out.println
(
"Spoon constructor"
);
}
}
class
Fork extends
Utensil {
Fork
(
int
i) {
super
(
i);
System.out.println
(
"Fork constructor"
);
}
}
class
Knife extends
Utensil {
Knife
(
int
i) {
super
(
i);
System.out.println
(
"Knife constructor"
);
}
}
// Une manière culturelle de faire quelque chose :
class
Custom {
Custom
(
int
i) {
System.out.println
(
"Custom constructor"
);
}
}
public
class
PlaceSetting extends
Custom {
private
static
Test monitor =
new
Test
(
);
private
Spoon sp;
private
Fork frk;
private
Knife kn;
private
DinnerPlate pl;
public
PlaceSetting
(
int
i) {
super
(
i +
1
);
sp =
new
Spoon
(
i +
2
);
frk =
new
Fork
(
i +
3
);
kn =
new
Knife
(
i +
4
);
pl =
new
DinnerPlate
(
i +
5
);
System.out.println
(
"PlaceSetting constructor"
);
}
public
static
void
main
(
String[] args) {
PlaceSetting x =
new
PlaceSetting
(
9
);
monitor.expect
(
new
String[] {
"Custom constructor"
,
"Utensil constructor"
,
"Spoon constructor"
,
"Utensil constructor"
,
"Fork constructor"
,
"Utensil constructor"
,
"Knife constructor"
,
"Plate constructor"
,
"DinnerPlate constructor"
,
"PlaceSetting constructor"
}
);
}
}
///:~
Alors que le compilateur force à initialiser les classes de base, et requiert que nous le fassions directement au début du constructeur, il ne vérifie pas que nous initialisons les objets membres, nous devons donc nous en souvenir et faire attention.
VI-C-1. Garantir un nettoyage propre▲
Java ne possède pas le concept C++ de destructeur, une méthode qui est automatiquement appelée quand un objet est détruit. La raison est probablement qu'en Java la pratique est simplement d'oublier les objets plutôt que les détruire, permettant au ramasse-miettes de réclamer la mémoire selon les besoins.
Souvent cela convient, mais il existe des cas où votre classe pourrait, durant son existence, exécuter des tâches nécessitant un nettoyage. Comme mentionné dans le chapitre 4, on ne peut pas savoir quand le ramasse-miettes sera exécuté, ou s'il sera appelé. Donc si l'on veut nettoyer quelque chose pour une classe, on doit écrire une méthode particulière pour le faire, et être sûr que le programmeur client sait qu'il doit appeler cette méthode. Par-dessus tout - comme décrit dans le chapitre 9 (« Gestion d'erreurs avec les exceptions ») - on doit se protéger contre une exception en mettant un tel nettoyage dans une clause finally.
Considérons un exemple d'un système de conception assistée par ordinateur qui dessine des images à l'écran :
//: c06:CADSystem.java
// Assurer un nettoyage propre.
package
c06;
import
com.bruceeckel.simpletest.*;
import
java.util.*;
class
Shape {
Shape
(
int
i) {
System.out.println
(
"Shape constructor"
);
}
void
dispose
(
) {
System.out.println
(
"Shape dispose"
);
}
}
class
Circle extends
Shape {
Circle
(
int
i) {
super
(
i);
System.out.println
(
"Drawing Circle"
);
}
void
dispose
(
) {
System.out.println
(
"Erasing Circle"
);
super
.dispose
(
);
}
}
class
Triangle extends
Shape {
Triangle
(
int
i) {
super
(
i);
System.out.println
(
"Drawing Triangle"
);
}
void
dispose
(
) {
System.out.println
(
"Erasing Triangle"
);
super
.dispose
(
);
}
}
class
Line extends
Shape {
private
int
start, end;
Line
(
int
start, int
end) {
super
(
start);
this
.start =
start;
this
.end =
end;
System.out.println
(
"Drawing Line: "
+
start+
", "
+
end);
}
void
dispose
(
) {
System.out.println
(
"Erasing Line: "
+
start+
", "
+
end);
super
.dispose
(
);
}
}
public
class
CADSystem extends
Shape {
private
static
Test monitor =
new
Test
(
);
private
Circle c;
private
Triangle t;
private
Line[] lines =
new
Line[5
];
public
CADSystem
(
int
i) {
super
(
i +
1
);
for
(
int
j =
0
; j <
lines.length; j++
)
lines[j] =
new
Line
(
j, j*
j);
c =
new
Circle
(
1
);
t =
new
Triangle
(
1
);
System.out.println
(
"Combined constructor"
);
}
public
void
dispose
(
) {
System.out.println
(
"CADSystem.dispose()"
);
// L'ordre de nettoyage est l'inverse
// de l'ordre d'initialisation
t.dispose
(
);
c.dispose
(
);
for
(
int
i =
lines.length -
1
; i >=
0
; i--
)
lines[i].dispose
(
);
super
.dispose
(
);
}
public
static
void
main
(
String[] args) {
CADSystem x =
new
CADSystem
(
47
);
try
{
// Code et gestion des exceptions...
}
finally
{
x.dispose
(
);
}
monitor.expect
(
new
String[] {
"Shape constructor"
,
"Shape constructor"
,
"Drawing Line: 0, 0"
,
"Shape constructor"
,
"Drawing Line: 1, 1"
,
"Shape constructor"
,
"Drawing Line: 2, 4"
,
"Shape constructor"
,
"Drawing Line: 3, 9"
,
"Shape constructor"
,
"Drawing Line: 4, 16"
,
"Shape constructor"
,
"Drawing Circle"
,
"Shape constructor"
,
"Drawing Triangle"
,
"Combined constructor"
,
"CADSystem.dispose()"
,
"Erasing Triangle"
,
"Shape dispose"
,
"Erasing Circle"
,
"Shape dispose"
,
"Erasing Line: 4, 16"
,
"Shape dispose"
,
"Erasing Line: 3, 9"
,
"Shape dispose"
,
"Erasing Line: 2, 4"
,
"Shape dispose"
,
"Erasing Line: 1, 1"
,
"Shape dispose"
,
"Erasing Line: 0, 0"
,
"Shape dispose"
,
"Shape dispose"
}
);
}
}
///:~
Tout dans ce système est une sorte de Shape (lequel est une sorte d'Object, puisqu'il hérite implicitement de la classe racine). Chaque classe redéfinit la méthode dispose( ) de Shape en plus d'appeler la méthode de la classe de base en utilisant super. Les classes Shape spécifiques - Circle, Triangle, et Line - ont toutes des constructeurs qui « dessinent », bien que n'importe quelle méthode appelée durant la durée de vie d'un objet pourrait être responsable de faire quelque chose qui nécessite un nettoyage. Chaque classe possède sa propre méthode dispose( ) pour restaurer les choses de la manière dont elles étaient avant que l'objet n'existe.
Dans le main( ), on peut noter deux nouveaux mots-clés, et qui ne seront pas introduits avant le chapitre 9 : try et finally. Le mot-clé try indique que le bloc qui suit (délimité par les accolades) est une région gardée, ce qui signifie qu'elle a un traitement particulier. Un de ces traitements particuliers est que le code dans la clause finally suivant la région gardée est toujours exécuté, quelle que soit la manière dont le bloc try termine. (Avec la gestion des exceptions, il est possible de quitter le bloc try de plusieurs manières non ordinaires.) Ici, la clause finally dit « de toujours appeler dispose( ) pour x, quoiqu'il arrive ». Ces mots-clés seront expliqués plus en détail au chapitre 9.
Notez que dans votre méthode de nettoyage, vous devez aussi faire attention à l'ordre d'appel des méthodes de nettoyage pour les classes de base et les objets membres dans le cas ou un des sous-objets dépend d'un autre. En général, vous devriez suivre la même forme qui est imposée pour le compilateur C++ sur ses destructeurs : premièrement, exécuter tout le nettoyage spécifique à votre classe, en ordre inverse de la création. (En général, cela nécessite que les éléments de la classe de base soient encore viables.) Ensuite appeler la méthode de nettoyage de la classe de base, comme montré ici.
Il peut y avoir plusieurs cas pour lesquels la question du nettoyage n'est pas un problème ; on laisse le ramasse-miettes faire le travail. Mais quand on doit le faire explicitement, diligence et attention sont requises, parce que vous ne pouvez pas vous fier les yeux fermés au ramasse-miettes. Le ramasse-miettes pourrait ne jamais être appelé. S'il l'est, il peut réclamer les objets dans l'ordre qu'il veut. Il est meilleur de ne pas s'appuyer sur le ramasse-miettes pour n'importe quoi, mais uniquement pour la récupération de la mémoire. Si vous voulez que le nettoyage ait lieu, faites vous propres méthodes de nettoyage et ne comptez pas sur finalize( ).
VI-C-2. Cacher les noms▲
Si une classe de base Java a un nom de méthode qui est surchargé plusieurs fois, redéfinir ce nom de méthode dans une sous-classe ne cachera aucune des versions de la classe de base (contrairement au C++). Par conséquent la surcharge fonctionne sans se soucier si la méthode était définie à ce niveau ou dans une classe de base :
//: c06:Hide.java
// Surcharge le nom d'une méthode de la classe de base dans une classe dérivée
// ne cache pas les versions de la classe de base.
import
com.bruceeckel.simpletest.*;
class
Homer {
char
doh
(
char
c) {
System.out.println
(
"doh(char)"
);
return
'd'
;
}
float
doh
(
float
f) {
System.out.println
(
"doh(float)"
);
return
1.0
f;
}
}
class
Milhouse {}
class
Bart extends
Homer {
void
doh
(
Milhouse m) {
System.out.println
(
"doh(Milhouse)"
);
}
}
public
class
Hide {
private
static
Test monitor =
new
Test
(
);
public
static
void
main
(
String[] args) {
Bart b =
new
Bart
(
);
b.doh
(
1
);
b.doh
(
'x'
);
b.doh
(
1.0
f);
b.doh
(
new
Milhouse
(
));
monitor.expect
(
new
String[] {
"doh(float)"
,
"doh(char)"
,
"doh(float)"
,
"doh(Milhouse)"
}
);
}
}
///:~
Vous pouvez remarquer que toutes les méthodes surchargées d'Homer sont disponibles pour Bart, bien que Bart introduise une nouvelle méthode surchargée (le faire en C++ cacherait les méthodes de la classe de base). Comme nous le verrons au prochain chapitre, il est beaucoup plus courant de surcharger les méthodes de même nom, utilisant exactement la même signature et le type retour que dans la classe de base. Sinon cela peut être source de confusion (c'est pourquoi le C++ ne le permet pas, pour empêcher de faire ce qui est probablement une erreur).
VI-D. Choisir entre la composition et l'héritage▲
La composition et l'héritage permettent tous les deux de placer des sous-objets à l'intérieur de votre nouvelle classe (la composition le fait explicitement alors que c'est implicite pour l'héritage). Vous devriez vous demander quelle est la différence entre les deux et quand choisir l'une plutôt que l'autre.
La composition est généralement utilisée quand on a besoin des caractéristiques d'une classe existante dans une nouvelle classe, mais pas de son interface. On inclut un objet donc on peut l'utiliser pour implémenter une fonctionnalité dans la nouvelle classe, mais l'utilisateur de la nouvelle classe voit l'interface qu'on a définie et non celle de l'objet inclus. Pour ce faire, il suffit d'inclure des objets private de classes existantes dans la nouvelle classe.
Parfois il est sensé de permettre à l'utilisateur d'une classe d'accéder directement à la composition de notre nouvelle classe ; pour ce faire on déclare les objets membres public. Les objets membres utilisent l'implémentation en se cachant les uns des autres, ce qui est une bonne chose. Quand l'utilisateur sait que l'on regroupe un ensemble de parties, cela rend l'interface plus facile à comprendre. Un objet, car est un bon exemple :
//: c06:Car.java
// Composition avec des objets publics.
class
Engine {
public
void
start
(
) {}
public
void
rev
(
) {}
public
void
stop
(
) {}
}
class
Wheel {
public
void
inflate
(
int
psi) {}
}
class
Window {
public
void
rollup
(
) {}
public
void
rolldown
(
) {}
}
class
Door {
public
Window window =
new
Window
(
);
public
void
open
(
) {}
public
void
close
(
) {}
}
public
class
, Car {
public
Engine engine =
new
Engine
(
);
public
Wheel[] wheel =
new
Wheel[4
];
public
Door
left =
new
Door
(
),
right =
new
Door
(
); // 2-door
public
, Car
(
) {
for
(
int
i =
0
; i <
4
; i++
)
wheel[i] =
new
Wheel
(
);
}
public
static
void
main
(
String[] args) {
Car, car =
new
, Car
(
);
car.left.window.rollup
(
);
car.wheel[0
].inflate
(
72
);
}
}
///:~
Du fait que la composition de la voiture fait partie de l'analyse du problème (et non pas simplement de la conception sous-jacente), rendre les membres publics aide le programmeur client à comprendre comment utiliser la classe et nécessite moins de complexité de code pour le créateur de la classe. Quoi qu'il en soit, gardez à l'esprit que c'est un cas spécial et qu'en général on devrait définir les champs private.
Quand on hérite, on prend la classe existante et on en fait une version spéciale. En général, cela signifie qu'on prend une classe d'usage général et on l'adapte à un besoin particulier. Avec un peu de bon sens, vous verrez que ça n'a pas de sens de composer une voiture en utilisant un objet véhicule, Une voiture ne contient pas un véhicule, c'est un véhicule. La relation est-un s'exprime par l'héritage, et la relation a-un s'exprime par la composition.
VI-E. protected▲
Maintenant que nous avons introduit l'héritage, le mot clé protected prend finalement un sens. Dans un monde idéal, le mot-clé private serait assez. Mais dans les projets réels, il arrive souvent qu'on veuille cacher quelque chose au monde au sens large et qu'on veuille permettre l'accès pour les membres des classes dérivées. Le mot-clé protected est un moyen pragmatique de le faire. Il dit « Ceci est private en ce qui concerne la classe utilisatrice, mais c'est disponible pour quiconque hérite de cette classe ou appartient au même package. » (En Java, protected fournit aussi un accès package.)
La meilleure approche est de laisser les champs private ; vous devriez toujours préserver le droit de changer l'implémentation sous-jacente. Ensuite vous pouvez permettre l'accès contrôlé pour les héritiers de votre classe à travers les méthodes protected :
//: c06:Orc.java
// Le mot clé protected.
import
com.bruceeckel.simpletest.*;
import
java.util.*;
class
Villain {
private
String name;
protected
void
set
(
String nm) {
name =
nm; }
public
Villain
(
String name) {
this
.name =
name; }
public
String toString
(
) {
return
"I'm a Villain and my name is "
+
name;
}
}
public
class
Orc extends
Villain {
private
static
Test monitor =
new
Test
(
);
private
int
orcNumber;
public
Orc
(
String name, int
orcNumber) {
super
(
name);
this
.orcNumber =
orcNumber;
}
public
void
change
(
String name, int
orcNumber) {
set
(
name); // Disponible car il est protected
this
.orcNumber =
orcNumber;
}
public
String toString
(
) {
return
"Orc "
+
orcNumber +
": "
+
super
.toString
(
);
}
public
static
void
main
(
String[] args) {
Orc orc =
new
Orc
(
"Limburger"
, 12
);
System.out.println
(
orc);
orc.change
(
"Bob"
, 19
);
System.out.println
(
orc);
monitor.expect
(
new
String[] {
"Orc 12: I'm a Villain and my name is Limburger"
,
"Orc 19: I'm a Villain and my name is Bob"
}
);
}
}
///:~
On peut voir que change( ) a accès à set( ) parce qu'il est protected. Notez aussi que de cette façon la méthode toString( ) de Orc est définie par la version toString( ) de la classe de base.
VI-F. Développement incrémental▲
Un des avantages de l'héritage est qu'il supporte le développement incrémental. On peut ajouter du code sans créer de bogues dans le code existant ; en fait, on isole les nouveaux bogues dans le nouveau code. En héritant d'une classe fonctionnelle existante et en ajoutant des données membres et des méthodes (et en redéfinissant des méthodes existantes), on laisse le code existant -que quelqu'un d'autre peut encore utiliser - inchangé et non bogué. Si un bogue survient, on sait alors qu'il est dans le nouveau code, lequel est beaucoup plus rapide et facile à lire que si on avait modifié le code existant.
La nette séparation des classes est assez surprenante. On n'a même pas besoin du code source des méthodes pour les réutiliser. Au pire, on importe simplement un package. (Ceci est vrai à la fois pour l'héritage et la composition).
Il est important de réaliser que le développement d'un programme est un processus incrémental, exactement comme l'apprentissage humain. On aura beau faire autant d'analyses que l'on veut, on ne connaîtra jamais toutes les réponses au démarrage d'un projet. On réussira bien mieux - et l'on aura bien plus de retours immédiats - si on commence par faire « grandir » son projet comme un organisme organique et évolutif plutôt que de le construire comme un gratte-ciel en verre.
Bien que l'héritage en vue de tests puisse être une technique utile, à un moment donné les choses se stabilisent et l'on doit jeter un regard neuf sur la hiérarchie des classes pour la réduire à une structure cohérente. Il faut se rappeler qu'à la base l'héritage est fait pour exprimer une relation indiquant que : « Cette nouvelle classe est un spécimen de cette ancienne classe ». Un programme ne devrait pas s'occuper de disséminer des ordres ici ou là, mais plutôt de créer et de manipuler des objets de différents types afin de rendre un modèle en fonction de la problématique posée.
VI-G. Transtypage ascendant▲
L'aspect le plus important de l'héritage n'est pas qu'il fournisse des méthodes pour les nouvelles classes. C'est la relation exprimée entre la nouvelle classe et la classe de base. Cette relation peut être résumée en disant : « La nouvelle classe est un type de la classe existante ».
Cette description n'est pas simplement une manière amusante d'expliquer l'héritage - c'est supporté directement par le langage. Comme exemple, considérons une classe de base appelée Instrument qui représente les instruments de musique, et une classe dérivée appelée Wind. Puisque l'héritage signifie que toutes les méthodes de la classe de base sont également disponibles pour la classe dérivée, n'importe quel message envoyé à la classe de base peut également être envoyé à la classe dérivée. Si la classe Instrument a une méthode play( ), les instruments Wind l'ont également. Cela signifie qu'il est exact de dire qu'un objet Wind est également un type d' Instrument. L'exemple suivant montre comment le compilateur implémente cette notion :
//: c06:Wind.java
// Héritage & transtypage ascendant.
import
java.util.*;
class
Instrument {
public
void
play
(
) {}
static
void
tune
(
Instrument i) {
// ...
i.play
(
);
}
}
// Les objets Wind sont des instruments
// parce qu'ils ont la même interface:
public
class
Wind extends
Instrument {
public
static
void
main
(
String[] args) {
Wind flute =
new
Wind
(
);
Instrument.tune
(
flute); // Transtypage ascendant
}
}
///:~
Ce qui est intéressant dans cet exemple, c'est la méthode tune( ) qui prend en paramètre une référence d'Instrument. Pourtant, dans Wind.main( ) la méthode tune( ) est appelée est passant une référence de Wind. Compte tenu de la particularité de Java au niveau de la vérification des types, il semble étrange qu'une méthode qui accepte un type donné puisse facilement en accepter un autre, jusqu'à ce qu'on réalise qu'un objet de type Wind est également un objet de type Instrument, et qu'il n'y a aucune méthode quetune( ) qui puisse appeler pour un Instrument qui ne soit pas également dans Wind. L'implémentation de tune( ) fonctionne pour un Instrument et n'importe quel sous-type d'Instrument, et le fait de convertir une référence de type Wind vers une référence de type Instrument se nomme le transtypage ascendant.
VI-G-1. Pourquoi le transtypage ascendant ?▲
La raison de ce terme est historique, et basée sur la manière dont les diagrammes d'héritage ont été traditionnellement dessinés : avec la racine au sommet de la page, et grandissant vers le bas. (Bien sûr vous pouvez dessiner vos diagrammes de la manière que vous trouvez le plus pratique). Le diagramme d'héritage pour Wind.java est :
Transtyper depuis une classe dérivée vers la classe de base nous déplace vers le haut dans le diagramme, on fait donc communément référence à un transtypage ascendant. Le transtypage ascendant est toujours sans danger parce qu'on va d'un type plus spécifique vers un type plus général. La classe dérivée est un sur-ensemble de la classe de base. Elle peut contenir plus de méthodes que la classe de base, mais elle contient au moins les méthodes de la classe de base. La seule chose qui puisse arriver à une classe pendant le transtypage ascendant est de perdre des méthodes, et non en gagner. C'est pourquoi le compilateur permet le transtypage ascendant sans transtypage explicite ou une notation spéciale.
On peut également faire l'inverse du transtypage ascendant, appelé transtypage descendant, mais cela génère un dilemme qui est le sujet du chapitre 10.
VI-G-1-a. Composition à la place de l'héritage revisité▲
En programmation orientée objet, la manière la plus probable pour créer et utiliser du code est simplement de mettre des méthodes et des données ensemble dans une classe, puis d'utiliser les objets de cette classe. On utilisera également les classes existantes pour construire les nouvelles classes avec la composition. Moins fréquemment, on utilisera l'héritage. Donc bien qu'on insiste beaucoup sur l'héritage en apprenant la programmation orientée objet, cela ne signifie pas qu'on doive l'utiliser partout où l'on peut. Au contraire, on devrait l'utiliser avec parcimonie, seulement quand il est clair que l'héritage est utile. Un des moyens les plus clairs pour déterminer si on doit utiliser la composition ou l'héritage est de se demander si on n’aura jamais besoin de faire un transtypage ascendant de la nouvelle classe vers la classe de base. Si on doit faire un transtypage ascendant, alors l'héritage est nécessaire, mais si on n'a pas besoin de faire un transtypage ascendant, alors il faut regarder avec attention pour savoir si on a besoin de l'héritage. Le prochain chapitre (sur le polymorphisme) fournit une des plus excitantes raisons pour le transtypage ascendant, mais si vous vous souvenez de vous demander « Ai-je besoin de transtypage ascendant ? », vous aurez un bon outil pour décider entre composition et héritage.
VI-H. Le mot-clé final▲
Le mot-clé Java final a un sens légèrement différent selon le contexte, mais en général il veut dire « Ceci ne peut être changé ». Vous pouvez vouloir empêcher les changements pour deux raisons : conception ou performance. Comme ces deux raisons sont tout à fait différentes, il est possible de mal utiliser le mot-clé final.
Les sections suivantes parlent des trois places où final peut être utilisé: pour des variables, des méthodes et des classes.
VI-H-1. Les variables final▲
Beaucoup de langages de programmation ont un moyen de dire au compilateur que des variables sont « constantes ». Une constante est utile pour deux raisons:
- Ce peut être une constante à la compilation qui ne changera jamais.
- Ce peut être une valeur initialisée lors de l'exécution que vous ne voulez pouvoir être changée.
Dans le cas d'une constante à la compilation, le compilateur est autorisé à « remplacer » la valeur de la constante dans tous les calculs dans lesquels elle est utilisée; ce qui veut dire que les calculs peuvent être faits à la compilation, éliminant du travail à l'exécution. En Java, ces constantes doivent être des primitives et sont exprimées avec le mot-clé final. Une valeur doit être donnée en même temps que la définition de ces constantes.
Un champ qui est en même temps static et final a seulement un seul emplacement de stockage qui ne peut être changé.
Quand on utilise final avec des références d'objet plutôt que des primitives, cela peut devenir un peu confus. Avec une primitive, final rend la valeur constante, mais avec une référence d'objet, final rend la référence constante. Dès que la référence est initialisée à un objet, elle ne peut être changée pour pointer vers un autre objet. Cependant, l'objet lui-même peut être modifié ; Java ne donne aucun moyen pour transformer n'importe quel objet en constante. (Vous pouvez, cependant, écrire votre classe telle que votre objet se comporte comme s'il était une constante). Ces restrictions incluent les tableaux, qui sont aussi des objets.
Voici un exemple qui démontre l'utilisation de champs final :
//: c06:FinalData.java
// Les effets de l'utilisation du mot-clé final sur des champs.
import
com.bruceeckel.simpletest.*;
import
java.util.*;
class
Value {
int
i; // accès de type Package
public
Value
(
int
i) {
this
.i =
i; }
}
public
class
FinalData {
private
static
Test monitor =
new
Test
(
);
private
static
Random rand =
new
Random
(
);
private
String id;
public
FinalData
(
String id) {
this
.id =
id; }
// Peuvent être des constantes à la compilation
private
final
int
VAL_ONE =
9
;
private
static
final
int
VAL_TWO =
99
;
// Constante public standard :
public
static
final
int
VAL_THREE =
39
;
// Ne peuvent être des constantes à la compilation
private
final
int
i4 =
rand.nextInt
(
20
);
static
final
int
i5 =
rand.nextInt
(
20
);
private
Value v1 =
new
Value
(
11
);
private
final
Value v2 =
new
Value
(
22
);
private
static
final
Value v3 =
new
Value
(
33
);
// Tableaux :
private
final
int
[] a =
{
1
, 2
, 3
, 4
, 5
, 6
}
;
public
String toString
(
) {
return
id +
": "
+
"i4 = "
+
i4 +
", i5 = "
+
i5;
}
public
static
void
main
(
String[] args) {
FinalData fd1 =
new
FinalData
(
"fd1"
);
//! fd1.VAL_ONE++; // Erreur: la valeur ne peut être changée
fd1.v2.i++
; // L'objet n'est pas une constante
fd1.v1 =
new
Value
(
9
); // OK -- ce n'est pas final
for
(
int
i =
0
; i <
fd1.a.length; i++
)
fd1.a[i]++
; // L'objet n'est pas une constante
//! fd1.v2 = new Value(0); // Erreur on ne peut changer
//! fd1.v3 = new Value(1); // la référence
//! fd1.a = new int[3];
System.out.println
(
fd1);
System.out.println
(
"Création d'un nouveau FinalData"
);
FinalData fd2 =
new
FinalData
(
"fd2"
);
System.out.println
(
fd1);
System.out.println
(
fd2);
monitor.expect
(
new
String[] {
"%% fd1: i4 =
\\
d+, i5 =
\\
d+"
,
"Création d'un nouveau FinalData"
,
"%% fd1: i4 =
\\
d+, i5 =
\\
d+"
,
"%% fd2: i4 =
\\
d+, i5 =
\\
d+"
}
);
}
}
///:~
Comme VAL_ONE et VAL_TWO sont des primitives final avec des valeurs à la compilation, elles peuvent toutes les deux être utilisées comme des constantes à la compilation et ne sont pas vraiment différentes. VAL_THREE est la manière la plus habituelle que vous verrez pour définir ce type de constantes : public donc elles sont utilisables en dehors du package, static pour insister sur le fait qu'il n'y en ait qu'une, et final pour dire que c'est une constante. Notez que les primitives final static avec des valeurs initiales constantes (ce qui veut dire constante à la compilation) sont nommées intégralement en majuscules par convention, avec des mots séparés par des underscores. (Juste comme les constantes en C, c'est de là que vient cette convention). Notez aussi que i5 ne peut être connue à la compilation, donc elle n'est pas déclarée en majuscule.
Le seul fait que quelque chose soit final ne veut pas dire que sa valeur est connue à la compilation. Ceci est démontré en initialisant i4 et i5 à l'exécution en utilisant des nombres aléatoirement générés. Cette partie de l'exemple montre aussi les différences entre déclarer une valeur final static ou non-static. Cette différence se voit uniquement quand les valeurs sont initialisées à l'exécution, comme les valeurs à la compilation sont traitées de la même manière par le compilateur. (Et on peut présumer, optimisées en dehors de leurs existences). La différence est visible à l'exécution du programme. Notez que les valeurs de i4 pour fd1 et fd2 sont uniques, mais que la valeur pour i5 n'est pas changée par la création du second objet FinalData. Ceci, car static et initialisé une seule fois au chargement et non à chaque création d'objet.
Les variables de v1 à v3 démontrent la signification d'une référence final. Comme vous pouvez le voir dans main( ), le fait que v2 est final ne veut pas dire que vous ne pouvez changer sa valeur. Comme c'est une référence final vous ne pouvez réassigner v2 à un nouvel objet. Vous pouvez aussi voir que la même signification est valable pour un tableau, qui est juste une autre sorte de référence. (Il n'y a aucune façon que je connaisse de rendre les références du tableau elles-mêmes final). Déclarer des références final semble moins utile que déclarer des primitives final.
VI-H-1-a. Final vides▲
Java autorise la création de final vide, qui sont des champs déclarés final mais auxquels on ne donne pas de valeur d'initialisation. Dans tous les cas, les final vides doivent être initialisés avant leurs utilisations, et le compilateur s'en assure. Cependant, les final vides donnent plus de flexibilité dans l'utilisation du mot-clé final comme, par exemple, un champ final à l'intérieur d'une classe qui peut ainsi être différent pour chaque objet, mais qui garde quand même sa qualité immuable. Voici un exemple :
//: c06:BlankFinal.java
// Champs final "vide"
class
Poppet {
private
int
i;
Poppet
(
int
ii) {
i =
ii; }
}
public
class
BlankFinal {
private
final
int
i =
0
; // Initialisation "final"
private
final
int
j; // "Final" vide
private
final
Poppet p; // Référence "final" vide
// Les final vides DOIVENT être initialisés dans le constructeur.
public
BlankFinal
(
) {
j =
1
; // Initilisation d'un final vide
p =
new
Poppet
(
1
); // Initialisation d'une référence final vide
}
public
BlankFinal
(
int
x) {
j =
x; // Initilisation d'un final vide
p =
new
Poppet
(
x); // Initialisation d'une référence final vide
}
public
static
void
main
(
String[] args) {
new
BlankFinal
(
);
new
BlankFinal
(
47
);
}
}
///:~
Vous êtes obligé d'assigner une valeur aux finals soit avec une expression lors de la définition du champ, soit dans tous les constructeurs. Par cela, vous garantissez que le champ final est toujours initialisé avant l'utilisation.
VI-H-1-b. Les arguments final▲
Java vous permet de définir des arguments final en les déclarant comme tels dans la liste des arguments. Cela veut dire qu'à l'intérieur de la méthode, vous ne pouvez changer ce vers quoi la référence de l'argument pointe :
//: c06:FinalArguments.java
// Utilisation de "final" avec les arguments de méthode
class
Gizmo {
public
void
spin
(
) {}
}
public
class
FinalArguments {
void
with
(
final
Gizmo g) {
//! g = new Gizmo(); // Illégal -- g est final
}
void
without
(
Gizmo g) {
g =
new
Gizmo
(
); // OK -- g n'est pas final
g.spin
(
);
}
// void f(final int i) { i++; } // Vous ne pouvez le changer
// Vous ne pouvez que lire depuis une primitive final
int
g
(
final
int
i) {
return
i +
1
; }
public
static
void
main
(
String[] args) {
FinalArguments bf =
new
FinalArguments
(
);
bf.without
(
null
);
bf.with
(
null
);
}
}
///:~
Les méthodes f( ) et g( ) montrent ce qui arrive quand un argument primitif est final : vous pouvez lire l'argument, mais vous ne pouvez le changer. Cette possibilité me semble très peu utile, et n'est probablement pas quelque chose que vous utiliserez.
VI-H-2. Les méthodes final▲
Il y a deux raisons à déclarer des méthodes final. La première est de mettre un verrou sur une méthode pour éviter qu'une classe héritante en change la signification. Ceci est fait pour des raisons de conception quand vous voulez être sûr que le comportement de la méthode est inchangé durant l'héritage et qu'elle ne puisse être redéfinie.
La deuxième raison pour déclarer une méthode final est la performance. Si vous définissez une méthode final, vous autorisez le compilateur à changer tous les appels à cette méthode en des appels en ligne (inline). Quand le compilateur voit un appel à une méthode final, il peut (à sa discrétion), changer l'approche normale d'insertion de code pour utiliser le mécanisme d'appel de méthode (mettre les paramètres dans la pile, aller au code de la méthode et l'exécuter, revenir et supprimer les paramètres de la pile, et gérer la valeur de retour) au lieu de remplacer l'appel de la méthode avec une copie du code actuel dans le corps de la méthode. Ceci élimine le surplus d'exécution d'un appel de méthode. Bien sûr, si une méthode est grosse, alors votre code commence à gonfler, et vous ne verrez probablement aucun gain de performance par rapport à des appels en ligne, à cause du fait que les améliorations seront amoindries par le temps perdu dans la méthode. D'une manière implicite, le compilateur JAVA est capable de détecter ces situations et peut choisir de faire des appels en ligne à une méthode final. Cependant, c'est mieux de laisser le compilateur et la JVM se charger des problèmes d'efficacité et de déclarer une méthode final seulement quand on veut explicitement empêcher sa redéfinition. (31)
VI-H-2-a. final et private▲
Toute méthode private dans une classe est aussi final de manière implicite. Comme vous ne pouvez accéder à une méthode private, vous ne pouvez la redéfinir. Vous pouvez ajouter le modificateur final à toutes vos méthodes private, mais cela n'ajoute rien de significatif à ces méthodes.
Cela peut prêter à confusion, car si vous essayez de redéfinir une méthode private (qui est implicitement final), cela semble marcher, et le compilateur ne donne aucun message d'erreur :
//: c06:FinalOverridingIllusion.java
// On dirait que l'on peut redéfinir
// une méthode private ou private final
import
com.bruceeckel.simpletest.*;
class
WithFinals {
// Identique à "private" tout seul :
private
final
void
f
(
) {
System.out.println
(
"WithFinals.f()"
);
}
// Aussi automatiquement "final" :
private
void
g
(
) {
System.out.println
(
"WithFinals.g()"
);
}
}
class
OverridingPrivate extends
WithFinals {
private
final
void
f
(
) {
System.out.println
(
"OverridingPrivate.f()"
);
}
private
void
g
(
) {
System.out.println
(
"OverridingPrivate.g()"
);
}
}
class
OverridingPrivate2 extends
OverridingPrivate {
public
final
void
f
(
) {
System.out.println
(
"OverridingPrivate2.f()"
);
}
public
void
g
(
) {
System.out.println
(
"OverridingPrivate2.g()"
);
}
}
public
class
FinalOverridingIllusion {
private
static
Test monitor =
new
Test
(
);
public
static
void
main
(
String[] args) {
OverridingPrivate2 op2 =
new
OverridingPrivate2
(
);
op2.f
(
);
op2.g
(
);
// Vous pouvez faire un transtypage ascendant :
OverridingPrivate op =
op2;
//, Mais vous ne pouvez appeler les méthodes :
//! op.f();
//! op.g();
// Pareil ici :
WithFinals wf =
op2;
//! wf.f();
//! wf.g();
monitor.expect
(
new
String[] {
"OverridingPrivate2.f()"
,
"OverridingPrivate2.g()"
}
);
}
}
///:~
On ne peut « redéfinir » que quelque chose qui fait partie de l'interface de base de la classe. Ce qui veut dire que vous devez être capable de faire un transtypage ascendant d'un objet dans son type de base et appeler la même méthode (cela deviendra clair dans le chapitre suivant). Si une méthode est private, elle ne fait pas partie de l'interface de base de la classe. C'est juste du code de la classe de base qui est caché de l'extérieur, et c'est juste une méthode nommée dans la classe de base, mais si vous créez une méthode public, protected ou package-access avec le même nom dans une classe dérivée, il n'y a aucun lien avec la méthode private qui peut avoir le même nom dans la classe de base. Vous n'avez pas redéfini la méthode ; vous avez juste créé une nouvelle méthode. Comme une méthode private est inaccessible et donc invisible, elle n'est prise en compte que pour l'organisation du code de la classe dans laquelle elle est définie.
VI-H-3. Les classes Final▲
Quand vous déclarez une classe entière final (en faisant précéder sa définition par le mot clé final), vous affirmez que vous ne voulez pas pouvoir hériter de votre classe ou que quelqu'un d'autre puisse le faire. En d'autres mots soit pour des raisons de conception votre classe est telle qu'il n'y aura jamais besoin d'y faire aucun changement, soit pour des raisons de sureté et de sécurité vous ne voulez pas qu'elle puisse être sous-classée.
//: c06:Jurassic.java
// Faire une classe entièrement final.
class
SmallBrain {}
final
class
Dinosaur {
int
i =
7
;
int
j =
1
;
SmallBrain x =
new
SmallBrain
(
);
void
f
(
) {}
}
//! class Further extends Dinosaur {}
// erreur: on ne peut étendre la classe final 'Dinosaur'
public
class
Jurassic {
public
static
void
main
(
String[] args) {
Dinosaur n =
new
Dinosaur
(
);
n.f
(
);
n.i =
40
;
n.j++
;
}
}
///:~
Notez que les champs d'une classe final peuvent être final eux-mêmes ou pas, c'est votre choix. Les modificateurs final des champs s'appliquent que votre classe soit final ou pas. Cependant, parce que cela empêche l'héritage, toutes les méthodes d'une classe final sont implicitement final comme il n'y a aucun moyen de les redéfinir. Vous pouvez ajouter le spécificateur final à une méthode d'une classe final, mais cela n'ajoute aucun sens.
VI-H-4. Avertissement sur l'utilisation du mot clé final▲
Cela peut paraître sensible de faire une méthode final quand vous concevez une classe. Vous pourriez penser que personne ne pourrait vouloir réécrire votre méthode. Parfois, ceci est vrai.
Mais soyez prudent avec vos assertions. En général, c'est difficile d'anticiper la manière dont une classe peut être utilisée, et spécialement si votre classe est une classe de base. Si vous définissez une méthode comme final, vous pourriez empêcher la possibilité de réutiliser votre classe par l'héritage dans le projet d'un autre programmeur simplement parce que vous ne pouviez pas imaginer qu'elle serait utilisée d'une certaine manière.
La bibliothèque Java standard est un bon exemple de ceci. En particulier, la classe Vector de Java 1.0/1.1 était utilisée communément et aurait été certainement plus utile si, au nom de l'efficacité (ce qui était certainement une illusion), toutes les méthodes n'avaient pas été faites final. C'est tout à fait concevable de vouloir hériter et réécrire une classe aussi fondamentalement utile, mais les concepteurs ont décidé que ce n'était pas approprié. C'est ironique pour deux raisons. Premièrement, Stack est héritée de Vector, ce qui veut dire qu'une Stack est un Vector, ce qui n'est pas réellement vrai d'un point de vue logique. Deuxièmement, beaucoup des plus importantes méthodes de Vector, telles que addElement() et elementAt(), sont synchronized. Comme vous allez le voir dans le chapitre 11, ceci entraîne une baisse de performance significative qui efface les gains induits par final. Cela rend encore plus crédible la théorie selon laquelle les programmeurs sont constamment mauvais à deviner où les optimisations doivent avoir lieu. C'est vraiment dommage qu'une conception si maladroite existe dans la bibliothèque standard, là où tout le monde doit faire avec. (Heureusement, les nouvelles parties des bibliothèques de Java 2 remplacent Vector par ArrayList, qui se comporte beaucoup plus civilement. Malheureusement, il y a encore beaucoup de nouveau code qui est écrit en utilisant les anciennes parties des bibliothèques.)
C'est aussi intéressant de noter que Hastable, une autre classe importante de la librairie standard de Java 1.0/1.1, n'a aucune méthode final. Comme mentionné quelque part dans ce livre, c'est évident que certaines classes ont été conçues par des gens totalement différents des autres. (Vous verrez que les noms des méthodes de Hashtable sont beaucoup plus courts comparés à ceux de Vector, une autre preuve.) C'est précisément le genre de chose qui n'est pas évident pour l'utilisateur d'une classe d'une bibliothèque. Quand les choses sont inconsistantes, cela donne plus de travail pour l'utilisateur, encore un qui ne croit pas dans la valeur de la conception et de l'écriture de code pas à pas. (Notez que les nouvelles parties des bibliothèques Java 2 remplacent Hashtable par HashMap.)
VI-I. Initialisation et chargement de classes▲
Dans des langages plus traditionnels, les programmes sont chargés en une seule fois dans le cadre de la procédure de démarrage. Ceci est suivi par l'initialisation, et ensuite le programme commence. Le processus d'initialisation dans ces langages doit être contrôlé avec beaucoup d'attention afin que l'ordre d'initialisation des statics ne pose pas de problème. C++, par exemple, a des problèmes si un élément static attend qu'un autre élémentstatic soit valide avant que le second ne soit initialisé.
Java n'a pas ce problème parce qu'il a une autre approche du chargement. Parce que tout en Java est un objet, beaucoup d'actions deviennent plus faciles, et ceci en est un exemple. Comme vous l'apprendrez plus complètement dans le prochain chapitre, le code compilé de chaque classe existe dans son propre fichier séparé. Ce fichier n'est pas chargé tant que ce n'est pas nécessaire. En général, on peut dire que « le code d'une classe est chargé au moment de la première utilisation ». C'est souvent au moment où le premier objet de cette classe est construit, mais le chargement se produit également lorsqu'on accède à un champ static ou une méthode static.
Le point de première utilisation est également là où l'initialisation des statics a lieu. Tous les objets static et le bloc de code static seront initialisés dans l'ordre textuel (l'ordre dans lequel ils sont définis dans la définition de la classe) au moment du chargement. Les statics, bien sûr, ne sont initialisés qu'une seule fois.
VI-I-1. Initialisation avec héritage▲
Il est utile de regarder l'ensemble du processus d'initialisation, incluant l'héritage, pour obtenir une compréhension globale de ce qui se passe. Considérons le code suivant :
//: c06:Beetle.java
// Le processus complet d'initialisation.
import
com.bruceeckel.simpletest.*;
class
Insect {
protected
static
Test monitor =
new
Test
(
);
private
int
i =
9
;
protected
int
j;
Insect
(
) {
System.out.println
(
"i = "
+
i +
", j = "
+
j);
j =
39
;
}
private
static
int
x1 =
print
(
"static Insect.x1 initialized"
);
static
int
print
(
String s) {
System.out.println
(
s);
return
47
;
}
}
public
class
Beetle extends
Insect {
private
int
k =
print
(
"Beetle.k initialized"
);
public
Beetle
(
) {
System.out.println
(
"k = "
+
k);
System.out.println
(
"j = "
+
j);
}
private
static
int
x2 =
print
(
"static Beetle.x2 initialized"
);
public
static
void
main
(
String[] args) {
System.out.println
(
"Beetle constructor"
);
Beetle b =
new
Beetle
(
);
monitor.expect
(
new
String[] {
"static Insect.x1 initialized"
,
"static Beetle.x2 initialized"
,
"Beetle constructor"
,
"i = 9, j = 0"
,
"Beetle.k initialized"
,
"k = 47"
,
"j = 39"
}
);
}
}
///:~
La première chose qui se passe quand on exécute Beetle en Java est qu'on essaye d'accéder à Beetle.main( ) (une méthode static), donc le chargeur cherche et trouve le code compilé pour la classe Beetle (en général dans le fichier appelé Beetle.class). Dans le processus de son chargement, le chargeur remarque qu'elle a une classe de base (c'est ce que le mot-clé extends veut dire), laquelle est alors chargée. Ceci se produit qu'on construise ou non un objet de la classe de base. (Essayez de commenter la création de l'objet pour vous le prouver).
Si la classe de base a elle-même une classe de base, cette seconde classe de base sera à son tour chargée, etc. Ensuite, l'initialisation static dans la classe de base racine (dans ce cas, Insect) est effectuée, ensuite la prochaine classe dérivée, etc. C'est important parce que l'initialisation static de la classe dérivée pourrait dépendre de l'initialisation correcte d'un membre de la classe de base.
À ce point, les classes nécessaires ont été chargées, donc l'objet peut être créé. Premièrement, toutes les primitives dans l'objet sont initialisées à leurs valeurs par défaut et les références objets sont initialisées à null - ceci se produit en une seule passe en mettant la mémoire dans l'objet au zéro binaire. Ensuite le constructeur de la classe de base est appelé. Dans ce cas, l'appel est automatique, mais on peut également spécifier l'appel du constructeur de la classe de base (comme la première opération dans le constructeur Beetle( )) en utilisant super. La construction de la classe de base suit le même processus dans le même ordre que pour celle de la classe dérivée. Lorsque le constructeur de la classe de base a terminé, les variables d'instance sont initialisées dans l'ordre textuel. Finalement le reste du corps du constructeur est exécuté.
VI-J. Résumé▲
L'héritage et la composition permettent tous les deux de créer de nouveaux types à partir de types existants. Cependant, on utilise typiquement la composition pour réutiliser des types existants comme partie de l'implémentation sous-jacente du nouveau type et l'héritage quand on veut réutiliser l'interface. Étant donné que la classe dérivée possède l'interface de la classe de base, on peut faire un transtypage ascendant vers la classe de base, lequel est critique pour le polymorphisme, comme vous le verrez dans le prochain chapitre.
En dépit de l'importance particulièrement forte de l'héritage dans la programmation orientée objet, quand on commence une conception on devrait généralement préférer la composition durant la première passe et utiliser l'héritage seulement quand c'est vraiment nécessaire. La composition tend à être plus flexible. De plus, par le biais de l'héritage, vous pouvez changer le type exact de vos objets, et donc le comportement, de ces objets membres à l'exécution. Par conséquent, on peut changer le comportement d'objets composés à l'exécution.
Lors de la conception d'un système, votre but est de trouver ou de créer un ensemble de classes dans lequel chaque classe a un usage spécifique et n'est ni trop grosse (englobant tellement de fonctionnalités qu'elle n'est pas assez maniable pour être réutilisée) ni trop ennuyeusement petite (on ne peut pas l'utiliser par elle-même ou sans ajouter de nouvelles fonctionnalités).
VI-K. 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 depuis www.BruceEckel.com.
- Créer deux classes, A et B, avec des constructeurs par défaut (liste d'arguments vide) qui s'annoncent eux-mêmes. Faire hériter une nouvelle classe C de A, et créer une classe membre B à l'intérieur de C. Ne pas créer un constructeur pour C. Créer un objet d'une classe C et observer les résultats.
- Modifier l'exercice 1 afin que A et B aient des constructeurs avec arguments au lieu de constructeurs par défaut. Écrire un constructeur pour C et effectuer toutes les initialisations à l'intérieur du constructeur de C.
- Créer une classe simple. À l'intérieur d'une seconde classe, définir une référence à un objet de la première classe. Utiliser l'initialisation paresseuse pour instancier cet objet.
- Hériter une nouvelle classe de la classe Detergent. Redéfinir scrub( ) et ajouter une nouvelle méthode appelée sterilize( ).
- Prendre le fichier Cartoon.java et enlever le commentaire autour du constructeur de la classe Cartoon. Expliquer ce qui arrive.
- Prendre le fichier Chess.java et enlever le commentaire autour du constructeur de la classe Chess. Expliquer ce qui arrive.
- Prouver que des constructeurs par défaut sont créés pour vous par le compilateur.
- Prouver que les constructeurs de la classe de base sont (a) toujours appelés et (b) appelés avant les constructeurs des classes dérivées.
- Créer une classe de base avec seulement un constructeur qui ne soit pas un constructeur par défaut, et une classe dérivée avec à la fois un constructeur par défaut (sans arg) et un deuxième constructeur. Dans les constructeurs de la classe dérivée, appeler le constructeur de la classe de base.
- Créer une classe appelée Root qui contient une instance de chaque classe (également à créer) appelée Component1, Component2, et Component3. Dériver une classe Stem de Root qui contienne également une instance de chaque « component ». Toutes les classes devraient avoir un constructeur par défaut qui affiche un message au sujet de cette classe.
- Modifier l'exercice 10 de sorte que chaque classe ne dispose que de constructeurs qui ne soient pas des constructeurs par défaut.
- Ajouter une hiérarchie propre de méthodes dispose( ) à toutes les classes dans l'exercice 11.
- Créer une classe avec une méthode surchargée trois fois. Créer une nouvelle classe héritée de la précédente, ajouter une nouvelle surcharge de la méthode et montrer que les quatre méthodes sont disponibles dans la classe dérivée.
- Dans Car.java ajouter une méthode service( ) à Engine et appeler cette méthode dans main( ).
- Créer une classe à l'intérieur d'un package. Cette classe doit contenir une méthode protected. À l'extérieur du package, essayer d'appeler la méthode protected et expliquer les résultats. Maintenant hériter de cette classe et appeler la méthode protected depuis l'intérieur d'une méthode de la classe dérivée.
- Créer une classe appelée Amphibian. De celle-ci, hériter une classe appelée Frog. Mettre les méthodes appropriées dans la classe de base. Dans main( ), créer une Frog et faire un transtypage ascendant Amphibian et démontrer que toutes les méthodes fonctionnent encore.
- Modifier l'exercice 16 de manière que Frog surcharge les définitions de méthodes de la classe de base (fournir de nouvelles définitions utilisant les mêmes signatures des méthodes). Noter ce qui se passe dans main( ).
- Créer une classe avec un champ static final et un champ final et démontrer la différence entre les deux.
- Créer une classe avec une référence final sans initialisation vers un objet. Exécuter l'initialisation de cette référence final vide à l'intérieur de tous les constructeurs. Démontrer qu'il est garanti que la référence final doit être initialisée avant de pouvoir être utilisée, et ne peut pas être changée une fois initialisée.
- Créer une classe avec une méthode final. Hériter de cette classe et tenter de redéfinir cette méthode.
- Créer une classe final et tenter d'en hériter.
- Prouver que le chargement d'une classe n'a lieu qu'une fois. Prouver que le chargement peut être causé soit par la création de la première instance de cette classe, soit par l'accès à un membre static.
- Dans Beetle.java, hériter un type spécifique de beetle de la classe Beetle, suivant le même format que les classes existantes. Regarder et expliquer le flux de sortie du programme.