IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Thinking in Java, 3rd ed. Revision 4.0


précédentsommairesuivant

IV. Initialisation et nettoyage

Au fur et à mesure de l'avancement de la révolution informatique, la programmation « sans filet de sécurité » est devenue l'une des causes majeures rendant la programmation coûteuse.

Deux de ces problèmes de sécurité sont l'initialisation et le nettoyage. Beaucoup de bogues en C arrivent lorsque le programmeur oublie d'initialiser une variable. Ceci est particulièrement vrai avec les bibliothèques tierces lorsque les utilisateurs ne savent pas comment ou même s'ils doivent initialiser un composant de la bibliothèque. Le nettoyage est un problème spécial, car il est facile de ne plus penser à un élément dont on ne se sert plus, car justement il ne nous intéresse plus. Dans ce cas, les ressources utilisées par cet élément sont conservées et vous pouvez facilement arriver à court de ressources (en particulier, de mémoire).

Le C++ introduit le concept de constructeur, une méthode spéciale qui est automatiquement appelée lorsqu'un objet est créé. Java a aussi adopté le constructeur et a de plus un ramasse-miettes qui libère automatiquement les ressources mémoire quand elles ne sont plus utilisées. Ce chapitre examine les problèmes de l'initialisation et du nettoyage, ainsi que leur support en Java.

IV-A. Garantie de l'initialisation avec le constructeur

On pourrait imaginer créer une méthode appelée initialise( ) pour chaque classe. Le nom inciterait cette méthode à être appelée avant d'utiliser l'objet. Malheureusement, cela signifierait que l'utilisateur doive se souvenir d'appeler explicitement la méthode. En Java, le concepteur d'une classe peut garantir l'initialisation de chaque objet en fournissant une méthode spéciale appelée un constructeur. Si une classe a un constructeur, Java appelle automatiquement le constructeur lors de la création de l'objet, avant même que les utilisateurs puissent le manipuler. Ainsi, une initialisation correcte est garantie.

La tâche suivante est de nommer cette méthode. Il y a deux problèmes. Le premier est que tout nom choisi pourrait entrer en conflit avec un nom que l'on aimerait utiliser pour un membre de la classe. Le second est que le compilateur doit toujours connaître ce nom puisqu'il est responsable de l'appel du constructeur. La solution adoptée en C++ semble la plus simple et la plus logique et c'est donc la même qui est utilisée en Java : le nom du constructeur est identique au nom de la classe. Il semble logique qu'une telle méthode soit appelée automatiquement à l'initialisation.

Voici une classe simple avec un constructeur :

 
Sélectionnez
//: c04:SimpleConstructor.java
// Démonstration d'un constructeur simple.
import com.bruceeckel.simpletest.*;

class Rock {
    Rock() { // Ceci est un constructeur
        System.out.println("Creating Rock");
    }
}

public class SimpleConstructor {
    static Test monitor = new Test();
    public static void main(String[] args) {
        for(int i = 0; i < 10; i++)
            new Rock();
        monitor.expect(new String[] {
            "Creating Rock",
            "Creating Rock",
            "Creating Rock",
            "Creating Rock",
            "Creating Rock",
            "Creating Rock",
            "Creating Rock",
            "Creating Rock",
            "Creating Rock",
            "Creating Rock"
        });
    }
} ///:~

Maintenant, lorsqu'un objet est créé :

 
Sélectionnez
new Rock();

l'emplacement mémoire est alloué et le constructeur est appelé. Cela garantit que l'objet sera proprement initialisé avant d'être manipulé par l'utilisateur.

Remarquez que la convention de nommage qui impose d'utiliser une lettre minuscule au début du nom d'une méthode ne s'applique pas aux constructeurs, puisque le nom du constructeur doit être exactement identique à celui de la classe.

Comme toute méthode, le constructeur peut avoir des arguments qui permettent de spécifier comment un objet sera créé. L'exemple précédent peut être facilement modifié afin que le constructeur prenne un argument :

 
Sélectionnez
//: c04:SimpleConstructor2.java
// // Les constructeurs peuvent avoir des paramètres.
import com.bruceeckel.simpletest.*;

class Rock2 {
    Rock2(int i) {
        System.out.println("Creating Rock number " + i);
    }
}

public class SimpleConstructor2 {
    static Test monitor = new Test();
    public static void main(String[] args) {
        for(int i = 0; i < 10; i++)
            new Rock2(i);
        monitor.expect(new String[] {
            "Creating Rock number 0",
            "Creating Rock number 1",
            "Creating Rock number 2",
            "Creating Rock number 3",
            "Creating Rock number 4",
            "Creating Rock number 5",
            "Creating Rock number 6",
            "Creating Rock number 7",
            "Creating Rock number 8",
            "Creating Rock number 9"
        });
    }
} ///:~

Les arguments du constructeur fournissent une manière de paramétrer l'initialisation d'un objet. Par exemple, si la classe Tree (arbre) a un constructeur avec comme argument un entier représentant la hauteur de l'arbre, on crée un objet Tree de la manière suivante :

 
Sélectionnez
Tree t = new Tree(12);  // // arbre de 12 pieds

Si Tree(int) est le seul constructeur, le compilateur ne permettra pas la création d'un Tree d'une autre façon.

Les constructeurs éliminent une grande famille de problèmes et rendent le code plus facile à lire. Par exemple, dans le fragment de code précédant, on ne voit pas d'appel explicite à une méthode initialise() qui serait conceptuellement séparée de la création. En Java, la création et l'initialisation sont des concepts unifiés, on ne peut avoir l'un sans l'autre.

Le constructeur est un type de méthode inhabituel puisqu’il n'a pas de valeur retour. Cela n'a absolument rien à voir avec le type de retour void, qui signifie qu'une méthode ne renvoie rien, mais qu'il aurait tout à fait été possible de lui faire renvoyer autre chose. Les constructeurs ne renvoient rien et ceci ne peut être modifié (l'expression new renvoie une référence au nouvel objet créé, mais le constructeur en lui-même n'a pas de valeur retour). S'il y avait une valeur retour pour un constructeur et que celle-ci peut être choisie, le compilateur devrait avoir une manière de savoir ce qu'il faut faire de cette valeur retour.

IV-B. La surcharge de méthodes

Une des caractéristiques importantes de tout langage de programmation est l'usage des noms. Lorsque l'on crée un objet, on donne un nom à un emplacement de stockage. Une méthode est un nom pour une action. En utilisant des noms pour décrire un système, on crée un programme qui est plus facile pour un humain de comprendre et de changer. C'est comme écrire de la prose : le but est de communiquer avec les lecteurs.

On réfère à tout objet et méthode en utilisant des noms. Des noms bien choisis rendent plus facile à la compréhension de votre code pour vous et les aux autres .

Un problème arrive lorsque l'on veut faire passer les nuances du langage humain dans un langage de programmation. Souvent, le même mot a différentes significations : il est surchargé. Ceci est utile, particulièrement lorsque les différences de signification sont triviales. On dit « lave le tee-shirt », « lave l'auto » et « lave le chien ». Il serait idiot d'être obligé de dire « tee-shirtLave le tee-shirt », « autoLave l'auto » et « chienLave le chien », simplement parce que l'auditeur n'a pas besoin de différencier l'action accomplie. Beaucoup de langages humains sont redondants, donc même si quelques mots manquent, on arrive quand même à déterminer le sens. On n'a pas besoin d'un identifiant unique : on peut déduire le sens du contexte.

Beaucoup de langages de programmation (en particulier le C) exigent d'avoir un identifiant unique pour chaque fonction. Ainsi, on ne peut pas avoir une fonction appelée print( ) pour imprimer des entiers et une autre appelée print( ) pour imprimer des réels : chaque fonction nécessite un nom unique.

En Java (et en C++), un autre facteur force la surcharge d'un nom des méthodes : le constructeur. En effet, le nom du constructeur étant prédéterminé par le nom de la classe, il ne peut y avoir qu'un seul nom de constructeur. Mais que faire si l'on souhaite créer un objet de différentes manières ? Par exemple, supposons que l'on définisse une classe qui puisse s'initialiser elle-même d'une manière standard ou à partir d'informations issues d'un fichier. Dans ce cas, on a besoin de deux constructeurs, un qui n'a pas d'arguments (le constructeur par défaut, (19) aussi appelé constructeur sans arguments) et un qui prend comme argument un String qui est le nom du fichier à partir duquel on initialise l'objet. Tous deux sont des constructeurs, donc ils ont le même nom qui est celui de la classe. Donc, la surcharge de méthode est essentielle et permet d'avoir le même nom de méthode utilisée avec différents types d'argument. Et bien que la surcharge de méthode soit un must pour les constructeurs, c'est un atout général qui peut être utilisé avec n'importe quelle méthode.

Voici un exemple qui montre à la fois des constructeurs surchargés et des méthodes ordinaires surchargées :

 
Sélectionnez
//: c04:Overloading.java
// Exemple de surcharge de constructeur
// et de méthode ordinaire.
import com.bruceeckel.simpletest.*;
import java.util.*;

class Tree {
    int height;
    Tree() {
    System.out.println("Planting a seedling");
    height = 0;
    }
    Tree(int i) {
    System.out.println("Creating new Tree that is "
        + i + " feet tall");
    height = i;
    }
    void info() {
    System.out.println("Tree is " + height + " feet tall");
    }
    void info(String s) {
    System.out.println(s + ": Tree is "
        + height + " feet tall");
    }
}

public class Overloading {
    static Test monitor = new Test();
    public static void main(String[] args) {
    for(int i = 0; i < 5; i++) {
        Tree t = new Tree(i);
        t.info();
        t.info("overloaded method");
    }
    // Overloaded constructor:
    new Tree();
    monitor.expect(new String[] {
        "Creating new Tree that is 0 feet tall",
        "Tree is 0 feet tall",
        "overloaded method: Tree is 0 feet tall",
        "Creating new Tree that is 1 feet tall",
        "Tree is 1 feet tall",
        "overloaded method: Tree is 1 feet tall",
        "Creating new Tree that is 2 feet tall",
        "Tree is 2 feet tall",
        "overloaded method: Tree is 2 feet tall",
        "Creating new Tree that is 3 feet tall",
        "Tree is 3 feet tall",
        "overloaded method: Tree is 3 feet tall",
        "Creating new Tree that is 4 feet tall",
        "Tree is 4 feet tall",
        "overloaded method: Tree is 4 feet tall",
        "Planting a seedling"
    });
    }
} ///:~

Un objet Tree (arbre) peut être créé comme une graine, sans argument, ou comme une plante développée sous serre, avec une hauteur. Pour permettre ceci, il y a le constructeur par défaut et un constructeur qui prend comme argument la hauteur.

De même, on peut souhaiter appeler une méthode info( ) de plusieurs manières. Par exemple, si on a un message supplémentaire que l'on veut imprimer, on peut utiliser la méthode info(String) et si on n'a pas de message supplémentaire, on peut utiliser la méthode info( ). Il semblerait étrange de donner deux noms différents à ce qui est de manière évidente le même concept. Heureusement, la surcharge de méthode permet d'utiliser le même nom pour les deux méthodes.

IV-B-1. Différencier les méthodes surchargées

Quand deux méthodes ont le même nom, comment Java peut-il décider quelle méthode est demandée ? Il y a une règle toute simple : chaque méthode surchargée doit prendre une liste unique de types de paramètres.

Si vous y pensez pendant une seconde, cela est sensé : comment le développeur lui-même pourrait-il choisir entre deux méthodes du même nom, autrement que par le type des paramètres ?

