Penser en Java 2nde édition | - | Sommaire | Préface | Avant-propos | Chapitre : 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | Annexe : A B C D | Tables des matières | - | Thinking in Java |
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 10, 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.
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 contructeurs:
Tant que le compilateur nous 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, donc nous devons nous souvenir de faire attention à cela.
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, laissant le ramasse-miette 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 on veut nettoyer quelque chose pour une classe, on doit écrire une méthode particulière, et être sûr que l'usager sait qu'il doit appeler cette méthode. Par dessus tout, comme décrit dans le chapitre 10 (« 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é par ordinateur qui dessine des images sur l'écran:
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 cleanup( ) 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 cleanup( ) pour restaurer les choses de la manière dont elles étaient avant que l'objet n'existe.
main( ), on peut noter deux nouveaux mot-clés qui ne seront pas officiellement introduits avant le chapitre 10 : 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 manière non ordinaire. Ici la clause finally dit de toujours appeler cleanup( ) pour x, quoiqu'il arrive. Ces mot-clés seront expliqués plus en profondeur dans le chapitre 10.
Notez que dans votre méthode cleanup vous devez faire attention à l'ordre d'appel pour les méthodes cleanup de la classe de base et celle des objet-membres au cas où un des sous-objets dépend des autres. 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, dans l'ordre inverse de la création. En général, cela nécessite que les éléments de la classe de base soient encore viable. Ensuite appeler la méthode cleanup de la classe de base, comme démontré ici.
Il y a beaucoup de cas pour lesquels le problème de 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 requis.
Il n'y a pas grand chose sur quoi on puisse se fier quand il s'agit de ramasse-miettes. Le ramasse-miette peut ne jamais être appelé. S'il l'est, il peut réclamer les objets dans n'importe quel ordre. Il est préférable de ne pas se fier au ramasse-miette pour autre chose que libérer la mémoire. Si on veut que le nettoyage ait lieu, faites vos propre méthodes de nettoyage et ne vous fiez pas à finalize( ). Comme mentionné dans le chapitre 4, Java peut être forcé d'appeler tous les finalizers.
Seuls les programmeurs C++ pourraient être surpris par le masquage de noms, étant donné que le fonctionnement est différent dans ce langage. 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. Donc la surcharge fonctionne sans savoir si la méthode était définie à ce niveau ou dans une classe de base:
Comme nous le verrons dans le prochain chapitre, il est beaucoup plus courant de surcharger les méthodes de même nom et 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.
La composition et l'héritage permettent tous les deux de placer des sous-objets à l'intérieur de votre nouvelle classe. 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 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éfini 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 qu'on assemble un ensemble de parties, cela rend l'interface plus facile à comprendre. Un objet car (voiture en anglais) est un bon exemple:
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 membre 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. Quoiqu'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 privés.
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 cas 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 avec l'héritage et la relation a-un s'exprime avec la composition.
Maintenant que nous avons introduit l'héritage, le mot clé protected prend finalement un sens. Dans un monde idéal, les membres private devraient toujours être des membres private purs et durs, 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 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 ». C'est pourquoi protected en Java est automatiquement « friendly ».
La meilleure approche est de laisser les membres de données 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 :
On peut voir que change( ) a accès à set( ) parce qu'il est protected.
Un des avantages de l'héritage est qu'il supporte le développement incrémental en permettant d'ajouter du code sans créer de bogues dans le code existant. Ceci permet également d'isoler les nouveaux bogues dans le nouveau code. En héritant d'une classe existante et fonctionnelle 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.
Il est plutôt surprenant de voir que les classes sont séparées proprement. Nous n'avons même pas besoin du code source des méthodes afin de pouvoir les 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 peut analyser autant que l'on veut, mais on ne connaîtra pas toutes les réponses au démarrage d'un projet. Vous aurez beaucoup plus de succès et de retour immédiat si vous commencez par faire « grandir » votre projet comme un organisme organique et évolutif plutôt que de le construire comme un gratte-ciel en verre.
Bien que l'héritage pour l'expérimentation puisse être une technique utile, à un moment donné les choses se stabilisent et vous devez jeter un regard neuf à votre hiérarchie de classes pour la réduire en une structure plus logique. Souvenons nous qu'en dessous de tout, l'héritage est utilisé pour exprimer une relation qui dit : « Cette nouvelle classe est du type de l'ancienne classe ». Votre programme ne devrait pas être concerné par la manipulation de bits ici ou là, mais par la création et la manipulation d'objets de différents types afin d'exprimer un modèle dans les termes propres à l'espace du problème.
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 également. Cela signifie qu'il est exact de dire qu'un objet Wind est également un type de Instrument. L'exemple suivant montre comment le compilateur implémente cette notion :
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é va facilement en accepter un autre, jusqu'à réaliser qu'un objet de type Wind est également un objet de type Instrument, et qu'il n'y a aucune méthode à appeler par tune( ) pour un Instrument qui n'est 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.