Skip to content

4. Classes, records, interfaces

4.1. L'objet par l'exemple

4.1.1. Généralités

Nous abordons maintenant, par l'exemple, la programmation objet. Un objet est une entité qui contient des données qui définissent son état (on les appelle des champs, attributs, …) et des fonctions (on les appelle des méthodes). Un objet est créé selon un modèle qu'on appelle une classe. Une classe [C1] déclare par exemple deux champs [Type1 p1;] et [Type2 p2;] et deux méthodes [Type3 m3(...) { ... }] et [Type4 m4(...) { ... }], le tout entre les accolades de la déclaration [public class C1 { ... }].

À partir de la classe [C1], on peut créer de nombreux objets [o1], [o2], … Tous auront les champs [p1], [p2], … et les méthodes [m3], [m4], … Mais ils auront des valeurs différentes pour leurs champs [pi], ayant ainsi chacun un état qui leur est propre. Si [o1] est un objet de type [C1], [o1.p1] désigne le champ [p1] de [o1] et [o1.m3(...)] l'appel de la méthode [m3] de [o1].

Considérons un premier modèle d'objet : la classe [Personne].

4.1.2. Un programme formé de plusieurs fichiers

Dans les exemples du chapitre précédent, un programme tenait dans un unique fichier source. À partir de maintenant, un programme pourra être formé de plusieurs fichiers source : une classe par fichier, plus le programme principal. En Java, une classe publique est obligatoirement placée dans un fichier qui porte son nom : la classe publique [Personne] est dans le fichier [Personne.java]. Nous rangeons les fichiers d'un même programme dans un dossier, ici le dossier [Personnes] du dossier [04-classes] :

1
2
3
Personnes/
  Personne.java     la classe Personne
  Main.java         le programme principal

Depuis Java 22, le lanceur de code source, déjà utilisé au chapitre précédent, sait exécuter un programme de plusieurs fichiers : la commande [java Main.java], tapée dans le dossier [Personnes], compile en mémoire le fichier [Main.java], puis, à la demande, les autres fichiers du dossier dont il a besoin, ici [Personne.java], et exécute le programme. Aucun fichier compilé n'est écrit sur le disque. On peut aussi compiler explicitement les fichiers avec le compilateur [javac], qui écrit un fichier [.class] (du code intermédiaire, le « bytecode ») par classe, puis exécuter le programme avec la commande [java] en lui indiquant où trouver les classes compilées (option [-cp], le « classpath ») :

1
2
3
4
5
6
> javac -d classes *.java
> ls classes
Main.class
Personne.class
> java -cp classes Main
[Jean, Dupont, 30]

Le lanceur multi-fichiers suffit pour les programmes de ce chapitre, qui n'utilisent que les classes du JDK. Lorsqu'un programme aura besoin de bibliothèques extérieures, nous utiliserons un projet Maven, présenté au chapitre 2 : Maven télécharge les bibliothèques, compile le projet et l'exécute. Les dossiers de ce chapitre peuvent être ouverts tels quels dans IntelliJ IDEA ou VS Code.

4.1.3. Définition de la classe Personne

La définition de la classe [Personne] dans le fichier source [Personne.java] sera la suivante :

public class Personne {
    // attributs (champs)
    private String prenom = "";
    private String nom = "";
    private int age;

    // méthode
    public void initialise(String p, String n, int age) {
        this.prenom = p;
        this.nom = n;
        this.age = age;
    }

    // méthode
    public void identifie() {
        IO.println("[" + prenom + ", " + nom + ", " + age + "]");
    }
}
  • ligne 1 : la classe est publique ([public]) : elle est utilisable par toutes les autres classes. Elle n'appartient à aucun paquetage, ou plus exactement au paquetage sans nom ; les paquetages sont présentés à la fin du chapitre ;
  • lignes 3-5 : les trois champs de la classe. Les deux chaînes sont initialisées avec la chaîne vide : sans cela, elles vaudraient [null] à la création de l'objet (un champ de type objet non initialisé vaut [null], un champ numérique 0, un champ booléen [false]) ;
  • lignes 8-12 : une méthode qui fixe l'état de l'objet ;
  • lignes 15-17 : une méthode qui affiche l'état de l'objet. [IO] est la classe d'entrées-sorties console de Java 25 que nous avons utilisée au chapitre précédent.

Nous avons ici la définition d'une classe, donc d'un type de données. Lorsqu'on va créer des variables de ce type, on les appellera des objets ou des instances de classe. Une classe est donc un moule à partir duquel sont construits des objets. Par convention, le nom d'une classe commence par une majuscule ([Personne]), celui d'un champ ou d'une méthode par une minuscule ([prenom], [initialise]).

Les membres d'une classe peuvent être des données (champs) ou des méthodes (fonctions). Ces membres peuvent être accompagnés de l'un des mots clés suivants :