Une différence dans l'ordre des arguments est suffisante pour distinguer deux méthodes (cette approche n'est généralement pas utilisée, car elle donne du code difficilement maintenable).

 
Sélectionnez
//: c04:OverloadingOrder.java
// Surcharge basée sur l'ordre des arguments.
import com.bruceeckel.simpletest.*;

public class OverloadingOrder {
    static Test monitor = new Test();
    static void print(String s, int i) {
        System.out.println("String: " + s + ", int: " + i);
    }
    static void print(int i, String s) {
        System.out.println("int: " + i + ", String: " + s);
    }
    public static void main(String[] args) {
        print("String first", 11);
        print(99, "Int first");
        monitor.expect(new String[] {
            "String: String first, int: 11",
            "int: 99, String: Int first"
        });
    }
} ///:~

Les deux méthodes print( ) ont les mêmes arguments, mais dans un ordre différent, et c'est ce qui les rend distinctes.

IV-B-2. Surcharge avec des types primitifs

Un type primitif peut être promu automatiquement d'un type plus petit vers un type plus grand et cela peut être un peu déconcertant lorsque ceci est combiné avec la surcharge. L'exemple suivant montre ce qui arrive lorsqu'un type primitif est passé à une méthode surchargée :

 
Sélectionnez
//: c04:PrimitiveOverloading.java
// Promotion of primitives and overloading.
import com.bruceeckel.simpletest.*;

public class PrimitiveOverloading {
    static Test monitor = new Test();
    void f1(char x) { System.out.println("f1(char)"); }
    void f1(byte x) { System.out.println("f1(byte)"); }
    void f1(short x) { System.out.println("f1(short)"); }
    void f1(int x) { System.out.println("f1(int)"); }
    void f1(long x) { System.out.println("f1(long)"); }
    void f1(float x) { System.out.println("f1(float)"); }
    void f1(double x) { System.out.println("f1(double)"); }
    
    void f2(byte x) { System.out.println("f2(byte)"); }
    void f2(short x) { System.out.println("f2(short)"); }
    void f2(int x) { System.out.println("f2(int)"); }
    void f2(long x) { System.out.println("f2(long)"); }
    void f2(float x) { System.out.println("f2(float)"); }
    void f2(double x) { System.out.println("f2(double)"); }
    
    void f3(short x) { System.out.println("f3(short)"); }
    void f3(int x) { System.out.println("f3(int)"); }
    void f3(long x) { System.out.println("f3(long)"); }
    void f3(float x) { System.out.println("f3(float)"); }
    void f3(double x) { System.out.println("f3(double)"); }
    
    void f4(int x) { System.out.println("f4(int)"); }
    void f4(long x) { System.out.println("f4(long)"); }
    void f4(float x) { System.out.println("f4(float)"); }
    void f4(double x) { System.out.println("f4(double)"); }
    
    void f5(long x) { System.out.println("f5(long)"); }
    void f5(float x) { System.out.println("f5(float)"); }
    void f5(double x) { System.out.println("f5(double)"); }
    
    void f6(float x) { System.out.println("f6(float)"); }
    void f6(double x) { System.out.println("f6(double)"); }
    
    void f7(double x) { System.out.println("f7(double)"); }
    
