Comment fonctionne l’initialisation des objets en Java ?

Au chapitre 1, nous avons abordé l’ordre d’initialisation, bien que de manière très simplifiée. L’ordre d’initialisation fait référence à la façon dont les membres d’une classe reçoivent des valeurs. Ils peuvent recevoir des valeurs par défaut, comme 0 pour un int, ou nécessiter des valeurs explicites, comme pour les variables final. Dans cette section, nous explorons plus en détail le fonctionnement de l’ordre d’initialisation.

Initialisation des Classes

Nous commençons notre discussion sur l’ordre d’initialisation avec l’initialisation des classes. Tout d’abord, nous initialisons la classe, ce qui implique d’invoquer tous les membres static dans la hiérarchie des classes, en commençant par la superclasse la plus élevée et en descendant. On parle parfois de chargement de la classe. La Machine Virtuelle Java (JVM) contrôle quand la classe est initialisée, bien que vous puissiez supposer que la classe est chargée avant d’être utilisée. La classe peut être initialisée au démarrage du programme, lorsqu’un membre static de la classe est référencé, ou peu avant qu’une instance de la classe soit créée.

L’une des règles les plus importantes concernant l’initialisation des classes est qu’elle se produit au maximum une fois pour chaque classe. La classe peut également ne jamais être chargée si elle n’est pas utilisée dans le programme. Nous résumons l’ordre d’initialisation d’une classe comme suit :

Initialiser la Classe X

  1. S’il existe une superclasse Y de X, alors initialiser la classe Y d’abord.
  2. Traiter toutes les déclarations de variables statiques dans l’ordre dans lequel elles apparaissent dans la classe.
  3. Traiter tous les initialiseurs statiques dans l’ordre dans lequel ils apparaissent dans la classe.

Examinons un exemple, que produit le programme suivant ?

public class Animal {
    static { System.out.print("A"); }
}

public class Hippopotame extends Animal {
    public static void main(String[] herbe) {
        System.out.print("C");
        new Hippopotame();
        new Hippopotame();
        new Hippopotame();
    }
    static { System.out.print("B"); }
}

Il imprime ABC exactement une fois. Comme la méthode main() est à l’intérieur de la classe Hippopotame, la classe doit être initialisée en premier, en commençant par la superclasse et en imprimant AB. Ensuite, la méthode main() est exécutée, imprimant C. Même si la méthode main() crée trois instances, la classe n’est chargée qu’une seule fois.

Pourquoi le Programme Hippopotame a Imprimé C Après AB

Dans l’exemple précédent, la classe Hippopotame a été initialisée avant l’exécution de la méthode main(). Cela s’est produit parce que notre méthode main() était à l’intérieur de la classe exécutée, donc elle devait être chargée au démarrage. Que se passerait-il si vous appeliez plutôt Hippopotame à l’intérieur d’un autre programme ?

public class AmiHippopotame {
    public static void main(String[] herbe) {
        System.out.print("C");
        new Hippopotame();
    }
}

En supposant que la classe n’est référencée nulle part ailleurs, ce programme imprimera probablement CAB, la classe Hippopotame n’étant pas chargée avant d’être nécessaire dans la méthode main(). Nous disons probablement parce que les règles de chargement des classes sont déterminées par la JVM au moment de l’exécution. Vous devez simplement savoir qu’une classe doit être initialisée avant d’être référencée ou utilisée. De plus, la classe contenant le point d’entrée du programme, c’est-à-dire la méthode main(), est chargée avant l’exécution de la méthode main().

Initialisation des Champs final

Avant d’approfondir l’ordre d’initialisation pour les membres d’instance, nous devons parler un moment des champs final (variables d’instance). Quand nous avons présenté les variables d’instance et de classe au chapitre 1, nous vous avons dit qu’elles reçoivent une valeur par défaut basée sur leur type si aucune valeur n’est spécifiée. Par exemple, un double est initialisé avec 0.0, tandis qu’une référence d’objet est initialisée à null. Cependant, une valeur par défaut n’est appliquée qu’à un champ non-final.

