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 |
A : Passage & et Retour d'Objets
Vous devriez maintenant être conscient que lorsque vous « passez » un objet, vous passez en fait une référence sur cet objet.
Presque tous les langages de programmation possèdent une façon « normale » de passer des objets, et la plupart du temps tout se passe bien. Mais il arrive toujours un moment où on doit faire quelque chose d'un peu hors-norme, et alors les choses se compliquent un peu (voire beaucoup dans le cas du C++). Java ne fait pas exception à la règle, et il est important de comprendre exactement les mécanismes du passage d'arguments et de la manipulation des objets passés. Cette annexe fournit des précisions quant à ces mécanismes.
Ou si vous préférez, si vous provenez d'un langage de programmation qui en disposait, cette annexe répond à la question « Est-ce que Java utilise des pointeurs ? ». Nombreux sont ceux qui ont affirmé que les pointeurs sont difficiles à manipuler et dangereux, donc à proscrire, et qu'en tant que langage propre et pur destiné à alléger le fardeau quotidien de la programmation, Java ne pouvait décemment contenir de telles choses. Cependant, il serait plus exact de dire que Java dispose de pointeurs ; en fait, chaque identifiant d'objet en Java (les scalaires exceptés) est un pointeur, mais leur utilisation est restreinte et surveillée non seulement par le compilateur mais aussi par le système d'exécution. Autrement dit, Java utilise les pointeurs, mais pas les pointeurs arithmétiques. C'est ce que j'ai appelé les « références » ; et vous pouvez y penser comme à des « pointeurs sécurisés », un peu comme des ciseaux de cours élémentaire - ils ne sont pas pointus, on ne peut donc se faire mal avec qu'en le cherchant bien, mais ils peuvent être lents et ennuyeux.
Quand on passe une référence à une méthode, on pointe toujours sur le même objet. Un simple test le démontre :
La méthode toString() est automatiquement appelée dans l'instruction print, dont PassReferences hérite directement de Object comme la méthode toString() n'est pas redéfinie. La version toString() de Object est donc utilisée, qui affiche la classe de l'objet suivie de l'adresse mémoire où se trouve l'objet (non pas la référence, mais bien là où est stocké l'objet). La sortie ressemble à ceci :
On peut constater que p et h référencent bien le même objet. Ceci est bien plus efficace que de créer un nouvel objet PassReferences juste pour envoyer un argument à une méthode. Mais ceci amène une importante question.
L'aliasing veut dire que plusieurs références peuvent être attachées au même objet, comme dans l'exemple précédent. Le problème de l'aliasing survient quand quelqu'un modifie cet objet. Si les propriétaires des autres références ne s'attendent pas à ce que l'objet change, ils vont avoir des surprises. Ceci peut être mis en évidence avec un simple exemple :
Dans la ligne :
une nouvelle référence Alias1 est créée, mais au lieu de se voir assigner un nouvel objet créé avec new, elle reçoit une référence existante. Le contenu de la référence x, qui est l'adresse de l'objet sur lequel pointe x, est assigné à y ; et donc x et y sont attachés au même objet. Donc quand on incrémente le i de x dans l'instruction :
le i de y sera modifié lui aussi. On peut le vérifier dans la sortie :
Une bonne solution dans ce cas est tout simplement de ne pas le faire : ne pas aliaser plus d'une référence à un même objet dans la même portée. Le code en sera d'ailleurs plus simple à comprendre et à débugguer. Cependant, quand on passe une référence en argument - de la façon dont Java est supposé le faire - l'aliasing entre automatiquement en jeu, et la référence locale créée peut modifier « l'objet extérieur » (l'objet qui a été créé en dehors de la portée de la méthode). En voici un exemple :
Le résultat est :
La méthode modifie son argument, l'objet extérieur. Dans ce genre de situations, il faut décider si cela a un sens, si l'utilisateur s'y attend, et si cela peut causer des problèmes.
En général, on appelle une méthode afin de produire une valeur de retour et/ou une modification de l'état de l'objet sur lequel est appelée la méthode (une méthode consiste à « envoyer un message » à cet objet). Il est bien moins fréquent d'appeler une méthode afin de modifier ses arguments ; on appelle cela « appeler une méthode pour ses effets de bord ». Une telle méthode qui modifie ses arguments doit être clairement documentée et prévenir à propos de ses surprises potentielles. A cause de la confusion et des chausses-trappes engendrés, il vaut mieux s'abstenir de modifier les arguments.
S'il y a besoin de modifier un argument durant un appel de méthode sans que cela ne se répercute sur l'objet extérieur, alors il faut protéger cet argument en en créant une copie à l'intérieur de la méthode. Cette annexe traite principalement de ce sujet.
En résumé : tous les passages d'arguments en Java se font par référence. C'est à dire que quand on passe « un objet », on ne passe réellement qu'une référence à un objet qui vit en dehors de la méthode ; et si des modifications sont faites sur cette référence, on modifie l'objet extérieur. De plus :
Si on ne fait que lire les informations d'un objet et qu'on ne le modifie pas, la forme la plus efficace de passage d'arguments consiste à passer une référence. C'est bien, car la manière de faire par défaut est aussi la plus efficace. Cependant, on peut avoir besoin de traiter l'objet comme s'il était « local » afin que les modifications apportées n'affectent qu'une copie locale et ne modifient pas l'objet extérieur. De nombreux langages proposent de créer automatiquement une copie locale de l'objet extérieur, à l'intérieur de la méthode href="#fn79" name="fnB79">[79]. Java ne dispose pas de cette fonctionnalité, mais il permet tout de même de mettre en oeuvre cet effet.
Ceci nous amène à discuter terminologie, ce qui est toujours bon dans un débat. Le sens de l'expression « passage par valeur » dépend de la perception qu'on a du fonctionnement du programme. Le sens général est qu'on récupère une copie locale de ce qu'on passe, mais cela est tempéré par notre façon de penser à propos de ce qu'on passe. Deux camps bien distincts s'affrontent quant au sens de « passage par valeur » :
Après avoir présenté les deux camps et précisé que « cela dépend de la façon dont on considère une référence », je vais tenter de mettre le problème de côté. En fin de compte, ce n'est pas si important que cela - ce qui est important, c'est de comprendre que passer une référence permet de modifier l'objet passé en argument.
La raison la plus courante de créer une copie locale d'un objet est qu'on veut modifier cet objet sans impacter l'objet de l'appelant. Si on décide de créer une copie locale, la méthode clone() permet de réaliser cette opération. C'est une méthode définie comme protected dans la classe de base Object, et qu'il faut redéfinir comme public dans les classes dérivées qu'on veut cloner. Par exemple, la classe ArrayList de la bibliothèque standard redéfinit clone(),on peut donc appeler clone() sur une ArrayList :
La méthode clone() produit un Object, qu'il faut alors retranstyper dans le bon type. Cet exemple montre que la méthode clone() de ArrayList n'essaie pas de cloner chacun des objets que l'ArrayList contient - l'ancienne ArrayList et l'ArrayList clonée référencent les mêmes objets. On appelle souvent cela une copie superficielle, puisque seule est copiée la « surface » d'un objet. L'objet réel est en réalité constitué de cette « surface », plus les objets sur lesquels les références pointent, plus tous les objets sur lesquels ces objets pointent, etc... On s'y réfère souvent en parlant de « réseau d'objets ». On appelle copie profonde le fait de copier la totalité de ce fouillis.
On peut voir les effets de la copie superficielle dans la sortie, où les actions réalisées sur v2 affectent v :
Ne pas essayer d'appeler clone() sur les objets contenus dans l'ArrayList est vraisemblablement une hypothèse raisonnable, car rien ne garantit que ces objets sont cloneables [80].
Bien que le méthode clone soit définie dans la classe Object, base de toutes les classes, le clonage n'est pas disponible dans toutes les classes [81]. Cela semble contraire à l'idée que les méthodes de la classe de base sont toujours disponibles dans les classes dérivées. Le clonage dans Java va contre cette idée ; si on veut le rendre disponible dans une classe, il faut explicitement ajouter du code pour que le clonage fonctionne.
Afin d'éviter de rendre chaque classe qu'on crée cloneable par défaut, la méthode clone() est protected dans la classe de base Object. Cela signifie non seulement qu'elle n'est pas disponible par défaut pour le programmeur client qui ne fait qu'utiliser la classe (sans en hériter), mais cela veut aussi dire qu'on ne peut pas appeler clone() via une référence à la classe de base (bien que cela puisse être utile dans certaines situations, comme le clonage polymorphique d'un ensemble d'Objects). C'est donc une manière de signaler, lors de la compilation, que l'objet n'est pas cloneable - et bizarrement, la plupart des classes de la bibliothèque standard Java ne le sont pas. Donc, si on écrit :
On aura un message d'erreur lors de la compilation disant que clone() n'est pas accessible (puisque Integer ne la redéfinit pas et qu'elle se réfère donc à la version protected).
Si, par contre, on se trouve dans une classe dérivée d'Object (comme le sont toutes les classes), alors on a la permission d'appeler Object.clone() car elle est protected et qu'on est un héritier. La méthode clone() de la classe de base fonctionne - elle duplique effectivement bit à bit l'objet de la classe dérivée, réalisant une opération de clonage classique. Cependant, il faut tout de même rendre sa propre méthode de clonage public pour la rendre accessible. Donc, les deux points capitaux quand on clone sont :
On voudra probablement redéfinir clone() dans de futures classes dérivées, sans quoi le clone() (maintenant public) de la classe actuelle sera utilisé, et pourrait ne pas marcher (cependant, puisque Object.clone() crée une copie de l'objet, ça pourrait marcher). L'astuce protected ne marche qu'une fois - la première fois qu'on crée une classe dont on veut qu'elle soit cloneable héritant d'une classe qui ne l'est pas. Dans chaque classe dérivée de cette classe la méthode clone() sera accessible puisqu'il n'est pas possible en Java de réduire l'accès à une méthode durant la dérivation. C'est à dire qu'une fois qu'une classe est cloneable, tout ce qui en est dérivé est cloneable à moins d'utiliser les mécanismes (décrits ci-après) pour « empêcher » le clonage.
Il y a une dernière chose à faire pour rendre un objet cloneable : implémenter l'interface Clonable. Cette interface est un peu spéciale, car elle est vide !
La raison d'implémenter cette interface vide n'est évidemment pas parce qu'on va surtyper jusqu'à Cloneable et appeler une de ses méthodes. L'utilisation d'interface dans ce contexte est considérée par certains comme une « astuce » car on utilise une de ses fonctionnalités dans un but autre que celui auquel on pensait originellement. Implémenter l'interface Cloneable agit comme une sorte de flag, codé en dur dans le type de la classe.
L'interface Cloneable existe pour deux raisons. Premièrement, on peut avoir une référence transtypée à un type de base et ne pas savoir s'il est possible de cloner cet objet. Dans ce cas, on peut utiliser le mot-clef instanceof (décrit au chapitre 12) pour savoir si la référence est connectée à un objet qui peut être cloné :
La deuxième raison en est qu'on ne veut pas forcément que tous les types d'objets soient cloneables. Donc Object.clone() vérifie qu'une classe implémente l'interface Cloneable, et si ce n'est pas le cas, elle génère une exception CloneNotSupportedException. Donc en général, on est forcé d'implémenter Cloneable comme partie du mécanisme de clonage.