    void testConstVal() {
    System.out.println("Testing with 5");
    f1(5);f2(5);f3(5);f4(5);f5(5);f6(5);f7(5);
    }
    void testChar() {
    char x = 'x';
    System.out.println("char argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
    }
    void testByte() {
    byte x = 0;
    System.out.println("byte argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
    }
    void testShort() {
    short x = 0;
    System.out.println("short argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
    }
    void testInt() {
    int x = 0;
    System.out.println("int argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
    }
    void testLong() {
    long x = 0;
    System.out.println("long argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
    }
    void testFloat() {
    float x = 0;
    System.out.println("float argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
    }
    void testDouble() {
    double x = 0;
    System.out.println("double argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
    }
    public static void main(String[] args) {
    PrimitiveOverloading p =
        new PrimitiveOverloading();
    p.testConstVal();
    p.testChar();
    p.testByte();
    p.testShort();
    p.testInt();
    p.testLong();
    p.testFloat();
    p.testDouble();
    monitor.expect(new String[] {
        "Testing with 5",
        "f1(int)",
        "f2(int)",
        "f3(int)",
        "f4(int)",
        "f5(long)",
        "f6(float)",
        "f7(double)",
        "char argument:",
        "f1(char)",
        "f2(int)",
        "f3(int)",
        "f4(int)",
        "f5(long)",
        "f6(float)",
        "f7(double)",
        "byte argument:",
        "f1(byte)",
        "f2(byte)",
        "f3(short)",
        "f4(int)",
        "f5(long)",
        "f6(float)",
        "f7(double)",
        "short argument:",
        "f1(short)",
        "f2(short)",
        "f3(short)",
        "f4(int)",
        "f5(long)",
        "f6(float)",
        "f7(double)",
        "int argument:",
        "f1(int)",
        "f2(int)",
        "f3(int)",
        "f4(int)",
        "f5(long)",
        "f6(float)",
        "f7(double)",
        "long argument:",
        "f1(long)",
        "f2(long)",
        "f3(long)",
        "f4(long)",
        "f5(long)",
        "f6(float)",
        "f7(double)",
        "float argument:",
        "f1(float)",
        "f2(float)",
        "f3(float)",
        "f4(float)",
        "f5(float)",
        "f6(float)",
        "f7(double)",
        "double argument:",
        "f1(double)",
        "f2(double)",
        "f3(double)",
        "f4(double)",
        "f5(double)",
        "f6(double)",
        "f7(double)"
    });
    }
} ///:~

On voit que la valeur constante 5 est traitée comme un int donc si une méthode surchargée prenant comme argument un int est disponible, elle est utilisée. Dans tous les autres cas, si on a un type de donnée plus petit que l'argument de la méthode, le type de donnée est promu vers le type plus grand. Le type char produit un effet légèrement différent, car s'il n'existe pas de correspondance exacte de type, celui-ci est promu vers le type int.

Que se passe-t-il si l'argument est de type plus grand que celui attendu par la méthode surchargée ? Une modification du programme précédent donne la réponse :

 
Sélectionnez
//: c04:Demotion.java
// Demotion of primitives and overloading.
import com.bruceeckel.simpletest.*;

public class Demotion {
    static Test monitor = new Test();
    void f1(char x) { System.out.println("f1(char)"); }
    void f1(byte x) { System.out.println("f1(byte)"); }
    void f1(short x) { System.out.println("f1(short)"); }
    void f1(int x) { System.out.println("f1(int)"); }
    void f1(long x) { System.out.println("f1(long)"); }
    void f1(float x) { System.out.println("f1(float)"); }
    void f1(double x) { System.out.println("f1(double)"); }
    
    void f2(char x) { System.out.println("f2(char)"); }
    void f2(byte x) { System.out.println("f2(byte)"); }
    void f2(short x) { System.out.println("f2(short)"); }
    void f2(int x) { System.out.println("f2(int)"); }
    void f2(long x) { System.out.println("f2(long)"); }
    void f2(float x) { System.out.println("f2(float)"); }
    
    void f3(char x) { System.out.println("f3(char)"); }
    void f3(byte x) { System.out.println("f3(byte)"); }
    void f3(short x) { System.out.println("f3(short)"); }
    void f3(int x) { System.out.println("f3(int)"); }
    void f3(long x) { System.out.println("f3(long)"); }
    
    void f4(char x) { System.out.println("f4(char)"); }
    void f4(byte x) { System.out.println("f4(byte)"); }
    void f4(short x) { System.out.println("f4(short)"); }
    void f4(int x) { System.out.println("f4(int)"); }
    
    void f5(char x) { System.out.println("f5(char)"); }
    void f5(byte x) { System.out.println("f5(byte)"); }
    void f5(short x) { System.out.println("f5(short)"); }
    
    void f6(char x) { System.out.println("f6(char)"); }
    void f6(byte x) { System.out.println("f6(byte)"); }
    
    void f7(char x) { System.out.println("f7(char)"); }
    
    void testDouble() {
    double x = 0;
    System.out.println("double argument:");
    f1(x);f2((float)x);f3((long)x);f4((int)x);
    f5((short)x);f6((byte)x);f7((char)x);
    }
    public static void main(String[] args) {
    Demotion p = new Demotion();
    p.testDouble();
    monitor.expect(new String[] {
        "double argument:",
        "f1(double)",
        "f2(float)",
        "f3(long)",
        "f4(int)",
        "f5(short)",
        "f6(byte)",
        "f7(char)"
    });
    }
} ///:~

Ici, les méthodes prennent des types plus petits. Si l'argument a un type plus grand, on doit le convertir vers le type nécessaire en plaçant le nom du type entre parenthèses. Si on ne le fait pas, le compilateur produira un message d'erreur.

Il faut être conscient que ceci est une conversion vers un type plus petit, ce qui signifie qu'il peut y avoir perte d'information durant la conversion. C'est pourquoi le compilateur force une conversion explicite.

IV-B-3. Surcharge pour les valeurs retour

Il est commun de se demander : « Pourquoi ne considérer que les noms des classes et les arguments des méthodes ? Pourquoi ne pas aussi distinguer les méthodes sur la base de leurs valeurs de retour ? » Par exemple, ces deux méthodes qui ont le même nom et les mêmes arguments se distinguent facilement l'une de l'autre :

 
Sélectionnez
void f() {}
int f() {}

Cela marche bien si le compilateur peut déterminer de manière univoque la signification à partir du contexte, comme pour int x = f( ). Malgré tout, il est aussi possible d'appeler une méthode en ignorant sa valeur retour. Ceci est souvent référé comme appeler une méthode pour ses effets de bord, car on ne se préoccupe pas de la valeur de retour, mais on souhaite plutôt d'autres effets. Donc si on appelle une méthode de cette manière :

 
Sélectionnez
f();

comment Java pourrait déterminer quel f( ) doit être appelé ? Et comment quelqu'un lisant le code pourrait le voir ? À cause de ce type de problème, on ne peut pas utiliser la valeur de retour pour distinguer les méthodes surchargées.

IV-B-4. Constructeurs par défaut

Comme mentionné précédemment, le constructeur par défaut (aussi connu sous le nom de constructeur "sans args") est sans arguments et est utilisé pour créer un "objet basique". Si l'on crée une classe qui n'a pas de constructeur, le compilateur créera automatiquement le constructeur par défaut pour nous. Par exemple :

 
Sélectionnez
//: c04:DefaultConstructor.java
class Bird {
    int i;
}

public class DefaultConstructor {
    public static void main(String[] args) {
    Bird nc = new Bird(); // Default!
    }
} ///:~

La ligne

 
Sélectionnez
new Bird();

crée un nouvel objet et appelle le constructeur par défaut, même si aucun constructeur n'avait été explicitement défini. Sans ceci, nous n'aurions aucune méthode à appeler pour construire notre objet. Malgré tout, si l'on définit au moins un constructeur (avec ou sans arguments), le compilateur n'en créera pas un pour nous :

 
Sélectionnez
class Hat {
    Hat(int i) {}
    Hat(double d) {}
}

Maintenant si l'on dit :

 
Sélectionnez
new Hat();

le compilateur se plaindra qu'il ne peut pas trouver un constructeur qui corresponde. Lorsque l'on ne définit aucun constructeur, tout se passe comme si le compilateur disait "On a besoin d'au moins un constructeur, donc je vais en faire un pour vous". Mais si l'on définit un constructeur, le compilateur dit « Vous avez écrit un constructeur donc vous savez ce que vous faites ; vous n'avez pas défini celui par défaut parce que vous avez décidé de ne pas l'utiliser ».

IV-B-5. Le mot clé this

Si on a deux objets du même type appelés a et b, on peut se demander comment il est possible que l'on puisse appeler une méthode f( ) sur les deux objets :

 
Sélectionnez
class Banana { void f(int i) { /* ... */ } }
Banana a = new Banana(), b = new Banana();
a.f(1);
b.f(2);

S'il n'y a qu'une seule méthode appelée f( ), comment cette méthode peut-elle savoir qu'elle est appelée pour l'objet a ou b ?

Pour permettre d'écrire un code avec une syntaxe commode et orientée objet dans laquelle on « envoie un message à un objet », le compilateur fait un travail sous le capot pour nous. Il y a un premier argument secret passé à la méthode f( ) et cet argument est la référence à l'objet qui est manipulé. Donc les deux appels de méthode deviennent quelque chose comme :

 
Sélectionnez
Banana.f(a,1);
Banana.f(b,2);

Ceci est une manipulation interne. On ne peut écrire ces expressions et obtenir que le compilateur les accepte, mais ceci nous donne une idée de ce qui se passe.

Supposons que l'on soit à l'intérieur d'une méthode et que l'on souhaite obtenir la référence à l'objet courant. Puisque la référence est passée secrètement par le compilateur, il n'y a pas d'identifiant pour lui. Malgré tout, il existe un mot clé pour ceci : this. Le mot clé this (qui ne peut être utilisé qu'à l'intérieur d'une méthode) donne la référence de l'objet pour lequel la méthode a été appelée. On peut traiter cette référence comme toute autre référence à un objet. Gardez en tête que lorsqu'on appelle une méthode à partir d'une autre méthode de la même classe, il n'est pas nécessaire d'utiliser this. On appelle simplement la méthode, car la référence courante à l'objet est automatiquement utilisée par l'autre méthode. Ainsi on peut dire :

 
Sélectionnez
class Apricot {
    void pick() { /* ... */ }
    void pit() { pick(); /* ... */ }
}

À l'intérieur de pit( ), on pourrait dire this.pick( ), mais ce n'est pas nécessaire. (20) Le compilateur le fait pour nous automatiquement. Le mot clé this est utilisé dans les cas spéciaux où il est nécessaire d'expliciter la référence à l'objet courant. Par exemple, il est souvent utilisé dans les return lorsque l'on veut retourner la référence à l'objet courant :

 
Sélectionnez
//: c04:Leaf.java
// Simple use of the "this" keyword.
import com.bruceeckel.simpletest.*;

public class Leaf {
    static Test monitor = new Test();
    int i = 0;
    Leaf increment() {
        i++;
        return this;
    }
    void print() {
        System.out.println("i = " + i);
    }
    public static void main(String[] args) {
        Leaf x = new Leaf();
        x.increment().increment().increment().print();
        monitor.expect(new String[] {
            "i = 3"
        });
    }
} ///:~

Puisque increment( ) retourne la référence à l'objet courant via le mot clé this, on peut chaîner facilement des méthodes sur le même objet.

IV-B-5-a. Appeler un constructeur à partir d'un constructeur

Lorsque l'on écrit plusieurs constructeurs pour une classe, il y a des moments où l'on aimerait appeler un constructeur à partir d'un autre pour éviter les duplications de code. On peut effectuer un tel appel en utilisant le mot clé this.

Normalement lorsque l'on dit this, c'est avec le sens de « cet objet » ou « l'objet courant ». Il renvoie alors une référence à l'objet courant. Dans un constructeur, le mot clé this prend un sens différent quand on lui passe une liste de paramètres. C'est alors un appel explicite au constructeur qui possède cette liste de paramètres. Ainsi, on a une manière directe d'appeler d'autres constructeurs :

 
Sélectionnez
//: c04:Flower.java
// ppel de constructeurs avec "this."
import com.bruceeckel.simpletest.*;

public class Flower {
    static Test monitor = new Test();
    int petalCount = 0;
    String s = new String("null");
    Flower(int petals) {
        petalCount = petals;
        System.out.println(
            "Constructor w/ int arg only, petalCount= "
            + petalCount);
        }
        Flower(String ss) {
        System.out.println(
            "Constructor w/ String arg only, s=" + ss);
        s = ss;
    }
    Flower(String s, int petals) {
        this(petals);
//!    this(s); // Impossible d'en appeler deux !
        this.s = s; // Autre usage de "this"
        System.out.println("String & int args");
    }
    Flower() {
        this("hi", 47);
        System.out.println("default constructor (no args)");
    }
    void print() {
//! this(11); // Pas à l'intérieur de non-constructeur!
        System.out.println(
            "petalCount = " + petalCount + " s = "+ s);
    }
    public static void main(String[] args) {
        Flower x = new Flower();
        x.print();
        monitor.expect(new String[] {
            "Constructor w/ int arg only, petalCount= 47",
            "String & int args",
            "default constructor (no args)",
            "petalCount = 47 s = hi"
        });
    }
} ///:~

Le constructeur Flower(String s, int petals) montre que l'on peut appeler un constructeur en utilisant this, mais pas deux. De plus, l'appel au constructeur doit absolument être la première instruction sinon le compilateur donnera un message d'erreur.

Cet exemple montre aussi un usage différent du mot-clé this. Le nom du paramètre s et du membre de données s étant le même, il y a ambiguïté. On la résout en utilisant this.s pour se référer au membre de données. Cette forme est très courante en Java et utilisée fréquemment dans ce livre.

Dans la méthode print( ) on peut voir que le compilateur ne permet pas l'appel d'un constructeur depuis toute méthode qui n'est pas un constructeur.

IV-B-5-b. La signification de static

En pensant au mot clé this, on comprend mieux le sens de rendre une méthode static. Cela signifie qu'il n'y a pas de this pour cette méthode. Il est impossible d'appeler une méthode non static depuis une méthode static(21) (bien que l'inverse soit possible) et il est possible d'appeler une méthode static sur la classe elle-même, sans aucun objet. En fait, c'est principalement la raison de l'existence des méthodes static. C'est l'équivalent d'une fonction globale en C. Comme les fonctions globales sont interdites en Java, ajouter une méthode static dans une classe permet d'y accéder, ainsi qu'à ses champs static, à partir d'autres méthodes static.

Certaines personnes disent que les méthodes static ne sont pas orientées objet puisqu'elles ont la sémantique des fonctions globales ; avec une méthode static on n'envoie pas un message vers un objet, puisqu'il n'y a pas de this. C'est probablement un argument valable, et si vous utilisez beaucoup de méthodes statiques vous devriez repenser votre stratégie. Pourtant, les méthodes static sont utiles et il y a des cas où on en a vraiment besoin. On peut donc laisser les théoriciens décider si oui ou non il s'agit d'une pratique correcte de la programmation orientée objet. D'ailleurs, même Smalltalk a un équivalent avec ses « méthodes de classe ».

IV-C. Nettoyage : finalisation et ramasse-miettes

Les programmeurs connaissent l'importance de l'initialisation, mais oublient souvent celle du nettoyage. Après tout, qui a besoin de nettoyer un int? Cependant, avec des bibliothèques, simplement "laisser aller" un objet après son utilisation n'est pas toujours sûr. Bien entendu, Java a un ramasse-miettes pour récupérer la mémoire prise par des objets qui ne sont plus utilisés. Considérons maintenant un cas particulier : supposons que votre objet alloue une zone de mémoire spéciale sans utiliser new. Le ramasse-miettes ne sait récupérer que la mémoire allouée avec new, donc il ne saura pas comment récupérer la zone "spéciale" de mémoire utilisée par l'objet. Pour gérer ce cas, Java fournit une méthode appelée finalize( ) qui peut être définie dans votre classe. Voici comment cela est supposé marcher. Quand le ramasse-miettes est prêt à libérer la mémoire utilisée par votre objet, il va d'abord appeler finalize( ), et ce n'est qu'à la prochaine passe du ramasse-miettes que la mémoire de l'objet est libérée. En choisissant d'utiliser finalize( ), cela vous donne la possibilité d'effectuer d'importantes tâches de nettoyage à l'exécution du ramasse-miettes.

C'est un piège de programmation parce que certains programmeurs, particulièrement les programmeurs C++, risquent au début de confondre finalize( ) avec le destructeur de C++, qui est une fonction toujours appelée quand un objet est détruit. Cependant il est important ici de faire la différence entre C++ et Java, car en C++, les objets sont toujours détruits (dans un programme sans bogues), alors qu'en Java, les objets ne sont pas toujours récupérés par le ramasse-miettes. Dit autrement :

  • 1. Vos objets peuvent ne pas être récupérés par le ramasse-miettes.
  • 2. Le mécanisme de ramasse-miettes n'est pas un mécanisme de destruction.

Si vous vous souvenez de cette règle de base, il n'y aura pas de problème. Cela veut dire que si une opération doit être effectuée avant que vous n'ayez plus besoin d'un objet, vous devez vous en charger vous-même. Java n'a pas de mécanisme équivalent au destructeur, il est donc nécessaire de créer une méthode ordinaire pour réaliser ce nettoyage. Par exemple, supposons qu'un objet se dessine à l'écran pendant sa création. Si son image n'est pas effacée explicitement de l'écran, il se peut qu'elle ne le soit jamais. Si l'on ajoute une fonctionnalité d'effacement dans finalize( ), alors si un objet est récupéré par le ramasse-miettes et que finalize( ) est appelé (il n'y a pas de garantie que cela arrivera), alors l'image sera effacée de l'écran, mais si ce n'est pas le cas, l'image restera.

Il se peut que la mémoire prise par un objet ne soit jamais libérée parce que le programme n'approche jamais la limite de mémoire qui lui a été attribuée. Si le programme se termine sans que le ramasse-miettes n'ait jamais libéré la mémoire prise par les objets, celle-ci sera rendue en masse (NDT : en français dans le texte) au système d'exploitation au moment où le programme s'arrête. C'est une bonne chose, car le ramasse-miettes implique un coût supplémentaire et s'il n'est jamais appelé, c'est autant d'économisé.

IV-C-1. À quoi sert finalize( ) ?

Si vous ne devez pas utiliser finalize( ) comme méthode générale de nettoyage, à quoi sert-elle ?

Un troisième point à se rappeler est :

  • 3. Le ramasse-miettes ne s'occupe que de la mémoire.

C'est-à-dire que la seule raison d'exister du ramasse-miettes est de récupérer la mémoire que le programme n'utilise plus. Par conséquent, toute activité associée au ramasse-miettes, plus particulièrement votre méthode finalize( ), doit se concentrer uniquement sur la mémoire et sa libération.

Est-ce que cela signifie que si un objet contient d'autres objets, finalize( ) devrait libérer ces objets explicitement ? La réponse est... non. Le ramasse-miettes prend soin de libérer tous les objets, quelle que soit la façon dont ils ont été créés. Il se trouve que l'on a uniquement besoin de finalize( ) dans des cas bien précis où un objet peut allouer de la mémoire sans créer un autre objet. Cependant vous devez vous dire que tout est objet en Java, donc comment est-ce possible ?

Il semblerait que finalize( ) ait été introduit parce qu'il est possible d'allouer de la mémoire à la C en utilisant un mécanisme autre que celui proposé normalement par Java. Cela arrive généralement avec l'usage des méthodes natives, qui sont une façon d'appeler du code non Java en Java. (Les méthodes natives sont expliquées en Appendice B de la 2de édition de ce livre, disponible sur le CD ROM du livre ou sur www.BruceEckel.com.) C et C++ sont les seuls langages actuellement supportés par les méthodes natives, mais comme elles peuvent appeler des sous-programmes écrits dans d'autres langages, vous pouvez en fait tout appeler. Dans ce code non Java, les fonctions C de la famille de malloc( ) peuvent être appelées pour allouer de la mémoire, et à moins d'un appel à free( ), cet espace ne sera pas libéré, provoquant une fuite mémoire. Bien sûr, free( ) est une fonction C et C++, ce qui veut dire qu'elle doit être appelée dans une méthode native dans le finalize( ) correspondant.

Après avoir lu cela, vous vous dites probablement que vous n'allez pas beaucoup utiliser finalize( ). (22) Vous avez raison : ce n'est pas l'endroit approprié pour effectuer des opérations normales de nettoyage. Dans ce cas, où celles-ci doivent-elles se passer ?

IV-C-2. Le nettoyage est impératif

Pour nettoyer un objet, son utilisateur doit appeler une méthode de nettoyage au moment où ce nettoyage est nécessaire. Cela semble assez simple, mais se heurte au concept de destructeur de C++. En C++, tous les objets sont, ou plutôt devraient être détruits. Si l'objet C++ est créé localement (i.e., sur la pile, ce qui n'est pas possible en Java), alors la destruction se produit à la fermeture de la portée dans laquelle l'objet a été créé. Si l'objet a été créé par new (comme en Java), le destructeur est appelé quand le programmeur appelle l'opérateur C++ delete (qui n'existe pas en Java). Si le programmeur C++ oublie d'appeler delete, le destructeur n'est jamais appelé, et l'on obtient une fuite mémoire, de plus les membres de l'objet ne sont jamais nettoyés. Ce genre de bogue peut être très difficile à repérer, et c'est une des raisons de changer de C++ pour Java.

En contraste, Java ne permet pas de créer des objets locaux, vous devez toujours utiliser new. Cependant en Java, Il n'y a pas de "delete" à appeler pour libérer l'objet, car le ramasse-miettes se charge automatiquement de récupérer la mémoire pour vous. Ainsi d'un point de vue simpliste, on pourrait dire qu'à cause du ramasse-miettes, Java n'a pas de destructeur. Vous verrez, lors de la lecture de ce livre, cependant, que la présence d'un ramasse-miettes ne change ni le besoin ni l'utilité des destructeurs. (De plus vous ne devriez jamais appeler finalize( ) directement, ce n'est donc pas une bonne piste pour une solution.) Si vous avez besoin d'effectuer des opérations de nettoyage autre que libérer la mémoire, vous devez encore appeler explicitement une méthode appropriée en Java, ce qui sera équivalent à un destructeur C++ sans être aussi pratique.

Il est important de se souvenir que ni le ramasse-miettes, ni la finalisation ne sont garantis. Si la JVM ne risque pas de manquer de mémoire, alors elle pourrait ne pas perdre son temps à récupérer de la mémoire grâce au ramasse-miettes.

IV-C-3. La condition de fin

En général, vous ne pouvez compter sur un appel à finalize( ) , et vous devez créer des fonctions spéciales de nettoyage et les appeler explicitement. Il semble donc que finalize( ) ne soit utile que pour effectuer des tâches de nettoyage mémoire très spécifiques dont la plupart des programmeurs n'aura jamais besoin. Cependant, il existe une très intéressante utilisation de finalize( ) qui ne nécessite pas que son appel soit systématique. Il s'agit de la vérification de condition de fin(23) d'un objet.

Au moment où un objet n'est plus intéressant, c'est à dire lorsqu'il est prêt à être réclamé par le ramasse-miettes, cet objet doit être dans un état où sa mémoire peut être libérée sans problème. Par exemple, si l'objet représente un fichier ouvert, celui-ci doit être fermé par le programmeur avant que la mémoire prise par l'objet ne soit réclamée. Si certaines parties de cet objet n'ont pas été nettoyées comme il se doit, il s'agit d'un bogue du programme qui peut être très difficile à localiser. L'intérêt de finalize( ) est qu'il est possible de l'utiliser pour découvrir cet état de l'objet, même si cette méthode n'est pas toujours appelée. Si une des finalisations trouve le bogue, alors le problème est découvert et c'est ce qui compte vraiment après tout.

Voici un petit exemple pour montrer comment on peut l'utiliser :

 
Sélectionnez
//: c04:TerminationCondition.java
// Comment utiliser finalize() pour détecter les objets qui
// n'ont pas été nettoyés correctement.
import com.bruceeckel.simpletest.*;

class Book {
    boolean checkedOut = false;
    Book(boolean checkOut) {
        checkedOut = checkOut;
    }
    void checkIn() {
        checkedOut = false;
    }
    public void finalize() {
        if(checkedOut)
            System.out.println("Error: checked out");
    }
}

public class TerminationCondition {
    static Test monitor = new Test();
    public static void main(String[] args) {
        Book novel = new Book(true);
        // Nettoyage correct :
        novel.checkIn();
        // Perd la référence et oublie le nettoyage :
        new Book(true);
        // Force l'exécution du ramasse-miettes et de la finalisation :
        System.gc();
        monitor.expect(new String[] {
            "Error: checked out"}, Test.WAIT);
    }
} ///:~

La condition de fin est que tous les objets Book doivent être "rendus" avant d'être récupérés par le ramasse-miettes, mais dans la fonction main( ), une erreur de programmation fait qu'un de ces livres n'est pas rendu. Sans finalize( ) pour vérifier la condition de fin, cela pourrait s'avérer un bogue difficile à trouver.

Il est important de noter l'utilisation de System.gc( ) pour forcer l'exécution de la finalisation (vous devriez le faire pendant le développement du programme pour accélérer le débogage). Cependant même sans son appel, il est très probable que l'objet Book perdu soit découvert par plusieurs exécutions successives du programme (en supposant que suffisamment de mémoire soit allouée pour que le ramasse-miettes se déclenche).

IV-C-4. Comment fonctionne un ramasse-miettes ?

Les utilisateurs de langages où l'allocation d'objets sur le tas coûte cher peuvent supposer que la façon qu'a Java de tout allouer sur le tas (à l'exception des types de base) coûte également cher. Cependant, il se trouve que l'utilisation d'un ramasse-miettes peut accélérer de manière importante la création d'objets. Ceci peut sembler un peu bizarre à première vue : la réclamation d'objets aurait un effet sur la création d'objets, mais c'est comme cela que certaines JVMs fonctionnent et cela veut dire qu'en Java, l'allocation d'objets sur le tas peut être presque aussi rapide que l'allocation sur la pile dans d'autres langages.