Comme vous l’avez vu au chapitre 5, les variables static final doivent être explicitement assignées une valeur exactement une fois. Les champs marqués final suivent des règles similaires. Ils peuvent recevoir des valeurs dans la ligne où ils sont déclarés ou dans un initialiseur d’instance.

public class MaisonSouris {
    private final int volume;
    private final String nom = "La Maison des Souris"; // Attribution à la déclaration
    {
        volume = 10; // Attribution dans l'initialiseur d'instance
    }
}

Contrairement aux membres de classe static, les champs d’instance final peuvent également être définis dans un constructeur. Le constructeur fait partie du processus d’initialisation, il est donc autorisé à assigner des variables d’instance final. Vous devez connaître une règle importante : au moment où le constructeur se termine, toutes les variables d’instance final doivent avoir reçu une valeur exactement une fois.

Essayons cela dans un exemple :

public class MaisonSouris {
    private final int volume;
    private final String nom;

    public MaisonSouris() {
        this.nom = "Maison Vide"; // Attribution dans le constructeur
    }
    {
        volume = 10; // Attribution dans l'initialiseur d'instance
    }
}

Contrairement aux variables locales final, qui ne sont pas obligées d’avoir une valeur à moins qu’elles ne soient effectivement utilisées, les variables d’instance final doivent être assignées une valeur. Si elles ne reçoivent pas de valeur lors de leur déclaration ou dans un initialiseur d’instance, elles doivent alors recevoir une valeur dans la déclaration du constructeur. Ne pas le faire entraînera une erreur de compilation sur la ligne qui déclare le constructeur.

public class MaisonSouris {
    private final int volume;
    private final String type;
    {
        this.volume = 10;
    }
    public MaisonSouris(String type) {
        this.type = type;
    }
    public MaisonSouris() { // NE COMPILE PAS
        this.volume = 2; // NE COMPILE PAS
    }
}

Dans cet exemple, le premier constructeur qui prend un argument String compile. En termes d’assignation de valeurs, chaque constructeur est examiné individuellement, c’est pourquoi le second constructeur ne compile pas. Premièrement, le constructeur ne définit pas de valeur pour la variable type. Le compilateur détecte qu’une valeur n’est jamais définie pour type et signale une erreur sur la ligne où le constructeur est déclaré. Deuxièmement, le constructeur définit une valeur pour la variable volume, alors qu’elle a déjà reçu une valeur par l’initialiseur d’instance.

Soyez vigilant à propos des variables d’instance marquées final. Assurez-vous qu’elles reçoivent une valeur dans la ligne où elles sont déclarées, dans un initialiseur d’instance, ou dans un constructeur. Elles ne doivent recevoir une valeur qu’une seule fois, et l’absence d’assignation est considérée comme une erreur de compilation dans le constructeur.

Qu’en est-il des variables d’instance final lorsqu’un constructeur en appelle un autre dans la même classe ? Dans ce cas, vous devez suivre attentivement le flux, en vous assurant que chaque variable d’instance final reçoit une valeur exactement une fois. Nous pouvons remplacer notre précédent constructeur défectueux par celui-ci qui compile :

public MaisonSouris() {
    this(null);
}

Ce constructeur n’effectue aucune assignation à des variables d’instance final, mais il appelle le constructeur MaisonSouris(String), qui compile sans problème. Nous utilisons null ici pour démontrer que la variable n’a pas besoin d’être une valeur d’objet. Nous pouvons assigner une valeur null aux variables d’instance final tant qu’elles sont explicitement définies.

Initialisation des Instances

Nous avons couvert l’initialisation des classes et des champs final, il est maintenant temps de passer à l’ordre d’initialisation pour les objets. Nous vous prévenons que cela peut être un peu fastidieux au début, mais nous vous promettons de le prendre lentement.

