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] :
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 ») :
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 :
- 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 :
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.
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] :
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] :
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] :
- 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 :
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 :
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 :
- 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 :
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] :
- 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 :
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] :
La méthode [IO.print] (lignes 5, 8, …) écrit son paramètre sans passer à la ligne. Résultats :
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 :
- 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 :
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 :
- 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 :
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] :
- 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 :
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] :
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 :
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 :
- 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 :
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 :
- 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 :
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] :
- 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 :
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] :
- 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 :
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 :
- 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 :
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] :
- 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] :
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 :
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 :
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 :
- 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 :
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 :
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 :
- 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 :
- 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] :

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 :
Résultats :
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] :
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 :
- 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 :
Si on décommente la classe [Artiste] (en lui donnant un constructeur), le compilateur signale :
À 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] :
- 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 :
- 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) :
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 :
- 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 :
- 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) :
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 :
- 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 :
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).

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 :
- 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 :
É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 :
- 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 :
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] :
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] :
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] :
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] :
- 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] :
- 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] :
- 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 :
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 :
- 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 :
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] :
- 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) :
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 :
- 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] :
- 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 » :
Celle de la classe [Inconnu] rend la référence [null] pour indiquer que l'utilisateur n'a pas été reconnu :
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] :
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 :
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 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 | |
- 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 :
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 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 | |
- 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 :
Voici les erreurs obtenues en exécutant le programme une fois les lignes 5 et 21 décommentées :
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 :
- 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 :
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 :
- 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 :
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 :
La première classe [Personne] [e1/Personne.java] :
La seconde [e2/Personne.java] :
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 :
- 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 :
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 :
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 :
- 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 :
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 :
- 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 :
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 :
- 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 :
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 :
- 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 :
Si on supprime le cas [Etudiant] de la méthode [categorie], le compilateur le signale :
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 :
- 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 :
[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] :
Tout d'abord, nous définissons un type capable d'encapsuler une ligne du tableau précédent [TrancheImpot.java] :
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] :
- 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] :
- 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] :
- 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] :
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] :
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).