Un exemple serait de considérer le tas en C++ comme une pelouse où chaque objet prend et délimite son morceau de gazon. Cet espace peut être abandonné un peu plus tard et doit être réutilisé. Avec certaines JVMs, le tas de Java est assez différent ; il ressemble plus à une chaîne de montage qui avancerait à chaque fois qu'un objet est alloué. Ce qui fait que l'allocation est remarquablement rapide. Le « pointeur du tas » progresse simplement dans l'espace vide, ce qui correspond donc à l'allocation sur la pile en C++ (il y a bien sûr une petite pénalité supplémentaire pour le fonctionnement interne, mais ce n'est pas comparable à la recherche de mémoire libre).

On peut remarquer que le tas n'est en fait pas vraiment une chaîne de montage, et s'il est traité de cette manière, la mémoire finira par avoir un taux de « pagging » (utiliser toute la mémoire virtuelle incluant la partie sur disque dur) important (ce qui représente un gros problème de performance) et finira par manquer de mémoire. Le ramasse-miettes apporte la solution en s'interposant et, alors qu'il collecte les miettes (les objets inutilisables), il compacte tous les objets du tas. Ceci représente l'action de déplacer le «pointeur du tas» un peu plus vers le début et donc plus loin du «page fault» (interruption pour demander au système d'exploitation des pages de mémoire supplémentaire situées dans la partie de la mémoire virtuelle qui se trouve sur disque dur). Le ramasse-miettes réarrange tout pour permettre l'utilisation de ce modèle d'allocation très rapide et utilisant une sorte de «tas infini».

Pour comprendre comment tout cela fonctionne, il serait bon de donner maintenant une meilleure description de la façon dont un ramasse-miettes fonctionne. Nous utiliserons l'acronyme GC (en anglais, un ramasse-miettes est appelé Garbage Collector) dans les paragraphes suivants. Une technique de GC relativement simple, mais lente est le compteur de référence. L'idée est que chaque objet contient un compteur de référence et à chaque fois qu'une nouvelle référence sur un objet est créée le compteur est incrémenté. À chaque fois qu'une référence est hors de portée ou que la valeur null lui est assignée, le compteur de références est décrémenté. Par conséquent, la gestion des compteurs de références représente un coût faible, mais constant tout au long du programme. Le ramasse-miettes se déplace à travers toute la liste d'objets et quand il en trouve un avec un compteur à zéro, il libère la mémoire. L'inconvénient principal est que si des objets se référencent de façon circulaire, ils ne peuvent jamais avoir un compteur à zéro tout en étant inaccessibles. Pour localiser ces objets qui se référencent mutuellement, le ramasse-miettes doit faire un important travail supplémentaire. Les compteurs de références sont généralement utilisés pour expliquer les ramasses-miettes, mais ils ne semblent pas être utilisés dans les implémentations de la JVM.

D'autres techniques, plus performantes, n'utilisent pas de compteur de références. Elles sont plutôt basées sur l'idée que l'on est capable de remonter la chaîne de références de tout objet «non mort» (i.e. encore en utilisation) jusqu'à une référence vivant sur la pile ou dans la zone statique. Cette chaîne peut très bien passer par plusieurs niveaux d'objets. Par conséquent, si l'on part de la pile et de la zone statique et que l'on trace toutes les références, on trouvera tous les objets encore en utilisation. Pour chaque référence que l'on trouve, il faut aller jusqu'à l'objet référencé et ensuite suivre toutes les références contenues dans cet objet, aller jusqu'aux objets référencés, etc. jusqu'à ce que l'on ait visité tous les objets que l'on peut atteindre depuis la référence sur la pile ou dans la zone statique. Chaque objet visité doit être encore vivant. Notez qu'il n'y a aucun problème avec les groupes qui s'autoréférencent : ils ne sont tout simplement pas trouvés et sont donc automatiquement morts.

Avec cette approche, la JVM utilise un ramasse-miettes adaptatif. Le sort des objets vivants trouvés dépend de la variante du ramasse-miettes utilisée à ce moment-là. Une de ces variantes est le stop-et-copie. L'idée est d'arrêter le programme dans un premier temps (ce n'est pas un ramasse-miettes qui s'exécute en arrière-plan). Puis, chaque objet vivant que l'on trouve est copié d'un tas à un autre, délaissant les objets morts. De plus, au moment où les objets sont copiés, ils sont rassemblés les uns à côté des autres, compactant de ce fait le nouveau tas (et permettant d'allouer de la mémoire en la récupérant à l'extrémité du tas comme cela a été expliqué auparavant).

Bien entendu, quand un objet est déplacé d'un endroit à un autre, toutes les références qui pointent (i.e., qui référencent) l'objet doivent être mises à jour. La référence qui part du tas ou de la zone statique vers l'objet peut être modifiée sur le champ, mais il y a d'autres références pointant sur cet objet qui seront trouvées « sur le chemin ». Elles seront corrigées dès qu'elles seront trouvées (on peut imaginer une table associant les anciennes adresses aux nouvelles).

