IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)
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

pages : 1 2 3 4 5 6 

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.

Passage de références

Quand on passe une référence à une méthode, on pointe toujours sur le même objet. Un simple test le démontre :

//: appendixa:PassReferences.java
// Le passage de références.

public class PassReferences {
  static void f(PassReferences h) {
    System.out.println("h inside f(): " + h);
  }
  public static void main(String[] args) {
    PassReferences p = new PassReferences();
    System.out.println("p inside main(): " + p);
    f(p);
  }
} ///:~

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 :

p inside main(): PassReferences@1653748
h inside f(): PassReferences@1653748

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.

Aliasing

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 :

//: appendixa:Alias1.java
// Aliasing : deux références sur un même objet.

public class Alias1 {
  int i;
  Alias1(int ii) { i = ii; }
  public static void main(String[] args) {
    Alias1 x = new Alias1(7);
    Alias1 y = x; // Assigne la référence.
    System.out.println("x: " + x.i);
    System.out.println("y: " + y.i);
    System.out.println("Incrementing x");
    x.i++;
    System.out.println("x: " + x.i);
    System.out.println("y: " + y.i);
  }
} ///:~

Dans la ligne :

Alias1 y = x; // Assigne la référence.

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 :

x.i++

le i de y sera modifié lui aussi. On peut le vérifier dans la sortie :

x: 7
y: 7
Incrementing x
x: 8
y: 8

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 :

//: appendixa:Alias2.java
// Les appels de méthodes aliasent implicitement
// leurs arguments.

public class Alias2 {
  int i;
  Alias2(int ii) { i = ii; }
  static void f(Alias2 reference) {
    reference.i++;
  }
  public static void main(String[] args) {
    Alias2 x = new Alias2(7);
    System.out.println("x: " + x.i);
    System.out.println("Calling f(x)");
    f(x);
    System.out.println("x: " + x.i);
  }
} ///:~

Le résultat est :

x: 7
Calling f(x)
x: 8

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.

Création de copies locales

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 :

  • l'aliasing survient automatiquement durant le passage d'arguments ;
  • il n'y a pas d'objets locaux, que des références locales ;
  • les références ont une portée, les objets non ;
  • la durée de vie d'un objet n'est jamais un problème en Java ;
  • le langage ne fournit pas d'aide (tel que « const ») pour éviter qu'un objet ne soit modifié (c'est à dire pour se prémunir contre les effets négatifs de l'aliasing).

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.

Passage par valeur

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 » :

  1. Java passe tout par valeur. Quand on passe un scalaire à une méthode, on obtient une copie distincte de ce scalaire. Quand on passe une référence à une méthode, on obtient une copie de la référence. Ainsi, tous les passages d'arguments se font par valeur. Bien sûr, cela suppose qu'on raisonne en terme de références, mais Java a justement été conçu afin de vous permettre d'ignorer (la plupart du temps) que vous travaillez avec une référence. C'est à dire qu'il permet d'assimiler la référence à « l'objet », car il la déréférence automatiquement lorsqu'on fait un appel à une méthode.
  2. Java passe les scalaires par valeur (pas de contestations sur ce point), mais les objets sont passés par référence. La référence est considérée comme un alias sur l'objet ; on ne pense donc pas passer une référence, mais on se dit plutôt « je passe l'objet ». Comme on n'obtient pas une copie locale de l'objet quand il est passé à une méthode, il est clair que les objets ne sont pas passés par valeur. Sun semble plutôt soutenir ce point de vue, puisque l'un des mot-clefs « réservés mais non implémentés » est byvalue (bien que rien ne précise si ce mot-clef verra le jour).

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.

Clonage d'objets

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 :

//: appendixa:Cloning.java
// L'opération clone() ne marche que pour quelques
// composants de la bibliothèque Java standard.
import java.util.*;

class Int {
  private int i;
  public Int(int ii) { i = ii; }
  public void increment() { i++; }
  public String toString() {
    return Integer.toString(i);
  }
}

public class Cloning {
  public static void main(String[] args) {
    ArrayList v = new ArrayList();
    for(int i = 0; i < 10; i++ )
      v.add(new Int(i));
    System.out.println("v: " + v);
    ArrayList v2 = (ArrayList)v.clone();
    // Incrémente tous les éléments de v2 :
    for(Iterator e = v2.iterator();
        e.hasNext(); )
      ((Int)e.next()).increment();
    // Vérifie si les éléments de v ont été modifiés :
    System.out.println("v: " + v);
  }
} ///:~

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 :

v: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
v: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

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].

Rendre une classe cloneable

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.

Utilisation d'une astuce avec protected

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 :

   Integer x = new Integer(1);
    x = x.clone();

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 :

  • Toujours appeler super.clone()
  • Rendre sa méthode clone public

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.

Implémenter l'interface Cloneable

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 !

interface Cloneable {}

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é :

if(myReference instanceof Cloneable) // ...

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.

Ce livre a été écrit par Bruce Eckel ( télécharger la version anglaise : Thinking in java )
Ce chapitre a été traduit par Jérome Quelin ( groupe de traduction )
télécharger la version francaise (PDF) | Commandez le livre en version anglaise (amazon) | télécharger la version anglaise
pages : 1 2 3 4 5 6 
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