Tout d’abord, commencez par le constructeur de niveau le plus bas où le mot-clé new est utilisé. Rappelez-vous que la première ligne de chaque constructeur est un appel à this() ou super(), et si omis, le compilateur insérera automatiquement un appel au constructeur parent sans argument super(). Ensuite, progressez vers le haut et notez l’ordre des constructeurs. Enfin, initialisez chaque classe en commençant par la superclasse, en traitant chaque initialiseur d’instance et constructeur dans l’ordre inverse dans lequel il a été appelé. Nous résumons l’ordre d’initialisation pour une instance comme suit :

Initialiser l’Instance de X

  1. Initialiser la classe X si elle n’a pas été précédemment initialisée.
  2. S’il existe une superclasse Y de X, alors initialiser l’instance de Y d’abord.
  3. Traiter toutes les déclarations de variables d’instance dans l’ordre dans lequel elles apparaissent dans la classe.
  4. Traiter tous les initialiseurs d’instance dans l’ordre dans lequel ils apparaissent dans la classe.
  5. Initialiser le constructeur, y compris tous les constructeurs surchargés référencés avec this().

Essayons un exemple sans héritage. Voyez si vous pouvez déterminer ce que produit l’application suivante :

public class TicketsZoo {
    private String nom = "MeilleurZoo";
    { System.out.print(nom + "-"); }
    private static int COMPTEUR = 0;
    static { System.out.print(COMPTEUR + "-"); }
    static { COMPTEUR += 10; System.out.print(COMPTEUR + "-"); }

    public TicketsZoo() {
        System.out.print("z-");
    }

    public static void main(String... clients) {
        new TicketsZoo();
    }
}

La sortie est la suivante :

0-10-MeilleurZoo-z-

D’abord, nous devons initialiser la classe. Comme il n’y a pas de superclasse déclarée, ce qui signifie que la superclasse est Object, nous pouvons commencer par les composants static de TicketsZoo. Dans ce cas, les lignes relatives à COMPTEUR sont exécutées, imprimant 0- et 10-. Ensuite, nous initialisons l’instance créée dans main(). À nouveau, comme aucune superclasse n’est déclarée, nous commençons par les composants d’instance. Les lignes relatives à nom sont exécutées, ce qui imprime MeilleurZoo-. Enfin, nous exécutons le constructeur, qui produit z-.

Essayons maintenant un exemple simple avec héritage :

class Primate {
    public Primate() {
        System.out.print("Primate-");
    }
}

class Singe extends Primate {
    public Singe(int fourrure) {
        System.out.print("Singe1-");
    }
    public Singe() {
        System.out.print("Singe2-");
    }
}

public class Chimpanze extends Singe {
    public Chimpanze() {
        super(2);
        System.out.print("Chimpanze-");
    }
    public static void main(String[] args) {
        new Chimpanze();
    }
}

Le compilateur insère la commande super() comme première instruction des constructeurs Primate et Singe. Le code s’exécutera avec les constructeurs parents appelés en premier et produira la sortie suivante :

Primate-Singe1-Chimpanze-

Notez que seul l’un des deux constructeurs Singe() est appelé. Vous devez commencer par l’appel à new Chimpanze() pour déterminer quels constructeurs seront exécutés. N’oubliez pas que les constructeurs sont exécutés de bas en haut, mais comme la première ligne de chaque constructeur est un appel à un autre constructeur, le flux finit par faire exécuter le constructeur parent avant le constructeur enfant.

L’exemple suivant est un peu plus difficile. Que pensez-vous qu’il se passe ici ?

public class Seiche {
    private String nom = "nageur";
    { System.out.println(nom); }
    private static int COMPTEUR = 0;
    static { System.out.println(COMPTEUR); }
    { COMPTEUR++; System.out.println(COMPTEUR); }

    public Seiche() {
        System.out.println("Constructeur");
    }

    public static void main(String[] args) {
        System.out.println("Prêt");
        new Seiche();
    }
}

La sortie ressemble à ceci :

0
Prêt
nageur
1
Constructeur