Il existe deux problèmes qui rendent ces « ramasse-miettes par copie » inefficaces. Le premier est l'utilisation de deux tas et le déplacement des objets d'un tas à l'autre, utilisant ainsi deux fois plus de mémoire que nécessaire. Certaines JVMs s'en sortent en allouant la mémoire par morceau et en copiant simplement les objets d'un morceau à un autre.

Le deuxième problème est la copie. Une fois que le programme atteint un état stable, il se peut qu'il ne génère pratiquement plus de miettes (i.e. d'objets morts). Malgré ça, le ramasse-miettes par copie va quand même copier toute la mémoire d'une zone à une autre, ce qui est du gaspillage pur et simple. Pour éviter cela, certaines JVMs détectent que peu d'objets meurent et choisissent alors une autre technique (c'est la partie d' « adaptation »). Cette autre technique est appelée mark and sweep (NDT : Littéralement, marque et balaye), et c'est ce que les premières versions de la JVM de Sun utilisaient en permanence. En général, le « mark and sweep » est assez lent, mais quand on sait que l'on génère peu ou pas de miettes, la technique est rapide.

La technique de « mark and sweep » suit la même logique de partir de la pile et de la zone de mémoire statique et de suivre toutes les références pour trouver les objets encore en utilisation. Cependant, à chaque fois qu'un objet vivant est trouvé, il est marqué avec un drapeau, mais rien n'est encore collecté. C'est seulement lorsque la phase de « mark » est terminée que le « sweep » commence. Pendant ce balayage, les objets morts sont libérés. Aucune copie n'est effectuée, donc si le ramasse-miettes décide de compacter la mémoire, il le fait en réarrangeant les objets.

Le « stop-and-copy » correspond à l'idée que ce type de ramasse-miettes ne s'exécute pas en tâche de fond, le programme est en fait arrêté pendant l'exécution du ramasse-miettes. La littérature de Sun mentionne assez souvent le ramasse-miettes comme une tâche de fond de basse priorité, mais il se trouve que le ramasse-miettes n'a pas été implémenté de cette manière, tout au moins dans les premières versions de la JVM de Sun. Le ramasse-miettes était plutôt exécuté quand il restait peu de mémoire libre. De plus, le « mark-and-sweep » nécessite l'arrêt du programme.

Comme il a été dit précédemment, la JVM décrite ici alloue la mémoire par blocs. Si un gros objet est alloué, un bloc complet lui est réservé. Le «stop-and-copy», strictement appliqué, nécessite la copie de chaque objet vivant du tas d'origine vers un nouveau tas avant de pouvoir libérer le vieux tas, ce qui se traduit par la manipulation de beaucoup de mémoire. Avec des blocs, le ramasse-miettes peut simplement utiliser les blocs vides (et/ou contenant uniquement des objets morts) pour y copier les objets. Chaque bloc possède un compteur de génération pour savoir s'il est « mort » (vide) ou non. Dans le cas normal, seuls les blocs créés depuis le ramasse-miettes sont compactés ; les compteurs de générations de tous les autres blocs sont mis à jour s'ils ont été référencés. Cela prend en compte le cas courant des nombreux objets ayant une durée de vie très courte. Régulièrement, un balayage complet est effectué, les gros objets ne sont toujours pas copiés (leurs compteurs de génération sont simplement mis à jour) et les blocs contenant des petits objets sont copiés et compactés. La JVM évalue constamment l'efficacité du ramasse-miettes et si cette technique devient une pénalité plutôt qu'un avantage, elle la change pour un « mark-and-sweep ». De même, la JVM évalue l'efficacité du mark-and-sweep et si le tas se fragmente, le stop-and-copy est réutilisé. C'est là où l' « adaptation » vient en place et finalement on peut utiliser ce terme anglophone à rallonge : « adaptive generational stop-and-copy mark-and-sweep » qui correspondrait à « adaptatif entre marque-et-balaye et stoppe-et-copie de façon générationnelle ».