Mot clé
Accès
[private]
un membre privé n'est accessible que par les seules méthodes internes de la classe
(aucun)
en l'absence de mot clé, le membre est accessible par toute méthode d'une classe du même paquetage. C'est l'accès par défaut, dit « accès paquetage »
[protected]
un membre protégé est accessible par les méthodes des classes du même paquetage et par celles des classes dérivées (voir plus loin le concept d'héritage)
[public]
un membre public est accessible par toute méthode, définie ou non au sein de la classe

En général, les données d'une classe sont déclarées privées alors que ses méthodes sont déclarées publiques. Cela signifie que l'utilisateur d'un objet (le programmeur) :

  • n'aura pas accès directement aux données privées de l'objet ;
  • pourra faire appel aux méthodes publiques de l'objet et notamment à celles qui donneront accès à ses données privées.

L'ordre de déclaration des membres [private], [protected] et [public] est quelconque. On notera que l'accès par défaut n'est pas l'accès privé : un champ déclaré sans mot clé est visible de toutes les classes du paquetage. Il faut donc penser à écrire [private].

4.1.4. La méthode initialise

Quel est le rôle de la méthode [initialise] ? Parce que [nom], [prenom] et [age] sont des données privées de la classe [Personne], une instruction comme [p1.prenom = "Jean";], écrite dans le programme principal, est illégale. Le compilateur la refuse :

1
2
3
4
5
6
> java Main.java
Main.java:4: error: prenom has private access in Personne
    p1.prenom = "Jean";
      ^
1 error
error: compilation failed

Le message du compilateur est en anglais : le JDK n'a pas de traduction française de ses messages. Il indique le fichier et la ligne de l'erreur, puis la désigne par un accent circonflexe. Il nous faut initialiser un objet de type [Personne] via une méthode publique. C'est le rôle de la méthode [initialise]. On écrira [p1.initialise("Jean", "Dupont", 30);]. Cette écriture est légale car [initialise] est d'accès public.

4.1.5. L'opérateur new

Il manque pourtant encore quelque chose. La déclaration [Personne p1;] déclare [p1] comme une référence à un objet de type [Personne]. Cet objet n'existe pas encore et donc [p1] n'est pas initialisé. Lorsqu'on écrit ensuite [p1.initialise("Jean", "Dupont", 30);], on fait appel à la méthode [initialise] de l'objet référencé par [p1]. Or cet objet n'existe pas et le compilateur signale l'erreur : la variable locale [p1] n'a pas reçu de valeur.

1
2
3
4
5
6
> java Main.java
Main.java:4: error: variable p1 might not have been initialized
    p1.initialise("Jean", "Dupont", 30);
    ^
1 error
error: compilation failed

Si on écrit [Personne p1 = null;] puis [p1.initialise("Jean", "Dupont", 30);], on indique explicitement avec le mot clé [null] que la variable [p1] ne référence encore aucun objet. Le compilateur accepte le programme mais, à l'exécution, l'appel de la méthode provoque une exception [NullPointerException] :

1
2
3
> java Main.java
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "Personne.initialise(String, String, int)" because "<local1>" is null
    at Main.main(Main.java:4)

Depuis Java 14, le message de l'exception précise l'opération qui a échoué ; [<local1>] désigne la variable locale [p1], dont le nom n'a pas été conservé par la compilation en mémoire. Pour que [p1] référence un objet, il faut écrire [Personne p1 = new Personne();].

Cela a pour effet de créer un objet de type [Personne] non encore initialisé : ses champs ont leur valeur initiale, ici la chaîne vide pour [nom] et [prenom] et 0 pour [age]. Maintenant que [p1] référence un objet, l'instruction d'initialisation de cet objet [p1.initialise("Jean", "Dupont", 30);] est valide.

4.1.6. Le mot clé this

Regardons le code de la méthode [initialise] :

    public void initialise(String p, String n, int age) {
        this.prenom = p;
        this.nom = n;
        this.age = age;
    }

L'instruction [this.prenom = p;] signifie que le champ [prenom] de l'objet courant ([this]) reçoit la valeur [p]. Le mot clé [this] désigne l'objet courant : celui sur lequel la méthode est exécutée. Comment le connaît-on ? Regardons comment se fait l'initialisation de l'objet référencé par [p1] dans le programme appelant : [p1.initialise("Jean", "Dupont", 30);]. C'est la méthode [initialise] de l'objet [p1] qui est appelée. Lorsque, dans cette méthode, on référence l'objet [this], on référence en fait l'objet [p1].

Lorsqu'une méthode d'un objet référence un champ [a] de cet objet, l'écriture [this.a] est implicite : la méthode [initialise] aurait pu écrire [prenom = p;] et [nom = n;]. On doit utiliser [this] explicitement lorsqu'il y a conflit d'identificateurs. C'est le cas de l'instruction [this.age = age;] (ligne 11) où [age] désigne à la fois un champ de l'objet courant et le paramètre [age] reçu par la méthode. Dans la méthode, le nom [age] seul désigne le paramètre ; il faut désigner le champ par [this.age].

4.1.7. Un programme de test

Voici un court programme de test. Celui-ci est écrit dans le fichier source [Main.java] :

1
2
3
4
5
6
7
// le programme principal
void main() {
    // un objet Personne
    Personne p1 = new Personne();
    p1.initialise("Jean", "Dupont", 30);
    p1.identifie();
}
  • ligne 2 : la méthode [main] est le point d'entrée du programme. Comme au chapitre précédent, elle est écrite sans classe englobante : le fichier [Main.java] est un fichier source compact (voir le paragraphe suivant) ;
  • ligne 4 : création d'un objet [Personne] référencé par [p1]. La classe [Personne] appartenant au même paquetage (le paquetage sans nom), elle est connue sans importation ;
  • ligne 5 : initialisation de l'objet ;
  • ligne 6 : affichage de son état.

Résultats :

> java Main.java
[Jean, Dupont, 30]

4.1.8. Les fichiers source compacts

Pour un exemple court, un dossier de plusieurs fichiers est inutilement lourd. Comme au chapitre précédent, nous écrirons alors un fichier source compact (Java 25) : un fichier qui contient la méthode [void main()], éventuellement d'autres méthodes et champs, et les classes dont le programme a besoin. On l'exécute avec le lanceur de code source : [java Classes01.java].

Il faut savoir comment le compilateur traite un tel fichier. Il l'enveloppe dans une classe implicite, qui porte le nom du fichier ([Classes01]) : les méthodes et champs du fichier deviennent des membres de cette classe, et les classes déclarées dans le fichier deviennent des classes imbriquées (nested classes) de cette classe implicite. La compilation explicite du fichier le montre :

1
2
3
4
> javac -d classes Classes01.java
> ls classes
Classes01$Personne.class
Classes01.class

Le nom complet de la classe [Personne] du fichier [Classes01.java] est donc [Classes01$Personne]. Cela a deux conséquences que nous rencontrerons dans les exemples :

  • une classe et les classes qu'elle englobe partagent leurs membres privés : la méthode [main] d'un fichier compact a accès aux champs privés des classes du fichier. Pour vérifier qu'un champ privé est inaccessible depuis l'extérieur de sa classe, il faut, comme dans le dossier [Personnes], placer la classe dans son propre fichier ;
  • une classe imbriquée ordinaire est une classe interne : ses objets sont liés à un objet de la classe englobante. Cela ne se voit pas tant que les objets sont créés par les méthodes du fichier, mais une méthode statique (paragraphe sur les membres de classe) ne peut pas en créer. On déclare alors la classe imbriquée [static]. Les records et les interfaces, présentés plus loin, sont toujours statiques.

Enfin, un fichier compact importe automatiquement toutes les classes publiques du module [java.base] : [List], [ArrayList], [BigDecimal], [Objects]… y sont utilisables sans clause [import]. Ce n'est pas le cas d'un fichier ordinaire comme [Personne.java], nous le verrons.

4.1.9. Une autre méthode initialise : la surcharge

Considérons toujours la classe [Personne] et ajoutons-lui une seconde méthode [initialise]. Le programme [Classes01.java] est le suivant :

// surcharge de la méthode initialise
void main() {
    Personne p1 = new Personne();
    p1.initialise("Jean", "Dupont", 30);
    p1.identifie();
    Personne p2 = new Personne();
    p2.initialise(p1);
    p2.identifie();
}

// la classe Personne est déclarée après la méthode main
class Personne {
    // attributs
    private String prenom = "";
    private String nom = "";
    private int age;

    // initialisation à partir de trois valeurs
    public void initialise(String p, String n, int age) {
        prenom = p;
        nom = n;
        this.age = age;
    }

    // initialisation à partir d'une autre personne
    public void initialise(Personne p) {
        prenom = p.prenom;
        nom = p.nom;
        age = p.age;
    }

    // identité de la personne
    public void identifie() {
        IO.println("[" + prenom + ", " + nom + ", " + age + "]");
    }
}
  • lignes 2-9 : le programme principal ;
  • ligne 12 : la classe [Personne] est déclarée après la méthode [main]. Elle n'est pas publique : dans un fichier compact, c'est inutile ;
  • lignes 19-23 : la méthode [initialise] du paragraphe précédent ;
  • lignes 26-30 : une seconde méthode [initialise].

On a maintenant deux méthodes portant le nom [initialise] : c'est légal tant qu'elles admettent des paramètres différents (en nombre ou en type). On dit que la méthode [initialise] est surchargée. Le compilateur choisit la méthode à appeler d'après les paramètres effectifs : [p1.initialise("Jean", "Dupont", 30)] (ligne 4) appelle la première, [p2.initialise(p1)] (ligne 7) appelle la seconde. Le type du résultat ne suffit pas à distinguer deux méthodes : deux méthodes de même nom et de mêmes paramètres qui ne différeraient que par leur type de retour seraient refusées.

Le paramètre de la seconde méthode est une référence [p] à une personne. Les champs de la personne [p] sont alors affectés à l'objet courant ([this]). On remarquera que la méthode [initialise] a un accès direct aux champs de l'objet [p] bien que ceux-ci soient privés (lignes 27-29). C'est toujours vrai : une méthode d'une classe [C] a accès aux membres privés de tous les objets de la même classe [C].

Résultats :

1
2
3
> java Classes01.java
[Jean, Dupont, 30]
[Jean, Dupont, 30]

4.1.10. Constructeurs de la classe Personne

Un constructeur est une méthode qui porte le nom de la classe et qui est appelée lors de la création de l'objet. On s'en sert généralement pour l'initialiser. C'est une méthode qui peut accepter des paramètres mais qui ne rend aucun résultat. Sa déclaration n'est précédée d'aucun type (pas même [void]).

Si une classe [C] a un constructeur acceptant n paramètres, la déclaration et l'initialisation d'un objet de cette classe pourront se faire sous la forme [C objet = new C(arg1, arg2, ... argn);], ou en deux temps : [C objet;] puis, plus loin, [objet = new C(arg1, arg2, ... argn);].

Lorsqu'une classe [C] a un ou plusieurs constructeurs, l'un de ces constructeurs doit obligatoirement être utilisé pour créer un objet de cette classe. Si une classe [C] n'a aucun constructeur, elle en a un par défaut, sans paramètres, qui ne fait rien. Les champs de l'objet gardent alors leurs valeurs initiales. C'est ce qui s'est passé dans les programmes précédents, où on avait écrit [new Personne()].

Le programme [Classes02.java] donne trois constructeurs à la classe [Personne] :

// constructeurs de la classe Personne
void main() {
    Personne p1 = new Personne("Jean", "Dupont", 30);
    p1.identifie();
    Personne p2 = new Personne(p1);
    p2.identifie();
    Personne p3 = new Personne();
    p3.identifie();
}

class Personne {
    // attributs
    private String prenom;
    private String nom;
    private int age;

    // constructeur principal
    public Personne(String prenom, String nom, int age) {
        this.prenom = prenom;
        this.nom = nom;
        this.age = age;
    }

    // constructeur de copie : il appelle le constructeur principal
    public Personne(Personne p) {
        this(p.prenom, p.nom, p.age);
    }

    // constructeur sans paramètres : il appelle lui aussi le constructeur principal
    public Personne() {
        this("?", "?", 0);
    }

    // identité de la personne
    public void identifie() {
        IO.println("[" + prenom + ", " + nom + ", " + age + "]");
    }
}
  • lignes 18-22 : le constructeur « principal » initialise les trois champs. Ses paramètres portant les mêmes noms que les champs, on distingue ces derniers avec [this] ;
  • lignes 25-27 : le constructeur de copie. L'instruction [this(p.prenom, p.nom, p.age);] appelle le constructeur de la même classe qui accepte trois paramètres [String], [String], [int]. On dit que les constructeurs sont chaînés. Le code d'initialisation n'est ainsi écrit qu'une fois ;
  • lignes 30-32 : le constructeur sans paramètres, chaîné lui aussi au constructeur principal. Comme la classe a déclaré des constructeurs, elle n'a plus de constructeur par défaut : si on veut pouvoir écrire [new Personne()], il faut le déclarer ;
  • lignes 13-15 : les champs ne sont plus initialisés à leur déclaration : tous les constructeurs, directement ou par chaînage, leur donnent une valeur.

L'appel [this(...)] d'un autre constructeur ne peut se faire que dans un constructeur. Jusqu'à Java 24, il devait en être la première instruction ; depuis Java 25, des instructions qui n'utilisent pas l'objet en cours de construction (pour vérifier ou préparer les paramètres, par exemple) peuvent le précéder. On pourrait aussi faire appeler une méthode [initialise] par les constructeurs ; le chaînage est préférable : le code d'initialisation est à un seul endroit, et un constructeur ne devrait pas appeler de méthode qu'une classe dérivée pourrait redéfinir, nous verrons pourquoi.

Résultats :

1
2
3
4
> java Classes02.java
[Jean, Dupont, 30]
[Jean, Dupont, 30]
[?, ?, 0]

4.1.11. Les références d'objets

Nous utilisons la classe [Personne] du programme précédent, sans son constructeur sans paramètres mais avec la méthode [initialise(String, String, int)] du début du chapitre. Le programme de test devient le suivant [Classes03.java] :

// les références d'objets
void main() {
    // p1
    Personne p1 = new Personne("Jean", "Dupont", 30);
    IO.print("p1="); p1.identifie();
    // p2 référence le même objet que p1
    Personne p2 = p1;
    IO.print("p2="); p2.identifie();
    // p3 référence un objet qui est une copie de l'objet référencé par p1
    Personne p3 = new Personne(p1);
    IO.print("p3="); p3.identifie();
    // on change l'état de l'objet référencé par p1
    p1.initialise("Micheline", "Benoît", 67);
    IO.print("p1="); p1.identifie();
    // comme p2 = p1, l'objet référencé par p2 a changé d'état
    IO.print("p2="); p2.identifie();
    // p3 ne référence pas le même objet que p1 : l'objet référencé par p3 n'a pas changé
    IO.print("p3="); p3.identifie();
    // p1 et p2 sont-elles la même référence ? et p1 et p3 ?
    IO.println("p1 == p2 : " + (p1 == p2) + ", p1 == p3 : " + (p1 == p3));
}

class Personne {
    // attributs
    private String prenom;
    private String nom;
    private int age;

    // constructeurs
    public Personne(String prenom, String nom, int age) {
        this.prenom = prenom;
        this.nom = nom;
        this.age = age;
    }

    public Personne(Personne p) {
        this(p.prenom, p.nom, p.age);
    }

    // initialisation
    public void initialise(String p, String n, int age) {
        prenom = p;
        nom = n;
        this.age = age;
    }

    // identité de la personne
    public void identifie() {
        IO.println("[" + prenom + ", " + nom + ", " + age + "]");
    }
}

La méthode [IO.print] (lignes 5, 8, …) écrit son paramètre sans passer à la ligne. Résultats :

1
2
3
4
5
6
7
8
> java Classes03.java
p1=[Jean, Dupont, 30]
p2=[Jean, Dupont, 30]
p3=[Jean, Dupont, 30]
p1=[Micheline, Benoît, 67]
p2=[Micheline, Benoît, 67]
p3=[Jean, Dupont, 30]
p1 == p2 : true, p1 == p3 : false

Lorsqu'on déclare la variable [p1] par [Personne p1 = new Personne("Jean", "Dupont", 30);] (ligne 4), [p1] référence l'objet [Personne("Jean", "Dupont", 30)] mais n'est pas l'objet lui-même. En C, on dirait que c'est un pointeur, c'est-à-dire l'adresse de l'objet créé. Si on écrit ensuite [p1 = null;], ce n'est pas l'objet [Personne("Jean", "Dupont", 30)] qui est modifié, c'est la référence [p1] qui change de valeur. L'objet sera « perdu » s'il n'est référencé par aucune autre variable : la mémoire qu'il occupe sera récupérée plus tard par le ramasse-miettes (« garbage collector ») de la machine virtuelle Java.

Lorsqu'on écrit [Personne p2 = p1;] (ligne 7), on initialise la référence [p2] : elle « pointe » sur le même objet (elle désigne le même objet) que la référence [p1]. Ainsi si on modifie l'objet « pointé » (ou référencé) par [p1], on modifie aussi celui référencé par [p2] (lignes 5-6 des résultats).

Lorsqu'on écrit [Personne p3 = new Personne(p1);] (ligne 10), il y a création d'un nouvel objet [Personne]. Ce nouvel objet sera référencé par [p3]. Si on modifie l'objet référencé par [p1], on ne modifie en rien celui référencé par [p3] (ligne 7 des résultats).

Enfin, ligne 20, l'opérateur [==] appliqué à deux références d'objets indique si elles désignent le même objet : [p1] et [p2] désignent le même objet, [p1] et [p3] deux objets différents, bien que de même contenu (ligne 8 des résultats). Nous verrons plus loin comment comparer le contenu de deux objets avec la méthode [equals].

4.1.12. Passage de paramètres de type référence d'objet

Dans le chapitre précédent, nous avons vu que Java passe les paramètres d'une méthode par valeur : la méthode reçoit une copie de la valeur du paramètre effectif. Voyons ce que cela signifie lorsque le paramètre est une référence d'objet. Nous utilisons pour cela la classe [StringBuilder] du JDK. Un objet [String] ne peut pas être modifié : une opération comme [s.toUpperCase()] rend un nouvel objet [String]. Un objet [StringBuilder], au contraire, est une chaîne modifiable : sa méthode [append] ajoute des caractères à la chaîne sans créer de nouvel objet.

Le programme [Classes04.java] est le suivant :

// passage de paramètres de type référence d'objet
void main() {
    StringBuilder sb0 = new StringBuilder("essai0"), sb1 = new StringBuilder("essai1");
    IO.println("Dans fonction appelante avant appel : sb0=" + sb0 + ", sb1=" + sb1);
    StringBuilder sb2 = changeStringBuilder(sb0, sb1);
    IO.println("Dans fonction appelante après appel : sb0=" + sb0 + ", sb1=" + sb1 + ", sb2=" + sb2);
}

// la fonction appelée
StringBuilder changeStringBuilder(StringBuilder sbf0, StringBuilder sbf1) {
    IO.println("Début fonction appelée : sbf0=" + sbf0 + ", sbf1=" + sbf1);
    // on modifie l'objet référencé par sbf0
    sbf0.append("*****");
    // sbf1 référence désormais un nouvel objet
    sbf1 = new StringBuilder("essai1*****");
    // un nouvel objet est rendu à l'appelant
    StringBuilder sbf2 = new StringBuilder("essai2*****");
    IO.println("Fin fonction appelée : sbf0=" + sbf0 + ", sbf1=" + sbf1 + ", sbf2=" + sbf2);
    return sbf2;
}
  • ligne 3 : deux références [sb0] et [sb1] à des objets de type [StringBuilder] ;
  • ligne 5 : elles sont passées à la méthode [changeStringBuilder], dont le résultat, une troisième référence, est affecté à [sb2] ;
  • lignes 10-20 : une méthode du fichier compact, qui a les paramètres formels [sbf0] et [sbf1]. Au démarrage de la méthode, [sbf0] et [sb0] sont deux références distinctes qui pointent sur le même objet : la référence [sb0] a été copiée dans [sbf0] (passage par valeur des références). Il en est de même pour [sbf1] et [sb1].

Résultats :

1
2
3
4
5
> java Classes04.java
Dans fonction appelante avant appel : sb0=essai0, sb1=essai1
Début fonction appelée : sbf0=essai0, sbf1=essai1
Fin fonction appelée : sbf0=essai0*****, sbf1=essai1*****, sbf2=essai2*****
Dans fonction appelante après appel : sb0=essai0*****, sb1=essai1, sb2=essai2*****

Explications :

  • [sb0] et [sbf0] sont deux références distinctes sur le même objet. Celui-ci a été modifié via [sbf0] (ligne 13). Cette modification est visible via [sb0] (ligne 5 des résultats) ;
  • [sb1] et [sbf1] sont deux références distinctes sur le même objet. [sbf1] reçoit une nouvelle valeur dans la méthode et pointe désormais sur un nouvel objet (ligne 15). Cela ne change en rien la valeur de [sb1] qui continue à pointer sur l'objet d'origine, non modifié ;
  • une méthode Java ne peut donc pas changer la valeur d'une variable de l'appelant. Pour transmettre un nouvel objet à l'appelant, elle le rend comme résultat (lignes 17 et 19). Lorsqu'il y a plusieurs résultats, on les rassemble dans un objet, un record par exemple (voir plus loin).

Retenons qu'une méthode peut modifier l'objet référencé par un paramètre, mais pas la variable de l'appelant qui contient la référence. Contrairement à d'autres langages comme C#, Java n'a pas de passage de paramètres par référence.

4.1.13. Les objets temporaires

Dans une expression, on peut faire appel explicitement au constructeur d'un objet : celui-ci est construit, mais nous n'avons pas de variable pour y accéder ultérieurement (pour le modifier par exemple). Cet objet temporaire est construit pour les besoins de l'évaluation de l'expression puis abandonné. L'espace mémoire qu'il occupait sera automatiquement récupéré ultérieurement par le ramasse-miettes, dont le rôle est de récupérer l'espace mémoire occupé par des objets qui ne sont plus référencés par les données du programme.

Considérons le programme [Classes05.java] suivant, où les constructeurs de la classe [Personne] affichent un message :

// les objets temporaires
void main() {
    new Personne(new Personne("Jean", "Dupont", 30)).identifie();
}

class Personne {
    // attributs
    private String prenom;
    private String nom;
    private int age;

    // constructeurs
    public Personne(String prenom, String nom, int age) {
        IO.println("Constructeur Personne(String, String, int)");
        this.prenom = prenom;
        this.nom = nom;
        this.age = age;
    }

    public Personne(Personne p) {
        IO.println("Constructeur Personne(Personne)");
        prenom = p.prenom;
        nom = p.nom;
        age = p.age;
    }

    // identité de la personne
    public void identifie() {
        IO.println("[" + prenom + ", " + nom + ", " + age + "]");
    }
}
  • ligne 3 : l'objet [Personne("Jean", "Dupont", 30)] est construit puis passé au constructeur de copie qui construit un second objet, dont on appelle la méthode [identifie]. Aucun des deux objets n'est référencé par une variable.

Résultats :

1
2
3
4
> java Classes05.java
Constructeur Personne(String, String, int)
Constructeur Personne(Personne)
[Jean, Dupont, 30]

Les résultats montrent la construction successive des deux objets temporaires.

4.1.14. Les accesseurs et les modifieurs

Nous ajoutons à la classe [Personne] les méthodes nécessaires pour lire ou modifier l'état des champs des objets [Classes06.java] :

// méthodes de lecture (accesseurs) et d'écriture (modifieurs) des attributs privés
void main() {
    Personne p = new Personne("Jean", "Michelin", 34);
    IO.println("p=(" + p.getPrenom() + "," + p.getNom() + "," + p.getAge() + ")");
    p.setAge(56);
    IO.println("p=(" + p.getPrenom() + "," + p.getNom() + "," + p.getAge() + ")");
}

class Personne {
    // attributs
    private String prenom;
    private String nom;
    private int age;

    // constructeur
    public Personne(String prenom, String nom, int age) {
        this.prenom = prenom;
        this.nom = nom;
        this.age = age;
    }

    // accesseurs
    public String getPrenom() {
        return prenom;
    }

    public String getNom() {
        return nom;
    }

    public int getAge() {
        return age;
    }

    // modifieurs
    public void setPrenom(String prenom) {
        this.prenom = prenom;
    }

    public void setNom(String nom) {
        this.nom = nom;
    }

    public void setAge(int age) {
        this.age = age;
    }
}
  • lignes 23-33 : les méthodes de lecture, appelées accesseurs (« getters ») ;
  • lignes 36-46 : les méthodes d'écriture, appelées modifieurs (« setters »).

Résultats :

1
2
3
> java Classes06.java
p=(Jean,Michelin,34)
p=(Jean,Michelin,56)

Les noms de ces méthodes suivent une convention établie dès les débuts de Java, celle des « JavaBeans » : l'accesseur du champ [age] s'appelle [getAge], son modifieur [setAge] ; l'accesseur d'un champ booléen [actif] s'appelle plutôt [isActif]. Beaucoup de bibliothèques (Jackson pour le JSON, Hibernate pour les bases de données, que nous utiliserons plus loin) s'appuient sur cette convention pour trouver les données d'un objet. Contrairement à d'autres langages comme C#, Java n'a pas de « propriétés » : l'accès à l'état d'un objet passe toujours par des méthodes, ou, pour les objets immuables, par les records que nous présenterons plus loin. Les environnements de développement savent générer accesseurs et modifieurs à partir des champs (dans IntelliJ IDEA, menu « Code / Generate… »).

4.1.15. Des modifieurs qui vérifient les valeurs reçues

L'intérêt des modifieurs, par rapport à des champs publics, est de pouvoir contrôler les valeurs affectées aux champs : un modifieur peut vérifier la validité de la valeur reçue et lancer une exception si elle est invalide. Considérons le programme [Classes07.java] :

// des modifieurs qui vérifient la validité des valeurs reçues
void main() {
    Personne p = new Personne("Jean", "Michelin", 34);
    IO.println("p=(" + p.getPrenom() + "," + p.getNom() + "," + p.getAge() + ")");
    p.setAge(56);
    IO.println("p=(" + p.getPrenom() + "," + p.getNom() + "," + p.getAge() + ")");
    try {
        p.setAge(-4);
    } catch (IllegalArgumentException ex) {
        IO.println(ex.getMessage());
    }
    // constructeur sans paramètres puis appel des modifieurs
    Personne p2 = new Personne();
    p2.setAge(7);
    p2.setPrenom("Arthur");
    p2.setNom("Martin");
    IO.println("p2=(" + p2.getPrenom() + "," + p2.getNom() + "," + p2.getAge() + ")");
    try {
        Personne p3 = new Personne();
        p3.setPrenom("Paul");
        p3.setNom(" ");
    } catch (IllegalArgumentException ex) {
        IO.println(ex.getMessage());
    }
}

class Personne {
    // attributs
    private String prenom = "";
    private String nom = "";
    private int age;

    // constructeurs
    public Personne(String prenom, String nom, int age) {
        // on passe par les modifieurs pour profiter de leurs vérifications
        setPrenom(prenom);
        setNom(nom);
        setAge(age);
    }

    public Personne() {
    }

    // accesseurs
    public String getPrenom() {
        return prenom;
    }

    public String getNom() {
        return nom;
    }

    public int getAge() {
        return age;
    }

    // modifieurs : ils sont déclarés final car le constructeur les appelle
    public final void setPrenom(String prenom) {
        // prénom valide ?
        if (prenom == null || prenom.isBlank()) {
            throw new IllegalArgumentException("prénom (" + prenom + ") invalide");
        }
        this.prenom = prenom;
    }

    public final void setNom(String nom) {
        // nom valide ?
        if (nom == null || nom.isBlank()) {
            throw new IllegalArgumentException("nom (" + nom + ") invalide");
        }
        this.nom = nom;
    }

    public final void setAge(int age) {
        // âge valide ?
        if (age < 0) {
            throw new IllegalArgumentException("âge (" + age + ") invalide");
        }
        this.age = age;
    }
}

Les trois modifieurs vérifient la valeur reçue (lignes 60-62, 68-70, 76-78).

  • lignes 60-62 : la méthode [isBlank] (Java 11) rend [true] si la chaîne est vide ou formée uniquement d'espaces. Le test [prenom == null] est fait en premier : si [prenom] vaut [null], l'opérateur [||] n'évalue pas son second opérande, qui provoquerait une exception [NullPointerException]. L'exception lancée est de type [IllegalArgumentException], l'exception prévue par Java pour signaler un paramètre invalide. C'est une exception non contrôlée : la méthode n'a pas à la déclarer avec [throws] ;
  • lignes 34-39 : le constructeur initialise l'objet via les modifieurs et non via les champs. Il profite ainsi des vérifications faites par les modifieurs, qui ne sont écrites qu'à un endroit ;
  • lignes 58, 66, 74 : les modifieurs sont déclarés [final]. Nous verrons avec l'héritage qu'une classe dérivée peut redéfinir les méthodes de sa classe parent ; le mot clé [final] l'interdit. Sans lui, une classe dérivée pourrait redéfinir [setAge], et le constructeur de [Personne] appellerait cette nouvelle version avant même que le constructeur de la classe dérivée ait été exécuté. Le compilateur, avec l'option [-Xlint:all], signale ce risque (avertissement [this-escape]) pour une classe publique écrite dans son propre fichier ;
  • lignes 41-42 : un constructeur sans paramètres, qui laisse les champs à leurs valeurs initiales.

Résultats :

1
2
3
4
5
6
> java Classes07.java
p=(Jean,Michelin,34)
p=(Jean,Michelin,56)
âge (-4) invalide
p2=(Arthur,Martin,7)
nom ( ) invalide

La ligne 4 des résultats montre que l'appel [p.setAge(-4)] (ligne 8) a été refusé par le modifieur. Lignes 13-16, une personne est créée avec le constructeur sans paramètres puis initialisée par ses modifieurs. Lignes 19-21, le nom formé d'un espace est refusé (ligne 6 des résultats).

Contrairement à d'autres langages, Java n'a pas d'initialiseur d'objet, syntaxe qui permettrait de fixer plusieurs champs dans l'expression [new] elle-même : on enchaîne, comme ici, le constructeur et les appels de modifieurs, ou bien on écrit un constructeur qui reçoit toutes les valeurs. Nous verrons deux autres solutions : les modifieurs « fluides » et les records.

4.1.16. Les champs final et les paramètres obligatoires

Un champ déclaré [final] doit recevoir une valeur une fois et une seule, à sa déclaration ou dans chacun des constructeurs ; il ne peut plus être modifié ensuite. Le compilateur le vérifie. Associé à l'absence de modifieur, il permet de créer des données en lecture seule. Le programme [Classes08.java] présente plusieurs cas :

// champs final, lecture seule, paramètres obligatoires
void main() {
    Personne p = new Personne("Jean", "Dupont", 30, "Angers");
    IO.println(p.getIdentite());
    // le prénom est en lecture / écriture
    p.setPrenom("Jacques");
    // l'âge n'est modifiable qu'à l'intérieur de la classe
    p.vieillir();
    IO.println(p.getIdentite());
    // les lignes suivantes sont refusées par le compilateur
    // p.setNom("Durand");   // il n'y a pas de modifieur pour le nom
    // p.setAge(40);         // il n'y a pas de modifieur pour l'âge
    // p.ville = "Paris";    // le champ ville est final

    // un paramètre obligatoire
    Contact c = new Contact("jean.dupont@exemple.fr");
    IO.println("Contact : " + c.getEmail() + ", " + c.getTelephone());
    try {
        Contact c2 = new Contact(null);
        IO.println("Contact : " + c2.getEmail());
    } catch (NullPointerException ex) {
        IO.println("Erreur : " + ex.getMessage());
    }
}

class Personne {
    // champs
    private String prenom;
    // champs final : ils reçoivent leur valeur une fois pour toutes dans le constructeur
    private final String nom;
    private final String ville;
    private int age;

    // constructeur
    public Personne(String prenom, String nom, int age, String ville) {
        this.prenom = prenom;
        this.nom = nom;
        this.age = age;
        this.ville = ville;
    }

    // prénom en lecture / écriture
    public String getPrenom() {
        return prenom;
    }

    public void setPrenom(String prenom) {
        this.prenom = prenom;
    }

    // nom, âge et ville en lecture seule
    public String getNom() {
        return nom;
    }

    public int getAge() {
        return age;
    }

    public String getVille() {
        return ville;
    }

    // une valeur calculée
    public String getIdentite() {
        return "[" + prenom + ", " + nom + ", " + age + ", " + ville + "]";
    }

    // l'âge n'est modifié que par les méthodes de la classe
    public void vieillir() {
        age++;
    }
}

class Contact {
    // l'email est obligatoire, le téléphone facultatif
    private final String email;
    private final String telephone;

    // constructeur avec les deux valeurs
    public Contact(String email, String telephone) {
        this.email = Objects.requireNonNull(email, "l'email est obligatoire");
        this.telephone = telephone;
    }

    // constructeur sans le téléphone : valeur par défaut
    public Contact(String email) {
        this(email, "inconnu");
    }

    public String getEmail() {
        return email;
    }

    public String getTelephone() {
        return telephone;
    }
}
  • lignes 28 et 43-49 : le prénom est en lecture et écriture : il a un accesseur et un modifieur ;
  • lignes 30-31 : le nom et la ville sont des champs [final]. Le constructeur leur donne une valeur (lignes 37 et 39), qui ne pourra plus changer ;
  • ligne 32 et lignes 56-58 : l'âge est lisible par tous mais n'a pas de modifieur public : il n'est modifiable que par les méthodes de la classe, comme [vieillir] (lignes 70-72) ;
  • lignes 65-67 : la méthode [getIdentite] rend une valeur calculée à partir des champs. Pour l'utilisateur de la classe, rien ne la distingue d'un accesseur ;
  • lignes 11-13 : trois instructions que le compilateur refuse, pour les raisons données en commentaires. Pour la troisième, le message du compilateur est : [cannot assign a value to final variable ville] ;
  • lignes 75-98 : la classe [Contact] a un champ obligatoire, l'email, et un champ facultatif, le téléphone. Le caractère obligatoire s'exprime par le constructeur : un objet [Contact] ne peut pas être créé sans email. La méthode [Objects.requireNonNull(valeur, message)] (ligne 82) rend son premier paramètre s'il ne vaut pas [null] et lance une exception [NullPointerException] avec le message indiqué sinon : un email [null] est refusé (lignes 18-23) ;
  • lignes 87-89 : le caractère facultatif du téléphone s'exprime par un second constructeur, qui fournit une valeur par défaut. Java n'a pas de paramètres facultatifs : on surcharge le constructeur.

Rappelons que dans un fichier compact, la méthode [main] a accès aux champs privés des classes du fichier : c'est pourquoi les commentaires des lignes 11-13 ne s'appuient pas sur le caractère privé des champs, mais sur l'absence de modifieur et sur le mot clé [final].

Résultats :

1
2
3
4
5
> java Classes08.java
[Jean, Dupont, 30, Angers]
[Jacques, Dupont, 31, Angers]
Contact : jean.dupont@exemple.fr, inconnu
Erreur : l'email est obligatoire

4.1.17. Les modifieurs fluides

Lorsqu'un objet a beaucoup de champs facultatifs, les constructeurs se multiplient et les appels de modifieurs s'allongent. Une technique répandue consiste à faire rendre l'objet courant ([this]) par chaque modifieur : les appels peuvent alors être enchaînés, on parle de méthodes « fluides ». Le programme [Classes09.java] l'illustre :

// des modifieurs « fluides » : ils rendent l'objet courant
void main() {
    Personne p = new Personne().setPrenom("  jean ").setNom("Dupont").setAge(30);
    IO.println(p.getIdentite());
    try {
        p.setNom("");
    } catch (IllegalArgumentException ex) {
        IO.println(ex.getMessage());
    }
    try {
        p.setAge(-4);
    } catch (IllegalArgumentException ex) {
        IO.println(ex.getMessage());
    }
    IO.println(p.getIdentite());
}

class Personne {
    // champs
    private String prenom = "";
    private String nom = "";
    private int age;

    // modifieurs fluides : chacun rend l'objet courant (this)
    public Personne setPrenom(String prenom) {
        this.prenom = verifier(prenom, "prénom");
        return this;
    }

    public Personne setNom(String nom) {
        this.nom = verifier(nom, "nom");
        return this;
    }

    public Personne setAge(int age) {
        if (age < 0) {
            throw new IllegalArgumentException("âge (" + age + ") invalide");
        }
        this.age = age;
        return this;
    }

    // identité
    public String getIdentite() {
        return "[" + prenom + ", " + nom + ", " + age + "]";
    }

    // vérification d'une chaîne obligatoire : elle est rendue sans ses espaces de début et de fin
    private static String verifier(String valeur, String libelle) {
        if (valeur == null || valeur.isBlank()) {
            throw new IllegalArgumentException(libelle + " (" + valeur + ") invalide");
        }
        return valeur.strip();
    }
}
  • lignes 25-28 : le modifieur [setPrenom] vérifie la valeur reçue, l'affecte au champ et rend l'objet courant. Son type de retour est donc [Personne] ;
  • ligne 3 : les appels sont enchaînés : [new Personne()] rend une personne, sur laquelle on appelle [setPrenom], qui rend la même personne, sur laquelle on appelle [setNom], etc. L'écriture rappelle un initialiseur d'objet ;
  • lignes 49-54 : les vérifications du prénom et du nom, identiques, sont factorisées dans la méthode privée [verifier]. Elle est déclarée [static] car elle n'utilise aucun champ de l'objet (les membres statiques sont présentés au paragraphe suivant). La méthode [strip] (Java 11) rend la chaîne débarrassée de ses espaces de début et de fin.

Résultats :

1
2
3
4
5
> java Classes09.java
[jean, Dupont, 30]
nom () invalide
âge (-4) invalide
[jean, Dupont, 30]

Le prénom « jean », fourni entouré d'espaces (ligne 3), a été mémorisé sans eux (ligne 2 des résultats). Les modifieurs fluides ne respectent pas la convention JavaBeans, selon laquelle un modifieur ne rend rien ([void]) : certaines bibliothèques ne les reconnaissent pas. Pour construire des objets complexes, on utilise aussi le patron de conception « Builder » : une classe auxiliaire, dotée de méthodes fluides, recueille les valeurs puis construit l'objet final, qui peut alors être immuable.

4.1.18. Les méthodes et attributs de classe

Supposons qu'on veuille compter le nombre d'objets [Personne] créés dans une application. On peut soi-même gérer un compteur mais on risque d'oublier les objets temporaires qui sont créés ici ou là. Il semble plus sûr d'inclure dans les constructeurs de la classe [Personne] une instruction incrémentant un compteur. Ce compteur ne peut pas être un champ ordinaire, puisque chaque objet a ses propres champs : c'est un champ de la classe elle-même et non celui d'une instance particulière de cette classe. On le déclare avec le mot clé [static] : [private static long nbPersonnes;].

Pour le désigner, on écrit [Personne.nbPersonnes] pour montrer que c'est un champ de la classe [Personne] elle-même. Ici, nous avons créé un champ privé auquel on n'aura pas accès directement en dehors de la classe. On crée donc un accesseur public pour donner accès au champ de classe [nbPersonnes]. Pour rendre la valeur de [nbPersonnes], cet accesseur n'a pas besoin d'un objet [Personne] particulier : aussi le déclare-t-on lui aussi [static]. De l'extérieur, il sera appelé avec la syntaxe [Personne.getNbPersonnes()]. Voici un exemple [Classes10.java] :

// membres de classe (static)
void main() {
    Personne p1 = new Personne("Jean", "Dupont", 30);
    Personne p2 = new Personne(p1);
    new Personne(p1);
    IO.println("Nombre de personnes créées : " + Personne.getNbPersonnes());
    // une méthode de classe
    Personne p3 = Personne.inconnue();
    p3.identifie();
    IO.println("Nombre de personnes créées : " + Personne.getNbPersonnes());
}

// classe imbriquée statique : voir les explications
static class Personne {
    // attribut de classe
    private static long nbPersonnes;

    // accesseur de classe
    public static long getNbPersonnes() {
        return nbPersonnes;
    }

    // méthode de classe
    public static Personne inconnue() {
        return new Personne("?", "?", 0);
    }

    // attributs d'instance
    private String prenom;
    private String nom;
    private int age;

    // constructeurs
    public Personne(String prenom, String nom, int age) {
        this.prenom = prenom;
        this.nom = nom;
        this.age = age;
        nbPersonnes++;
    }

    public Personne(Personne p) {
        this(p.prenom, p.nom, p.age);
    }

    // identité de la personne
    public void identifie() {
        IO.println("[" + prenom + ", " + nom + ", " + age + "]");
    }
}
  • ligne 16 : le champ de classe. Comme tout champ numérique, il est initialisé à 0 ;
  • lignes 19-21 : l'accesseur de classe ;
  • lignes 24-26 : une méthode de classe. Elle s'appelle avec le nom de la classe : [Personne.inconnue()] (ligne 8). Une méthode de classe n'a pas d'objet courant : le mot clé [this] n'y a pas de sens et elle ne peut utiliser directement que les membres [static] de la classe. Une méthode statique qui crée et rend un objet de sa classe, comme celle-ci, est appelée une fabrique statique (« static factory ») ;
  • ligne 38 : le constructeur principal incrémente le compteur. Le constructeur de copie, chaîné au constructeur principal, l'incrémente donc aussi ;
  • ligne 14 : la classe [Personne] est déclarée [static]. Nous l'avons expliqué au paragraphe sur les fichiers compacts : une classe déclarée dans un fichier compact est une classe imbriquée de la classe implicite. Sans le mot clé [static], c'est une classe interne, dont chaque objet est lié à un objet de la classe englobante ; la méthode statique [inconnue], qui n'a pas d'objet courant, ne pourrait pas en créer, et le compilateur signalerait l'erreur [non-static variable this cannot be referenced from a static context]. Une classe écrite dans son propre fichier n'a pas ce problème.

Résultats :

1
2
3
4
> java Classes10.java
Nombre de personnes créées : 3
[?, ?, 0]
Nombre de personnes créées : 4

Le programme a bien compté l'objet temporaire créé ligne 5. Nous utilisons depuis le début des membres de classe : [IO.println], [Integer.parseInt], [Math.sqrt] sont des méthodes [static], [Math.PI] un champ [static]. La classe [Math] ne contient que des membres statiques : elle est déclarée [final] et son unique constructeur est privé, ce qui interdit d'en créer des instances. C'est la façon Java d'écrire une classe utilitaire.

4.1.19. Un tableau de personnes

Un objet est une donnée comme une autre et à ce titre plusieurs objets peuvent être rassemblés dans un tableau [Classes11.java] :

// un tableau de personnes
void main() {
    Personne[] amis = new Personne[3];
    amis[0] = new Personne("Jean", "Dupont", 30);
    amis[1] = new Personne("Sylvie", "Vartan", 52);
    amis[2] = new Personne("Neil", "Armstrong", 66);
    // affichage
    for (Personne ami : amis) {
        ami.identifie();
    }
}

class Personne {
    // attributs
    private String prenom;
    private String nom;
    private int age;

    // constructeur
    public Personne(String prenom, String nom, int age) {
        this.prenom = prenom;
        this.nom = nom;
        this.age = age;
    }

    // identité de la personne
    public void identifie() {
        IO.println("[" + prenom + ", " + nom + ", " + age + "]");
    }
}
  • ligne 3 : crée un tableau de 3 éléments de type [Personne]. Ces 3 éléments sont initialisés ici avec la valeur [null], c'est-à-dire qu'ils ne référencent aucun objet. Par abus de langage, on parle de tableau d'objets alors que ce n'est qu'un tableau de références d'objets. La création du tableau, qui est un objet lui-même (présence de [new]), ne crée aucun objet du type de ses éléments : il faut le faire ensuite ;
  • lignes 4-6 : création des 3 objets de type [Personne] ;
  • lignes 8-10 : affichage du contenu du tableau [amis].

Résultats :

1
2
3
4
> java Classes11.java
[Jean, Dupont, 30]
[Sylvie, Vartan, 52]
[Neil, Armstrong, 66]

4.1.20. L'inférence de type et l'initialisation des tableaux

Le programme [Classes12.java] présente deux écritures qui allègent la création des objets et des tableaux :

// inférence de type avec var et initialisation des tableaux
void main() {
    // le type de la variable est déduit de l'expression qui l'initialise
    var p1 = new Personne("Jean", "Dupont", 30);
    var p2 = new Personne();
    p2.setPrenom("Arthur");
    p2.setNom("Martin");
    p2.setAge(7);
    // un tableau créé et initialisé par une liste de valeurs entre accolades
    Personne[] amis = {
        p1,
        p2,
        new Personne("Sylvie", "Vartan", 52),
        new Personne("Neil", "Armstrong", 66),
    };
    for (var ami : amis) {
        IO.println(ami.getIdentite());
    }
    // un tableau anonyme passé en paramètre
    affiche(new Personne[] { new Personne("Pauline", "Leduc", 12), p1 });
}

// une méthode qui reçoit un tableau de personnes
void affiche(Personne[] personnes) {
    for (Personne p : personnes) {
        IO.println("Affiche : " + p.getIdentite());
    }
}

class Personne {
    // champs
    private String prenom = "";
    private String nom = "";
    private int age;

    // constructeurs
    public Personne(String prenom, String nom, int age) {
        this.prenom = prenom;
        this.nom = nom;
        this.age = age;
    }

    public Personne() {
    }

    // modifieurs
    public void setPrenom(String prenom) {
        this.prenom = prenom;
    }

    public void setNom(String nom) {
        this.nom = nom;
    }

    public void setAge(int age) {
        this.age = age;
    }

    // identité
    public String getIdentite() {
        return "[" + prenom + ", " + nom + ", " + age + "]";
    }
}
  • ligne 4 : avec le mot clé [var] (Java 10), le type de la variable locale est déduit de l'expression qui l'initialise, ici [Personne]. La variable n'en est pas moins typée : [p1] est de type [Personne] et ne pourra référencer qu'une personne. [var] n'est utilisable que pour les variables locales initialisées à leur déclaration, pas pour les champs ni les paramètres ;
  • lignes 10-15 : un tableau créé et rempli par une liste de valeurs placées entre accolades et séparées par des virgules. La taille du tableau est le nombre de valeurs. Une virgule finale (ligne 14) est tolérée : elle facilite l'ajout d'un élément. Cette écriture n'est possible que dans la déclaration du tableau ;
  • ligne 16 : [var] dans une boucle [for] ;
  • ligne 20 : ailleurs que dans une déclaration, on crée un tableau initialisé en précisant son type : [new Personne[] { ... }]. On parle de tableau anonyme : il n'est référencé par aucune variable de la méthode [main] ;
  • lignes 24-28 : une méthode du fichier compact qui reçoit un tableau de personnes.

Résultats :

1
2
3
4
5
6
7
> java Classes12.java
[Jean, Dupont, 30]
[Arthur, Martin, 7]
[Sylvie, Vartan, 52]
[Neil, Armstrong, 66]
Affiche : [Pauline, Leduc, 12]
Affiche : [Jean, Dupont, 30]

4.2. L'héritage par l'exemple

4.2.1. Généralités

Nous abordons ici la notion d'héritage. Le but de l'héritage est de « personnaliser » une classe existante pour qu'elle satisfasse à nos besoins. Supposons qu'on veuille créer une classe [Enseignant] : un enseignant est une personne particulière. Il a des attributs qu'une autre personne n'aura pas : la matière qu'il enseigne par exemple. Mais il a aussi les attributs de toute personne : prénom, nom et âge. Un enseignant fait donc pleinement partie de la classe [Personne] mais a des attributs supplémentaires. Plutôt que d'écrire une classe [Enseignant] à partir de rien, on préfère reprendre l'acquis de la classe [Personne] qu'on adapte au caractère particulier des enseignants. C'est le concept d'héritage qui nous permet cela.

Pour exprimer que la classe [Enseignant] hérite de la classe [Personne], on écrit [public class Enseignant extends Personne].

[Personne] est appelée la classe parent (ou mère, ou de base, ou superclasse) et [Enseignant] la classe dérivée (ou fille, ou sous-classe). Un objet [Enseignant] a toutes les qualités d'un objet [Personne] : il a les mêmes champs et les mêmes méthodes. Ces champs et méthodes de la classe parent ne sont pas répétés dans la définition de la classe fille : on se contente d'indiquer les champs et méthodes ajoutés par la classe fille. Une classe Java ne peut dériver que d'une seule classe parent.

Les exemples de ce paragraphe sont deux programmes de plusieurs fichiers, dans les dossiers [Heritage1] et [Heritage2]. Dans le dossier [Heritage1], la classe [Personne] est définie comme suit [Personne.java] :

public class Personne {
    // champ de classe : le nombre de personnes créées
    private static long nbPersonnes;

    // champs d'instance
    private String prenom;
    private String nom;
    private int age;

    // constructeur
    public Personne(String prenom, String nom, int age) {
        setPrenom(prenom);
        setNom(nom);
        setAge(age);
        nbPersonnes++;
        IO.println("Constructeur Personne(String, String, int)");
    }

    // nombre de personnes créées
    public static long getNbPersonnes() {
        return nbPersonnes;
    }

    // accesseurs
    public String getPrenom() {
        return prenom;
    }

    public String getNom() {
        return nom;
    }

    public int getAge() {
        return age;
    }

    // modifieurs
    public final void setPrenom(String prenom) {
        this.prenom = verifier(prenom, "prénom");
    }

    public final void setNom(String nom) {
        this.nom = verifier(nom, "nom");
    }

    public final void setAge(int age) {
        if (age < 0) {
            throw new IllegalArgumentException("âge (" + age + ") invalide");
        }
        this.age = age;
    }

    // identité de la personne
    public String getIdentite() {
        return "[" + prenom + ", " + nom + ", " + age + "]";
    }

    // vérification d'une chaîne obligatoire
    private static String verifier(String valeur, String libelle) {
        if (valeur == null || valeur.isBlank()) {
            throw new IllegalArgumentException(libelle + " (" + valeur + ") invalide");
        }
        return valeur;
    }
}
  • ligne 3 : le compteur de personnes du paragraphe précédent ;
  • lignes 11-17 : le constructeur initialise l'objet via ses modifieurs, incrémente le compteur et affiche un message de suivi ;
  • lignes 38-51 : trois modifieurs qui vérifient leur valeur. Les vérifications du prénom et du nom, identiques, sont factorisées dans la méthode privée de classe [verifier] (lignes 59-64). Les modifieurs sont déclarés [final], car le constructeur les appelle (voir le programme [Classes07.java]) ;
  • lignes 54-56 : la méthode [identifie] des exemples précédents a été remplacée par la méthode [getIdentite], qui rend l'identité de la personne au lieu de l'afficher.

Nous créons une classe [Enseignant] héritant de la classe [Personne] [Enseignant.java] :

public class Enseignant extends Personne {
    // champ propre à la classe Enseignant
    private int section;

    // constructeur
    public Enseignant(String prenom, String nom, int age, int section) {
        // construction de la partie Personne de l'objet
        super(prenom, nom, age);
        // on mémorise la section
        this.section = section;
        // suivi
        IO.println("Constructeur Enseignant(String, String, int, int)");
    }

    // accesseur et modifieur de la section
    public int getSection() {
        return section;
    }

    public void setSection(int section) {
        this.section = section;
    }
}

La classe [Enseignant] ajoute aux champs et méthodes de la classe [Personne] :

  • ligne 1 : la classe [Enseignant] dérive de la classe [Personne] ;
  • ligne 3 : un champ [section] qui est le numéro de la section à laquelle appartient l'enseignant dans le corps des enseignants (une section par discipline en gros) ;
  • lignes 6-13 : un constructeur permettant d'initialiser tous les champs d'un enseignant ;
  • lignes 16-22 : l'accesseur et le modifieur du champ [section].

4.2.2. Construction d'un objet Enseignant

Une classe fille n'hérite pas des constructeurs de sa classe parent. Elle doit définir ses propres constructeurs. Le constructeur de la classe [Enseignant] est le suivant :

1
2
3
4
5
6
7
8
    public Enseignant(String prenom, String nom, int age, int section) {
        // construction de la partie Personne de l'objet
        super(prenom, nom, age);
        // on mémorise la section
        this.section = section;
        // suivi
        IO.println("Constructeur Enseignant(String, String, int, int)");
    }

Le constructeur reçoit quatre paramètres [prenom], [nom], [age], [section] et en passe trois ([prenom], [nom], [age]) à sa classe parent, ici la classe [Personne], avec l'instruction [super(prenom, nom, age);] (ligne 3). On sait que cette classe a un constructeur [Personne(String, String, int)] qui va permettre de construire une personne avec les paramètres passés. Une fois la construction de la partie [Personne] terminée, la construction de l'objet [Enseignant] se poursuit par l'exécution de la suite du constructeur (lignes 5-7).

En résumé, le constructeur d'une classe dérivée :

  • passe à sa classe parent, avec [super(...)], les paramètres dont celle-ci a besoin pour se construire ;
  • utilise les autres paramètres pour initialiser les champs qui lui sont propres.

On aurait pu préférer initialiser directement les champs [prenom], [nom] et [age] dans le constructeur de la classe [Enseignant], avec des instructions comme [this.prenom = prenom;]. C'est impossible, pour deux raisons. D'abord, les champs privés [prenom], [nom], [age] ne sont accessibles qu'aux méthodes de la classe [Personne]. Tous les autres objets, y compris des objets de classes dérivées comme ici, doivent passer par des membres publics (ou protégés) pour y avoir accès. Cela aurait été différent si la classe [Personne] avait déclaré ces champs protégés ([protected]) : elle aurait alors autorisé les classes dérivées à y avoir un accès direct. Ensuite, la partie [Personne] de l'objet doit de toute façon être construite par un constructeur de la classe [Personne] : en l'absence d'appel [super(...)], le compilateur insère un appel [super()] au constructeur sans paramètres de la classe parent. Or la classe [Personne] n'en a pas. Si on supprime l'instruction [super(prenom, nom, age);], le compilateur signale l'erreur :

1
2
3
4
5
6
7
8
9
> java Main.java
.../Heritage1/Enseignant.java:6: error: constructor Personne in class Personne cannot be applied to given types;
    public Enseignant(String prenom, String nom, int age, int section) {
                                                                       ^
  required: String,String,int
  found:    no arguments
  reason: actual and formal argument lists differ in length
1 error
error: compilation failed

Utiliser le constructeur de la classe parent est donc la bonne solution et c'est la méthode habituelle : lors de la construction d'un objet fils, on appelle d'abord le constructeur de l'objet parent puis on complète les initialisations propres cette fois à l'objet fils ([section] dans notre exemple). Comme pour [this(...)], seules des instructions qui n'utilisent pas l'objet en cours de construction peuvent précéder l'appel [super(...)] (depuis Java 25 ; auparavant, [super(...)] devait être la première instruction).

4.2.3. Les méthodes héritées

La classe [Enseignant] ne définit pas de méthode [getIdentite]. L'expression [e.getIdentite()], où [e] est un enseignant, désigne donc la méthode [getIdentite] héritée de la classe [Personne] : elle est publique et devient par héritage une méthode publique de la classe [Enseignant]. Le programme de test [Main.java] du dossier [Heritage1] est le suivant :

void main() {
    // un enseignant
    Enseignant e = new Enseignant("Lucile", "Dumas", 56, 61);
    // la méthode getIdentite héritée de la classe Personne
    IO.println(e.getIdentite());
    // la méthode getSection propre à la classe Enseignant
    IO.println("section=" + e.getSection());
    // une personne
    Personne p = new Personne("Jean", "Dupont", 30);
    // la méthode affiche reçoit une Personne ou un objet d'une classe dérivée
    affiche(e);
    affiche(p);
    // la méthode toString héritée de la classe Object
    IO.println(e.toString());
    IO.println(p.toString());
    IO.println("Nombre de personnes créées : " + Personne.getNbPersonnes());
}

// affiche l'identité de p
void affiche(Personne p) {
    IO.println(p.getIdentite());
}
  • ligne 3 : création d'un enseignant ;
  • ligne 5 : affichage de son identité, obtenue par la méthode [getIdentite] héritée de la classe [Personne] ;
  • ligne 7 : la méthode [getSection], propre à la classe [Enseignant] ;
  • lignes 11-12 : appel de la méthode [affiche] (lignes 20-22) avec un enseignant puis une personne. Nous y revenons au paragraphe suivant ;
  • lignes 14-15 : appel de la méthode [toString] des deux objets. Nous y revenons également.

Résultats :

> java Main.java
Constructeur Personne(String, String, int)
Constructeur Enseignant(String, String, int, int)
[Lucile, Dumas, 56]
section=61
Constructeur Personne(String, String, int)
[Lucile, Dumas, 56]
[Jean, Dupont, 30]
Enseignant@70325e14
Personne@37ceb1df
Nombre de personnes créées : 2

On voit que :

  • un objet [Personne] (ligne 2 des résultats) a été construit avant l'objet [Enseignant] (ligne 3) ;
  • [e.getIdentite()] a donné l'identité de la partie [Personne] de l'enseignant, sans sa section (ligne 4) ;
  • le compteur de personnes compte aussi l'enseignant (ligne 11) : un enseignant est une personne.

Pour obtenir l'identité complète d'un enseignant, la classe [Enseignant] doit définir sa propre méthode [getIdentite]. C'est ce que fait le dossier [Heritage2].

4.2.4. Le polymorphisme

Considérons une lignée de classes : C0 ← C1 ← C2 ← … ← Cn, où Ci ← Cj indique que la classe Cj est dérivée de la classe Ci. Cela entraîne que la classe Cj a toutes les caractéristiques de la classe Ci plus d'autres. Soient des objets Oi de type Ci. Il est légal d'écrire [Oi = Oj] avec j > i. En effet, par héritage, la classe Cj a toutes les caractéristiques de la classe Ci plus d'autres. Donc un objet Oj de type Cj contient en lui un objet de type Ci. L'opération [Oi = Oj] fait que la variable Oi, de type Ci, référence l'objet Oj : on ne pourra utiliser au travers d'elle que les membres définis dans la classe Ci.

Le fait qu'une variable Oi de classe Ci puisse en fait référencer non seulement un objet de la classe Ci mais tout objet dérivé de la classe Ci est appelé polymorphisme : la faculté pour une variable de référencer différents types d'objets.

Prenons un exemple et considérons la méthode [affiche] du programme précédent :

1
2
3
void affiche(Personne p) {
    IO.println(p.getIdentite());
}

On peut aussi bien lui passer une [Personne] qu'un [Enseignant] : dans ce dernier cas, le paramètre formel [p] de type [Personne] reçoit une valeur de type [Enseignant]. Comme le type [Enseignant] dérive du type [Personne], c'est légal. L'instruction [p.getIdentite()] a exécuté ici la méthode [getIdentite] de la classe [Personne], la seule qui existe dans le programme : pour l'enseignant (ligne 7 des résultats), on n'obtient que l'identité de sa partie « Personne ». Voyons ce qui se passe lorsque la classe [Enseignant] a sa propre méthode [getIdentite].

4.2.5. La redéfinition de méthodes et la liaison dynamique

Dans le dossier [Heritage2], la classe [Personne] est celle du dossier [Heritage1] avec un ajout :

    // identité de la personne : méthode redéfinie dans la classe Enseignant
    public String getIdentite() {
        return "[" + prenom + ", " + nom + ", " + age + "]";
    }

    // redéfinition de la méthode toString de la classe Object
    @Override
    public String toString() {
        return getIdentite();
    }
  • lignes 6-10 : une redéfinition de la méthode [toString], étudiée au paragraphe suivant.

Dans la classe [Enseignant], la méthode [getIdentite] est redéfinie comme suit :

// une classe finale : on ne peut pas en dériver
public final class Enseignant extends Personne {
    // champ propre à la classe Enseignant
    private int section;

    // constructeur
    public Enseignant(String prenom, String nom, int age, int section) {
        super(prenom, nom, age);
        this.section = section;
        IO.println("Constructeur Enseignant(String, String, int, int)");
    }

    // accesseur et modifieur de la section
    public int getSection() {
        return section;
    }

    public void setSection(int section) {
        this.section = section;
    }

    // la méthode getIdentite redéfinit celle de la classe parent
    @Override
    public String getIdentite() {
        return "Enseignant[" + super.getIdentite() + "," + section + "]";
    }
}
  • lignes 23-26 : la méthode [getIdentite] redéfinit (en anglais, on dit « override ») celle de la classe parent : elle a le même nom, les mêmes paramètres et le même type de résultat. Elle s'appuie sur la méthode [getIdentite] de sa classe parent, désignée par [super.getIdentite()], pour obtenir la partie « Personne » puis complète avec la section, qui est propre à la classe [Enseignant] ;
  • ligne 23 : l'annotation [@Override] indique au compilateur que la méthode a l'intention de redéfinir une méthode de la classe parent. Elle n'est pas obligatoire, mais elle est fortement conseillée : si la méthode ne redéfinissait rien (faute de frappe dans le nom, paramètres différents), le compilateur signalerait l'erreur au lieu de créer silencieusement une nouvelle méthode ;
  • ligne 2 : la classe est finale ([final]), ce que nous expliquons un peu plus loin.

Soit un objet enseignant [E]. Cet objet contient en son sein un objet [Personne] :

Image

La méthode [getIdentite] est définie à la fois dans la classe [Enseignant] et dans sa classe mère [Personne]. En Java, toutes les méthodes d'instance qui ne sont ni privées ni finales peuvent être redéfinies, et le choix de la méthode exécutée par [p.getIdentite()] est fait à l'exécution, d'après le type réel de l'objet référencé par [p], et non à la compilation d'après le type déclaré de la variable. On parle de liaison dynamique. Pour exécuter [o.m()], la machine virtuelle cherche une méthode [m] dans l'ordre suivant :

  • dans la classe de l'objet [o] ;
  • dans sa classe mère s'il en a une ;
  • dans la classe mère de sa classe mère si elle existe ;
  • etc.

L'héritage permet donc de redéfinir dans la classe fille des méthodes de même signature que dans la classe mère. C'est ce qui permet d'adapter la classe fille à ses propres besoins. Le programme [Main.java] du dossier [Heritage2] est le suivant :

void main() {
    // un enseignant
    Enseignant e = new Enseignant("Lucile", "Dumas", 56, 61);
    affiche(e);
    // une personne
    Personne p = new Personne("Jean", "Dupont", 30);
    affiche(p);
    // vu comme une Personne, l'enseignant reste un enseignant
    Personne pe = e;
    IO.println(pe.getIdentite());
    IO.println(((Personne) e).getIdentite());
    // IO.println(Object) affiche le résultat de la méthode toString de l'objet
    IO.println(e);
    IO.println(p);
    // un tableau de personnes de types différents
    Personne[] personnes = { p, e };
    for (Personne personne : personnes) {
        IO.println(personne.getClass().getSimpleName() + " : " + personne);
    }
}

// affiche l'identité de p
void affiche(Personne p) {
    IO.println(p.getIdentite());
}

Résultats :

> java Main.java
Constructeur Personne(String, String, int)
Constructeur Enseignant(String, String, int, int)
Enseignant[[Lucile, Dumas, 56],61]
Constructeur Personne(String, String, int)
[Jean, Dupont, 30]
Enseignant[[Lucile, Dumas, 56],61]
Enseignant[[Lucile, Dumas, 56],61]
Enseignant[[Lucile, Dumas, 56],61]
[Jean, Dupont, 30]
Personne : [Jean, Dupont, 30]
Enseignant : Enseignant[[Lucile, Dumas, 56],61]

Cette fois-ci, ligne 4 des résultats, la méthode [affiche] a bien affiché l'identité complète de l'enseignant. Les lignes 9-11 du programme montrent que, vu au travers d'une variable de type [Personne] ou converti en [Personne] par l'opérateur [(Personne)], l'enseignant reste un enseignant : c'est toujours la méthode [getIdentite] de la classe [Enseignant] qui est exécutée (lignes 7-8 des résultats). La conversion change le type de la référence, pas celui de l'objet. Contrairement à d'autres langages comme C#, où une méthode n'est redéfinissable que si elle est déclarée virtuelle, les méthodes Java sont « virtuelles » par défaut ; il n'existe pas de masquage d'une méthode d'instance.

4.2.6. La classe Object et la méthode toString

La classe [java.lang.Object] est la classe « mère » de toutes les classes Java. Ainsi lorsqu'on écrit [public class Personne], on écrit implicitement [public class Personne extends Object].

La classe [Object] définit quelques méthodes dont héritent tous les objets :

  • [String toString()] : rend une représentation textuelle de l'objet. Par défaut, le nom de la classe de l'objet, suivi du caractère [@] et d'un code de hachage écrit en hexadécimal ;
  • [boolean equals(Object o)] : indique si l'objet est « égal » à l'objet [o]. Par défaut, il ne l'est qu'à lui-même : la méthode compare les références, comme l'opérateur [==] ;
  • [int hashCode()] : rend un code de hachage de l'objet, utilisé par les dictionnaires et les ensembles (chapitre suivant). Les méthodes [equals] et [hashCode] sont étudiées plus loin ;
  • [Class<?> getClass()] : rend la description de la classe réelle de l'objet. La méthode [getSimpleName] de cette description rend le nom de la classe, [getName] son nom complet.

C'est la méthode [toString] de la classe [Object] qu'ont utilisée les appels [e.toString()] et [p.toString()] du dossier [Heritage1] : [Enseignant@70325e14] et [Personne@37ceb1df] (lignes 9-10 des résultats ; les nombres changent d'une exécution à l'autre). Bien que nous n'ayons pas redéfini la méthode [toString] dans les classes [Personne] et [Enseignant], la méthode [toString] de la classe [Object] a été capable d'afficher le nom réel de la classe de l'objet.

Dans le dossier [Heritage2], la classe [Personne] redéfinit la méthode [toString] (voir plus haut). Revenons au programme [Main.java] du dossier [Heritage2] :

1
2
3
4
5
6
7
8
    // IO.println(Object) affiche le résultat de la méthode toString de l'objet
    IO.println(e);
    IO.println(p);
    // un tableau de personnes de types différents
    Personne[] personnes = { p, e };
    for (Personne personne : personnes) {
        IO.println(personne.getClass().getSimpleName() + " : " + personne);
    }

Ligne 2, la méthode [IO.println(Object)] accepte un paramètre de type [Object], donc, puisque [Object] est la classe mère de toutes les classes, n'importe quel objet, ici un [Enseignant]. Elle écrit sur la console le résultat de la méthode [toString] de l'objet. La liaison étant dynamique, c'est la méthode [toString] de l'objet réel qui est utilisée, ici celle héritée de la classe [Personne]. Celle-ci rend [getIdentite()], méthode redéfinie : pour l'enseignant [e], c'est donc la méthode [getIdentite] de la classe [Enseignant] qui est exécutée, ce que montre la ligne 9 des résultats. Il est ainsi inutile de redéfinir [toString] dans la classe [Enseignant].

Le même mécanisme s'applique à la concaténation d'une chaîne et d'un objet : dans [" : " + personne] (ligne 7), l'objet [personne] est converti en chaîne par sa méthode [toString]. La boucle des lignes 6-8 parcourt un tableau de références [Personne] qui désignent en réalité des objets de types différents : [getClass().getSimpleName()] rend le nom de la classe réelle de chaque objet (lignes 11-12 des résultats).

4.2.7. Les classes finales et les classes scellées

La classe [Enseignant] du dossier [Heritage2] est déclarée [public final class Enseignant extends Personne]. Le mot clé [final] interdit de dériver une classe de la classe [Enseignant] : la déclaration [class EnseignantChercheur extends Enseignant] serait refusée par le compilateur. On rend une classe finale lorsqu'on ne l'a pas conçue pour être dérivée. Nous avons vu que le mot clé s'applique aussi à une méthode : une méthode [final] ne peut pas être redéfinie par les classes dérivées, sans que la dérivation de la classe soit interdite. La classe [String] du JDK est par exemple finale.

Entre la classe ouverte à toutes les dérivations et la classe finale, Java 17 a ajouté une possibilité intermédiaire : la classe scellée ([sealed]), qui donne la liste exhaustive de ses classes dérivées directes. Le programme [Classes13.java] l'illustre :

// classes finales et classes scellées (Java 17)
void main() {
    Personne[] personnes = {
        new Enseignant("Lucile", "Dumas", 61),
        new Etudiant("Paul", "Martin", "informatique"),
        new Vacataire("Marc", "Petit"),
        new Intervenant("Lise", "Morel", "Airbus"),
    };
    for (Personne p : personnes) {
        IO.println(p.getClass().getSimpleName() + " : " + p.getIdentite());
    }
    // les sous-classes directes autorisées de la classe scellée Personne
    IO.println("Personne est scellée : " + Personne.class.isSealed());
    for (Class<?> c : Personne.class.getPermittedSubclasses()) {
        IO.println("sous-classe autorisée : " + c.getSimpleName());
    }
}

// une classe scellée : seules les classes citées après permits peuvent en dériver
abstract sealed class Personne permits Enseignant, Etudiant, Vacataire {
    private final String prenom;
    private final String nom;

    protected Personne(String prenom, String nom) {
        this.prenom = prenom;
        this.nom = nom;
    }

    public String getIdentite() {
        return "[" + prenom + ", " + nom + "]";
    }
}

// une classe dérivée d'une classe scellée doit être final, sealed ou non-sealed
final class Enseignant extends Personne {
    private final int section;

    public Enseignant(String prenom, String nom, int section) {
        super(prenom, nom);
        this.section = section;
    }

    @Override
    public String getIdentite() {
        return super.getIdentite() + ", section " + section;
    }
}

final class Etudiant extends Personne {
    private final String filiere;

    public Etudiant(String prenom, String nom, String filiere) {
        super(prenom, nom);
        this.filiere = filiere;
    }

    @Override
    public String getIdentite() {
        return super.getIdentite() + ", filière " + filiere;
    }
}

// non-sealed : la hiérarchie est de nouveau ouverte à partir de cette classe
non-sealed class Vacataire extends Personne {
    public Vacataire(String prenom, String nom) {
        super(prenom, nom);
    }
}

// une classe dérivée de Vacataire : autorisée, Vacataire n'étant pas scellée
class Intervenant extends Vacataire {
    private final String entreprise;

    public Intervenant(String prenom, String nom, String entreprise) {
        super(prenom, nom);
        this.entreprise = entreprise;
    }

    @Override
    public String getIdentite() {
        return super.getIdentite() + ", entreprise " + entreprise;
    }
}

// refusé par le compilateur : Artiste n'est pas citée dans la clause permits de Personne
// final class Artiste extends Personne { ... }
  • ligne 20 : la classe abstraite (voir plus loin : on ne peut pas créer d'objets de cette classe, seulement de ses classes dérivées) [Personne] est scellée. Seules les classes [Enseignant], [Etudiant] et [Vacataire], citées après le mot clé [permits], peuvent en dériver. Lorsque les classes dérivées sont déclarées dans le même fichier, comme ici, la clause [permits] peut être omise : les classes autorisées sont alors celles du fichier qui en dérivent directement. Dans un programme plus important, les classes autorisées doivent appartenir au même paquetage que la classe scellée (au même module si celle-ci appartient à un module nommé) ;
  • ligne 24 : le constructeur est protégé ([protected]) : il n'est destiné qu'aux classes dérivées ;
  • lignes 35 et 49 : une classe dérivée d'une classe scellée doit dire comment elle-même peut être dérivée. Les classes [Enseignant] et [Etudiant] sont finales ;
  • ligne 64 : la classe [Vacataire] est déclarée [non-sealed] : elle rouvre la hiérarchie, toute classe peut en dériver. Une troisième possibilité est de la déclarer elle-même [sealed], avec sa propre liste de classes autorisées ;
  • lignes 71-83 : la classe [Intervenant] dérive de [Vacataire], ce qui est autorisé ;
  • lignes 85-86 : une classe [Artiste] dérivée de [Personne] serait refusée ;
  • lignes 13-16 : la description d'une classe ([Personne.class]) indique si elle est scellée ([isSealed]) et donne ses classes dérivées directes autorisées ([getPermittedSubclasses]).

Résultats :

1
2
3
4
5
6
7
8
9
> java Classes13.java
Enseignant : [Lucile, Dumas], section 61
Etudiant : [Paul, Martin], filière informatique
Vacataire : [Marc, Petit]
Intervenant : [Lise, Morel], entreprise Airbus
Personne est scellée : true
sous-classe autorisée : Enseignant
sous-classe autorisée : Etudiant
sous-classe autorisée : Vacataire

Si on décommente la classe [Artiste] (en lui donnant un constructeur), le compilateur signale :

1
2
3
4
5
6
> java Classes13.java
Classes13.java:86: error: class is not allowed to extend sealed class: Classes13.Personne (as it is not listed in its 'permits' clause)
final class Artiste extends Personne { Artiste() { super("a", "b"); } }
      ^
1 error
error: compilation failed

À quoi sert une classe scellée ? Elle permet à l'auteur d'une hiérarchie de classes d'en garder la maîtrise : il sait que toute [Personne] est un [Enseignant], un [Etudiant] ou un [Vacataire]. Le compilateur le sait aussi, et l'exploite dans le filtrage par motif, présenté à la fin du chapitre : un [switch] qui traite les trois cas est complet. Le mécanisme s'applique également aux interfaces ([sealed interface]), présentées plus loin.

4.3. Pas de surcharge des opérateurs : equals, compareTo…

Certains langages, comme C# ou C++, permettent de redéfinir la signification des opérateurs ([+], [==], [<]…) pour une classe. Java ne le permet pas : les opérateurs ont toujours la signification que leur donne le langage. La seule exception est l'opérateur [+] appliqué à une chaîne, qui réalise la concaténation, et qui utilise pour cela la méthode [toString] de l'objet concaténé. À la place des opérateurs, on écrit des méthodes, qui suivent des conventions bien établies :

Opération
Méthode Java
Exemple
égalité de contenu ([==] compare les références)
[boolean equals(Object o)], héritée de [Object]
[s1.equals(s2)]
comparaison ([<], [>]…)
[int compareTo(T autre)] de l'interface [Comparable<T>] : résultat négatif, nul ou positif
[s1.compareTo(s2) < 0]
opérations arithmétiques
méthodes de nom explicite
[a.add(b)], [a.multiply(b)] pour [BigDecimal]
ajout à une collection ([+=])
[add]
[liste.add(p)]

4.3.1. Un exemple

Le dossier [Listes] contient une classe [ListeDePersonnes], une liste de personnes, dont les méthodes jouent le rôle des opérateurs [+] et [+=]. Les classes [Personne] et [Enseignant] de ce dossier sont celles du dossier [Heritage2], simplifiées : les champs sont [final] et n'ont pas de modifieurs, il n'y a plus de messages de suivi ni de compteur. Les personnes sont rangées dans une liste générique [List<Personne>] (les classes génériques sont présentées plus loin dans ce chapitre ; les listes sont étudiées au chapitre suivant). Plutôt que de faire hériter la classe [ListeDePersonnes] d'une classe de liste du JDK, nous plaçons la liste dans un champ privé de la classe. On dit qu'on utilise la composition plutôt que l'héritage : la classe [ListeDePersonnes] n'expose ainsi que les opérations qu'elle a choisies. De l'interface [List<Personne>], nous n'utilisons que les éléments suivants :

  • la méthode [l.add(p)] qui ajoute à la liste [l] la personne [p], et la méthode [l.addAll(l2)] qui lui ajoute tous les éléments de la liste [l2] ;
  • la méthode [l.size()] qui donne le nombre d'éléments de la liste [l] ;
  • les méthodes [l.get(i)] et [l.set(i, p)] qui lisent et modifient l'élément n° i de la liste [l].

La définition de la classe [ListeDePersonnes] est la suivante [ListeDePersonnes.java] :

import java.util.ArrayList;
import java.util.List;
import java.util.StringJoiner;

public class ListeDePersonnes {
    // les personnes de la liste
    private final List<Personne> personnes = new ArrayList<>();

    // nombre de personnes dans la liste
    public int size() {
        return personnes.size();
    }

    // l'équivalent d'un opérateur + : rend une nouvelle liste contenant les personnes
    // de la liste courante et la personne p
    public ListeDePersonnes plus(Personne p) {
        ListeDePersonnes resultat = new ListeDePersonnes();
        resultat.personnes.addAll(personnes);
        resultat.personnes.add(p);
        return resultat;
    }

    // l'équivalent d'un opérateur += : ajoute p à la liste courante, qui est rendue
    public ListeDePersonnes ajouter(Personne p) {
        personnes.add(p);
        return this;
    }

    // l'équivalent d'un indexeur : lecture et écriture de l'élément n° i
    public Personne get(int i) {
        return personnes.get(i);
    }

    public void set(int i, Personne p) {
        personnes.set(i, p);
    }

    // position de la personne portant le nom indiqué, -1 si elle n'existe pas
    public int indexOf(String nom) {
        for (int i = 0; i < personnes.size(); i++) {
            if (personnes.get(i).getNom().equals(nom)) {
                return i;
            }
        }
        return -1;
    }

    // toString : rend (personne1,personne2,...)
    @Override
    public String toString() {
        StringJoiner joiner = new StringJoiner(",", "(", ")");
        for (Personne p : personnes) {
            joiner.add(p.toString());
        }
        return joiner.toString();
    }
}
  • lignes 1-3 : contrairement au fichier compact [Main.java], un fichier ordinaire n'importe pas automatiquement les classes du module [java.base] : il doit importer celles qu'il utilise (hormis celles du paquetage [java.lang], comme [String], toujours connues). Les clauses [import] sont présentées avec les paquetages ;
  • ligne 7 : la liste des personnes. Le mot clé [final] indique que le champ désignera toujours la même liste ; la liste elle-même pourra changer de contenu. [new ArrayList<>()] crée une liste vide ; l'opérateur « diamant » [<>] laisse le compilateur déduire le type des éléments, [Personne], du type du champ ;
  • lignes 10-12 : le nombre de personnes ;
  • lignes 16-21 : la méthode [plus] joue le rôle d'un opérateur [+] pour l'opération [l.plus(p)], où [l] est de type [ListeDePersonnes] et [p] de type [Personne] ou dérivé. Elle crée une nouvelle liste (ligne 17), y recopie les personnes de la liste courante (ligne 18) puis ajoute la personne [p] (ligne 19). Comme pour l'opérateur [+] des nombres ou des chaînes, les opérandes ne sont pas modifiés. La liste [resultat] étant de même classe, la méthode a accès à son champ privé [resultat.personnes]. La liste rendue est de type [ListeDePersonnes], ce qui permet d'enchaîner les appels comme dans [l.plus(p1).plus(p2)] ;
  • lignes 24-27 : la méthode [ajouter] joue le rôle d'un opérateur [+=] : elle modifie la liste courante, en lui ajoutant la personne [p], et rend cette même liste, ce qui permet aussi d'enchaîner les appels ;
  • lignes 49-56 : nous redéfinissons la méthode [toString] afin d'afficher une liste de personnes sous la forme (personne1,personne2,...) où personnei est lui-même le résultat de la méthode [toString] de la personne. Un objet [StringJoiner] (ligne 51) construit une chaîne à partir d'éléments successifs ([add], ligne 53) en les séparant par le séparateur indiqué (ici la virgule) et en l'encadrant par un préfixe et un suffixe (ici les parenthèses).

On notera que la classe [ListeDePersonnes] n'a pas de constructeur. Elle a donc le constructeur par défaut, sans paramètres, qui ne fait rien d'autre qu'initialiser le champ [personnes] avec la valeur indiquée à sa déclaration. Les lignes 29-46 sont étudiées au paragraphe suivant.

Le programme de test [Main.java] commence ainsi :

void main() {
    // --- des méthodes à la place des opérateurs
    String s1 = "jean";
    String s2 = new StringBuilder("je").append("an").toString();
    IO.println("s1 == s2 : " + (s1 == s2) + ", s1.equals(s2) : " + s1.equals(s2));
    IO.println("s1.compareTo(\"paul\") : " + s1.compareTo("paul"));
    BigDecimal a = new BigDecimal("0.10"), b = new BigDecimal("0.20");
    IO.println("a + b = " + a.add(b) + ", a < b : " + (a.compareTo(b) < 0));

    // --- la méthode plus
    // une liste de personnes
    ListeDePersonnes l = new ListeDePersonnes();
    // ajout de personnes
    l = l.plus(new Personne("jean", "martin", 10)).plus(new Personne("pauline", "leduc", 12));
    // affichage
    IO.println("l=" + l);
    // l.plus(p) rend une nouvelle liste : l n'est pas modifiée
    ListeDePersonnes l2 = l.plus(new Personne("louis", "durand", 8));
    IO.println("l=" + l);
    IO.println("l2=" + l2);

    // --- la méthode ajouter
    ListeDePersonnes l3 = l;
    // ajouter modifie la liste référencée par l, sans créer de nouvelle liste
    l.ajouter(new Enseignant("camille", "germain", 27, 60));
    IO.println("l=" + l);
    // l et l3 référencent toujours le même objet
    IO.println("l3 == l : " + (l3 == l));
  • lignes 3-5 : deux chaînes de même contenu mais distinctes : la chaîne [s2] est construite à l'exécution. L'opérateur [==] compare les références, la méthode [equals] les contenus. Pour comparer deux chaînes, il faut toujours utiliser [equals] ;
  • ligne 6 : la méthode [compareTo] rend un nombre négatif car « jean » précède « paul » dans l'ordre des codes des caractères ;
  • lignes 7-8 : la classe [BigDecimal] (nombres décimaux exacts, que nous utiliserons pour les montants) remplace les opérateurs [+] et [<] par les méthodes [add] et [compareTo] ;
  • ligne 12 : création d'une liste de personnes [l] vide ;
  • ligne 14 : ajout de deux personnes avec la méthode [plus] ;
  • ligne 18 : [l.plus(p)] crée une nouvelle liste [l2] ; la liste [l] n'est pas modifiée ;
  • ligne 25 : ajout d'un enseignant avec la méthode [ajouter] ;
  • lignes 16-20 : la concaténation d'une chaîne et d'un objet utilise la méthode [toString] redéfinie de la classe [ListeDePersonnes].

Résultats (début) :

1
2
3
4
5
6
7
8
9
> java Main.java
s1 == s2 : false, s1.equals(s2) : true
s1.compareTo("paul") : -6
a + b = 0.30, a < b : true
l=([jean, martin, 10],[pauline, leduc, 12])
l=([jean, martin, 10],[pauline, leduc, 12])
l2=([jean, martin, 10],[pauline, leduc, 12],[louis, durand, 8])
l=([jean, martin, 10],[pauline, leduc, 12],Enseignant[[camille, germain, 27],60])
l3 == l : true

Pourquoi deux méthodes, alors que [l = l.plus(p)] produit le même contenu que [l.ajouter(p)] ? La méthode [plus] crée une nouvelle liste, y recopie toutes les personnes de [l], puis [l] désigne cette nouvelle liste et l'ancienne est abandonnée au ramasse-miettes. La méthode [ajouter] ajoute la personne à la liste existante, sans copie. La ligne 23 mémorise une seconde référence [l3] sur la liste [l] ; après l'appel [l.ajouter(...)], les deux références désignent toujours le même objet (ligne 9 des résultats), ce qui prouve que la liste n'a pas été recréée. Pour une grosse liste, ou dans une boucle, la différence de performances est importante. Le choix entre les deux styles se retrouve dans le JDK : les objets [String] et [BigDecimal], immuables, rendent de nouveaux objets ; les listes et les [StringBuilder] se modifient.

4.4. Pas d'indexeurs : des méthodes get et set

Nous continuons ici à utiliser la classe [ListeDePersonnes]. Si [l] est un objet [ListeDePersonnes], nous souhaitons pouvoir désigner la personne n° i de la liste [l] aussi bien en lecture qu'en écriture. Certains langages permettent pour cela de définir un « indexeur » qui autorise la notation des tableaux [l[i]] sur un objet. Java ne le permet pas : la notation [[i]] est réservée aux tableaux. Par convention, on écrit des méthodes [get(int i)] et [set(int i, valeur)], comme le font les listes du JDK :

    // l'équivalent d'un indexeur : lecture et écriture de l'élément n° i
    public Personne get(int i) {
        return personnes.get(i);
    }

    public void set(int i, Personne p) {
        personnes.set(i, p);
    }

    // position de la personne portant le nom indiqué, -1 si elle n'existe pas
    public int indexOf(String nom) {
        for (int i = 0; i < personnes.size(); i++) {
            if (personnes.get(i).getNom().equals(nom)) {
                return i;
            }
        }
        return -1;
    }
  • lignes 2-4 : la méthode [get] rend la personne n° i de la liste privée [personnes] ;
  • lignes 6-8 : la méthode [set] lui affecte la personne [p] ;
  • lignes 11-18 : on veut pouvoir également obtenir la position dans la liste de la personne portant un nom donné, ou -1 si cette personne n'est pas dans la liste. Le nom [indexOf] est celui de la méthode équivalente des listes et des chaînes du JDK. Les noms sont comparés avec [equals] (ligne 13).

Le programme de test se poursuit de la façon suivante :

    // --- les méthodes get, set et indexOf à la place des indexeurs
    // changement de l'élément n° 1
    l.set(1, new Personne("franck", "gallon", 5));
    // affichage de l'élément n° 1
    IO.println("l.get(1)=" + l.get(1));
    // affichage de la liste l
    IO.println("l=" + l);
    // recherche de personnes par leur nom
    String[] noms = { "martin", "germain", "xx" };
    for (String nom : noms) {
        int inom = l.indexOf(nom);
        if (inom != -1) {
            IO.println("Personne(" + nom + ")=" + l.get(inom));
        } else {
            IO.println("Personne(" + nom + ") n'existe pas");
        }
    }
  • ligne 3 : appel de la méthode [set] ;
  • ligne 5 : appel de la méthode [get] ;
  • ligne 11 : appel de la méthode [indexOf] ;
  • ligne 13 : appel de la méthode [get].

Résultats (fin) :

1
2
3
4
5
6
...
l.get(1)=[franck, gallon, 5]
l=([jean, martin, 10],[franck, gallon, 5],Enseignant[[camille, germain, 27],60])
Personne(martin)=[jean, martin, 10]
Personne(germain)=Enseignant[[camille, germain, 27],60]
Personne(xx) n'existe pas

4.5. Pas de structures : types primitifs et records

4.5.1. Valeur et référence

D'autres langages, comme C ou C#, proposent, à côté des classes, des structures : des types dont les variables contiennent directement les données, et non une référence vers un objet. Java n'en a pas. Il distingue deux familles de types :

  • les huit types primitifs ([byte], [short], [int], [long], [float], [double], [char], [boolean]) : une variable de type primitif contient la valeur elle-même ;
  • les types référence : classes (dont [String]), interfaces, tableaux, records… Une variable de type référence contient une référence vers un objet, ou [null].

Le programme [Classes14.java] met en évidence la différence :

// types primitifs et types référence : copie de valeur, copie de référence
void main() {
    // deux variables de type primitif
    int age1 = 10;
    int age2 = age1;
    IO.println("age1=" + age1 + ", age2=" + age2);
    // age2 est modifiée
    age2 = 30;
    // vérification age1 et age2
    IO.println("age1=" + age1 + ", age2=" + age2);

    // un objet op1
    CPersonne op1 = new CPersonne();
    op1.nom = "paul";
    op1.age = 10;
    IO.println("op1=CPersonne(" + op1.nom + "," + op1.age + ")");
    // un objet op2
    CPersonne op2 = op1;
    IO.println("op2=CPersonne(" + op2.nom + "," + op2.age + ")");
    // op2 est modifié
    op2.nom = "nicole";
    op2.age = 30;
    // vérification op1 et op2
    IO.println("op1=CPersonne(" + op1.nom + "," + op1.age + ")");
    IO.println("op2=CPersonne(" + op2.nom + "," + op2.age + ")");

    // une copie explicite de l'objet
    CPersonne op3 = new CPersonne();
    op3.nom = op1.nom;
    op3.age = op1.age;
    op3.age = 50;
    IO.println("op1=CPersonne(" + op1.nom + "," + op1.age + ")");
    IO.println("op3=CPersonne(" + op3.nom + "," + op3.age + ")");
}

// classe CPersonne
class CPersonne {
    String nom = "";
    int age;
}
  • lignes 37-40 : une classe avec deux champs à accès paquetage : [nom], [age]. Les champs non privés, déconseillés en temps normal, rendent ici l'exemple plus lisible.

Résultats :

1
2
3
4
5
6
7
8
9
> java Classes14.java
age1=10, age2=10
age1=10, age2=30
op1=CPersonne(paul,10)
op2=CPersonne(paul,10)
op1=CPersonne(nicole,30)
op2=CPersonne(nicole,30)
op1=CPersonne(nicole,30)
op3=CPersonne(nicole,50)

La déclaration [int age1 = 10;] (ligne 4) réserve un emplacement mémoire qui contient l'entier 10 : la valeur de [age1] est l'entier lui-même. Dans le cas de la classe, la création de l'objet ([nom], [age]) se fait explicitement par l'opérateur [new] (ligne 13). L'objet est placé dans une zone de la mémoire gérée par le ramasse-miettes, le tas (« heap »), et la valeur de [op1] est l'adresse (la référence) de cet objet.

Lorsque dans le programme on écrit [int age2 = age1;] (ligne 5), la valeur de [age1], l'entier 10, est recopiée dans [age2] : c'est une recopie de valeur. Modifier [age2] ne modifie pas [age1] (ligne 3 des résultats). Considérons maintenant l'instruction [CPersonne op2 = op1;] (ligne 18). La valeur de [op1] est recopiée dans [op2], mais comme cette valeur est l'adresse de l'objet, celui-ci n'est pas dupliqué : [op1] et [op2] désignent le même objet. Si on modifie l'objet pointé par [op2], celui pointé par [op1] est modifié puisque c'est le même (lignes 6-7 des résultats). Pour obtenir un second objet, il faut le créer et y recopier les champs du premier (lignes 28-30) : modifier la copie ne modifie pas l'original (lignes 8-9 des résultats).

Image

On retiendra donc de ces explications que :

  • la valeur d'une variable de type primitif est la donnée elle-même ;
  • la valeur d'une variable de type référence est l'adresse de l'objet pointé ;
  • l'affectation et le passage de paramètres recopient toujours la valeur de la variable : la donnée pour un type primitif, la référence pour un objet.

À chaque type primitif correspond une classe dite « enveloppe » (« wrapper ») du paquetage [java.lang] : [Integer] pour [int], [Double] pour [double], [Boolean] pour [boolean], etc. Le compilateur convertit automatiquement une valeur primitive en objet enveloppe et inversement (« autoboxing » et « unboxing ») : nous en aurons besoin avec les génériques, qui n'acceptent que des types référence. Une variable [Integer] peut valoir [null], une variable [int] jamais.

Pour représenter une petite donnée composée (un point, une couleur, une date, un montant) qu'on manipule comme une valeur, Java utilise des classes immuables : leurs objets ne peuvent pas être modifiés après leur création. Partager une référence vers un objet immuable est sans danger : personne ne peut le modifier, et l'objet se comporte alors comme une valeur. Deux outils complètent le dispositif : la méthode [equals], qui compare deux objets par leur contenu, et les records, qui déclarent en une ligne une classe immuable. C'est l'objet des deux paragraphes suivants.

4.5.2. L'égalité de deux objets : equals et hashCode

Nous l'avons vu, l'opérateur [==] appliqué à deux références indique si elles désignent le même objet, et la méthode [equals] héritée de [Object] fait de même. Pour que deux objets distincts de même contenu soient considérés comme égaux, une classe redéfinit la méthode [equals], et, obligatoirement avec elle, la méthode [hashCode]. Le programme [Classes15.java] le montre :

// l'égalité de deux objets : equals et hashCode
void main() {
    Personne p1 = new Personne("paul", 10);
    Personne p2 = new Personne("paul", 10);
    Personne p3 = new Personne("nicole", 30);
    // == compare les références, equals compare les valeurs
    IO.println("p1 == p2 : " + (p1 == p2) + ", p1.equals(p2) : " + p1.equals(p2)
            + ", p1.equals(p3) : " + p1.equals(p3));
    // deux objets égaux ont le même code de hachage
    IO.println("p1.hashCode() == p2.hashCode() : " + (p1.hashCode() == p2.hashCode()));
    // un ensemble (Set) ne contient pas deux objets égaux
    Set<Personne> personnes = new HashSet<>();
    personnes.add(p1);
    personnes.add(p2);
    personnes.add(p3);
    IO.println("taille de l'ensemble : " + personnes.size());
    IO.println("l'ensemble contient (paul,10) : " + personnes.contains(new Personne("paul", 10)));
}

class Personne {
    // champs
    private final String nom;
    private final int age;

    // constructeur
    public Personne(String nom, int age) {
        this.nom = nom;
        this.age = age;
    }

    // deux personnes sont égales si elles ont mêmes nom et âge
    @Override
    public boolean equals(Object o) {
        // même objet ?
        if (this == o) {
            return true;
        }
        // o est-il une Personne (et non null) ?
        if (!(o instanceof Personne autre)) {
            return false;
        }
        // comparaison des champs
        return age == autre.age && Objects.equals(nom, autre.nom);
    }

    // code de hachage cohérent avec equals
    @Override
    public int hashCode() {
        return Objects.hash(nom, age);
    }

    @Override
    public String toString() {
        return "Personne(" + nom + "," + age + ")";
    }
}
  • lignes 32-44 : la méthode [equals] redéfinit celle de la classe [Object]. Son paramètre est de type [Object] : on peut comparer une personne à n'importe quel objet. La méthode suit un schéma classique :
    • lignes 35-37 : un objet est égal à lui-même ;
    • lignes 39-41 : l'expression [o instanceof Personne autre] (Java 16) est vraie si [o] référence un objet de type [Personne] (ou d'une classe dérivée), et fausse sinon, en particulier si [o] vaut [null]. Lorsqu'elle est vraie, la variable [autre], de type [Personne], référence l'objet [o] : on peut utiliser ses champs. Ce « motif de type » est présenté en détail à la fin du chapitre ;
    • ligne 43 : on compare les champs. La méthode [Objects.equals(a, b)] compare deux objets avec [equals] en traitant le cas où l'un d'eux vaut [null] ;
  • lignes 47-50 : la méthode [hashCode] rend un entier calculé à partir des mêmes champs, ici par la méthode [Objects.hash]. La règle est impérative : deux objets égaux au sens de [equals] doivent avoir le même code de hachage. Les dictionnaires et les ensembles du JDK (chapitre suivant) rangent les objets d'après ce code et ne fonctionneraient plus si la règle n'était pas respectée ;
  • lignes 12-17 : un ensemble [HashSet] ne contient jamais deux objets égaux : l'ajout de [p2], égal à [p1], est sans effet, et la recherche d'une nouvelle personne ([paul, 10]) aboutit.

Résultats :

1
2
3
4
5
> java Classes15.java
p1 == p2 : false, p1.equals(p2) : true, p1.equals(p3) : false
p1.hashCode() == p2.hashCode() : true
taille de l'ensemble : 2
l'ensemble contient (paul,10) : true

Écrire [equals] et [hashCode] à la main est fastidieux et source d'erreurs ; les environnements de développement savent les générer. Les records, eux, les fournissent automatiquement.

4.5.3. Les objets immuables

Le programme [Classes16.java] présente deux façons de définir une donnée immuable :

// objets immuables : une classe immuable et un record
void main() {
    Point a = new Point(1, 2);
    // a.x = 5;   // refusé : le champ x est final
    Point b = a.deplacer(3, 4);
    IO.println("a=" + a + ", b=" + b);

    // un record : constructeur, accesseurs, toString, equals et hashCode sont générés
    Couleur rouge = new Couleur(255, 0, 0);
    Couleur rouge2 = new Couleur(255, 0, 0);
    IO.println("rouge=" + rouge + ", r=" + rouge.r());
    IO.println("rouge.equals(rouge2) : " + rouge.equals(rouge2) + ", rouge == rouge2 : " + (rouge == rouge2));
    // une copie modifiée, créée par des méthodes écrites à la main
    Couleur rose = rouge.avecV(128).avecB(128);
    IO.println("rouge=" + rouge + ", rose=" + rose);
}

// une classe immuable : final, champs privés et final, pas de modifieur
final class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int getX() {
        return x;
    }

    public int getY() {
        return y;
    }

    // on ne modifie pas le point : on en rend un nouveau
    public Point deplacer(int dx, int dy) {
        return new Point(x + dx, y + dy);
    }

    @Override
    public String toString() {
        return "(" + x + "," + y + ")";
    }
}

// un record : une classe immuable déclarée en une ligne... ou presque
record Couleur(int r, int v, int b) {
    // des méthodes qui rendent une copie modifiée du record
    public Couleur avecV(int v) {
        return new Couleur(r, v, b);
    }

    public Couleur avecB(int b) {
        return new Couleur(r, v, b);
    }
}
  • lignes 19-45 : une classe immuable écrite à la main. Elle est finale, pour qu'aucune classe dérivée ne puisse lui ajouter un état modifiable ; ses champs sont privés et [final] ; elle n'a pas de modifieur. Pour « déplacer » un point, la méthode [deplacer] rend un nouveau point (lignes 37-39) ;
  • ligne 48 : un record (Java 16). L'en-tête [record Couleur(int r, int v, int b)] déclare une classe finale dotée de trois champs privés et [final], appelés les composants du record, d'un constructeur à trois paramètres, de trois accesseurs [r()], [v()], [b()], et des méthodes [toString], [equals] et [hashCode], qui utilisent tous les composants ;
  • lignes 50-56 : on peut ajouter des méthodes à un record. Un record étant immuable, on ne le modifie pas : on crée une copie dont un composant a changé. Ces méthodes sont appelées des « withers » (de l'anglais « with », avec) ; d'autres langages fournissent pour cela une expression [with], qui n'existe pas en Java 25 ;
  • ligne 11 : l'accesseur d'un composant porte le nom du composant, sans préfixe [get] ;
  • ligne 12 : deux records de mêmes composants sont égaux au sens de [equals], bien que distincts ;
  • ligne 14 : les méthodes « wither » s'enchaînent.

Résultats :

1
2
3
4
5
> java Classes16.java
a=(1,2), b=(4,6)
rouge=Couleur[r=255, v=0, b=0], r=255
rouge.equals(rouge2) : true, rouge == rouge2 : false
rouge=Couleur[r=255, v=0, b=0], rose=Couleur[r=255, v=128, b=128]

Le [toString] généré affiche le nom du record et ses composants (ligne 3 des résultats). Retenons ici qu'un record est la façon la plus concise de déclarer une petite donnée immuable : c'est ce type que nous utiliserons pour les tranches du barème de l'impôt dans l'application exemple de ce chapitre. Les records sont étudiés en détail plus loin.

4.6. Les interfaces

4.6.1. Un premier exemple

Une interface est un ensemble de déclarations de méthodes qui forme un contrat. Une classe qui décide d'implémenter une interface s'engage à fournir une implémentation de toutes les méthodes abstraites définies dans l'interface. C'est le compilateur qui vérifie cette implémentation.

Voici par exemple la définition (simplifiée) de l'interface [java.util.Iterator<E>] du JDK, qui permet de parcourir les éléments d'une collection : [public interface Iterator<E> { boolean hasNext(); E next(); }]. Les méthodes de l'interface ne sont définies que par leurs signatures, suivies d'un point-virgule : elles n'ont pas de code et sont implicitement publiques et abstraites. Ce sont les classes qui implémentent l'interface qui donnent du code à ses méthodes. Une classe [C] déclare qu'elle implémente l'interface avec le mot clé [implements] : [public class C implements Iterator<String>], et elle doit alors définir les deux méthodes publiques [public boolean hasNext() { ... }] et [public String next() { ... }].

Une convention répandue (mais non universelle : le JDK ne la suit pas) fait commencer le nom d'une interface par la lettre I. Les exemples qui suivent forment le dossier [Statistiques]. Considérons l'interface suivante [IStats.java] :

1
2
3
4
5
6
7
public interface IStats {
    // moyenne d'une série de valeurs
    double moyenne();

    // écart-type de la série
    double ecartType();
}

L'interface [IStats] présente :

  • une méthode [moyenne] : pour calculer la moyenne d'une série de valeurs ;
  • une méthode [ecartType] : pour en calculer l'écart-type.

On notera qu'il n'est nulle part précisé de quelle série de valeurs il s'agit. Il peut s'agir de la moyenne des notes d'une classe, de la moyenne mensuelle des ventes d'un produit particulier, de la température moyenne dans un lieu donné… C'est le principe des interfaces : on suppose l'existence de méthodes dans l'objet mais pas celle de données particulières. Les méthodes n'ont pas de préfixe [get] : ce sont des calculs plutôt que des accès à des champs, et ce choix permettra à un record d'implémenter l'interface.

Une première classe d'implémentation de l'interface [IStats] pourrait être une classe servant à mémoriser les notes des élèves d'une classe dans une matière donnée. Un élève est caractérisé par le record [Eleve] suivant [Eleve.java] :

1
2
3
// un élève
public record Eleve(String prenom, String nom) {
}

L'élève est identifié par son prénom et son nom. Le record, présenté au paragraphe précédent, le fait en une ligne. Une note est caractérisée par le record [Note] suivant [Note.java] :

1
2
3
4
5
6
7
// la note d'un élève
public record Note(Eleve eleve, double valeur) {
    @Override
    public String toString() {
        return "[" + eleve.prenom() + "," + eleve.nom() + "," + valeur + "]";
    }
}

La note est définie par l'élève noté et la note elle-même. Le record redéfinit la méthode [toString] générée par le compilateur pour afficher une note sous la forme [prénom,nom,valeur].

Les notes de tous les élèves dans une matière donnée sont rassemblées dans la classe [TableauDeNotes] suivante [TableauDeNotes.java] :

import java.util.Arrays;

public class TableauDeNotes implements IStats {
    // champs
    private final String matiere;
    private final Note[] notes;
    private final double moyenne;
    private final double ecartType;

    // constructeur
    public TableauDeNotes(String matiere, Note[] notes) {
        // mémorisation des données
        this.matiere = matiere;
        this.notes = notes;
        // calcul de la moyenne des notes
        double somme = 0;
        for (Note note : notes) {
            somme += note.valeur();
        }
        moyenne = notes.length != 0 ? somme / notes.length : -1;
        // calcul de l'écart-type
        double carres = 0;
        for (Note note : notes) {
            carres += Math.pow(note.valeur() - moyenne, 2);
        }
        ecartType = notes.length != 0 ? Math.sqrt(carres / notes.length) : -1;
    }

    // accesseurs
    public String getMatiere() {
        return matiere;
    }

    public Note[] getNotes() {
        return notes;
    }

    // implémentation des méthodes de l'interface IStats
    @Override
    public double moyenne() {
        return moyenne;
    }

    @Override
    public double ecartType() {
        return ecartType;
    }

    // toString
    @Override
    public String toString() {
        return "matière=" + matiere + ", notes=" + Arrays.toString(notes);
    }
}
  • ligne 3 : la classe [TableauDeNotes] implémente l'interface [IStats]. Elle doit donc implémenter les méthodes [moyenne] et [ecartType]. Celles-ci sont implémentées lignes 39-47. L'annotation [@Override] s'emploie aussi pour l'implémentation d'une méthode d'interface ;
  • lignes 5-8 : quatre champs [final] : la matière dont l'objet mémorise les notes, le tableau des notes, la moyenne et l'écart-type des notes ;
  • lignes 11-27 : le constructeur. On suppose ici que les notes transmises au constructeur ne bougeront plus par la suite. Aussi utilise-t-on le constructeur pour calculer tout de suite la moyenne et l'écart-type de ces notes et les mémoriser. Un champ [final] comme [moyenne] peut être affecté dans le constructeur (ligne 20) ; il ne le peut plus ensuite. Les expressions [condition ? valeur1 : valeur2] donnent la valeur -1 à la moyenne et à l'écart-type d'un tableau vide. [Math.pow(x, 2)] calcule x², [Math.sqrt] la racine carrée ;
  • lignes 50-53 : la méthode [toString] utilise la méthode statique [Arrays.toString], qui rend les éléments d'un tableau entre crochets, séparés par des virgules, chacun étant converti en chaîne par sa méthode [toString], ici celle du record [Note].

Dans la classe précédente, les notes étaient enregistrées dans un tableau. Il n'était pas possible d'ajouter une nouvelle note après construction de l'objet [TableauDeNotes]. Nous proposons maintenant une seconde implémentation de l'interface [IStats], appelée [ListeDeNotes], où cette fois les notes sont enregistrées dans une liste [List<Note>], avec possibilité d'ajouter des notes après construction initiale de l'objet [ListeDeNotes] [ListeDeNotes.java] :

import java.util.List;

public class ListeDeNotes implements IStats {
    // champs
    private final String matiere;
    private final List<Note> notes;
    // moyenne et écart-type mémorisés (null : pas encore calculés)
    private Double moyenne;
    private Double ecartType;

    // constructeur
    public ListeDeNotes(String matiere, List<Note> notes) {
        this.matiere = matiere;
        this.notes = notes;
    }

    // accesseurs
    public String getMatiere() {
        return matiere;
    }

    public List<Note> getNotes() {
        return notes;
    }

    // ajout d'une note
    public void ajouter(Note note) {
        notes.add(note);
        // la moyenne et l'écart-type devront être recalculés
        moyenne = null;
        ecartType = null;
    }

    // moyenne des notes : calculée à la première demande
    @Override
    public double moyenne() {
        if (moyenne == null) {
            moyenne = calculerMoyenne();
        }
        return moyenne;
    }

    // écart-type des notes : calculé à la première demande
    @Override
    public double ecartType() {
        if (ecartType == null) {
            ecartType = calculerEcartType();
        }
        return ecartType;
    }

    private double calculerMoyenne() {
        if (notes.isEmpty()) {
            return -1;
        }
        double somme = 0;
        for (Note note : notes) {
            somme += note.valeur();
        }
        return somme / notes.size();
    }

    private double calculerEcartType() {
        if (notes.isEmpty()) {
            return -1;
        }
        double m = moyenne();
        double carres = 0;
        for (Note note : notes) {
            carres += Math.pow(note.valeur() - m, 2);
        }
        return Math.sqrt(carres / notes.size());
    }

    // toString
    @Override
    public String toString() {
        return "matière=" + matiere + ", notes=" + notes;
    }
}
  • ligne 3 : la classe [ListeDeNotes] implémente l'interface [IStats] ;
  • ligne 6 : les notes sont mises maintenant dans une liste plutôt qu'un tableau ;
  • lignes 27-32 : on peut désormais ajouter une note à celles déjà mémorisées. La moyenne et l'écart-type ne sont plus calculés dans le constructeur mais à la demande, dans les méthodes de l'interface elles-mêmes : [moyenne] (lignes 35-41) et [ecartType] (lignes 44-50). Une fois calculées, ces valeurs sont mémorisées pour ne pas être recalculées à chaque demande : on dit qu'elles sont mises en cache. L'ajout d'une note invalide le cache ;
  • lignes 8-9 : le cache. Le type [Double] est la classe enveloppe du type primitif [double] : contrairement à une variable [double], une variable [Double] peut valoir [null], qui signifie ici « pas encore calculé ». Les champs non initialisés valent d'ailleurs [null] à la construction de l'objet ;
  • lignes 38 et 40 : le compilateur convertit automatiquement le [double] rendu par [calculerMoyenne] en objet [Double] (« autoboxing »), et l'objet [Double] en [double] pour le résultat de la méthode (« unboxing »).

Un programme de test pourrait être le suivant [Main.java] :

void main() {
    // quelques élèves et leurs notes d'anglais
    Eleve[] eleves = {
        new Eleve("Paul", "Martin"), new Eleve("Maxime", "Germain"), new Eleve("Berthine", "Samin")
    };
    Note[] notes = { new Note(eleves[0], 14), new Note(eleves[1], 16), new Note(eleves[2], 18) };
    // qu'on enregistre dans un objet TableauDeNotes
    TableauDeNotes anglais = new TableauDeNotes("anglais", notes);
    // affichage moyenne et écart-type
    afficheStats(anglais);
    // les mêmes notes dans un objet ListeDeNotes
    ListeDeNotes francais = new ListeDeNotes("français", new ArrayList<>(List.of(notes)));
    afficheStats(francais);
    // on ajoute une note
    francais.ajouter(new Note(new Eleve("Jérôme", "Jaric"), 10));
    afficheStats(francais);
    // une variable peut avoir pour type une interface
    IStats stats = anglais;
    IO.println("Moyenne d'anglais : " + stats.moyenne());
    stats = francais;
    IO.println("Moyenne de français : " + stats.moyenne());
}

// affichage de la moyenne et de l'écart-type d'un objet implémentant IStats
void afficheStats(IStats valeurs) {
    IO.println(valeurs + ", Moyenne=" + valeurs.moyenne() + ", Ecart-type=" + valeurs.ecartType());
}
  • lignes 3-5 : création d'un tableau d'élèves ;
  • ligne 6 : création d'un tableau de notes ;
  • ligne 8 : un objet [TableauDeNotes] dont on affiche la moyenne et l'écart-type ligne 10 ;
  • ligne 12 : un objet [ListeDeNotes] dont on affiche la moyenne et l'écart-type ligne 13. [List.of(notes)] rend une liste non modifiable des éléments du tableau ; le constructeur de la classe [ArrayList] en fait une liste modifiable, à laquelle on pourra ajouter des notes ;
  • ligne 15 : ajout d'une nouvelle note, puis nouvel affichage ;
  • lignes 25-27 : la méthode [afficheStats] reçoit pour paramètre un type [IStats], donc un type interface. Cela signifie que le paramètre effectif peut être tout objet implémentant l'interface [IStats]. Quand on utilise une donnée ayant le type d'une interface, on n'utilise que les méthodes de l'interface implémentées par la donnée. On fait abstraction du reste. On a là une propriété proche du polymorphisme vu pour les classes. Si un ensemble de classes Ci non liées entre elles par héritage (on ne peut donc pas utiliser le polymorphisme de l'héritage) présente un ensemble de méthodes de même signature, il peut être intéressant de regrouper ces méthodes dans une interface I qu'implémenteraient toutes les classes concernées. Des instances de ces classes Ci peuvent alors être utilisées comme paramètres effectifs de méthodes admettant un paramètre formel de type I, c'est-à-dire des méthodes n'utilisant que les méthodes des objets Ci définies dans l'interface I et non les champs et méthodes particuliers des différentes classes Ci ;
  • lignes 18-21 : une variable peut être du type d'une interface. Elle peut alors référencer tout objet d'une classe implémentant l'interface. Le compilateur ne permet l'accès qu'aux méthodes de l'interface (et à celles de la classe [Object], comme [toString]).

Résultats :

1
2
3
4
5
6
> java Main.java
matière=anglais, notes=[[Paul,Martin,14.0], [Maxime,Germain,16.0], [Berthine,Samin,18.0]], Moyenne=16.0, Ecart-type=1.632993161855452
matière=français, notes=[[Paul,Martin,14.0], [Maxime,Germain,16.0], [Berthine,Samin,18.0]], Moyenne=16.0, Ecart-type=1.632993161855452
matière=français, notes=[[Paul,Martin,14.0], [Maxime,Germain,16.0], [Berthine,Samin,18.0], [Jérôme,Jaric,10.0]], Moyenne=14.5, Ecart-type=2.958039891549808
Moyenne d'anglais : 16.0
Moyenne de français : 14.5

La concaténation d'un nombre [double] et d'une chaîne utilise la méthode [Double.toString], qui écrit toujours le nombre avec un point décimal et au moins une décimale (16.0), quelle que soit la langue du poste. Pour un affichage à la française, on utilise [String.format("%.2f", x)], comme dans l'exemple suivant.

Notons enfin que l'implémentation d'interfaces peut être multiple, c'est-à-dire qu'on peut écrire [public class ClasseDerivee extends ClasseDeBase implements I1, I2, ..., In], où les Ij sont des interfaces : une classe ne dérive que d'une classe, mais peut implémenter autant d'interfaces qu'elle le souhaite. Une interface peut elle-même dériver d'une ou plusieurs autres interfaces : [interface I3 extends I1, I2].

4.6.2. Les méthodes d'interface par défaut, statiques et privées

Depuis Java 8, une interface peut fournir une implémentation par défaut de certaines de ses méthodes, déclarées avec le mot clé [default]. Une classe qui implémente l'interface peut alors utiliser cette implémentation ou fournir la sienne. Cela permet notamment d'ajouter une méthode à une interface déjà publiée sans obliger toutes les classes qui l'implémentent à être modifiées : c'est ainsi que les collections du JDK se sont enrichies au fil des versions. Une interface peut aussi contenir des méthodes statiques (Java 8) et des méthodes privées (Java 9). Le programme [Classes17.java] en donne un exemple :

// méthodes d'interface par défaut (Java 8), statiques (Java 8) et privées (Java 9)
void main() {
    IForme[] formes = { new Carre(2), new Cercle(1), IForme.carreUnite() };
    for (IForme forme : formes) {
        IO.println(forme.description());
    }
    // la méthode par défaut est héritée par la classe Carre
    Carre carre = new Carre(3);
    IO.println(carre.description());
}

interface IForme {
    // méthode abstraite : à implémenter
    double surface();

    // méthode avec une implémentation par défaut
    default String description() {
        return getClass().getSimpleName() + " de surface " + formater(surface());
    }

    // méthode statique : elle s'appelle avec le nom de l'interface
    static IForme carreUnite() {
        return new Carre(1);
    }

    // méthode privée : utilisable par les seules méthodes de l'interface
    private String formater(double valeur) {
        return String.format("%.2f", valeur);
    }
}

// Carre utilise l'implémentation par défaut de description
// (classe statique car l'interface crée des carrés : voir Classes10)
static class Carre implements IForme {
    private final double cote;

    public Carre(double cote) {
        this.cote = cote;
    }

    @Override
    public double surface() {
        return cote * cote;
    }
}

// Cercle fournit sa propre implémentation de description
class Cercle implements IForme {
    private final double rayon;

    public Cercle(double rayon) {
        this.rayon = rayon;
    }

    @Override
    public double surface() {
        return Math.PI * rayon * rayon;
    }

    @Override
    public String description() {
        return String.format("Cercle de rayon %s et de surface %.2f", rayon, surface());
    }
}
  • lignes 12-30 : l'interface [IForme] :
    • ligne 14 : une méthode abstraite [surface], à implémenter ;
    • lignes 17-19 : une méthode [description] dotée d'une implémentation par défaut. Celle-ci peut utiliser les autres méthodes de l'interface ([surface], [formater]) et celles de [Object] ([getClass]) ;
    • lignes 22-24 : une méthode statique, qui s'appelle avec le nom de l'interface : [IForme.carreUnite()] (ligne 3). C'est une fabrique statique ;
    • lignes 27-29 : une méthode privée, utilisable par les seules méthodes de l'interface. [String.format("%.2f", valeur)] formate le nombre avec deux décimales, en respectant les conventions de la langue du poste, ici la virgule décimale française ;
  • lignes 34-45 : la classe [Carre] n'implémente que [surface] : elle utilise la méthode [description] par défaut. Elle est déclarée [static] parce que la méthode statique [carreUnite] de l'interface crée des carrés (voir [Classes10.java]) ;
  • lignes 48-64 : la classe [Cercle] fournit sa propre méthode [description]. Le format [%s] y insère le rayon converti en chaîne par [Double.toString] (« 1.0 »), le format [%.2f] la surface avec deux décimales ;
  • ligne 9 : la méthode par défaut est héritée par la classe [Carre] comme n'importe quelle méthode : on peut l'appeler sur une variable de type [Carre].

Résultats :

1
2
3
4
5
> java Classes17.java
Carre de surface 4,00
Cercle de rayon 1.0 et de surface 3,14
Carre de surface 1,00
Carre de surface 9,00

Une classe peut implémenter deux interfaces qui fournissent chacune une méthode par défaut de même signature : le compilateur exige alors que la classe redéfinisse la méthode, quitte à appeler l'une des deux versions par [I1.super.methode()]. Contrairement à une classe abstraite, une interface n'a pas d'état : elle ne peut pas déclarer de champ d'instance, seulement des constantes ([static final], implicitement).

4.7. Les classes abstraites

Une classe abstraite est une classe qu'on ne peut pas instancier. Il faut créer des classes dérivées qui, elles, pourront être instanciées. On peut utiliser des classes abstraites pour factoriser le code d'une lignée de classes. Examinons le cas suivant, qui forme le dossier [Utilisateurs] [Utilisateur.java] :

public abstract class Utilisateur {
    // champs
    private final String login;
    private final String motDePasse;
    private final String role;

    // constructeur
    @SuppressWarnings("this-escape")
    protected Utilisateur(String login, String motDePasse) throws ExceptionUtilisateurInconnu {
        // on enregistre les informations
        this.login = login;
        this.motDePasse = motDePasse;
        // on identifie l'utilisateur
        String role = identifie();
        // identifié ?
        if (role == null) {
            throw new ExceptionUtilisateurInconnu("[" + login + "," + motDePasse + "]");
        }
        this.role = role;
    }

    // toString
    @Override
    public String toString() {
        return "Utilisateur[" + login + "," + motDePasse + "," + role + "]";
    }

    // identification : rend le rôle de l'utilisateur ou null s'il n'est pas reconnu
    protected abstract String identifie();
}
  • ligne 1 : la classe est déclarée abstraite avec le mot clé [abstract] ;
  • lignes 9-20 : le constructeur de la classe [Utilisateur]. Cette classe mémorise des informations sur l'utilisateur d'une application web. Celle-ci a divers types d'utilisateurs authentifiés par un login et un mot de passe (lignes 3-4). Ces deux informations sont vérifiées auprès d'un annuaire LDAP pour certains utilisateurs, auprès d'une base de données pour d'autres, etc. Le constructeur est [protected] : il est destiné aux constructeurs des classes dérivées ;
  • lignes 11-12 : les informations d'authentification sont mémorisées ;
  • ligne 14 : elles sont vérifiées par une méthode [identifie]. Parce que la méthode d'identification n'est pas connue à ce niveau, elle est déclarée abstraite (ligne 29) avec le mot clé [abstract]. Une méthode abstraite n'a pas de corps : ce sont les classes dérivées qui lui en donneront un. Une classe qui a une méthode abstraite doit elle-même être déclarée abstraite. La méthode [identifie] rend une chaîne de caractères précisant le rôle de l'utilisateur (en gros ce qu'il a le droit de faire), ou [null] si l'utilisateur n'est pas reconnu ;
  • lignes 16-18 : si l'utilisateur n'est pas reconnu, une exception est lancée. L'exception [ExceptionUtilisateurInconnu] est une exception contrôlée (voir plus bas) : le constructeur doit la déclarer par la clause [throws ExceptionUtilisateurInconnu] (ligne 9) ;
  • lignes 23-26 : la méthode [toString] qui identifie une instance de la classe.

On suppose ici que le développeur veut avoir la maîtrise de la construction des instances de la classe [Utilisateur] et des classes dérivées, peut-être parce qu'il veut être sûr qu'une exception d'un certain type est lancée si l'utilisateur n'est pas reconnu. Les classes dérivées pourront s'appuyer sur ce constructeur. Elles devront pour cela fournir la méthode [identifie]. On notera que le constructeur appelle ici une méthode redéfinie par les classes dérivées alors que celles-ci n'ont pas encore exécuté leur propre constructeur : la méthode [identifie] ne doit donc pas utiliser de champs de la classe dérivée, qui ne sont pas encore initialisés. C'est une pratique à manier avec précaution, et le compilateur la signale lorsqu'on compile avec l'option [-Xlint:all], qui active tous les avertissements (ici sans l'annotation de la ligne 8, d'où le numéro de ligne 13 dans le message) :

1
2
3
4
5
> javac -Xlint:all -d classes *.java
Utilisateur.java:13: warning: [this-escape] possible 'this' escape before subclass is fully initialized
        String role = identifie();
                               ^
1 warning

L'avertissement [this-escape] (Java 21) signale que l'objet en construction ([this]) « s'échappe » du constructeur avant d'être complètement initialisé. Ici, c'est voulu et sans danger, car les méthodes [identifie] des classes dérivées n'utilisent aucun champ. Après avoir vérifié ce point, on fait taire l'avertissement, pour ce seul constructeur, par l'annotation [@SuppressWarnings("this-escape")] (ligne 8). Le lanceur de code source ([java Main.java]) n'active pas ces avertissements : il est bon de compiler régulièrement ses programmes avec [javac -Xlint:all], ce que nous avons fait pour tous les programmes de ce cours.

La classe [ExceptionUtilisateurInconnu] est la suivante :

1
2
3
4
5
6
7
8
public class ExceptionUtilisateurInconnu extends Exception {
    // numéro de version de la classe (voir les explications)
    private static final long serialVersionUID = 1L;

    public ExceptionUtilisateurInconnu(String message) {
        super(message);
    }
}
  • ligne 1 : elle dérive de la classe [Exception]. C'est donc une exception contrôlée (« checked exception ») : le compilateur vérifie que toute méthode qui peut la lancer la déclare par une clause [throws], et que tout code qui appelle une telle méthode l'intercepte ou la déclare à son tour. Une exception dérivée de [RuntimeException], comme [IllegalArgumentException], ne l'est pas ;
  • ligne 3 : les exceptions sont des objets « sérialisables » (transformables en suite d'octets) ; le compilateur, avec l'option [-Xlint:all], demande qu'une classe sérialisable déclare un numéro de version [serialVersionUID]. Nous nous contentons de la valeur 1 ;
  • lignes 5-7 : elle n'a qu'un unique constructeur qui admet pour paramètre un message d'erreur. Celui-ci est passé à la classe parent, qui a ce même constructeur.

Nous dérivons maintenant la classe [Utilisateur] dans la classe fille [Administrateur] :

public class Administrateur extends Utilisateur {
    // constructeur
    public Administrateur(String login, String motDePasse) throws ExceptionUtilisateurInconnu {
        super(login, motDePasse);
    }

    // identification
    @Override
    protected String identifie() {
        // identification LDAP
        // ...
        return "admin";
    }
}
  • lignes 3-5 : le constructeur se contente de passer à sa classe parent les paramètres qu'il reçoit. Il déclare lui aussi l'exception que le constructeur parent peut lancer ;
  • lignes 8-13 : la méthode [identifie] de la classe [Administrateur]. On suppose qu'un administrateur est identifié par un annuaire LDAP. Cette méthode implémente la méthode abstraite [identifie] de sa classe parent. Elle garde l'accès [protected] de la méthode qu'elle redéfinit (une méthode redéfinie peut élargir l'accès, pas le restreindre).

Les classes [Observateur] et [Inconnu] dérivent elles aussi de la classe [Utilisateur]. Elles ne diffèrent de la classe [Administrateur] que par leur méthode [identifie]. Celle de la classe [Observateur] suppose qu'un observateur est identifié par une base de données et rend le rôle « observateur » :

1
2
3
4
5
6
7
    // identification
    @Override
    protected String identifie() {
        // identification par une base de données
        // ...
        return "observateur";
    }

Celle de la classe [Inconnu] rend la référence [null] pour indiquer que l'utilisateur n'a pas été reconnu :

1
2
3
4
5
6
7
    // identification
    @Override
    protected String identifie() {
        // utilisateur pas connu
        // ...
        return null;
    }

Au final, les objets [Administrateur], [Observateur] et [Inconnu] sont initialisés par le même constructeur, celui de la classe parent [Utilisateur]. Ce constructeur utilise la méthode [identifie] que ces classes fournissent. Un programme de test pourrait être le suivant [Main.java] :

void main() {
    try {
        IO.println(new Observateur("observer", "mdp1"));
        IO.println(new Administrateur("admin", "mdp2"));
        IO.println(new Inconnu("xx", "yy"));
    } catch (ExceptionUtilisateurInconnu e) {
        IO.println("Utilisateur non connu : " + e.getMessage());
    }
    // Utilisateur u = new Utilisateur("x", "y");   // refusé : la classe Utilisateur est abstraite
}

On notera que lignes 3 et 4, c'est la méthode [Utilisateur.toString()] qui est utilisée par la méthode [println]. Ligne 5, le constructeur lance une exception [ExceptionUtilisateurInconnu], interceptée ligne 6 : l'objet n'est pas créé et rien n'est affiché par [println]. Les trois créations sont placées dans un bloc [try], car le compilateur exige que l'exception contrôlée soit interceptée. La ligne 9, si on la décommente, est refusée par le compilateur ([Utilisateur is abstract; cannot be instantiated]) : on ne peut pas créer d'objet d'une classe abstraite.

Résultats :

1
2
3
4
> java Main.java
Utilisateur[observer,mdp1,observateur]
Utilisateur[admin,mdp2,admin]
Utilisateur non connu : [xx,yy]

Une classe abstraite ressemble à une interface : toutes deux déclarent des méthodes que d'autres classes devront implémenter. Mais une classe abstraite peut avoir des champs, des constructeurs, et du code commun à ses classes dérivées ; en contrepartie, une classe ne peut dériver que d'une seule classe abstraite, alors qu'elle peut implémenter plusieurs interfaces. On combine souvent les deux : une interface définit le contrat, une classe abstraite qui l'implémente factorise le code commun à plusieurs implémentations. C'est ce que fera l'application exemple de ce chapitre.

4.8. Les classes, interfaces et méthodes génériques

4.8.1. Classes et méthodes génériques

Supposons qu'on veuille écrire une classe [PaireEntiers] qui mémorise deux entiers et sait les échanger : deux champs [int premier] et [int second], et une méthode [echanger()] qui permute leurs valeurs au moyen d'une variable temporaire [int temp]. Si on voulait ensuite une paire de chaînes, puis une paire de personnes, on écrirait deux autres classes [PaireChaines] et [PairePersonnes], identiques à la précédente au type près : [String] ou [Personne] au lieu de [int].

Les classes, interfaces et méthodes génériques répondent au besoin de code qui ne diffère que par le type de certaines données. Elles sont apparues avec Java 5 (2004) : avant elles, on utilisait le type [Object], qui accepte tout, au prix de conversions de type explicites et d'erreurs détectées seulement à l'exécution. On notera que Java n'a pas de passage de paramètres par référence : une méthode ne peut pas échanger les valeurs de deux variables de l'appelant, mais elle peut échanger deux éléments d'un objet, une paire ou un tableau.

Le programme [Classes18.java] présente une classe générique, une méthode générique et une seconde classe générique :

// classes et méthodes génériques
void main() {
    // --- une classe générique Paire<T>
    // Integer
    Paire<Integer> pi = new Paire<>(1, 2);
    pi.echanger();
    IO.println("pi=" + pi);
    // String
    Paire<String> ps = new Paire<>("s1", "s2");
    ps.echanger();
    IO.println("ps=" + ps);
    // Personne
    Personne p1 = new Personne("jean", "clu", 20), p2 = new Personne("pauline", "dard", 55);
    Paire<Personne> pp = new Paire<>(p1, p2);
    pp.echanger();
    IO.println("pp=" + pp);
    // le premier élément est de type Personne : pas de conversion
    Personne premiere = pp.getPremier();
    IO.println("premier élément : " + premiere);

    // --- une méthode générique Generic2.echanger<T>
    Integer[] entiers = { 1, 2, 3 };
    Generic2.<Integer>echanger(entiers, 0, 2);
    IO.println("entiers=" + Arrays.toString(entiers));
    // le compilateur déduit T du type des paramètres effectifs
    Personne[] personnes = { p1, p2 };
    Generic2.echanger(personnes, 0, 1);
    IO.println("personnes=" + Arrays.toString(personnes));

    // --- une classe générique avec un état : une pile d'éléments de type T
    Pile<Personne> pile = new Pile<>();
    pile.empiler(p1);
    pile.empiler(p2);
    IO.println("La pile contient " + pile.size() + " personnes");
    Personne sommet = pile.depiler();
    IO.println("Personne dépilée : " + sommet + ", il en reste " + pile.size());
    // pile.empiler("x");   // refusé : la pile n'accepte que des objets Personne
}

// une classe générique : une paire de deux éléments de type T
class Paire<T> {
    private T premier;
    private T second;

    public Paire(T premier, T second) {
        this.premier = premier;
        this.second = second;
    }

    public T getPremier() {
        return premier;
    }

    public T getSecond() {
        return second;
    }

    // on échange les deux éléments
    public void echanger() {
        T temp = premier;
        premier = second;
        second = temp;
    }

    @Override
    public String toString() {
        return "(" + premier + "," + second + ")";
    }
}

// une classe non générique avec une méthode générique
class Generic2 {
    // échange les éléments n° i et j d'un tableau d'éléments de type T
    public static <T> void echanger(T[] tableau, int i, int j) {
        T temp = tableau[i];
        tableau[i] = tableau[j];
        tableau[j] = temp;
    }
}

// une pile d'éléments de type T
class Pile<T> {
    // les éléments de la pile
    private final List<T> elements = new ArrayList<>();

    // nombre d'éléments
    public int size() {
        return elements.size();
    }

    // ajout d'un élément au sommet de la pile
    public void empiler(T element) {
        elements.add(element);
    }

    // retrait de l'élément au sommet de la pile
    public T depiler() {
        if (elements.isEmpty()) {
            throw new IllegalStateException("pile vide");
        }
        return elements.removeLast();
    }
}

// une personne
record Personne(String prenom, String nom, int age) {
    @Override
    public String toString() {
        return "[" + prenom + ", " + nom + ", " + age + "]";
    }
}
  • lignes 41-69 : la classe [Paire] est paramétrée par un type noté [T], placé entre chevrons après le nom de la classe. On peut lui donner le nom que l'on veut ; par convention, c'est une lettre majuscule ([T] pour type, [E] pour élément, [K] et [V] pour clé et valeur). Ce type [T] est ensuite utilisé dans la classe comme n'importe quel type : pour les deux champs, les paramètres du constructeur, le résultat des accesseurs et la variable temporaire [temp] (ligne 60). On dit que la classe [Paire] est une classe générique ;
  • ligne 5 : lorsqu'on utilise une classe générique paramétrée par des types T1, T2, …, ces derniers doivent être précisés. Ici, [Paire<Integer>] est une paire d'objets [Integer]. Un paramètre générique ne peut être qu'un type référence : on écrit [Paire<Integer>] et non [Paire<int>]. Les entiers 1 et 2 sont convertis automatiquement en objets [Integer] (« autoboxing »). À droite, l'opérateur « diamant » [new Paire<>(...)] laisse le compilateur déduire le type [Integer] de celui de la variable. Lignes 9 et 14, la même chose avec les types [String] et [Personne] ;
  • ligne 18 : la méthode [getPremier] d'une [Paire<Personne>] rend un objet de type [Personne], sans conversion ;
  • lignes 72-79 : la classe [Generic2] n'est pas générique mais sa méthode statique [echanger] l'est : le paramètre de type [<T>] est déclaré avant le type du résultat (ligne 74). La méthode échange les éléments n° i et j d'un tableau d'éléments de type [T] ;
  • ligne 23 : on appelle la méthode [echanger] en précisant entre [<>] le type [T]. En fait, le compilateur est capable de déduire [T] du type des paramètres effectifs. Aussi l'écriture de la ligne 27 est-elle légale, et c'est celle qu'on utilise en pratique ;
  • lignes 82-103 : une classe générique avec un état : une pile d'éléments de type [T]. On empile au sommet de la pile, on dépile depuis le sommet. Les éléments sont rangés dans une liste générique [List<T>] (ligne 84), elle-même paramétrée par le type [T] de la pile. La méthode [removeLast] (Java 21) retire et rend le dernier élément de la liste. Une pile vide provoque une exception [IllegalStateException], l'exception prévue par Java pour une opération demandée à un objet qui n'est pas dans l'état voulu ;
  • ligne 31 : une pile de personnes. Le compilateur garantit qu'elle ne contiendra que des personnes : l'instruction de la ligne 37 serait refusée. La méthode [depiler] rend un objet de type [Personne], sans conversion ;
  • lignes 106-111 : la personne est un record dont on a redéfini la méthode [toString].

Résultats :

1
2
3
4
5
6
7
8
9
> java Classes18.java
pi=(2,1)
ps=(s2,s1)
pp=([pauline, dard, 55],[jean, clu, 20])
premier élément : [pauline, dard, 55]
entiers=[3, 2, 1]
personnes=[[pauline, dard, 55], [jean, clu, 20]]
La pile contient 2 personnes
Personne dépilée : [pauline, dard, 55], il en reste 1

4.8.2. Les bornes des paramètres génériques

Dans la classe [Paire<T>], le compilateur ne sait rien du type [T] : il n'autorise sur les objets de type [T] que les méthodes de la classe [Object]. Pour pouvoir utiliser d'autres méthodes, on borne le paramètre générique avec le mot clé [extends] :

Borne
Signification
[<T extends Classe>]
T doit être la classe indiquée ou une classe dérivée
[<T extends Interface>]
T doit implémenter l'interface indiquée (on écrit [extends], et non [implements])
[<T extends Classe & I1 & I2>]
plusieurs bornes : au plus une classe, en premier, puis des interfaces
[<T extends Comparable<T>>]
une borne qui utilise T lui-même : T doit être comparable à T

Il n'existe pas de borne pour exiger que T soit un type référence (c'est toujours le cas) ni qu'il ait un constructeur sans paramètres : on ne peut pas écrire [new T()] dans une classe générique (voir plus loin l'effacement de type). Le programme [Classes19.java] utilise une borne :

// les bornes des paramètres génériques
void main() {
    // --- T est toujours un type objet : int est remplacé par Integer
    Paire<Integer> pi = new Paire<>(1, 2);
    // Paire<int> pe = new Paire<>(1, 2);   // erreur : int est un type primitif
    IO.println("pi=" + pi);

    // --- la borne T extends IValeur
    // ordonner des paires d'objets de type Aleatoire
    for (int i = 0; i < 5; i++) {
        Paire<Aleatoire> paire = new Paire<>(new Aleatoire(), new Aleatoire());
        IO.print("Avant --> " + paire);
        Generic4.ordonner(paire);
        IO.println("   Après --> " + paire);
    }
    // ordonner une paire d'objets de type Chaine
    Paire<Chaine> chaines = new Paire<>(new Chaine("xxxxxxxxxxxxxx"), new Chaine("xx"));
    IO.println("Avant --> " + chaines);
    Generic4.ordonner(chaines);
    IO.println("Après --> " + chaines);
    // Generic4.ordonner(pi);   // erreur : Integer n'implémente pas IValeur
}

// une paire de deux éléments de type T
class Paire<T> {
    private T premier;
    private T second;

    public Paire(T premier, T second) {
        this.premier = premier;
        this.second = second;
    }

    public T getPremier() {
        return premier;
    }

    public T getSecond() {
        return second;
    }

    public void echanger() {
        T temp = premier;
        premier = second;
        second = temp;
    }

    @Override
    public String toString() {
        return "(" + premier + "," + second + ")";
    }
}

// le type T doit implémenter IValeur
class Generic4 {
    public static <T extends IValeur> void ordonner(Paire<T> paire) {
        // si le 1er élément est plus grand que le 2e, on échange les éléments
        if (paire.getPremier().valeur() > paire.getSecond().valeur()) {
            paire.echanger();
        }
    }
}

// un objet ayant une valeur entière
interface IValeur {
    int valeur();
}

// un objet ayant une valeur aléatoire
class Aleatoire implements IValeur {
    // générateur de nombres aléatoires partagé par toutes les instances
    // (initialisé avec une graine fixe pour que les résultats soient reproductibles)
    private static final Random generateur = new Random(2026);
    // valeur de l'objet
    private final int valeur = generateur.nextInt(100);

    @Override
    public int valeur() {
        return valeur;
    }

    @Override
    public String toString() {
        return String.valueOf(valeur);
    }
}

// un objet dont la valeur est la longueur d'une chaîne
class Chaine implements IValeur {
    private final String s;

    public Chaine(String s) {
        this.s = s;
    }

    @Override
    public int valeur() {
        return s.length();
    }

    @Override
    public String toString() {
        return s;
    }
}
  • ligne 5 : si on décommente cette ligne, le compilateur signale une erreur car le type [int] est un type primitif ;
  • lignes 55-62 : la méthode générique [ordonner] exige que le type [T] implémente l'interface [IValeur] (lignes 65-67). Celle-ci a une méthode [valeur], utilisée ligne 58, qui donne la valeur de l'objet de type [T]. Sans la borne, le compilateur refuserait l'appel [paire.getPremier().valeur()] : un type [T] quelconque n'a pas de méthode [valeur]. Les deux éléments de la paire ne sont échangés que si la valeur du premier est supérieure à celle du second : la méthode [ordonner] range les deux éléments par valeur croissante ;
  • lignes 70-86 : la classe [Aleatoire] implémente l'interface [IValeur]. La valeur d'une instance est un nombre aléatoire entre 0 et 99 (ligne 75), fourni par un générateur de nombres aléatoires de la classe [Random] partagé par toutes les instances (ligne 73). Ce générateur est initialisé avec une « graine » fixe : il produit alors toujours la même suite de nombres, ce qui rend les résultats reproductibles. Sans besoin de reproductibilité, on utilise le générateur créé par [new Random()], ou, pour des usages de sécurité, la classe [SecureRandom] ;
  • lignes 89-105 : la classe [Chaine] implémente elle aussi l'interface [IValeur] : la valeur d'une instance est la longueur de la chaîne de caractères passée au constructeur ;
  • lignes 10-15 : des paires d'objets de type [Aleatoire] sont ordonnées ;
  • lignes 17-20 : une paire d'objets de type [Chaine] est ordonnée ;
  • ligne 21 : si on décommente cette ligne, le compilateur signale que le type [Integer] ne respecte pas la borne.

Résultats :

1
2
3
4
5
6
7
8
9
> java Classes19.java
pi=(1,2)
Avant --> (99,50)   Après --> (50,99)
Avant --> (97,30)   Après --> (30,97)
Avant --> (49,38)   Après --> (38,49)
Avant --> (73,36)   Après --> (36,73)
Avant --> (34,78)   Après --> (34,78)
Avant --> (xxxxxxxxxxxxxx,xx)
Après --> (xx,xxxxxxxxxxxxxx)

Voici les erreurs obtenues en exécutant le programme une fois les lignes 5 et 21 décommentées :

> java Classes19.java
Classes19.java:5: error: unexpected type
    Paire<int> pe = new Paire<>(1, 2);   // erreur : int est un type primitif
          ^
  required: reference
  found:    int
Classes19.java:21: error: method ordonner in class Classes19.Generic4 cannot be applied to given types;
    Generic4.ordonner(pi);   // erreur : Integer n'implémente pas IValeur
            ^
  required: Classes19.Paire<T>
  found:    Classes19.Paire<Integer>
  reason: inference variable T has incompatible bounds
    equality constraints: Integer
    upper bounds: IValeur
  where T is a type-variable:
    T extends IValeur declared in method <T>ordonner(Classes19.Paire<T>)
2 errors
error: compilation failed

Le second message est typique des erreurs sur les génériques : le compilateur a tenté de déduire le type [T] ; la paire impose [T] = [Integer] (« equality constraints »), la borne impose que [T] implémente [IValeur] (« upper bounds ») ; les deux contraintes sont incompatibles.

4.8.3. Des interfaces génériques : Comparable<T> et Comparator<T>

Pour illustrer la notion d'interface générique, nous allons trier un tableau de personnes selon différents critères. La méthode qui nous permet de trier un tableau est la méthode statique [sort] de la classe [java.util.Arrays]. Elle est surchargée ; nous en utiliserons deux variantes :

  • [Arrays.sort(Object[] tableau)] trie le tableau selon l'ordre naturel de ses éléments : ceux-ci doivent implémenter l'interface [Comparable<T>], dont l'unique méthode [int compareTo(T autre)] compare l'objet courant à l'objet [autre] ;
  • [Arrays.sort(T[] tableau, Comparator<? super T> comparateur)] trie le tableau selon l'ordre défini par un objet comparateur, qui implémente l'interface [Comparator<T>], dont la méthode [int compare(T x, T y)] compare deux objets (le type [? super T] est expliqué au paragraphe suivant).

Les méthodes [compareTo] et [compare] suivent la même règle : elles rendent un nombre positif si x > y, 0 si x == y, un nombre négatif si x < y. C'est au développeur de donner une signification aux opérateurs <, ==, >. Par exemple, si [p1] et [p2] sont deux objets [Personne], on pourra dire que p1 > p2 si le nom de [p1] suit le nom de [p2] dans l'ordre alphabétique. On aura alors un tri croissant selon le nom des personnes. Si on veut un tri selon l'âge, on dira que p1 > p2 si l'âge de [p1] est supérieur à l'âge de [p2]. Pour avoir un tri dans l'ordre décroissant, il suffit d'inverser le signe des résultats.

Nous en savons assez pour trier un tableau de personnes. Le programme [Classes20.java] est le suivant :

// les interfaces génériques Comparable<T> et Comparator<T> : trier un tableau de personnes
void main() {
    Personne[] personnes = {
        new Personne("claude", "pollon", 25),
        new Personne("valentine", "germain", 35),
        new Personne("paul", "germain", 32),
    };
    // affichage
    affiche("Tableau à trier", personnes);
    // tri selon l'ordre naturel des personnes (Comparable) : l'âge
    Arrays.sort(personnes);
    affiche("Tableau après le tri selon l'âge", personnes);
    // tri selon les nom et prénom (Comparator)
    Arrays.sort(personnes, new CompareNoms());
    affiche("Tableau après le tri selon les nom et prénom", personnes);
    // tri selon l'âge décroissant, avec une classe anonyme
    Arrays.sort(personnes, new Comparator<Personne>() {
        @Override
        public int compare(Personne p1, Personne p2) {
            return Integer.compare(p2.getAge(), p1.getAge());
        }
    });
    affiche("Tableau après le tri selon l'âge décroissant", personnes);
}

// affichage d'un tableau de personnes
void affiche(String texte, Personne[] personnes) {
    IO.println(texte + "-".repeat(50 - texte.length()));
    for (Personne p : personnes) {
        IO.println(p);
    }
}

// une personne, comparable à une autre personne
class Personne implements Comparable<Personne> {
    private final String prenom;
    private final String nom;
    private final int age;

    public Personne(String prenom, String nom, int age) {
        this.prenom = prenom;
        this.nom = nom;
        this.age = age;
    }

    public String getPrenom() {
        return prenom;
    }

    public String getNom() {
        return nom;
    }

    public int getAge() {
        return age;
    }

    // l'ordre naturel des personnes : l'ordre des âges
    @Override
    public int compareTo(Personne autre) {
        return Integer.compare(age, autre.age);
    }

    @Override
    public String toString() {
        return "[" + prenom + ", " + nom + ", " + age + "]";
    }
}

// comparaison des noms et prénoms de deux personnes
class CompareNoms implements Comparator<Personne> {
    // comparaison de chaînes selon les règles de la langue française
    private final Collator collator = Collator.getInstance(Locale.FRENCH);

    @Override
    public int compare(Personne p1, Personne p2) {
        // on compare les noms
        int i = collator.compare(p1.getNom(), p2.getNom());
        if (i != 0) {
            return i;
        }
        // égalité des noms : on compare les prénoms
        return collator.compare(p1.getPrenom(), p2.getPrenom());
    }
}
  • lignes 3-7 : le tableau de personnes ;
  • lignes 35-68 : la classe [Personne] implémente l'interface générique [Comparable<Personne>] : ses objets sont comparables à d'autres personnes. Sa méthode [compareTo] (lignes 59-62) définit l'ordre naturel des personnes, ici l'ordre des âges. La méthode statique [Integer.compare(a, b)] rend un nombre négatif, nul ou positif selon que [a] est inférieur, égal ou supérieur à [b] : c'est exactement ce qu'attend la méthode [compareTo] ;
  • ligne 11 : le tri selon l'ordre naturel ;
  • ligne 14 : le tri du tableau de personnes selon les nom et prénom. Le second paramètre de la méthode [sort] est une instance de la classe [CompareNoms] implémentant l'interface générique [Comparator<Personne>] ;
  • lignes 71-85 : la classe [CompareNoms]. Sa méthode [compare] implémente la méthode [int compare(T x, T y)] de l'interface [Comparator<T>], avec ici [T] = [Personne]. Les chaînes ne sont pas comparées par leur méthode [compareTo], qui suit l'ordre des codes des caractères (les majuscules avant les minuscules, les lettres accentuées après le z), mais par un objet [Collator] (ligne 73) qui applique les règles de tri de la langue française ;
  • lignes 17-22 : le tri selon l'âge décroissant. Le comparateur est cette fois une instance d'une classe anonyme : l'expression [new Comparator<Personne>() { ... }] déclare une classe sans nom qui implémente l'interface [Comparator<Personne>] et crée aussitôt une instance de cette classe. Le corps de la classe (lignes 18-21) fournit la méthode [compare]. Une classe anonyme évite de déclarer une classe qui ne sert qu'une fois ;
  • ligne 28 : la méthode [repeat] (Java 11) répète une chaîne : la ligne de titre est complétée par des tirets jusqu'à 50 caractères.

Résultats :

> java Classes20.java
Tableau à trier-----------------------------------
[claude, pollon, 25]
[valentine, germain, 35]
[paul, germain, 32]
Tableau après le tri selon l'âge------------------
[claude, pollon, 25]
[paul, germain, 32]
[valentine, germain, 35]
Tableau après le tri selon les nom et prénom------
[paul, germain, 32]
[valentine, germain, 35]
[claude, pollon, 25]
Tableau après le tri selon l'âge décroissant------
[valentine, germain, 35]
[paul, germain, 32]
[claude, pollon, 25]

Nous avons maintenant rencontré trois sortes de classes déclarées à l'intérieur d'une autre : les classes imbriquées statiques ([static class]), les classes internes (sans [static], liées à un objet de la classe englobante) et les classes anonymes. Nous verrons au chapitre 7 qu'on peut faire encore plus court qu'une classe anonyme pour une interface qui n'a qu'une méthode abstraite, comme [Comparator] : une lambda, [Arrays.sort(personnes, (p1, p2) -> Integer.compare(p2.getAge(), p1.getAge()));], ou une référence de méthode, [Arrays.sort(personnes, Comparator.comparing(Personne::getNom));].

4.8.4. Les jokers et l'effacement de type

Une [List<Integer>] n'est pas une [List<Number>], bien que [Integer] dérive de [Number] : si elle l'était, on pourrait ajouter un [Double] à une liste d'entiers au travers d'une variable de type [List<Number>]. Une méthode qui reçoit une [List<Number>] refuse donc une [List<Integer>]. Pour écrire des méthodes qui acceptent des listes d'éléments de types apparentés, Java utilise des jokers (« wildcards ») :

Type
Signification
Utilisation
[List<? extends Number>]
une liste d'éléments d'un type inconnu dérivé de [Number]
on peut en lire des [Number], pas y ajouter d'élément
[List<? super Integer>]
une liste d'éléments d'un type inconnu dont [Integer] dérive ([Integer], [Number], [Object])
on peut y ajouter des [Integer]
[List<?>]
une liste d'éléments d'un type inconnu
on peut en lire des [Object]

Le programme [Classes21.java] illustre les jokers et une particularité des génériques Java, l'effacement de type :

// jokers (wildcards), bornes et effacement de type
void main() {
    List<Integer> entiers = List.of(1, 2, 3);
    List<Double> reels = List.of(1.5, 2.25);
    // ? extends Number : une liste d'éléments d'un type dérivé de Number
    IO.println("Somme d'entiers : " + somme(entiers));
    IO.println("Somme de réels : " + somme(reels));
    // ? super Integer : une liste qui peut recevoir des Integer
    List<Number> nombres = new ArrayList<>();
    ajouterEntiers(nombres, 3);
    nombres.add(2.5);
    IO.println("nombres=" + nombres + ", somme=" + somme(nombres));
    // une borne récursive : T doit être comparable à lui-même
    IO.println("max : " + max(List.of("paul", "jean", "valentine")) + ", " + max(entiers));
    // l'effacement de type : à l'exécution, une liste est une liste
    List<String> l1 = new ArrayList<>();
    List<Integer> l2 = new ArrayList<>();
    IO.println("même classe : " + (l1.getClass() == l2.getClass()) + " (" + l1.getClass().getName() + ")");
}

// somme des éléments d'une liste de nombres
double somme(List<? extends Number> valeurs) {
    double somme = 0;
    for (Number valeur : valeurs) {
        somme += valeur.doubleValue();
    }
    // valeurs.add(1);   // refusé : le type exact des éléments est inconnu
    return somme;
}

// ajout des entiers 1, 2, ..., n à une liste capable de recevoir des Integer
void ajouterEntiers(List<? super Integer> liste, int n) {
    for (int i = 1; i <= n; i++) {
        liste.add(i);
    }
}

// le plus grand élément d'une liste non vide
<T extends Comparable<? super T>> T max(List<T> elements) {
    T max = elements.getFirst();
    for (T element : elements) {
        if (element.compareTo(max) > 0) {
            max = element;
        }
    }
    return max;
}
  • lignes 22-29 : la méthode [somme] accepte une liste d'éléments de n'importe quel type dérivé de [Number] : [Integer], [Double], [BigDecimal]… Elle lit les éléments comme des [Number] et utilise leur méthode [doubleValue]. Java n'a pas d'arithmétique générique : on ne peut pas écrire [a + b] pour deux objets de type [T], d'où le passage par [double]. L'ajout d'un élément à la liste (ligne 27) est refusé : le compilateur ne sait pas si c'est une liste d'[Integer] ou de [Double] ;
  • lignes 6-7 : la même méthode additionne une liste d'[Integer] et une liste de [Double]. [List.of(...)] crée une liste non modifiable ;
  • lignes 32-36 : la méthode [ajouterEntiers] accepte toute liste capable de recevoir des [Integer], ici une [List<Number>] (ligne 10). On retient la règle « producteur : [extends], consommateur : [super] » : une liste dont on lit les éléments s'écrit avec [extends], une liste qu'on remplit avec [super] ;
  • lignes 39-47 : la méthode générique [max] rend le plus grand élément d'une liste. La borne [T extends Comparable<? super T>] exige que les éléments soient comparables entre eux ; elle s'applique aux chaînes comme aux entiers (ligne 14). La méthode [getFirst] (Java 21) rend le premier élément de la liste ;
  • lignes 16-18 : à l'exécution, une [List<String>] et une [List<Integer>] sont deux objets de la même classe [ArrayList]. Le compilateur vérifie les types génériques, puis les efface : le code compilé ne connaît que des [ArrayList] d'[Object], et des conversions sont insérées là où c'est nécessaire. Cet « effacement de type » (« type erasure »), choisi en 2004 pour que le code générique reste compatible avec le code existant, explique plusieurs limites : on ne peut pas écrire [new T()] ni [new T[10]], ni tester [o instanceof List<String>], ni utiliser un type primitif comme paramètre générique.

Résultats :

1
2
3
4
5
6
> java Classes21.java
Somme d'entiers : 6.0
Somme de réels : 3.75
nombres=[1, 2, 3, 2.5], somme=8.5
max : valentine, 3
même classe : true (java.util.ArrayList)

Contrairement à d'autres langages comme C#, où les interfaces peuvent déclarer des membres statiques abstraits pour écrire des calculs génériques, Java ne permet pas d'exiger d'un type [T] qu'il possède une méthode statique : les méthodes statiques d'une interface s'appellent avec le nom de l'interface et ne sont pas héritées par les classes qui l'implémentent.

4.9. Les paquetages

Pour écrire une ligne à l'écran, nous utilisons l'instruction [IO.println(...)]. Si nous regardons la définition de la classe [IO] dans la documentation du JDK, nous découvrons qu'elle fait partie du paquetage (« package ») [java.lang]. Cela signifie que son nom complet est [java.lang.IO] et qu'on pourrait écrire [java.lang.IO.println(...)]. Les classes du paquetage [java.lang] ([String], [Object], [Math], [Integer]…) sont toujours connues par leur nom simple. Pour les autres, on utilise une clause [import], placée en tête de fichier :

  • [import java.util.List;] importe la classe [List] du paquetage [java.util] ;
  • [import java.util.*;] importe à la demande toutes les classes publiques du paquetage [java.util] ;
  • [import module java.base;] (Java 25) importe toutes les classes publiques des paquetages exportés par le module [java.base] (voir plus bas). C'est ce que fait implicitement un fichier compact ;
  • [import static java.lang.Math.sqrt;] importe un membre statique d'une classe, qu'on peut alors utiliser sans le nom de sa classe : [sqrt(2)].

Lorsque le compilateur rencontre le nom simple d'une classe, il la cherche dans le paquetage courant et parmi les classes importées. Une clause [import] n'ajoute rien au programme : elle évite seulement d'écrire les noms complets.

Lorsqu'on crée une classe, on la place dans un paquetage. Le but des paquetages est d'éviter les conflits de noms entre classes, lorsque celles-ci sont distribuées par exemple. Considérons deux entreprises E1 et E2 distribuant des classes empaquetées respectivement dans les archives [e1.jar] et [e2.jar]. Soit un client C qui achète ces deux ensembles de classes, dans lesquels les deux entreprises ont défini toutes deux une classe [Personne]. Si l'entreprise E1 prend soin de créer ses classes dans un paquetage appelé [e1] et l'entreprise E2 dans un paquetage appelé [e2], les deux classes [Personne] s'appelleront alors [e1.Personne] et [e2.Personne]. Le client pourra importer l'une d'elles et désigner l'autre par son nom complet. Le paquetage permet de lever l'ambiguïté. Par convention, les noms de paquetages sont en minuscules et commencent par le nom de domaine inversé de l'organisation qui les publie ([com.mysql.cj.jdbc], [org.hibernate]…) ; pour nos exemples, nous nous contenterons de noms courts.

Pour placer une classe dans un paquetage, on écrit en tête de son fichier la déclaration [package nom;]. Contrairement à d'autres langages, Java impose une correspondance entre paquetages et dossiers : les fichiers source du paquetage [e1] sont rangés dans un dossier [e1], ceux d'un paquetage [impots.dao] dans un dossier [impots/dao], à partir de la racine des sources. Le dossier [EspacesDeNoms] illustre ces notions :

1
2
3
4
5
6
EspacesDeNoms/
  Main.java             le programme principal (paquetage sans nom)
  e1/
    Personne.java       la classe e1.Personne
  e2/
    Personne.java       la classe e2.Personne

La première classe [Personne] [e1/Personne.java] :

// les classes de ce fichier appartiennent au paquetage e1
package e1;

public class Personne {
    private final String prenom;
    private final String nom;

    public Personne(String prenom, String nom) {
        this.prenom = prenom;
        this.nom = nom;
    }

    @Override
    public String toString() {
        return "e1.Personne[" + prenom + ", " + nom + "]";
    }
}

La seconde [e2/Personne.java] :

// les classes de ce fichier appartiennent au paquetage e2
package e2;

public class Personne {
    private final String nom;

    public Personne(String nom) {
        this.nom = nom;
    }

    public String getNom() {
        return nom;
    }

    @Override
    public String toString() {
        return "e2.Personne[" + nom + "]";
    }
}

Les deux classes sont publiques : une classe sans le mot clé [public] n'est accessible qu'aux classes de son propre paquetage. Le programme principal [Main.java] est le suivant :

// importation d'une classe d'un paquetage
import e1.Personne;
// importation statique d'un membre statique d'une classe
import static java.lang.Math.sqrt;

void main() {
    // Personne désigne e1.Personne, qui a été importée
    Personne p1 = new Personne("Jean", "Dupont");
    // le nom complet d'une classe lève toute ambiguïté
    e2.Personne p2 = new e2.Personne("Durand");
    IO.println(p1 + " " + p2);
    // nom complet et paquetage d'une classe
    IO.println(Personne.class.getName() + ", " + e2.Personne.class.getName());
    IO.println("paquetage de la classe Personne : " + Personne.class.getPackageName());
    // le module qui contient une classe
    IO.println("e1.Personne est dans un module nommé : " + Personne.class.getModule().isNamed());
    IO.println(String.class.getName() + " est dans le module " + String.class.getModule().getName());
    // StringBuilder est connu grâce à l'importation automatique du module java.base
    StringBuilder sb = new StringBuilder("abc");
    IO.println(sb.append("def"));
    // sqrt est connue grâce à l'importation statique
    IO.println("sqrt(2)=" + sqrt(2));
}
  • ligne 2 : importation de la classe [e1.Personne] ;
  • ligne 4 : importation statique de la méthode [sqrt] de la classe [Math] ;
  • ligne 8 : [Personne] désigne la classe [e1.Personne], qui a été importée ;
  • ligne 10 : le nom complet de la classe [e2.Personne] lève toute ambiguïté. Java n'a pas d'alias de type : on ne peut pas donner un autre nom à une classe ;
  • lignes 13-14 : la méthode [getName] de la description d'une classe rend son nom complet, préfixé par son paquetage ; [getPackageName] rend le nom du paquetage ;
  • lignes 16-17 : la méthode [getModule] rend le module qui contient la classe (voir ci-dessous) ;
  • lignes 19-20 : la classe [java.lang.StringBuilder] est connue sans importation ;
  • ligne 22 : utilisation de l'importation statique.

Le lanceur de code source trouve seul les classes des sous-dossiers : la commande [java Main.java], tapée dans le dossier [EspacesDeNoms], compile à la demande les fichiers [e1/Personne.java] et [e2/Personne.java]. Résultats :

1
2
3
4
5
6
7
8
> java Main.java
e1.Personne[Jean, Dupont] e2.Personne[Durand]
e1.Personne, e2.Personne
paquetage de la classe Personne : e1
e1.Personne est dans un module nommé : false
java.lang.String est dans le module java.base
abcdef
sqrt(2)=1.4142135623730951

Si on remplace l'importation de la ligne 2 par les deux importations à la demande [import e1.*;] et [import e2.*;], le nom [Personne] devient ambigu et le compilateur le refuse :

1
2
3
4
5
6
> java Main.java
Main.java:9: error: reference to Personne is ambiguous
    Personne p1 = new Personne("Jean", "Dupont");
    ^
  both class e2.Personne in e2 and class e1.Personne in e1 match
...

4.9.1. Les modules

Depuis Java 9, les paquetages sont eux-mêmes regroupés en modules (« Java Platform Module System », JPMS). Le JDK est découpé en modules : [java.base] contient les paquetages fondamentaux ([java.lang], [java.util], [java.math], [java.io]…), [java.sql] l'accès aux bases de données, [java.net.http] le client HTTP, etc. Un module déclare, dans un fichier [module-info.java] placé à la racine de ses sources, son nom, les modules dont il a besoin ([requires java.sql;]) et les paquetages qu'il rend accessibles aux autres modules ([exports impots.metier;]) : un paquetage non exporté est invisible de l'extérieur, même pour ses classes publiques. Les classes d'une application qui ne déclare pas de module, comme les nôtres, appartiennent au module sans nom (ligne 5 des résultats), qui a accès à tous les modules. C'est le cas de la plupart des applications, et de toutes celles de ce cours : les modules intéressent surtout les auteurs de bibliothèques et du JDK lui-même.

4.10. Les records

Beaucoup de classes ne servent qu'à transporter des données : une personne, une note, une tranche d'impôt… Pour elles, on écrit toujours le même code : des champs privés et [final], un constructeur qui recopie ses paramètres dans les champs, des accesseurs, une méthode [toString] qui affiche les champs, et les méthodes [equals] et [hashCode]. Java 16 a introduit les records (enregistrements) : le compilateur génère ce code à partir d'une déclaration d'une ligne. Nous les avons rencontrés au paragraphe sur les objets immuables ; le programme [Classes22.java] les présente plus complètement :

// les records (Java 16)
void main() {
    Personne r1 = new Personne("Jean", "Dupont", 30);
    Personne r2 = new Personne("Jean", "Dupont", 30);
    // toString généré par le compilateur
    IO.println(r1);
    // les accesseurs portent le nom des composants
    IO.println("prénom=" + r1.prenom() + ", identité=" + r1.identite());
    // égalité par valeur : r1 et r2 sont deux objets différents mais égaux
    IO.println("r1.equals(r2) : " + r1.equals(r2) + ", r1 == r2 : " + (r1 == r2));
    // les composants sont en lecture seule
    // r1.age = 31;   // refusé : le champ age est final
    // une copie modifiée, avec une méthode « wither »
    Personne r3 = r1.avecAge(31);
    IO.println(r3);
    IO.println("r1.equals(r3) : " + r1.equals(r3));
    // déconstruction en variables avec un motif de record (Java 21)
    if (r1 instanceof Personne(String prenom, String nom, int age)) {
        IO.println("prénom=" + prenom + ", nom=" + nom + ", âge=" + age);
    }

    // la même chose avec une classe
    PersonneC c1 = new PersonneC("Jean", "Dupont", 30);
    PersonneC c2 = new PersonneC("Jean", "Dupont", 30);
    IO.println(c1);
    IO.println("c1.equals(c2) : " + c1.equals(c2));

    // deux records qui implémentent la même interface
    Enseignant e = new Enseignant("Lucile", "Dumas", 56, 61);
    IO.println(e);
    IO.println(e.identite());
    // l'égalité tient compte du type : une Personne n'est pas égale à un Enseignant
    Personne pe = new Personne("Lucile", "Dumas", 56);
    IO.println("pe.equals(e) : " + pe.equals(e));
    // le type commun : l'interface AvecIdentite
    AvecIdentite[] identites = { pe, e };
    for (AvecIdentite i : identites) {
        IO.println(i.identite());
    }
}

// une interface implémentée par les records
interface AvecIdentite {
    String identite();
}

// un record : prenom, nom, age sont ses composants
record Personne(String prenom, String nom, int age) implements AvecIdentite {
    // on peut ajouter des méthodes
    @Override
    public String identite() {
        return "[" + prenom + ", " + nom + ", " + age + "]";
    }

    // une méthode « wither » : une copie dont un composant a changé
    public Personne avecAge(int age) {
        return new Personne(prenom, nom, age);
    }
}

// un record ne peut pas dériver d'un autre record
record Enseignant(String prenom, String nom, int age, int section) implements AvecIdentite {
    @Override
    public String identite() {
        return "Enseignant[[" + prenom + ", " + nom + ", " + age + "]," + section + "]";
    }
}

// une classe équivalente... ou presque
final class PersonneC {
    private final String prenom;
    private final String nom;
    private final int age;

    public PersonneC(String prenom, String nom, int age) {
        this.prenom = prenom;
        this.nom = nom;
        this.age = age;
    }

    public String prenom() {
        return prenom;
    }

    public String nom() {
        return nom;
    }

    public int age() {
        return age;
    }
}
  • lignes 48-59 : le record [Personne]. La déclaration [record Personne(String prenom, String nom, int age)] génère :
    • trois champs privés et [final] [prenom], [nom], [age], les composants du record ;
    • un constructeur à trois paramètres, appelé le constructeur canonique ;
    • trois accesseurs publics [prenom()], [nom()], [age()] (ligne 8) ;
    • une méthode [toString] qui affiche le nom du type et ses composants (ligne 6) ;
    • les méthodes [equals] et [hashCode], qui comparent les valeurs des composants et non les références ;
  • lignes 51-53 et 56-58 : un record peut, comme une classe, recevoir des méthodes supplémentaires et implémenter des interfaces (ligne 48) ;
  • ligne 10 : [r1] et [r2] sont deux objets distincts ([==] rend [false]) mais ils sont égaux au sens de [equals] car leurs composants ont les mêmes valeurs. Pour une classe ordinaire, [equals] compare les références (ligne 26) ;
  • ligne 14 : un record étant immuable, on ne le modifie pas (ligne 12) : la méthode « wither » [avecAge] crée une copie de [r1] dont l'âge a changé ;
  • lignes 18-20 : un motif de record (Java 21) : si [r1] est une [Personne], ses composants sont extraits dans les variables [prenom], [nom] et [age]. On parle de déconstruction du record. Nous retrouverons ces motifs avec le filtrage par motif ;
  • lignes 62-67 : un record est implicitement final et dérive implicitement de la classe [java.lang.Record] : il ne peut ni dériver d'une autre classe ou d'un autre record, ni être dérivé. Pour donner un type commun aux records [Personne] et [Enseignant], on leur fait implémenter une même interface, ici [AvecIdentite] (lignes 43-45). Le record [Enseignant] reprend donc les composants de la personne ;
  • ligne 34 : l'égalité des records tient compte du type réel des objets : une [Personne] n'est jamais égale à un [Enseignant], même si leurs composants communs sont égaux ;
  • lignes 36-39 : l'interface commune permet de traiter les deux records de façon polymorphe ;
  • lignes 70-92 : une classe qui ressemble au record [Personne]… à ceci près qu'il a fallu écrire les champs, le constructeur et les accesseurs, et que l'égalité par valeur et [toString] ne sont pas fournis.

Résultats :

> java Classes22.java
Personne[prenom=Jean, nom=Dupont, age=30]
prénom=Jean, identité=[Jean, Dupont, 30]
r1.equals(r2) : true, r1 == r2 : false
Personne[prenom=Jean, nom=Dupont, age=31]
r1.equals(r3) : false
prénom=Jean, nom=Dupont, âge=30
Classes22$PersonneC@206a70ef
c1.equals(c2) : false
Enseignant[prenom=Lucile, nom=Dumas, age=56, section=61]
Enseignant[[Lucile, Dumas, 56],61]
pe.equals(e) : false
[Lucile, Dumas, 56]
Enseignant[[Lucile, Dumas, 56],61]

On notera que la méthode [toString] de la classe [PersonneC] est celle de la classe [Object] (ligne 8 des résultats) et que l'égalité des deux objets [PersonneC] est fausse (ligne 9).

Résumons les différences entre une classe et un record :

 
Classe
Record
égalité [equals]
même objet (sauf redéfinition)
mêmes valeurs des composants, même type
[toString]
nom de la classe et code de hachage
nom du record et composants
champs, constructeur, accesseurs
à écrire
générés par la déclaration
état
modifiable ou non
immuable (champs [final]), pas d'autre champ d'instance
copie modifiée
à écrire
à écrire (méthodes « wither »)
héritage
d'une classe, de plusieurs interfaces
d'aucune classe, de plusieurs interfaces ; record toujours final

On utilise un record pour une donnée définie par sa valeur (une personne lue dans une base, un message échangé sur le réseau, une tranche d'impôt) et une classe pour un objet doté d'une identité et d'un comportement (un service de calcul, une connexion, une liste modifiable). Attention : un record est « superficiellement » immuable : si un composant référence un objet modifiable, une liste par exemple, le contenu de cette liste reste modifiable. On utilise alors des listes non modifiables ([List.copyOf]).

4.11. Les constructeurs des records

Le constructeur canonique d'un record se contente de recopier ses paramètres dans les champs. On peut cependant vouloir vérifier ou normaliser ces paramètres. Java offre pour cela le constructeur compact, ainsi que la possibilité d'ajouter d'autres constructeurs. Le programme [Classes23.java] l'illustre :

// les constructeurs des records : constructeur compact, autres constructeurs
void main() {
    Personne p = new Personne("  Jean ", "Dupont", 30);
    IO.println(p.identite());
    // un record est immuable : vieillir rend une nouvelle personne
    Personne p2 = p.vieillir();
    IO.println(p.identite() + " -> " + p2.identite());
    // un autre constructeur
    Personne inconnu = new Personne("Martin");
    IO.println(inconnu.identite());
    // les vérifications du constructeur compact
    try {
        new Personne("Paul", " ", 20);
    } catch (IllegalArgumentException ex) {
        IO.println(ex.getMessage());
    }
    try {
        new Personne("Paul", "Martin", -4);
    } catch (IllegalArgumentException ex) {
        IO.println(ex.getMessage());
    }
    // un record avec une méthode et une fabrique statique
    Point pt = new Point(3, 4);
    IO.println("pt=" + pt + ", distance à l'origine : " + pt.distance() + ", origine=" + Point.origine());
}

record Personne(String prenom, String nom, int age) {
    // constructeur compact : il vérifie et normalise les paramètres,
    // les champs sont ensuite initialisés automatiquement
    Personne {
        prenom = verifier(prenom, "prénom");
        nom = verifier(nom, "nom");
        if (age < 0) {
            throw new IllegalArgumentException("âge (" + age + ") invalide");
        }
    }

    // tout autre constructeur doit appeler le constructeur canonique
    Personne(String nom) {
        this("?", nom, 0);
    }

    // une méthode qui rend une nouvelle personne
    public Personne vieillir() {
        return new Personne(prenom, nom, age + 1);
    }

    public String identite() {
        return "[" + prenom + ", " + nom + ", " + age + "]";
    }

    // une méthode statique de vérification
    private static String verifier(String valeur, String libelle) {
        if (valeur == null || valeur.isBlank()) {
            throw new IllegalArgumentException(libelle + " (" + valeur + ") invalide");
        }
        return valeur.strip();
    }
}

record Point(double x, double y) {
    // un champ statique est autorisé, pas un champ d'instance
    private static final Point ORIGINE = new Point(0, 0);

    public static Point origine() {
        return ORIGINE;
    }

    public double distance() {
        return Math.sqrt(x * x + y * y);
    }
}
  • lignes 30-36 : le constructeur compact. Il s'écrit sans liste de paramètres : ses paramètres sont implicitement ceux du constructeur canonique ([prenom], [nom], [age]). Son code s'exécute en premier ; puis les champs sont initialisés automatiquement avec les valeurs, éventuellement modifiées, des paramètres. Ici, le prénom et le nom sont vérifiés et débarrassés de leurs espaces de début et de fin (lignes 31-32), et un âge négatif est refusé (lignes 33-35). Dans un constructeur compact, on ne peut pas affecter les champs ([this.prenom = ...] est refusé) : on modifie les paramètres ;
  • lignes 39-41 : un record peut avoir d'autres constructeurs, mais ceux-ci doivent appeler le constructeur canonique avec [this(...)] : toute création de [Personne] passe ainsi par les vérifications du constructeur compact ;
  • lignes 44-46 : un record est immuable : vieillir, c'est rendre une nouvelle personne ;
  • lignes 53-58 : une méthode privée statique de vérification ;
  • lignes 61-72 : un record peut avoir des champs statiques (ligne 63) et des méthodes statiques (lignes 65-67), comme la fabrique statique [origine], mais pas de champs d'instance en plus de ses composants.

Résultats :

1
2
3
4
5
6
7
> java Classes23.java
[Jean, Dupont, 30]
[Jean, Dupont, 30] -> [Jean, Dupont, 31]
[?, Martin, 0]
nom ( ) invalide
âge (-4) invalide
pt=Point[x=3.0, y=4.0], distance à l'origine : 5.0, origine=Point[x=0.0, y=0.0]

Le prénom, fourni entouré d'espaces (ligne 3), a été mémorisé sans eux (ligne 2 des résultats). Le constructeur compact est le moyen idiomatique de garantir qu'un record est toujours valide : il n'existe aucun moyen de créer une [Personne] sans passer par lui.

4.12. Pas de méthodes d'extension : les méthodes statiques utilitaires

Comment ajouter une méthode à une classe qu'on ne peut pas modifier, la classe [String] du JDK par exemple ? Certains langages, comme C# ou Kotlin, proposent des « méthodes d'extension » : des méthodes statiques qui s'utilisent comme des méthodes d'instance du type étendu. Java ne les a pas. On écrit des méthodes statiques, rassemblées dans une classe utilitaire, et on les appelle avec le nom de leur classe, en leur passant l'objet en paramètre. Le JDK en est rempli : [Arrays], [Collections], [Objects], [Math]… Le programme [Classes24.java] l'illustre :

// pas de méthodes d'extension en Java : des méthodes statiques utilitaires
import static java.lang.Character.toUpperCase;

void main() {
    Personne p = new Personne("jean", "dupont", 30);
    // les méthodes utilitaires s'appellent avec le nom de leur classe
    IO.println(Outils.initiales(p));
    IO.println(Outils.premiereMajuscule("jean"));
    IO.println(Outils.nomComplet(p) + " est majeur : " + Outils.estMajeur(p));
    IO.println("\"il fait beau\" a " + Outils.nbMots("il fait beau") + " mots");
    // une méthode statique du record lui-même
    Personne inconnue = Personne.inconnue();
    IO.println(Outils.nomComplet(inconnue) + " est majeur : " + Outils.estMajeur(inconnue));
}

// une personne
record Personne(String prenom, String nom, int age) {
    // fabrique statique : une personne inconnue
    static Personne inconnue() {
        return new Personne("?", "?", 0);
    }
}

// une classe utilitaire : finale, constructeur privé, méthodes statiques
final class Outils {
    private Outils() {
    }

    static String initiales(Personne p) {
        return toUpperCase(p.prenom().charAt(0)) + "." + toUpperCase(p.nom().charAt(0)) + ".";
    }

    static String premiereMajuscule(String s) {
        return s.isEmpty() ? s : toUpperCase(s.charAt(0)) + s.substring(1);
    }

    static boolean estMajeur(Personne p) {
        return p.age() >= 18;
    }

    static String nomComplet(Personne p) {
        return premiereMajuscule(p.prenom()) + " " + p.nom().toUpperCase();
    }

    static int nbMots(String s) {
        return s.isBlank() ? 0 : s.strip().split("\\s+").length;
    }
}
  • lignes 25-48 : la classe utilitaire [Outils]. Elle est finale et son unique constructeur est privé (lignes 26-27) : on ne peut ni la dériver ni l'instancier. Toutes ses méthodes sont statiques ;
  • lignes 29-31 : la méthode [initiales] reçoit la personne en paramètre. Comme une classe utilitaire écrite dans un autre fichier, elle n'utilise que les membres publics de la personne, ici ses accesseurs (dans un fichier compact, elle aurait pourtant accès aux champs privés) ; [charAt(0)] rend le premier caractère d'une chaîne ;
  • ligne 2 : l'importation statique de la méthode [Character.toUpperCase], qui met un caractère en majuscule, permet de l'appeler sans le nom de sa classe (lignes 30 et 34) ;
  • lignes 45-47 : la méthode [nbMots] découpe la chaîne avec la méthode [split], dont le paramètre est une expression régulière : ["\\s+"] désigne une suite d'un ou plusieurs caractères d'espacement ;
  • lignes 7-10 : les méthodes utilitaires s'appellent avec le nom de la classe : [Outils.initiales(p)] et non [p.initiales()] ;
  • lignes 19-21 : lorsqu'on peut modifier le type, on y place naturellement ses méthodes : la fabrique statique [inconnue] est une méthode du record [Personne] (ligne 12).

Résultats :

1
2
3
4
5
6
> java Classes24.java
J.D.
Jean
Jean DUPONT est majeur : true
"il fait beau" a 3 mots
? ? est majeur : false

Avec une importation statique ([import static outils.Outils.*;] si la classe était dans un paquetage [outils]), on pourrait écrire [initiales(p)] sans le nom de la classe. Pour qu'une méthode utilitaire soit utilisable, sa classe doit être accessible et, si elle est dans un autre paquetage, importée.

4.13. Le filtrage par motif sur les objets

L'instruction [switch] et le filtrage par motif (« pattern matching ») ne se limitent pas aux valeurs simples (nombres, chaînes) : ils s'appliquent aussi aux objets. On peut tester le type d'un objet, déconstruire un record, ajouter une condition, et récupérer l'objet converti dans le bon type, le tout en une seule expression. Le programme [Classes25.java] utilise une interface scellée et trois records qui l'implémentent :

// le filtrage par motif sur les objets
void main() {
    Object[] objets = {
        new Particulier("Jean", "Dupont", 30),
        new Particulier("Lise", "Morel", 12),
        new Enseignant("Lucile", "Dumas", 56, 61),
        new Enseignant("Marc", "Petit", 44, 27),
        new Etudiant("Paul", "Martin", 19, "informatique"),
        "une chaîne",
        42,
        null,
    };

    // 1 - le motif de type : instanceof Type variable (Java 16)
    for (Object o : objets) {
        if (o instanceof Personne p) {
            // ici p est une Personne
            IO.println(p.prenom() + " est de type " + p.getClass().getSimpleName());
        }
    }

    // 2 - le motif de type suivi d'une condition
    for (Object o : objets) {
        if (o instanceof Personne p && p.age() >= 18) {
            IO.println(p.prenom() + " est majeur");
        }
    }

    // 3 - le switch sur le type de l'objet, avec gardes et motifs de records (Java 21)
    for (Object o : objets) {
        IO.println(decrire(o));
    }

    // 4 - un switch exhaustif sur une interface scellée
    for (Object o : objets) {
        if (o instanceof Personne p) {
            IO.println(p.prenom() + " : " + categorie(p));
        }
    }
}

String decrire(Object o) {
    return switch (o) {
        case Enseignant e when e.section() == 27 -> e.prenom() + " enseigne l'informatique";
        case Enseignant e -> e.prenom() + " enseigne dans la section " + e.section();
        case Etudiant(var prenom, _, _, var filiere) -> prenom + " étudie " + filiere;
        case Personne p when p.age() < 18 -> p.prenom() + " est mineur";
        case Personne p -> p.prenom() + " a " + p.age() + " ans";
        case String s -> "une chaîne de " + s.length() + " caractères";
        case Integer n when n > 0 -> "l'entier positif " + n;
        case null -> "la référence null";
        default -> "un objet inconnu";
    };
}

// pas de default : le compilateur sait que les trois cas couvrent toutes les personnes
String categorie(Personne p) {
    return switch (p) {
        case Particulier _ -> "particulier";
        case Enseignant _ -> "enseignant";
        case Etudiant _ -> "étudiant";
    };
}

// une interface scellée et les trois records qui l'implémentent
sealed interface Personne permits Particulier, Enseignant, Etudiant {
    String prenom();
    String nom();
    int age();
}

record Particulier(String prenom, String nom, int age) implements Personne {
}

record Enseignant(String prenom, String nom, int age, int section) implements Personne {
}

record Etudiant(String prenom, String nom, int age, String filiere) implements Personne {
}
  • lignes 66-79 : une interface scellée [Personne], implémentée par trois records [Particulier], [Enseignant] et [Etudiant]. L'interface déclare les méthodes [prenom], [nom] et [age] : les records les implémentent automatiquement par leurs accesseurs, puisqu'ils ont des composants de mêmes noms. Un record étant final, il peut implémenter une interface scellée sans autre précision ;
  • lignes 3-12 : un tableau d'objets de types divers, y compris un entier (converti automatiquement en [Integer]) et la référence [null] ;
  • ligne 16 : le motif de type [o instanceof Personne p] (Java 16) est vrai si [o] référence un objet de type [Personne] ; dans ce cas, la variable [p], de type [Personne], référence cet objet. On évite ainsi la séquence du Java d'avant la version 16 : tester le type avec [instanceof], puis convertir avec [(Personne) o]. Le motif est faux si [o] vaut [null] ;
  • ligne 24 : la variable [p] du motif est utilisable dans la suite de la condition, après [&&] : Java n'a pas de motif de propriété ; on teste la valeur des champs par une condition ;
  • lignes 42-54 : l'expression [switch] sur un objet (Java 21) essaie les motifs dans l'ordre et rend la valeur associée au premier qui convient :
    • ligne 44 : un enseignant de la section 27. La clause [when] ajoute une condition, appelée garde, au motif de type ;
    • ligne 45 : tout autre enseignant. L'ordre est important : placé avant le précédent, ce motif l'aurait rendu inaccessible, ce que le compilateur aurait signalé par une erreur ;
    • ligne 46 : un motif de record : il extrait les composants du record [Etudiant] dans l'ordre de leur déclaration ; [var] laisse le compilateur déduire leur type, et le caractère [_] (Java 22) ignore un composant ;
    • lignes 47-48 : une personne mineure, puis toute autre personne ;
    • ligne 50 : un entier positif ;
    • ligne 51 : le cas [null]. Sans lui, un [switch] sur une référence nulle lance une exception [NullPointerException] ;
    • ligne 52 : le cas [default] accepte tout le reste. Sans lui, le compilateur signalerait que l'expression [switch] ne traite pas tous les cas : un [switch] sur un objet doit être exhaustif ;
  • lignes 57-63 : un [switch] sur une [Personne], sans [default]. Il est pourtant exhaustif : l'interface étant scellée, le compilateur sait qu'une personne est un [Particulier], un [Enseignant] ou un [Etudiant]. Le motif [Particulier _] teste le type sans déclarer de variable.

Résultats :

> java Classes25.java
Jean est de type Particulier
Lise est de type Particulier
Lucile est de type Enseignant
Marc est de type Enseignant
Paul est de type Etudiant
Jean est majeur
Lucile est majeur
Marc est majeur
Paul est majeur
Jean a 30 ans
Lise est mineur
Lucile enseigne dans la section 61
Marc enseigne l'informatique
Paul étudie informatique
une chaîne de 10 caractères
l'entier positif 42
la référence null
Jean : particulier
Lise : particulier
Lucile : enseignant
Marc : enseignant
Paul : étudiant

Si on supprime le cas [Etudiant] de la méthode [categorie], le compilateur le signale :

1
2
3
4
5
6
> java Classes25.java
Classes25.java:58: error: the switch expression does not cover all possible input values
    return switch (p) {
           ^
1 error
error: compilation failed

C'est le grand intérêt de l'association des types scellés et du filtrage par motif : si on ajoute un quatrième record à la hiérarchie, le compilateur désigne tous les [switch] à compléter. On notera aussi que le motif de type correspond aux types qui implémentent l'interface : la boucle du motif de type (lignes 15-20) a retenu les trois sortes de personnes. Le filtrage par motif remplace avantageusement les cascades de [if (o instanceof ...)] ; il ne doit cependant pas remplacer le polymorphisme : quand le comportement dépend du type de l'objet et que l'on maîtrise la hiérarchie de classes, une méthode redéfinie dans chaque classe reste souvent la solution orientée objet. Le filtrage par motif convient bien aux hiérarchies de données (records) sur lesquelles on veut écrire des traitements extérieurs à ces données.

4.14. La gestion de la valeur null

Toute variable de type référence peut valoir [null], et l'utilisation d'une référence nulle provoque l'exception [NullPointerException], la plus fréquente des erreurs d'exécution en Java. Contrairement à d'autres langages comme C# ou Kotlin, le compilateur Java ne distingue pas les types qui peuvent valoir [null] de ceux qui ne le peuvent pas. Le JDK et les bibliothèques offrent cependant plusieurs outils pour limiter le risque :

  • la classe [Optional<T>] (Java 8), le type de résultat d'une méthode qui peut ne rien rendre ;
  • les méthodes de la classe [Objects] : [requireNonNull] pour refuser [null] dès l'entrée d'une méthode ou d'un constructeur, [requireNonNullElse] pour fournir une valeur par défaut ;
  • des annotations qui documentent l'intention et que certains outils d'analyse vérifient. Les plus récentes, proposées par un groupe d'éditeurs (Google, JetBrains, Spring…), sont celles de la bibliothèque JSpecify : [@Nullable] sur un type qui peut valoir [null], [@NullMarked] sur une classe ou un paquetage pour indiquer que les autres types ne le peuvent pas. Spring Framework 7, que nous utiliserons, les emploie. Elles nécessitent une bibliothèque, aussi ne les utiliserons-nous pas dans ce chapitre.

Le programme [Classes26.java] présente les deux premiers :

// la gestion de la valeur null : Optional, Objects.requireNonNull...
void main() {
    Personne jean = new Personne("Jean", "Dupont");
    jean.setSecondPrenom("Marie");
    Personne[] personnes = { jean, new Personne("Paul", "Martin") };
    for (Personne p : personnes) {
        // une valeur par défaut lorsque la référence est null
        String second = Objects.requireNonNullElse(p.getSecondPrenom(), "(aucun)");
        // un test explicite
        String longueur = p.getSecondPrenom() == null ? "null" : String.valueOf(p.getSecondPrenom().length());
        IO.println(p.getPrenom() + " : second prénom=" + second + ", longueur=" + longueur);
    }

    // une méthode qui peut ne rien trouver : elle rend un Optional<Personne>
    Optional<Personne> trouvee = chercher(personnes, "Durand");
    IO.println("Durand trouvé : " + trouvee.isPresent());
    IO.println(trouvee.map(Personne::getPrenom).orElse("Durand : personne non trouvée"));
    // on teste la présence d'une valeur avant de l'utiliser
    trouvee = chercher(personnes, "Martin");
    if (trouvee.isPresent()) {
        IO.println("Martin : " + trouvee.get().getPrenom());
    }
    // ifPresent : l'action n'est faite que si une valeur est présente
    chercher(personnes, "Dupont").ifPresent(p -> p.setSecondPrenom("Louise"));
    chercher(personnes, "Durand").ifPresent(p -> p.setSecondPrenom("Louise"));
    IO.println(jean.getSecondPrenom());
    // orElseThrow : on affirme que la valeur est présente, sinon une exception est lancée
    Personne martin = chercher(personnes, "Martin").orElseThrow();
    IO.println(martin.getPrenom());

    // Objects.requireNonNull : un paramètre obligatoire
    try {
        new Personne(null, "Morel");
    } catch (NullPointerException ex) {
        IO.println("Erreur : " + ex.getMessage());
    }
    // l'exception NullPointerException et son message détaillé (Java 14)
    try {
        Personne lise = new Personne("Lise", "Morel");
        IO.println(lise.getSecondPrenom().length());
    } catch (NullPointerException ex) {
        IO.println("Erreur : " + ex.getMessage());
    }
}

// recherche d'une personne par son nom
Optional<Personne> chercher(Personne[] personnes, String nom) {
    for (Personne p : personnes) {
        if (p.getNom().equals(nom)) {
            return Optional.of(p);
        }
    }
    return Optional.empty();
}

class Personne {
    // champs obligatoires : ils ne doivent jamais valoir null
    private final String prenom;
    private final String nom;
    // champ facultatif : null signifie « pas de second prénom »
    private String secondPrenom;

    public Personne(String prenom, String nom) {
        this.prenom = Objects.requireNonNull(prenom, "le prénom est obligatoire");
        this.nom = Objects.requireNonNull(nom, "le nom est obligatoire");
    }

    public String getPrenom() {
        return prenom;
    }

    public String getNom() {
        return nom;
    }

    public String getSecondPrenom() {
        return secondPrenom;
    }

    public void setSecondPrenom(String secondPrenom) {
        this.secondPrenom = secondPrenom;
    }
}
  • lignes 56-83 : la classe [Personne] a deux champs obligatoires, initialisés par le constructeur, et un champ facultatif [secondPrenom] : [null] signifie « pas de second prénom ». Le constructeur refuse un prénom ou un nom [null] avec [Objects.requireNonNull] (lignes 64-65) : l'erreur est détectée à la création de l'objet plutôt que bien plus tard, lors d'une utilisation du champ ;
  • ligne 8 : [Objects.requireNonNullElse(valeur, défaut)] (Java 9) rend [valeur] si elle ne vaut pas [null], [défaut] sinon ;
  • ligne 10 : le test explicite reste souvent le plus simple ;
  • lignes 47-54 : la méthode [chercher] rend la personne cherchée enveloppée dans un objet [Optional], ou un [Optional] vide : son type de retour, [Optional<Personne>], avertit l'appelant que le résultat peut manquer, et l'oblige à traiter ce cas pour obtenir la personne. [Optional.of(p)] crée un [Optional] contenant [p], [Optional.empty()] un [Optional] vide ;
  • ligne 16 : [isPresent] indique si l'[Optional] contient une valeur ;
  • ligne 17 : [map(Personne::getPrenom)] transforme la personne éventuellement présente en son prénom, [orElse(valeur)] rend la valeur présente ou, à défaut, la valeur indiquée. L'écriture [Personne::getPrenom] est une référence de méthode, que nous étudierons au chapitre 7 ; elle désigne ici « la méthode [getPrenom] de la personne » ;
  • lignes 20-22 : [get] rend la valeur présente ; on ne l'appelle qu'après avoir vérifié sa présence ;
  • lignes 24-25 : [ifPresent] n'exécute l'action indiquée que si une valeur est présente. L'action est une lambda (chapitre 7) : [p -> p.setSecondPrenom("Louise")] se lit « pour la personne p, fixer son second prénom ». Dupont existe et reçoit un nouveau second prénom ; Durand n'existe pas et l'instruction ne fait rien ;
  • ligne 28 : [orElseThrow] rend la valeur présente, ou lance une exception [NoSuchElementException] si l'[Optional] est vide. On l'utilise quand on est sûr que la valeur est présente ;
  • lignes 32-36 : une personne sans prénom est refusée par le constructeur ;
  • lignes 38-43 : l'utilisation d'une référence nulle lance l'exception [NullPointerException]. Depuis Java 14, son message indique précisément l'expression nulle.

Résultats :

> java Classes26.java
Jean : second prénom=Marie, longueur=5
Paul : second prénom=(aucun), longueur=null
Durand trouvé : false
Durand : personne non trouvée
Martin : Paul
Louise
Paul
Erreur : le prénom est obligatoire
Erreur : Cannot invoke "String.length()" because the return value of "Classes26$Personne.getSecondPrenom()" is null

[Optional] est destiné aux résultats de méthodes ; on ne l'utilise ni pour les champs, ni pour les paramètres. Dans ce cours, on documente le caractère facultatif d'une valeur, on vérifie les paramètres obligatoires avec [Objects.requireNonNull], et on préfère les objets toujours complets (construits avec toutes leurs valeurs obligatoires) aux objets remplis progressivement.

4.15. Application exemple - V2

On reprend le calcul de l'impôt déjà étudié dans le chapitre précédent (version 1) et on le traite maintenant en utilisant des classes et des interfaces. Rappelons le problème. On se propose d'écrire un programme permettant de calculer l'impôt d'un contribuable. On se place dans le cas simplifié d'un contribuable n'ayant que son seul salaire à déclarer (barème 2004 pour les revenus 2003, conservé tel quel : il s'agit d'un exercice) :

  • on calcule le nombre de parts du salarié : nbParts = nbEnfants / 2 + 1 s'il n'est pas marié, nbEnfants / 2 + 2 s'il est marié, où nbEnfants est son nombre d'enfants ;
  • s'il a au moins trois enfants, il a une demi-part de plus ;
  • on calcule son revenu imposable R = 0,72 × S où S est son salaire annuel ;
  • on calcule son quotient familial QF = R / nbParts ;
  • on calcule son impôt I à l'aide du tableau suivant :
limite
coeffR
coeffN
4962
0
0
8382
0,068
291,09
14753
0,191
1322,92
23888
0,283
2668,39
38868
0,374
4846,98
47932
0,426
6883,66
0
0,481
9505,54

Chaque ligne a trois champs. Pour calculer l'impôt I, on recherche la première ligne où QF <= limite. Par exemple, si QF = 5000 on trouvera la ligne (8382 ; 0,068 ; 291,09). L'impôt I est alors égal à 0,068 × R - 291,09 × nbParts. Si QF est tel que la relation QF <= limite n'est jamais vérifiée, alors ce sont les coefficients de la dernière ligne qui sont utilisés, ce qui donne l'impôt I = 0,481 × R - 9505,54 × nbParts.

L'application forme le dossier [ImpotsV2]. Les classes du calcul sont rangées dans un paquetage [impots] :

1
2
3
4
5
6
7
ImpotsV2/
  Main.java                 le programme de dialogue avec l'utilisateur
  impots/
    TrancheImpot.java       une tranche du barème
    IImpot.java             l'interface du calcul de l'impôt
    AbstractImpot.java      une classe abstraite implémentant IImpot
    HardwiredImpot.java     une classe dérivée, avec des données écrites en dur

Tout d'abord, nous définissons un type capable d'encapsuler une ligne du tableau précédent [TrancheImpot.java] :

1
2
3
4
5
6
7
package impots;

import java.math.BigDecimal;

// une tranche du barème de l'impôt
public record TrancheImpot(BigDecimal limite, BigDecimal coeffR, BigDecimal coeffN) {
}

Nous utilisons un record :

  • parce qu'une tranche est une petite donnée sans identité, définie par sa seule valeur, et qu'elle n'a aucune raison d'être modifiée une fois créée : un record est immuable ;
  • parce que le compilateur génère le constructeur, les accesseurs, [toString] et l'égalité à partir d'une seule ligne.

Les composants sont de type [BigDecimal] (paquetage [java.math], d'où l'importation de la ligne 3). Les types [float] et [double] représentent les nombres en binaire : un nombre décimal comme 0,068 n'y a pas de représentation exacte, et les calculs accumulent de petites erreurs d'arrondi. La classe [BigDecimal] représente exactement les nombres décimaux : c'est le type à utiliser pour les montants. Son inconvénient est l'absence d'opérateurs : on écrit [a.add(b)], [a.multiply(b)], [a.compareTo(b)].

Puis nous définissons une interface [IImpot] capable de calculer l'impôt [IImpot.java] :

1
2
3
4
5
6
package impots;

public interface IImpot {
    // calcul de l'impôt d'un contribuable
    int calculer(boolean marie, int nbEnfants, int salaire);
}
  • ligne 5 : la méthode de calcul de l'impôt à partir de trois données : l'état marié ou non du contribuable, son nombre d'enfants, son salaire annuel. Elle rend l'impôt arrondi à l'euro inférieur.

Ensuite, nous définissons une classe abstraite implémentant cette interface [AbstractImpot.java] :

package impots;

import java.math.BigDecimal;
import java.math.MathContext;
import java.util.List;

public abstract class AbstractImpot implements IImpot {
    // les tranches d'impôt nécessaires au calcul proviennent d'une source extérieure
    // que seules les classes dérivées connaissent
    protected abstract List<TrancheImpot> getTranchesImpot();

    // calcul de l'impôt
    @Override
    public int calculer(boolean marie, int nbEnfants, int salaire) {
        // calcul du nombre de parts
        BigDecimal nbParts = BigDecimal.valueOf(nbEnfants).divide(BigDecimal.TWO)
                .add(BigDecimal.valueOf(marie ? 2 : 1));
        if (nbEnfants >= 3) {
            nbParts = nbParts.add(new BigDecimal("0.5"));
        }
        // calcul du revenu imposable et du quotient familial
        BigDecimal revenu = new BigDecimal("0.72").multiply(BigDecimal.valueOf(salaire));
        BigDecimal quotient = revenu.divide(nbParts, MathContext.DECIMAL128);
        // recherche de la tranche d'impôt : la dernière sert si aucune limite ne convient
        List<TrancheImpot> tranches = getTranchesImpot();
        TrancheImpot tranche = tranches.getLast();
        for (int i = 0; i < tranches.size() - 1; i++) {
            if (quotient.compareTo(tranches.get(i).limite()) <= 0) {
                tranche = tranches.get(i);
                break;
            }
        }
        // calcul de l'impôt
        return revenu.multiply(tranche.coeffR()).subtract(nbParts.multiply(tranche.coeffN())).intValue();
    }
}
  • ligne 1 : la classe appartient au paquetage [impots] ; son fichier est donc dans le dossier [impots] ;
  • ligne 7 : la classe [AbstractImpot] implémente l'interface [IImpot] ;
  • ligne 10 : les données du barème, fournies par une méthode abstraite protégée. La classe [AbstractImpot] ne sait pas d'où viennent ces données (écrites dans le code, lues dans un fichier, une base de données…). Elle en laisse le soin aux classes dérivées. C'est pourquoi elle est déclarée abstraite, ce qui en interdit toute instanciation. La méthode abstraite oblige les classes dérivées à fournir les tranches : le compilateur le vérifie ;
  • lignes 13-35 : l'implémentation de la méthode [calculer] de l'interface [IImpot]. Les classes dérivées n'auront pas à réécrire cette méthode. La classe [AbstractImpot] sert ainsi de classe de factorisation des classes dérivées : on y met ce qui est commun à toutes ;
  • lignes 16-17 : le nombre de parts. [BigDecimal.valueOf(n)] convertit un entier en [BigDecimal] ; la division par [BigDecimal.TWO] est exacte (3 / 2 = 1,5), contrairement à la division entière [nbEnfants / 2] (3 / 2 = 1). L'expression [marie ? 2 : 1] vaut 2 pour un contribuable marié, 1 sinon ;
  • lignes 18-20 : la demi-part supplémentaire. Un [BigDecimal] est immuable : [add] rend un nouvel objet, qu'il faut affecter à la variable ;
  • lignes 22-23 : le revenu imposable et le quotient familial. On crée les constantes décimales à partir de chaînes ([new BigDecimal("0.72")]) pour qu'elles soient exactes. La division d'un [BigDecimal] peut avoir un nombre infini de décimales (1 / 3) : on précise la précision voulue, ici [MathContext.DECIMAL128] (34 chiffres significatifs) ;
  • lignes 25-32 : la recherche de la tranche. Par défaut, c'est la dernière tranche ([tranches.getLast()], ligne 26). La boucle parcourt toutes les tranches sauf la dernière et s'arrête ([break]) à la première dont la limite est supérieure ou égale au quotient familial, ce que teste [compareTo(...) <= 0] ;
  • ligne 34 : le calcul de l'impôt. La méthode [intValue] tronque le résultat à sa partie entière.

Une classe implémentant l'interface [IImpot] peut être construite en dérivant la classe [AbstractImpot]. C'est ce que nous faisons maintenant [HardwiredImpot.java] :

package impots;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

public class HardwiredImpot extends AbstractImpot {
    // données du calcul de l'impôt (barème 2004 sur les revenus 2003), écrites « en dur »
    private static final double[] LIMITES = { 4962, 8382, 14753, 23888, 38868, 47932, 0 };
    private static final double[] COEFF_R = { 0, 0.068, 0.191, 0.283, 0.374, 0.426, 0.481 };
    private static final double[] COEFF_N = { 0, 291.09, 1322.92, 2668.39, 4846.98, 6883.66, 9505.54 };

    // les tranches d'impôt
    private final List<TrancheImpot> tranchesImpot;

    // constructeur
    public HardwiredImpot() {
        // création et remplissage de la liste des tranches d'impôt
        List<TrancheImpot> tranches = new ArrayList<>();
        for (int i = 0; i < LIMITES.length; i++) {
            tranches.add(new TrancheImpot(BigDecimal.valueOf(LIMITES[i]),
                    BigDecimal.valueOf(COEFF_R[i]), BigDecimal.valueOf(COEFF_N[i])));
        }
        // la liste mémorisée n'est pas modifiable
        tranchesImpot = List.copyOf(tranches);
    }

    // implémentation de la méthode abstraite de la classe parent
    @Override
    protected List<TrancheImpot> getTranchesImpot() {
        return tranchesImpot;
    }
}
  • lignes 9-11 : la classe [HardwiredImpot] définit en dur (« hardwired ») les données nécessaires au calcul de l'impôt. Ce sont des champs de classe ([static]) [final] : ils sont partagés par toutes les instances et ne peuvent pas être remplacés par d'autres tableaux. Par convention, les noms des constantes ([static final]) sont écrits en majuscules ;
  • lignes 17-26 : le constructeur utilise ces données pour construire la liste des tranches d'impôt. [BigDecimal.valueOf(double)] (lignes 21-22) convertit un [double] en [BigDecimal] en passant par sa représentation décimale la plus courte : [BigDecimal.valueOf(0.068)] vaut exactement 0,068. Il ne faut pas utiliser le constructeur [new BigDecimal(0.068)], qui reproduit toutes les décimales de la valeur binaire approchée (0.068000000000000004884981308350688…) ;
  • ligne 25 : [List.copyOf] rend une copie non modifiable de la liste : aucune classe ne pourra modifier le barème ;
  • lignes 29-32 : la méthode abstraite de la classe parent est implémentée : elle rend la liste des tranches.

Le chapitre suivant ajoutera d'autres classes dérivées de [AbstractImpot], qui liront les tranches dans un fichier texte ou JSON : elles n'auront qu'à fournir la méthode [getTranchesImpot].

Un programme de test pourrait être le suivant [Main.java] :

// programme interactif de calcul de l'impôt
// l'utilisateur tape trois données au clavier : marié nbEnfants salaire
// le programme affiche alors l'impôt à payer
import impots.HardwiredImpot;
import impots.IImpot;

static final String SYNTAXE = """
        syntaxe : Marié NbEnfants Salaire
        Marié : o pour marié, n pour non marié
        NbEnfants : nombre d'enfants
        Salaire : salaire annuel en euros""";

void main() {
    // création d'un objet IImpot
    IImpot impot = new HardwiredImpot();

    // boucle de saisie
    while (true) {
        // on demande les paramètres du calcul de l'impôt
        String parametres = IO.readln("Paramètres du calcul de l'impôt au format : "
                + "Marié (o/n) NbEnfants Salaire ou rien pour arrêter : ");
        // fin de l'entrée standard ou ligne vide : on s'arrête
        if (parametres == null || parametres.isBlank()) {
            break;
        }
        // vérification du nombre de champs de la ligne saisie
        String[] champs = parametres.strip().split("\\s+");
        if (champs.length != 3) {
            IO.println(SYNTAXE);
            continue;
        }
        // vérification de la validité des paramètres
        // marié
        String marie = champs[0].toLowerCase();
        if (!marie.equals("o") && !marie.equals("n")) {
            IO.println(SYNTAXE + "\nArgument marié incorrect : tapez o ou n");
            continue;
        }
        // nbEnfants
        int nbEnfants = entierPositif(champs[1]);
        if (nbEnfants < 0) {
            IO.println(SYNTAXE + "\nArgument NbEnfants incorrect : tapez un entier positif ou nul");
            continue;
        }
        // salaire
        int salaire = entierPositif(champs[2]);
        if (salaire < 0) {
            IO.println(SYNTAXE + "\nArgument salaire incorrect : tapez un entier positif ou nul");
            continue;
        }
        // les paramètres sont corrects : on calcule l'impôt
        IO.println("Impôt=" + impot.calculer(marie.equals("o"), nbEnfants, salaire) + " euros");
    }
}

// rend l'entier positif ou nul représenté par la chaîne, -1 si la chaîne n'en représente pas un
int entierPositif(String chaine) {
    try {
        int valeur = Integer.parseInt(chaine);
        return valeur >= 0 ? valeur : -1;
    } catch (NumberFormatException ex) {
        return -1;
    }
}

Le programme ci-dessus permet à l'utilisateur de faire des simulations répétées de calcul d'impôt.

  • lignes 4-5 : le programme principal est dans le paquetage sans nom ; il importe les deux types du paquetage [impots] dont il a besoin. Ces types doivent être publics ;
  • lignes 7-11 : le message d'aide est une constante de la classe implicite, écrite sous la forme d'un bloc de texte (Java 15), délimité par trois guillemets : la chaîne peut s'étendre sur plusieurs lignes, et l'indentation commune des lignes est retirée. Les guillemets fermants étant placés à la fin de la dernière ligne, la chaîne ne se termine pas par un passage à la ligne ;
  • ligne 15 : création d'un objet [impot] implémentant l'interface [IImpot]. Cet objet est obtenu par instanciation d'un type [HardwiredImpot], un type qui implémente l'interface [IImpot]. On notera qu'on n'a pas donné à la variable [impot] le type [HardwiredImpot] mais le type [IImpot]. En écrivant cela, on indique qu'on ne s'intéresse qu'à la méthode [calculer] de l'objet [impot] et pas au reste. Pour utiliser les données d'un fichier, il suffira de changer cette seule ligne ;
  • lignes 18-53 : la boucle des simulations de calcul de l'impôt ;
  • lignes 20-21 : les trois paramètres nécessaires à la méthode [calculer] sont demandés en une seule ligne tapée au clavier. [IO.readln(message)] affiche le message puis lit une ligne ; elle rend [null] si l'entrée standard est fermée : le test de la ligne 23 arrête alors le programme, comme une ligne vide ;
  • ligne 27 : la méthode [split] décompose la ligne, débarrassée de ses espaces de début et de fin, en mots séparés par un ou plusieurs caractères d'espacement (expression régulière ["\\s+"]) ;
  • lignes 40 et 46 : la méthode [entierPositif] (lignes 57-64) vérifie que les deux derniers champs sont des entiers positifs ou nuls. [Integer.parseInt] lance une exception [NumberFormatException] si la chaîne ne représente pas un entier : la méthode l'intercepte et rend -1, valeur qui signale une erreur ;
  • ligne 52 : appel de la méthode [calculer] de l'objet [impot] implémentant l'interface [IImpot].

Voici un exemple d'exécution du programme, lancé dans le dossier [ImpotsV2] :

> java Main.java
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : q s d
syntaxe : Marié NbEnfants Salaire
Marié : o pour marié, n pour non marié
NbEnfants : nombre d'enfants
Salaire : salaire annuel en euros
Argument marié incorrect : tapez o ou n
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : o 2 d
syntaxe : Marié NbEnfants Salaire
Marié : o pour marié, n pour non marié
NbEnfants : nombre d'enfants
Salaire : salaire annuel en euros
Argument salaire incorrect : tapez un entier positif ou nul
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : q s d f
syntaxe : Marié NbEnfants Salaire
Marié : o pour marié, n pour non marié
NbEnfants : nombre d'enfants
Salaire : salaire annuel en euros
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : o -1 60000
syntaxe : Marié NbEnfants Salaire
Marié : o pour marié, n pour non marié
NbEnfants : nombre d'enfants
Salaire : salaire annuel en euros
Argument NbEnfants incorrect : tapez un entier positif ou nul
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : o 2 60000
Impôt=4282 euros
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : o 3 60000
Impôt=2959 euros
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : n 0 60000
Impôt=11519 euros
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : 

Les valeurs de contrôle sont bien retrouvées : 4282 euros pour un contribuable marié avec deux enfants et un salaire annuel de 60000 euros (ligne 26), 2959 euros avec trois enfants (ligne 28). Un contribuable non marié sans enfant paie 11519 euros pour le même salaire (ligne 30).

Par rapport à la version 1, le calcul est désormais isolé dans des classes, derrière une interface : le programme de dialogue ne connaît que l'interface [IImpot]. Les versions suivantes exploiteront cette séparation : la version 3 (chapitre suivant) changera l'origine des données en dérivant de nouvelles classes de [AbstractImpot], puis l'application sera organisée en couches (chapitre 6).