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

  Chapitre 6 -  Réutiliser les classes

pages : 1 2 3 4 

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.

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

// ! c06:PlaceSetting.java
// Mélanger composition & héritage.

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 {
  Spoon sp;
  Fork frk;
  Knife kn;
  DinnerPlate pl;
  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);
  }
} ///:~

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.

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

// ! c06:CADSystem.java
// Assurer un nettoyage propre.
import java.util.*;

class Shape {
  Shape(int i) {
    System.out.println("Shape constructor");
  }
  void cleanup() {
    System.out.println("Shape cleanup");
  }
}

class Circle extends Shape {
  Circle(int i) {
    super(i);
    System.out.println("Drawing a Circle");
  }
  void cleanup() {
    System.out.println("Erasing a Circle");
    super.cleanup();
  }
}

class Triangle extends Shape {
  Triangle(int i) {
    super(i);
    System.out.println("Drawing a Triangle");
  }
  void cleanup() {
    System.out.println("Erasing a Triangle");
    super.cleanup();
  }
}

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 a Line : " +
           start + ", " + end);
  }
  void cleanup() {
    System.out.println("Erasing a Line : " +
           start + ", " + end);
    super.cleanup();
  }
}

public class CADSystem extends Shape {
  private Circle c;
  private Triangle t;
  private Line[] lines = new Line[10];
  CADSystem(int i) {
    super(i + 1);
    for(int j = 0; j < 10; j++)
      lines[j] = new Line(j, j*j);
    c = new Circle(1);
    t = new Triangle(1);
    System.out.println("Combined constructor");
  }
  void cleanup() {
    System.out.println("CADSystem.cleanup()");
    // L'ordre de nettoyage est l'inverse
    // de l'ordre d'initialisation
    t.cleanup();
    c.cleanup();
    for(int i = lines.length - 1; i >= 0; i--)
      lines[i].cleanup();
    super.cleanup();
  }
  public static void main(String[] args) {
    CADSystem x = new CADSystem(47);
    try {
      // Code et gestion des exceptions...
    } finally {
      x.cleanup();
    }
  }
} ///:~

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.

L'ordre du ramasse-miettes

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.

Cacher les noms

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:

// ! c06:Hide.java
// Surchage 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.

class Homer {
  char doh(char c) {
    System.out.println("doh(char)");
    return 'd';
  }
  float doh(float f) {
    System.out.println("doh(float)");
    return 1.0f;
  }
}

class Milhouse {}

class Bart extends Homer {
  void doh(Milhouse m) {}
}

class Hide {
  public static void main(String[] args) {
    Bart b = new Bart();
    b.doh(1); // doh(float) utilisé
    b.doh('x');
    b.doh(1.0f);
    b.doh(new Milhouse());
  }
} ///:~

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.

Choisir la composition à la place de 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. 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:

// ! 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 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.

protected

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 :

// ! c06:Orc.java
// Le mot clé protected .
import java.util.*;

class Villain {
  private int i;
  protected int read() { return i; }
  protected void set(int ii) { i = ii; }
  public Villain(int ii) { i = ii; }
  public int value(int m) { return m*i; }
}

public class Orc extends Villain {
  private int j;
  public Orc(int jj) { super(jj); j = jj; }
  public void change(int x) { set(x); }
} ///:~

On peut voir que change( ) a accès à set( ) parce qu'il est protected.

Développement incrémental

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.

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

// ! 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:
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é 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.

Ce livre a été écrit par Bruce Eckel ( télécharger la version anglaise : Thinking in java )
Ce chapitre a été traduit par Olivier Thomann ( 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 
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