Il existe un certain nombre d'autres optimisations possibles dans une JVM. Une d'entre elles, très importante, implique le module de chargement des classes et le compilateur Just-In-Time (JIT). Quand une classe doit être chargée (généralement la première fois que l'on veut créer un objet de cette classe), le fichier .class est trouvé et le byte-code pour cette classe est chargé en mémoire. À ce moment-là, une possibilité est d'utiliser le JIT sur tout le code, mais cela a deux inconvénients. Tout d'abord c'est un peu plus coûteux en temps et, quand on considère toutes les classes chargées sur la durée de vie du programme, cela peut devenir conséquent. De plus, la taille de l'exécutable est augmentée (les byte codes sont bien plus compacts que le code résultant du JIT) et peut donc causer l'utilisation de pages dans la mémoire virtuelle, ce qui ralentit clairement le programme. Une autre approche est l'évaluation paresseuse qui n'utilise le JIT que lorsque cela est nécessaire. Par conséquent, si une partie du code n'est jamais exécutée, il est possible qu'elle ne soit jamais compilée par le JIT. Les technologies Java HotSpot dans les JDKs récents adoptent une approche similaire en optimisant de plus en plus un morceau de code chaque fois qu'il est exécuté, ainsi plus le code s'exécute, plus il devient rapide.

IV-D. Initialisation de membre

Java prend en charge l'initialisation des variables avant leur utilisation. Dans le cas des variables locales à une méthode, cette garantie prend la forme d'une erreur à la compilation. Ainsi le code suivant :

 
Sélectionnez
void f() {
    int i;
    i++; // Erreur -- i n'est pas initialisé
}

générera un message d'erreur disant que la variable i pourrait ne pas avoir été initialisée. Bien entendu, le compilateur aurait pu donner à i une valeur par défaut, mais il est plus probable qu'il s'agisse d'une erreur de programmation et une valeur par défaut aurait masqué ce problème. En forçant le programmeur à donner une valeur par défaut, il y a plus de chances de repérer un bogue.

Cependant, si une valeur primitive est un membre d'une classe, les choses sont un peu différentes. Comme n'importe quelle méthode peut initialiser ou utiliser cette donnée, il ne serait pas très pratique ou faisable de forcer l'utilisateur à l'initialiser correctement avant son utilisation. Cependant, il n'est pas correct de la laisser avec une valeur poubelle, Java garantit donc de donner une valeur initiale à chaque membre avec un type primitif. On peut voir ces valeurs ici :

 
Sélectionnez
//: c04:InitialValues.java
// Montre les valeurs initiales par défaut.
import com.bruceeckel.simpletest.*;

public class InitialValues {
    static Test monitor = new Test();
    boolean t;
    char c;
    byte b;
    short s;
    int i;
    long l;
    float f;
    double d;
    void print(String s) { System.out.println(s); }
    void printInitialValues() {
        print("Data type      Initial value");
        print("boolean        " + t);
        print("char           [" + c + "]");
        print("byte           " + b);
        print("short          " + s);
        print("int            " + i);
        print("long           " + l);
        print("float          " + f);
        print("double         " + d);
    }
    public static void main(String[] args) {
        InitialValues iv = new InitialValues();
        iv.printInitialValues();
        /* Vous pouvez aussi écrire :
        new InitialValues().printInitialValues();
        */
        monitor.expect(new String[] {
            "Data type      Initial value",
            "boolean        false",
            "char           [" + (char)0 + "]",
            "byte           0",
            "short          0",
            "int            0",
            "long           0",
            "float          0.0",
            "double         0.0"
        });
    }
} ///:~

On peut voir que même si des valeurs ne sont pas spécifiées, les données sont initialisées automatiquement. (La valeur pour char est zéro, ce qui affiche un espace). Il n'y a donc pas de risque de travailler par inattention avec des variables non initialisées.

Nous verrons plus tard que quand on définit une référence sur un objet dans une classe sans l'initialiser à un nouvel objet, cette référence est donnée par une valeur spéciale null (qui est un mot clé Java).

IV-D-1. Spécifier l'initialisation

Comment peut-on donner une valeur initiale à une variable ? Une manière directe de le faire est la simple affectation au moment de la définition de la variable dans la classe (note : il n'est pas possible de le faire en C++ bien que tous les débutants essayent). Les définitions des champs de la classe InitialValues sont modifiées ici pour fournir des valeurs initiales :

 
Sélectionnez
class InitialValues {
    boolean b = true;
    char c = 'x';
    byte B = 47;
    short s = 0xff;
    int i = 999;
    long l = 1;
    float f = 3.14f;
    double d = 3.14159;
    //. . .

On peut initialiser des objets de type non primitif de la même manière. Si Depth est une classe, on peut ajouter une variable et l'initialiser de cette façon :

 
Sélectionnez
class Measurement {
    Depth d = new Depth();
    // . . .

Si d ne reçoit pas de valeur initiale et que l'on essaye de l'utiliser malgré tout, on obtient une erreur à l'exécution appelée exception (explications au chapitre 9).

Il est même possible d'appeler une méthode pour fournir une valeur d'initialisation :

 
Sélectionnez
class CInit {
    int i = f();
    //...
}

Bien sûr cette méthode peut avoir des arguments, mais ceux-ci ne peuvent pas être d'autres membres non encore initialisés, de la classe. Par conséquent ce code est valide :

 
Sélectionnez
class CInit {
    int i = f();
    int j = g(i);
    //...
}

Mais pas celui-ci :

 
Sélectionnez
class CInit {
    int j = g(i);
    int i = f();
    //...
}

C'est un des endroits où le compilateur se plaint avec raison du « forward referencing » (référence à un objet déclaré plus loin dans le code), car il s'agit d'une question d'ordre d'initialisation et non pas de la façon dont le programme est compilé.

Cette approche de l'initialisation est très simple. Elle est également limitée dans le sens où chaque objet du type InitialValues aura les mêmes valeurs d'initialisation. Quelquefois c'est exactement ce dont on a besoin, mais d'autres fois un peu plus de flexibilité serait nécessaire.

IV-D-2. Initialisation par constructeur

On peut utiliser le constructeur pour effectuer les initialisations. Cela apporte plus de flexibilité pour le programmeur, car il est possible d'appeler des méthodes et effectuer des actions à l'exécution pour déterminer les valeurs initiales. Cependant il y a une chose à se rappeler : cela ne remplace pas l'initialisation automatique qui est faite avant l'exécution du constructeur. Donc par exemple :

 
Sélectionnez
class Counter {
    int i;
    Counter() { i = 7; }
    // . . .

alors i sera d'abord initialisé à 0 puis à 7. C'est ce qui se passe pour tous les types primitifs et les références sur objet, y compris ceux qui ont été initialisés explicitement au moment de leur définition. Pour cette raison, le compilateur ne force pas l'utilisateur à initialiser les éléments dans le constructeur à un endroit donné, ni avant leur utilisation : l'initialisation est toujours garantie. (24)

IV-D-2-a. Ordre d'initialisation

Dans une classe, l'ordre d'initialisation est déterminé par l'ordre dans lequel les variables sont définies. Les définitions de variables peuvent être disséminées n'importe où et même entre les définitions des méthodes, mais elles sont initialisées avant tout appel à une méthode, même le constructeur. Par exemple :

 
Sélectionnez
//: c04:OrderOfInitialization.java
// Montre l'ordre d'initialisation.
import com.bruceeckel.simpletest.*;

// Quand le constructeur est appelé pour créer
// un objet Tag, un message s'affichera :
class Tag {
    Tag(int marker) {
        System.out.println("Tag(" + marker + ")");
      }
}

class Card {
    Tag t1 = new Tag(1); // Avant le constructeur
    Card() {
        // Montre que l'on est dans le constructeur :
        System.out.println("Card()");
        t3 = new Tag(33); // Réinitialisation de t3
    }
    Tag t2 = new Tag(2); // Après le constructeur
    void f() {
        System.out.println("f()");
    }
    Tag t3 = new Tag(3); // à la fin
}

public class OrderOfInitialization {
    static Test monitor = new Test();
    public static void main(String[] args) {
        Card t = new Card();
        t.f(); // Montre que la construction a été effectuée
        monitor.expect(new String[] {
            "Tag(1)",
            "Tag(2)",
            "Tag(3)",
            "Card()",
            "Tag(33)",
            "f()"
        });
    }
} ///:~

Dans la classe Card, les définitions des objets Tag sont intentionnellement dispersées pour prouver que ces objets seront tous initialisés avant toute action, y compris l'appel du constructeur. De plus, t3 est réinitialisé dans le constructeur.

De la sortie, vous pouvez voir que, la référence sur t3 est initialisée deux fois : une fois avant et une fois pendant l'appel du constructeur (on jette le premier objet pour qu'il soit récupéré par le ramasse-miettes plus tard). À première vue, cela ne semble pas très efficace, mais cela garantit une initialisation correcte ; que se passerait-il si l'on surchargeait le constructeur avec un autre constructeur qui n'initialiserait pas t3 et qu'il n'y avait pas d'initialisation « par défaut » dans la définition de t3 ?

IV-D-2-b. Initialisation de données statiques

Quand les données sont static, la même chose se passe ; s'il s'agit d'une donnée de type primitif et qu'elle n'est pas initialisée, la variable reçoit une valeur initiale standard. Si c'est une référence à un objet, c'est null à moins qu'un nouvel objet ne soit créé et sa référence donnée comme valeur à la variable.

Pour une initialisation à l'endroit de la définition, les mêmes règles que pour les variables non static sont appliquées. Il n'y a qu'une seule version (une seule zone mémoire) pour une variable static, quel que soit le nombre d'objets créés. Mais une question se pose lorsque cette zone static est initialisée. Un exemple va rendre cette question claire :

 
Sélectionnez
//: c04:StaticInitialization.java
// Préciser des valeurs initiales dans une définition de classe.
import com.bruceeckel.simpletest.*;

class Bowl {
    Bowl(int marker) {
        System.out.println("Bowl(" + marker + ")");
    }
    void f(int marker) {
        System.out.println("f(" + marker + ")");
    }
}

class Table {
    static Bowl b1 = new Bowl(1);
    Table() {
        System.out.println("Table()");
        b2.f(1);
    }
    void f2(int marker) {
        System.out.println("f2(" + marker + ")");
    }
    static Bowl b2 = new Bowl(2);
}

class Cupboard {
    Bowl b3 = new Bowl(3);
    static Bowl b4 = new Bowl(4);
    Cupboard() {
        System.out.println("Cupboard()");
        b4.f(2);
    }
    void f3(int marker) {
        System.out.println("f3(" + marker + ")");
    }
    static Bowl b5 = new Bowl(5);
}

public class StaticInitialization {
    static Test monitor = new Test();
    public static void main(String[] args) {
        System.out.println("Creating new Cupboard() in main");
        new Cupboard();
        System.out.println("Creating new Cupboard() in main");
        new Cupboard();
        t2.f2(1);
        t3.f3(1);
        monitor.expect(new String[] {
            "Bowl(1)",
            "Bowl(2)",
            "Table()",
            "f(1)",
            "Bowl(4)",
            "Bowl(5)",
            "Bowl(3)",
            "Cupboard()",
            "f(2)",
            "Creating new Cupboard() in main",
            "Bowl(3)",
            "Cupboard()",
            "f(2)",
            "Creating new Cupboard() in main",
            "Bowl(3)",
            "Cupboard()",
            "f(2)",
            "f2(1)",
            "f3(1)"
        });
    }
    static Table t2 = new Table();
    static Cupboard t3 = new Cupboard();
} ///:~

Bowl permet de visionner la création d'une classe, Table et Cupboard créent des membres static de Bowl partout au travers de leur définition de classe. Noter que Cupboard crée un Bowl b3 non static avant les définitions static.

De la sortie, vous pouvez voir que l'initialisation static intervient seulement si cela est nécessaire. Si vous ne créez pas un objet Table et que vous ne faites jamais référence à Table.b1 ou à Table.b2, les membres static Bowl b1 et b2 ne seront jamais créés. Il sont initialisés seulement quand le premier objet Table est créé (ou quand le premier accès static est effectué). Après cela, les objets static ne sont pas réinitialisés.

Dans l'ordre d'initialisation, les membres statics viennent en premier, s'ils n'avaient pas déjà été initialisés par une précédente création d'objet, et ensuite les objets non static. On peut le voir clairement dans la sortie du programme.

Il peut être utile de résumer le processus de création d'un objet. Considérons une classe appelée Dog :

  • La première fois qu'un objet de type Dog est créé (le constructeur est en fait une méthode static), ou la première fois qu'une méthode static ou un champ static de la classe Dog est accédé, l'interpréteur Java doit localiser Dog.class, ce qu'il fait en cherchant dans le classpath ;
  • Au moment où Dog.class est chargée (créant un objet Class, que nous verrons plus tard), toutes les fonctions d'initialisation static sont exécutées. Par conséquent, l'initialisation static n'arrive qu'une fois, au premier chargement de l'objet Class ;
  • Lorsque l'on exécute new Dog( ), pour créer un nouvel objet de type Dog le processus de construction commence par allouer suffisamment d'espace mémoire sur le tas pour contenir un objet Dog.
  • Cet espace est mis à zéro, donnant automatiquement à tous les membres de type primitif dans cet objet Dogleurs valeurs par défaut (zéro pour les nombres et l'équivalent pour les boolean et les char) et null pour les références ;
  • Toute initialisation effectuée au moment de la définition des champs est exécutée ;
  • Les constructeurs sont exécutés. Comme nous le verrons au chapitre 6, ceci peut en fait déclencher beaucoup d'activité, surtout lorsqu'il y a de l'héritage.
IV-D-2-c. Initialisation statique explicite

Java permet au programmeur de grouper toute autre initialisation statique dans une « clause de construction » static (quelquefois appelée static block) dans une classe. Cela ressemble à ceci :

 
Sélectionnez
class Spoon {
    static int i;
    static {
        i = 47;
    }
    // . . .

On dirait une méthode, mais il s'agit simplement du mot-clé static suivi d'un corps de méthode. Ce code, comme les autres initialisations static, est exécuté une seule fois, à la création du premier objet de cette classe ou au premier accès à un membre déclaré static de cette classe (même si on ne crée jamais d'objet de cette classe). Par exemple :

 
Sélectionnez
//: c04:ExplicitStatic.java
// Initialisation statique explicite avec l'instruction "static".
import com.bruceeckel.simpletest.*;

class Cup {
    Cup(int marker) {
        System.out.println("Cup(" + marker + ")");
    }
    void f(int marker) {
        System.out.println("f(" + marker + ")");
    }
}

class Cups {
    static Cup c1;
    static Cup c2;
    static {
        c1 = new Cup(1);
        c2 = new Cup(2);
    }
    Cups() {
        System.out.println("Cups()");
    }
}

public class ExplicitStatic {
    static Test monitor = new Test();
    public static void main(String[] args) {
        System.out.println("Inside main()");
        Cups.c1.f(99);  // (1)
        monitor.expect(new String[] {
            "Inside main()",
            "Cup(1)",
            "Cup(2)",
            "f(99)"
        });
    }
    // static Cups x = new Cups();  // (2)
    // static Cups y = new Cups();  // (2)
} ///:~

Les instructions static d'initialisation pour Cups sont exécutées soit quand l'accès à l'objet static c1 intervient à la ligne (1), soit si la ligne (1) est mise en commentaire et les lignes marquées (2) ne le sont pas. Si à la fois (1) et (2) sont en commentaire, l'initialisation static pour Cups n'intervient jamais. De plus, que l'on enlève les commentaires pour les deux lignes (2) ou pour une seule n'a aucune importance : l'initialisation statique n'est effectuée qu'une seule fois.

IV-D-2-d. Initialisation d'instance non statique

Java offre une syntaxe similaire pour initialiser les variables non static pour chaque objet. Voici un exemple :

 
Sélectionnez
//: c04:Mugs.java
// Java "Instance Initialization." (Initialisation d'instance de Java)
import com.bruceeckel.simpletest.*;

class Mug {
    Mug(int marker) {
        System.out.println("Mug(" + marker + ")");
    }
    void f(int marker) {
        System.out.println("f(" + marker + ")");
     }
}

public class Mugs {
    static Test monitor = new Test();
    Mug c1;
    Mug c2;
    {
        c1 = new Mug(1);
        c2 = new Mug(2);
        System.out.println("c1 & c2 initialized");
    }
    Mugs() {
        System.out.println("Mugs()");
    }
    public static void main(String[] args) {
        System.out.println("Inside main()");
        Mugs x = new Mugs();
        monitor.expect(new String[] {
            "Inside main()",
            "Mug(1)",
            "Mug(2)",
            "c1 & c2 initialized",
            "Mugs()"
        });
    }
} ///:~

On peut voir que la clause d'initialisation d'instance :

 
Sélectionnez
{
    c1 = new Mug(1);
    c2 = new Mug(2);
    System.out.println("c1 & c2 initialized");
}

ressemble exactement à la clause d'initialisation statique moins le mot-clé static. Cette syntaxe est nécessaire pour permettre l'initialisation de classes internes anonymes (voir Chapter 8).

IV-E. Initialisation des tableaux

L'initialisation des tableaux en C est laborieuse et source d'erreurs. C++ utilise l'initialisation d'agrégats pour rendre cette opération plus sûre. (25) Java n'a pas d'« agrégats » comme C++, puisque tout est objet en Java. Java possède pourtant des tableaux avec initialisation.

Un tableau est simplement une suite d'objets ou de types de base, tous du même type et réunis ensemble sous un même nom. Les tableaux sont définis et utilisés avec l'opérateur d'indexation [ ] (crochets ouvrant et fermant). Pour définir un tableau, il suffit d'ajouter des crochets vides après le nom du type :

 
Sélectionnez
int[] a1;

Les crochets peuvent également être placés après le nom de la variable avec exactement la même signification :

 
Sélectionnez
int a1[];

Cela correspond aux attentes des programmeurs C et C++. Toutefois, la première syntaxe est probablement plus sensée, car elle annonce le type comme « un tableau d'int ». C'est la syntaxe utilisée dans ce livre.

Le compilateur ne permet pas de spécifier la taille du tableau à sa définition. Cela nous ramène à ce problème de « référence ». À ce point on ne dispose que d'une référence sur un tableau, et aucune place n'a été allouée pour ce tableau. Pour créer un espace de stockage pour le tableau, il faut écrire une expression d'initialisation. Pour les tableaux, l'initialisation peut apparaître à tout moment dans le code, mais on peut également utiliser un type spécial d'initialisation qui doit alors apparaître à la déclaration du tableau. Cette initialisation spéciale est un ensemble de valeurs entre accolades. L'allocation de l'espace de stockage pour le tableau (l'équivalent de new) est prise en charge par le compilateur dans ce cas. Par exemple :

 
Sélectionnez
int[] a1 = { 1, 2, 3, 4, 5 };

Mais pourquoi voudrait-on définir une référence sur tableau sans un tableau ?

 
Sélectionnez
int[] a2;

Il est possible d'affecter un tableau à un autre en Java, on peut donc écrire :

 
Sélectionnez
a2 = a1;

Cette expression effectue en fait une copie de référence, comme le montre la suite :

 
Sélectionnez
//: c04:Arrays.java
// Tableau de types primitifs.
import com.bruceeckel.simpletest.*;

public class Arrays {
    static Test monitor = new Test();
    public static void main(String[] args) {
        int[] a1 = { 1, 2, 3, 4, 5 };
        int[] a2;
        a2 = a1;
        for(int i = 0; i < a2.length; i++)
            a2[i]++;
        for(int i = 0; i < a1.length; i++)
            System.out.println(
                "a1[" + i + "] = " + a1[i]);
        monitor.expect(new String[] {
            "a1[0] = 2",
            "a1[1] = 3",
            "a1[2] = 4",
            "a1[3] = 5",
            "a1[4] = 6"
        });
    }
} ///:~

On peut voir que a1 a une valeur initiale tandis qu' a2 n'en a pas ; a2 prend une valeur plus tard - dans ce cas, d'un autre tableau.

Maintenant voyons quelque chose de nouveau : tous les tableaux ont un membre intrinsèque (qu'ils soient tableaux d'objets ou de types de base) que l'on peut interroger - mais pas changer - il donne le nombre d'éléments dans le tableau. Ce membre s'appelle length. Puisque les tableaux en Java , comme C et C++, commencent à la case zéro, le plus grand nombre d'éléments que l'on peut indexer est length - 1. Lorsqu'on dépasse ces bornes, C et C++ acceptent cela tranquillement et la mémoire peut être corrompue, ceci est la cause de bogues infâmes. Cependant, Java empêche ce genre de problèmes en générant une erreur d'exécution (une exception, le sujet du chapitre 9) lorsque le programme essaye d'accéder à une valeur en dehors des limites. Bien sûr, vérifier ainsi chaque accès coûte du temps et du code ; comme il n'y a aucun moyen de désactiver ces vérifications, les accès tableaux peuvent être une source de lenteur dans un programme s'ils sont placés à certains points critiques de l'exécution. Les concepteurs de Java ont pensé que cette vitesse légèrement réduite était largement contrebalancée par les aspects de sécurité sur Internet et la meilleure productivité des programmeurs.

Que faire quand on ne sait pas au moment où le programme est écrit, combien d'éléments vont être requis à l'exécution ? Il suffit d'utiliser new pour créer les éléments du tableau. Dans ce cas, new fonctionne même pour la création d'un tableau de types de base (new ne peut pas créer un type de base) :

 
Sélectionnez
//: c04:ArrayNew.java
// Créer des tableaux avec new.
import com.bruceeckel.simpletest.*;
import java.util.*;

public class ArrayNew {
    static Test monitor = new Test();
    static Random rand = new Random();
    public static void main(String[] args) {
        int[] a;
        a = new int[rand.nextInt(20)];
        System.out.println("length of a = " + a.length);
        for(int i = 0; i < a.length; i++)
            System.out.println("a[" + i + "] = " + a[i]);
        monitor.expect(new Object[] {
            "%% length of a = \\d+",
            new TestExpression("%% a\\[\\d+\\] = 0", a.length)
        });
    }
} ///:~

L'expression expect( ) contient quelque chose de nouveau dans cet exemple : la classe TestExpression. Un objet TestExpression prend une expression, soit une chaîne ordinaire soit une expression régulière comme montré ici, et un entier en deuxième argument qui indique que l'expression précédente sera répétée plusieurs fois. Non seulement TestExpression évite des duplications inutiles dans le code, mais dans ce cas, il permet que le nombre de répétitions soit déterminé à l'exécution.

La taille du tableau est choisie aléatoirement en utilisant la méthode Random.nextInt( ), qui renvoie une valeur entre zéro et cet argument. À cause de ce hasard, il est clair que la création du tableau intervient réellement au moment de l'exécution. De plus, on peut voir en exécutant le programme que les tableaux de types primitifs sont automatiquement initialisés avec des valeurs « vides » (pour les nombres et les char, cette valeur est zéro, et pour les boolean, cette valeur est false).

Bien sûr le tableau pourrait aussi avoir été défini et initialisé sur la même ligne :

 
Sélectionnez
int[] a = new int[rand.nextInt(20)];

C'est la façon préférée de le faire, si vous le pouvez.

Lorsque l'on travaille avec un tableau d'objets non primitifs, il faut toujours utiliser new. Encore une fois, le problème des références revient, car ce que l'on crée est un tableau de références. Considérons le type englobant Integer, qui est une classe et non un type de base :

 
Sélectionnez
//: c04:ArrayClassObj.java
// Création d'un tableau d'objets non primitifs
import com.bruceeckel.simpletest.*;
import java.util.*;

public class ArrayClassObj {
    static Test monitor = new Test();
    static Random rand = new Random();
    public static void main(String[] args) {
        Integer[] a = new Integer[rand.nextInt(20)];
        System.out.println("length of a = " + a.length);
        for(int i = 0; i < a.length; i++) {
            a[i] = new Integer(rand.nextInt(500));
            System.out.println("a[" + i + "] = " + a[i]);
        }
        monitor.expect(new Object[] {
            "%% length of a = \\d+",
            new TestExpression("%% a\\[\\d+\\] = \\d+", a.length)
        });
    }
} ///:~

Ici, même après que new a été appelé pour créer le tableau :

 
Sélectionnez
Integer[] a = new Integer[rand.nextInt(20)];

c'est uniquement un tableau de références, et l'initialisation n'est pas complète tant que cette référence n'a pas elle-même été initialisée en créant un nouvel objet Integer :

 
Sélectionnez
a[i] = new Integer(rand.nextInt(500));

Oublier de créer l'objet produira une exception d'exécution dès que l'on accédera à l'emplacement.

Regardons la formation de l'objet String à l'intérieur de print. On peut voir que la référence vers l'objet Integer est automatiquement convertie pour produire une String représentant la valeur à l'intérieur de l'objet.

Il est également possible d'initialiser des tableaux d'objets en utilisant la liste délimitée par des accolades. Il y a deux formes :

 
Sélectionnez
//: c04:ArrayInit.java
// Initialisation de tableaux.

public class ArrayInit {
    public static void main(String[] args) {
        Integer[] a = {
            new Integer(1),
            new Integer(2),
            new Integer(3),
        };
        Integer[] b = new Integer[] {
            new Integer(1),
            new Integer(2),
            new Integer(3),
        };
    }
} ///:~

La première forme est parfois utile, mais d'un usage plus limité, car la taille du tableau est déterminée à la compilation. La virgule finale dans la liste est optionnelle (cette fonctionnalité permet une gestion plus facile des listes longues).

La deuxième forme d'initialisation de tableaux offre une syntaxe pratique pour créer et appeler des méthodes qui permet de donner le même effet que les listes à nombre d'arguments variable en C (connus sous le nom de « varargs » en C). Ces dernières permettent le passage d'un nombre inconnu de paramètres, chacun de type inconnu. Comme toutes les classes héritent d'une classe racine Object (un sujet qui sera couvert en détail tout au long du livre), on peut créer une méthode qui prend un tableau d'Object et l'appeler ainsi :

 
Sélectionnez
//: c04:VarArgs.java
// Utilisation de la syntaxe des tableaux pour créer des listes variable d'arguments
import com.bruceeckel.simpletest.*;

class A { int i; }

public class VarArgs {
    static Test monitor = new Test();
    static void print(Object[] x) {
        for(int i = 0; i < x.length; i++)
            System.out.println(x[i]);
    }
    public static void main(String[] args) {
        print(new Object[] {
            new Integer(47), new VarArgs(),
            new Float(3.14), new Double(11.11)
        });
        print(new Object[] {"one", "two", "three" });
        print(new Object[] {new A(), new A(), new A()});
        monitor.expect(new Object[] {
            "47",
            "%% VarArgs@\\p{XDigit}+",
            "3.14",
            "11.11",
            "one",
            "two",
            "three",
            new TestExpression("%% A@\\p{XDigit}+", 3)
        });
    }
} ///:~

Vous pouvez voir que print( ) prend un tableau d'Object, itère sur ce tableau et affiche chaque objet. La bibliothèque Java standard produit des sorties raisonnables, mais les objets des classes créés ici - A et VarArgs - affiche le nom de la classe, suivi par le signe '@', et encore une autre expression régulière de construction, \p{XDigit}, qui indique un chiffre hexadécimal. Le symbole '+' signifie qu'il y aura un ou plusieurs chiffres hexadécimaux. Ainsi, le comportement par défaut (si vous ne définissez pas une méthode toString( ) pour votre classe, ce qui sera décrit plus tard dans le livre) est d'afficher le nom de la classe et l'adresse de l'objet.

IV-E-1. Tableaux multidimensionnels

Java permet de créer facilement des tableaux multidimensionnels :

 
Sélectionnez
//: c04:MultiDimArray.java
// Création de tableaux multidimensionnels.
import com.bruceeckel.simpletest.*;
import java.util.*;

public class MultiDimArray {
    static Test monitor = new Test();
    static Random rand = new Random();
    public static void main(String[] args) {
        int[][] a1 = {
            { 1, 2, 3, },
            { 4, 5, 6, },
        };
        for(int i = 0; i < a1.length; i++)
            for(int j = 0; j < a1[i].length; j++)
                System.out.println(
                    "a1[" + i + "][" + j + "] = " + a1[i][j]);
        // tableau 3-D avec taille fixe :
        int[][][] a2 = new int[2][2][4];
        for(int i = 0; i < a2.length; i++)
            for(int j = 0; j < a2[i].length; j++)
                for(int k = 0; k < a2[i][j].length; k++)
                    System.out.println("a2[" + i + "][" + j + "][" +
                        k + "] = " + a2[i][j][k]);
        // tableau 3-D avec vecteurs de taille variable :
        int[][][] a3 = new int[rand.nextInt(7)][][];
        for(int i = 0; i < a3.length; i++) {
            a3[i] = new int[rand.nextInt(5)][];
            for(int j = 0; j < a3[i].length; j++)
                a3[i][j] = new int[rand.nextInt(5)];
        }
        for(int i = 0; i < a3.length; i++)
            for(int j = 0; j < a3[i].length; j++)
                for(int k = 0; k < a3[i][j].length; k++)
                    System.out.println("a3[" + i + "][" + j + "][" +
                        k + "] = " + a3[i][j][k]);
        // Tableau d'objets non primitifs :
        Integer[][] a4 = {
            { new Integer(1), new Integer(2)},
            { new Integer(3), new Integer(4)},
            { new Integer(5), new Integer(6)},
        };
        for(int i = 0; i < a4.length; i++)
            for(int j = 0; j < a4[i].length; j++)
                System.out.println("a4[" + i + "][" + j +
                    "] = " + a4[i][j]);
        Integer[][] a5;
        a5 = new Integer[3][];
        for(int i = 0; i < a5.length; i++) {
            a5[i] = new Integer[3];
            for(int j = 0; j < a5[i].length; j++)
                a5[i][j] = new Integer(i * j);
        }
        for(int i = 0; i < a5.length; i++)
            for(int j = 0; j < a5[i].length; j++)
                System.out.println("a5[" + i + "][" + j +
                    "] = " + a5[i][j]);
        // Test d'affichage de sortie
        int ln = 0;
        for(int i = 0; i < a3.length; i++)
            for(int j = 0; j < a3[i].length; j++)
                for(int k = 0; k < a3[i][j].length; k++)
                    ln++;
        monitor.expect(new Object[] {
            "a1[0][0] = 1",
            "a1[0][1] = 2",
            "a1[0][2] = 3",
            "a1[1][0] = 4",
            "a1[1][1] = 5",
            "a1[1][2] = 6",
            new TestExpression(
                "%% a2\\[\\d\\]\\[\\d\\]\\[\\d\\] = 0", 16),
            new TestExpression(
                "%% a3\\[\\d\\]\\[\\d\\]\\[\\d\\] = 0", ln),
            "a4[0][0] = 1",
            "a4[0][1] = 2",
            "a4[1][0] = 3",
            "a4[1][1] = 4",
            "a4[2][0] = 5",
            "a4[2][1] = 6",
            "a5[0][0] = 0",
            "a5[0][1] = 0",
            "a5[0][2] = 0",
            "a5[1][0] = 0",
            "a5[1][1] = 1",
            "a5[1][2] = 2",
            "a5[2][0] = 0",
            "a5[2][1] = 2",
            "a5[2][2] = 4"
        });
    }
} ///:~

Le code d'affichage utilise length de cette façon il ne force pas une taille de tableau fixe.

Le premier exemple montre un tableau multidimensionnel de types primitifs. Chaque vecteur du tableau est délimité par des accolades :

 
Sélectionnez
int[][] a1 = {
    { 1, 2, 3, },
    { 4, 5, 6, },
};

Chaque paire de crochets donne accès à la dimension suivante du tableau.

Le deuxième exemple montre un tableau à trois dimensions alloué par new. Ici le tableau entier est alloué en une seule fois :

 
Sélectionnez
int[][][] a2 = new int[2][2][4];

Par contre, le troisième exemple montre que les vecteurs dans les tableaux qui forment la matrice peuvent être de longueurs différentes :

 
Sélectionnez
int[][][] a3 = new int[rand.nextInt(7)][][];
for(int i = 0; i < a3.length; i++) {
    a3[i] = new int[rand.nextInt(5)][];
    for(int j = 0; j < a3[i].length; j++)
        a3[i][j] = new int[rand.nextInt(5)];
}

Le premier new crée un tableau avec une longueur aléatoire pour le premier élément et le reste de longueur indéterminée. Le deuxième new à l'intérieur de la boucle for remplit les éléments, mais laisse le troisième index indéterminé jusqu'au troisième new.

On peut voir à l'exécution que les valeurs des tableaux sont automatiquement initialisées à zéro si on ne leur donne pas explicitement de valeur initiale.

Les tableaux d'objets non primitifs fonctionnent exactement de la même manière, comme le montre le quatrième exemple, qui présente la possibilité d'utiliser new dans les accolades d'initialisation :

 
Sélectionnez
Integer[][] a4 = {
    { new Integer(1), new Integer(2)},
    { new Integer(3), new Integer(4)},
    { new Integer(5), new Integer(6)},
};

Le cinquième exemple montre comment un tableau d'objets non primitifs peut être construit pièce par pièce :

 
Sélectionnez
Integer[][] a5;
a5 = new Integer[3][];
for(int i = 0; i < a5.length; i++) {
    a5[i] = new Integer[3];
    for(int j = 0; j < a5[i].length; j++)
        a5[i][j] = new Integer(i*j);
}

L'expression i*j est là uniquement pour donner une valeur intéressante à l'Integer.

IV-F. Résumé

Le mécanisme apparemment sophistiqué d'initialisation que l'on appelle constructeur souligne l'importance donnée à l'initialisation dans ce langage. Quand Bjarne Stroustrup était en train de créer C++, une des premières observations qu'il fit à propos de la productivité en C était qu'une initialisation inappropriée des variables cause de nombreux problèmes de programmation. Ce genre de bogues est difficile à trouver, des problèmes similaires se retrouvent avec un mauvais nettoyage. Parce que les constructeurs permettent de garantir une initialisation et un nettoyage correct (le compilateur n'autorisera pas la création d'un objet sans un appel valide du constructeur), le programmeur a un contrôle complet en toute sécurité.

En C++, la destruction est importante parce que les objets créés avec new doivent être détruits explicitement. En Java, le ramasse-miettes libère automatiquement la mémoire pour tous les objets, donc la méthode de nettoyage équivalente en Java n'est pratiquement jamais nécessaire (mais quand c'est le cas, comme observé dans ce chapitre, vous devez le faire vous-même). Dans les cas où un comportement du style destructeur n'est pas nécessaire, le ramasse-miettes de Java simplifie grandement la programmation et ajoute une sécurité bien nécessaire à la gestion mémoire. Certains ramasse-miettes peuvent même s'occuper du nettoyage d'autres ressources telles que les graphiques et les fichiers. Cependant, le prix du ramasse-miettes est payé par une augmentation du temps d'exécution, qu'il est toutefois difficile d'évaluer à cause de la lenteur globale des interpréteurs Java. Bien que Java a eu une augmentation importante de ses performances avec le temps, le problème de vitesse a laissé une empreinte sur l'adoption de ce langage pour certains types de problèmes de programmation.

Parce que Java garantit la construction de tous les objets, le constructeur est, en fait, plus conséquent que ce qui est expliqué ici. En particulier, quand on crée de nouvelles classes en utilisant soit la composition, soit l'héritage la garantie de construction est maintenue et une syntaxe supplémentaire est nécessaire. La composition, l'héritage et leurs effets sur les constructeurs sont expliqués dans les chapitres suivants.

IV-G. Exercices

Les solutions aux exercices choisis peuvent être trouvées dans le document électronique The Thinking in Java Annotated Solution Guide, disponible pour une modeste somme à l'adresse www.BruceEckel.com.

  1. Créez une classe avec un constructeur par défaut (c'est-à-dire sans argument) qui imprime un message. Créez un objet de cette classe.
  2. Ajoutez à la classe de l'exercice 1 un constructeur surchargé qui prend une String en argument et qui l'imprime avec votre message.
  3. Créez un tableau de références sur des objets de la classe que vous avez créée à l'exercice 2, mais ne créez pas les objets eux-mêmes. Quand le programme s'exécute, voyez si les messages d'initialisation du constructeur sont imprimés.
  4. Terminez l'exercice 3 en créant les objets pour remplir le tableau de références.
  5. Créez un tableau d'objets String et affectez une chaîne de caractères à chaque élément. Imprimez le tableau en utilisant une boucle for.
  6. Créez une classe Dog avec une méthode bark( ) (NDT: to bark = aboyer) surchargée. Cette méthode sera surchargée en utilisant divers types primitifs de données et devra imprimer différents types d'aboiements, hurlements... suivant la version surchargée qui est appelée. Écrivez également une méthode main( ) qui appellera toutes les versions.
  7. Modifiez l'exercice 6 pour que deux des méthodes surchargées aient deux paramètres (de deux types différents), mais dans l'ordre inverse l'une par rapport à l'autre. Vérifiez que cela fonctionne.
  8. Créez une classe sans constructeur et créez ensuite un objet de cette classe dans main( ) pour vérifier que le constructeur par défaut est construit automatiquement.
  9. Créez une classe avec deux méthodes. Dans la première méthode, appelez la seconde méthode deux fois : la première fois sans utiliser this, et la seconde fois en utilisant this.
  10. Créez une classe avec deux constructeurs (surchargés). En utilisant this, appelez le second constructeur dans le premier.
  11. Créez une classe avec une méthode finalize( ) qui imprime un message. Dans main( ), créez un objet de cette classe. Expliquez le comportement de ce programme.
  12. Modifiez l'exercice 11 pour que votre finalize( ) soit toujours appelé.
  13. Créez une classe Tank (NDT: citerne) qui peut être remplie et vidée et qui a une condition de fin qui est que la citerne doit être vide quand l'objet est nettoyé. Écrivez une méthode finalize( ) qui vérifie cette condition de fin. Dans main( ), testez tous les scénarii possibles d'utilisation de Tank.
  14. Créez une classe contenant un int et un charnon initialisés et imprimez leurs valeurs pour vérifier que Java effectue leurs initialisations par défaut.
  15. Créez une classe contenant une référence non initialisée à uneString. Montrez que cette référence est initialisée à null par Java.
  16. Créez une classe avec un champ String qui est initialisé à l'endroit de sa définition et un autre qui est initialisé par le constructeur. Quelle est la différence entre les deux approches ?
  17. Créez une classe avec un champ static String qui est initialisé à l'endroit de la définition et un autre qui est initialisé par un bloc static. Ajoutez une méthode static qui imprime les deux champs et montre qu'ils sont initialisés avant d'être utilisés.
  18. Créez une classe avec un champ String qui est initialisé par une « initialisation d'instance ». Décrire une utilisation de cette fonctionnalité (autre que celle spécifiée dans ce livre).
  19. Écrivez une méthode qui crée et initialise un tableau de double. La taille de ce tableau est déterminée par les arguments de la méthode, et les valeurs d'initialisation sont un intervalle déterminé par des valeurs de début et de fin également données en paramètres de la méthode. Créez une deuxième méthode qui imprimera le tableau généré par la première. Dans main( ) testez les méthodes en créant et en imprimant plusieurs tableaux de différentes tailles.
  20. Recommencez l'exercice 19 pour un tableau à trois dimensions.
  21. Mettez en commentaire la ligne marquée (1) dans ExplicitStatic.java et vérifiez que la clause d'initialisation statique n'est pas appelée. Maintenant, décommentez une des lignes marquées (2) et vérifiez que la clause d'initialisation statique est appelée. Décommentez maintenant l'autre ligne marquée (2) et vérifiez que l'initialisation statique n'est effectuée qu'une fois.

précédentsommairesuivant
Dans une partie de la littérature Java de Sun, celui-ci est désigné avec le nom maladroit, mais descriptif de « constructeur sans arguments ». Le terme de « constructeur par défaut » est par ailleurs utilisé depuis des années et sera donc adopté ici.
Certaines personnes mettront obsessionnellement le this devant chaque appel de méthode et chaque attribut, défendant cette pratique en disant qu'elle est « plus claire et plus explicite ». Ne le faites pas. Il y a une bonne raison pour laquelle on utilise des langages de haut niveau : ils font des choses pour nous. En mettant this quand cela n'est pas nécessaire, on amène de la confusion et cela dérangera toute personne qui lira le code puisque tout autre code qu'elle lira n'utilisera pas le this à tout bout de champ. Suivre un style de code consistant et direct épargne du temps et de l'argent.
Le seul cas où cela est possible est lorsque l'on passe une référence à l'objet à l'intérieur de la méthode static. Alors, via la référence (qui est maintenant effectivement this), on peut appeler des méthodes non static et accéder aux champs non static. Mais typiquement, si l'on souhaite faire ceci, autant créer une méthode ordinaire non static.
Joshua Bloch va plus loin dans la section appelée "avoid finalizers": "Finalizers are unpredictable, often dangerous, and generally unnecessary." Effective Java, page 20 (Addison-Wesley 2001).
Un terme inventé par Bill Venners (www.artima.com) pour un séminaire que lui et moi avions donné ensemble.
Au contraire, C++ a le constructeur initialiseur de listes qui provoque l'initialisation avant d'entrer dans le corps, et qui met en application les objets. Voir Thinking in C++, 2de édition (disponible sur le CD ROM de ce livre et à www.BruceEckel.com).
Voir Thinking in C++, 2de édition pour une description de l'initialisation d'agrégats.

Ce document est issu de http://www.developpez.com et reste la propriété exclusive de son auteur. La copie, modification et/ou distribution par quelque moyen que ce soit est soumise à l'obtention préalable de l'autorisation de l'auteur.