Aucune superclasse n’est déclarée, donc nous pouvons ignorer toutes les étapes liées à l’héritage. Nous traitons d’abord les variables statiques et les initialiseurs statiques — les lignes relatives à COMPTEUR, avec l’initialiseur statique imprimant 0. Maintenant que les initialiseurs statiques sont traités, la méthode main() peut s’exécuter, ce qui imprime Prêt. Ensuite, nous créons une instance déclarée dans la ligne new Seiche(). Les lignes relatives à nom et à l’incrémentation de COMPTEUR sont traitées, avec l’initialiseur d’instance imprimant nageur puis 1. Enfin, le constructeur est exécuté, ce qui imprime Constructeur.

Prêt pour un exemple plus difficile, du genre que vous pourriez voir durant un examen ? Que produit le code suivant ?

class FamilleGirafe {
    static { System.out.print("A"); }
    { System.out.print("B"); }

    public FamilleGirafe(String nom) {
        this(1);
        System.out.print("C");
    }

    public FamilleGirafe() {
        System.out.print("D");
    }

    public FamilleGirafe(int rayures) {
        System.out.print("E");
    }
}

public class Okapi extends FamilleGirafe {
    static { System.out.print("F"); }

    public Okapi(int rayures) {
        super("sucre");
        System.out.print("G");
    }
    { System.out.print("H"); }

    public static void main(String[] herbe) {
        new Okapi(1);
        System.out.println();
        new Okapi(2);
    }
}

Le programme imprime ce qui suit :

AFBECHG
BECHG

Analysons-le. Commençons par initialiser la classe Okapi. Comme elle a une superclasse FamilleGirafe, nous l’initialisons d’abord, imprimant A sur la ligne 2. Ensuite, nous initialisons la classe Okapi, imprimant F sur la ligne 19.

Après l’initialisation des classes, nous exécutons la méthode main() sur la ligne 27. La première ligne de la méthode main() crée un nouvel objet Okapi, déclenchant le processus d’initialisation d’instance. Selon la première règle, l’instance de superclasse FamilleGirafe est initialisée en premier. Selon notre troisième règle, l’initialiseur d’instance dans la superclasse FamilleGirafe est appelé, et B est imprimé sur la ligne 3. Selon la quatrième règle, nous initialisons les constructeurs. Dans ce cas, cela implique d’appeler le constructeur sur la ligne 5, qui à son tour appelle le constructeur surchargé sur la ligne 14. Le résultat est que EC est imprimé, car les corps des constructeurs sont déroulés dans l’ordre inverse de leur appel.

Le processus se poursuit ensuite avec l’initialisation de l’instance Okapi elle-même. Selon les troisième et quatrième règles, H est imprimé sur la ligne 25, et G est imprimé sur la ligne 23, respectivement. Le processus est beaucoup plus simple lorsque vous n’avez pas à appeler de constructeurs surchargés. La ligne 29 insère ensuite un saut de ligne dans la sortie. Enfin, la ligne 30 initialise un nouvel objet Okapi. L’ordre et l’initialisation sont les mêmes que pour la ligne 28, sans l’initialisation de classe, donc BECHG est imprimé à nouveau. Notez que D n’est jamais imprimé, car seuls deux des trois constructeurs dans la superclasse FamilleGirafe sont appelés.

Cet exemple est complexe pour plusieurs raisons. Il y a plusieurs constructeurs surchargés, de nombreux initialiseurs, et un chemin de constructeur complexe à suivre. Si vous en rencontrez un, notez simplement ce qui se passe au fur et à mesure que vous lisez le code.

Nous concluons cette section en énumérant les règles importantes que vous devriez connaître :

  • Une classe est initialisée au maximum une fois par la JVM avant d’être référencée ou utilisée.
  • Toutes les variables static final doivent recevoir une valeur exactement une fois, soit lors de leur déclaration, soit dans un initialiseur statique.
  • Tous les champs final doivent recevoir une valeur exactement une fois, soit lors de leur déclaration, dans un initialiseur d’instance, soit dans un constructeur.
  • Les variables statiques et d’instance non-final définies sans valeur reçoivent une valeur par défaut basée sur leur type.
  • L’ordre d’initialisation est le suivant : déclarations de variables, puis initialiseurs, et enfin constructeurs.