Skip to content

3. Les bases du langage Java

3.1. Introduction

Nous traitons Java d'abord comme un langage de programmation classique. Nous aborderons les classes dans le chapitre suivant. Dans un programme, on trouve deux choses :

  • des données ;
  • les instructions qui les manipulent.

On s'efforce généralement de séparer les données des instructions :

Image

Tous les exemples de ce chapitre sont des fichiers source compacts : un seul fichier [.java], sans projet Maven, que l'on exécute avec la commande [java Bases01.java]. Le lanceur [java] compile alors le fichier en mémoire puis l'exécute, sans produire de fichier [.class]. Cette possibilité a été présentée au chapitre 2 « Installation de l'environnement de travail ». Les exemples se trouvent dans le dossier [03-bases] du code du cours. Ils s'exécutent avec la langue et les conventions françaises (la « locale » fr-FR), comme sur un poste Windows français. Nous verrons que cela n'a d'effet que sur les nombres et les dates mis en forme par un format : la simple conversion d'un réel en chaîne de caractères utilise toujours le point décimal.

3.2. Le programme minimal

3.2.1. Le fichier source compact

Le programme [Bases01.java] est le suivant :

// programme minimal écrit sous forme de fichier source compact (Java 25)
// ni déclaration de classe, ni mots clés public et static : une simple méthode main
void main() {
    IO.println("Bonjour le monde !");
    // la forme historique : le champ out de la classe System, puis sa méthode println
    System.out.println("Bonjour le monde !");
    // List (paquetage java.util) est utilisable sans importation dans un fichier compact
    List<String> mots = List.of("Bonjour", "le", "monde");
    IO.println(mots);
}

Résultats :

1
2
3
4
> java Bases01.java
Bonjour le monde !
Bonjour le monde !
[Bonjour, le, monde]

Commentaires :

  • le fichier ne contient aucune déclaration de classe : seulement une méthode [main]. C'est un fichier source compact (« compact source file », Java 25). Le compilateur enveloppe lui-même le contenu du fichier dans une classe, déclarée implicitement, qui porte le nom du fichier : [Bases01]. Cette possibilité a été proposée en préversion de Java 21 à Java 24 avant de devenir définitive en Java 25 ;
  • ligne 3 : la méthode [main] est le point d'entrée du programme : c'est elle que le lanceur [java] exécute. Depuis Java 25, elle peut s'écrire simplement [void main()], sans les mots clés [public] et [static] et sans paramètre. Le corps de la méthode est placé entre accolades ;
  • ligne 4 : la méthode [println] de la classe [IO] (Java 25) écrit une ligne à l'écran ;
  • ligne 6 : la forme historique de la même opération. [System] est une classe, [out] l'un de ses champs, qui représente la sortie standard (l'écran), et [println] une méthode de cet objet. [IO.println(x)] n'est qu'un raccourci pour [System.out.println(x)]. Nous utiliserons [IO.println] dans les fichiers compacts et [System.out.println] dans les classes ordinaires, où on le rencontre dans tout le code Java existant ;
  • ligne 8 : [List] est une liste de chaînes de caractères ; les listes seront étudiées au chapitre 5. [List] appartient au paquetage [java.util] : un paquetage (« package ») regroupe des classes apparentées, comme un dossier regroupe des fichiers. Le nom complet de la classe est [java.util.List]. Elle est utilisable sans directive d'importation parce que le fichier est un fichier compact (voir plus loin) ;
  • ligne 9 : une liste s'affiche avec ses éléments entre crochets ;
  • chaque instruction se termine par un point-virgule ; les lignes qui commencent par // sont des commentaires, ignorés par le compilateur.

Avec deux arguments sur la ligne de commande (ils se placent après le nom du fichier) :

1
2
3
4
> java Bases01.java a0 a1
Bonjour le monde !
Bonjour le monde !
[Bonjour, le, monde]

Les arguments sont ignorés, car la méthode [void main()] n'a pas de paramètre pour les recevoir. Pour en disposer, on la déclare [void main(String[] args)] : le tableau de chaînes [args] contient alors les arguments de la ligne de commande. Nous y reviendrons dans la section « Arguments du programme principal ».

3.2.2. La forme classique avec une classe et une méthode main

Jusqu'à Java 24, le même programme s'écrivait obligatoirement de la façon suivante [Bases02.java] :

// la forme classique du programme (la seule forme standard jusqu'à Java 24)
import java.util.Arrays;

public class Bases02 {
    // la méthode main est le point d'entrée du programme
    public static void main(String[] args) {
        System.out.println("Bonjour le monde !");
        System.out.println("Nombre d'arguments reçus : " + args.length);
        // Arrays (paquetage java.util) doit être importée dans une classe ordinaire
        System.out.println("Arguments : " + Arrays.toString(args));
    }
}

Résultats :

1
2
3
4
> java Bases02.java a0 a1
Bonjour le monde !
Nombre d'arguments reçus : 2
Arguments : [a0, a1]

Commentaires :

  • ligne 2 : la directive [import] importe la classe [Arrays] du paquetage [java.util]. On peut alors écrire [Arrays] au lieu de [java.util.Arrays]. Dans une classe ordinaire, seules les classes du paquetage [java.lang] ([System], [String], [Math], [Integer], [IO]...) sont utilisables sans importation ;
  • ligne 4 : en Java, tout code appartient à une classe. La classe s'appelle ici [Bases02]. Une classe déclarée [public] doit se trouver dans un fichier qui porte son nom : [Bases02.java] ;
  • ligne 6 : la méthode [main] est le point d'entrée du programme. Elle est [public] (accessible depuis l'extérieur de la classe, en particulier par le lanceur [java]), [static] (elle s'exécute sans qu'il soit nécessaire de créer un objet de la classe), ne rend aucun résultat ([void]) et reçoit les arguments de la ligne de commande dans le tableau [args] ;
  • ligne 8 : le nombre d'éléments du tableau [args] est [args.length]. L'opérateur + concatène ici une chaîne et un nombre : le nombre est d'abord converti en chaîne ;
  • ligne 10 : la méthode [Arrays.toString] construit une chaîne avec les éléments d'un tableau.

Un fichier qui contient une classe ordinaire peut lui aussi être exécuté directement par le lanceur ([java Bases02.java]). On peut également le compiler d'abord avec le compilateur [javac], qui produit un fichier [Bases02.class] contenant le code intermédiaire (« bytecode ») de la classe, puis exécuter cette classe avec la commande [java] suivie du nom de la classe (sans extension). C'est ce que font, en coulisse, Maven et les environnements de développement :

1
2
3
4
5
6
7
> javac Bases02.java
> ls
Bases02.class  Bases02.java
> java Bases02 a0 a1
Bonjour le monde !
Nombre d'arguments reçus : 2
Arguments : [a0, a1]

Les deux programmes [Bases01.java] et [Bases02.java] sont de même nature. Lorsqu'il rencontre un fichier compact, le compilateur génère lui-même une classe [final] portant le nom du fichier, dont les méthodes et les champs sont ceux écrits dans le fichier. Le fichier compact n'est donc qu'une écriture abrégée de la forme classique. Il convient bien aux petits programmes de ce chapitre ; la forme classique s'impose dès qu'un programme comporte plusieurs classes, ce qui sera le cas à partir du chapitre 4.

Depuis Java 25, la méthode [main] peut avoir quatre formes : [static void main(String[] args)], [static void main()], [void main(String[] args)] et [void main()] ; si une classe en contient plusieurs, le lanceur préfère celle qui a le paramètre [String[]]. Les deux dernières formes sont des méthodes d'instance : le lanceur crée d'abord un objet de la classe, puis appelle sa méthode [main]. Le mot clé [public] n'est plus obligatoire.

3.2.3. L'importation automatique du module java.base

Le programme [Bases01.java] utilise la classe [List] sans directive [import java.util.List;]. C'est possible parce que le compilateur ajoute automatiquement à tout fichier compact la directive suivante (Java 25) :

import module java.base;

Les classes de la bibliothèque standard de Java sont rangées dans des paquetages, eux-mêmes regroupés en modules. Le module [java.base] est le module fondamental : il contient les paquetages les plus utilisés. La directive [import module java.base;] importe d'un coup toutes les classes publiques de tous les paquetages exportés par ce module, ce qui revient, entre autres, aux directives suivantes :

import java.io.*;
import java.math.*;
import java.net.*;
import java.nio.file.*;
import java.text.*;
import java.time.*;
import java.time.format.*;
import java.util.*;
import java.util.function.*;
import java.util.regex.*;
import java.util.stream.*;
...

Dans un fichier compact, on dispose donc sans rien écrire des listes et des dictionnaires ([java.util]), des fichiers ([java.io], [java.nio.file]), des grands nombres ([java.math]), des dates ([java.time]), etc. Une classe d'un autre module, comme les classes JDBC du module [java.sql], doit toujours être importée explicitement. Dans une classe ordinaire, rien n'est importé d'office en dehors du paquetage [java.lang] : on écrit une directive par classe ([import java.util.Arrays;]), une directive par paquetage ([import java.util.*;]) ou, depuis Java 25, une directive par module ([import module java.base;]). La classe [IO] fait partie du paquetage [java.lang] : elle est utilisable partout.

3.3. Les données de Java

Java utilise les types de données suivants :

  • les nombres entiers ;
  • les nombres réels ;
  • les nombres décimaux ;
  • les caractères et chaînes de caractères ;
  • les booléens ;
  • les objets.

3.3.1. Les types de données prédéfinis

Type Java
Classe enveloppe
Donnée représentée
Suffixe des littéraux
Codage
Domaine de valeurs
[char]
[Character]
caractère
 
2 octets
caractère Unicode (UTF-16), de '\u0000' à '\uffff'
[byte]
[Byte]
nombre entier
 
1 octet
[-2⁷, 2⁷-1] soit [-128, 127]
[short]
[Short]
nombre entier
 
2 octets
[-2¹⁵, 2¹⁵-1] soit [-32768, 32767]
[int]
[Integer]
nombre entier
 
4 octets
[-2³¹, 2³¹-1] soit [-2147483648, 2147483647]
[long]
[Long]
nombre entier
L
8 octets
[-2⁶³, 2⁶³-1] soit [-9223372036854775808, 9223372036854775807]
[float]
[Float]
nombre réel
F
4 octets
[1,4 10⁻⁴⁵, 3,4 10³⁸] en valeur absolue, 7 chiffres significatifs
[double]
[Double]
nombre réel
D (facultatif)
8 octets
[4,9 10⁻³²⁴, 1,8 10³⁰⁸] en valeur absolue, 15 à 16 chiffres significatifs
[boolean]
[Boolean]
booléen
 
non fixé
[true], [false]
[String] (classe)
 
chaîne de caractères
  
référence sur une séquence de caractères Unicode
[BigDecimal] (classe)
 
nombre décimal
 
variable
nombre décimal exact, de précision quelconque
[Object] (classe)
 
référence d'objet
  
référence sur un objet de n'importe quel type

Les huit premiers types du tableau, dont le nom s'écrit en minuscules, sont les types primitifs de Java. Tous les autres types sont des types référence : les classes ([String], [BigDecimal], [Object]...), mais aussi les tableaux, les énumérations, les interfaces et les records que nous rencontrerons plus loin. La différence est fondamentale :

  • une variable de type primitif contient directement sa valeur ;
  • une variable de type référence contient l'adresse de l'objet (sa référence), l'objet lui-même étant rangé ailleurs en mémoire.

Une valeur de type primitif n'est pas un objet : elle n'a ni champs ni méthodes. À chaque type primitif correspond cependant une classe enveloppe (« wrapper class ») du paquetage [java.lang] : [Integer] pour [int], [Double] pour [double], [Character] pour [char], etc. Un objet [Integer] enveloppe une valeur [int]. Ces classes offrent aussi des constantes et des méthodes utiles : [Integer.MAX_VALUE], [Integer.BYTES], [Integer.parseInt]... Depuis Java 5, le compilateur passe automatiquement d'un type primitif à sa classe enveloppe (emballage, en anglais « boxing ») et inversement (déballage, « unboxing »). Ainsi, on peut écrire :

String nomDuType = Integer.valueOf(3).getClass().getName();

Le littéral 3 est de type [int]. Comme il n'a pas de méthodes, on ne peut pas écrire [3.getClass()] : il faut d'abord l'envelopper dans un objet [Integer] avec [Integer.valueOf(3)]. Tout objet a une méthode [getClass()] qui rend un objet décrivant sa classe ; la méthode [getName()] de cet objet donne le nom complet de la classe, ici [java.lang.Integer].

Java n'a pas d'entiers non signés, en dehors du type [char] (16 bits). Les classes enveloppes offrent en revanche des méthodes qui interprètent les bits d'un entier comme ceux d'un nombre non signé : [Integer.toUnsignedString], [Integer.parseUnsignedInt], [Integer.compareUnsigned], [Byte.toUnsignedInt]... Il n'a pas non plus de type primitif décimal : les montants se calculent avec la classe [BigDecimal] du paquetage [java.math], que nous présentons un peu plus loin.

Le programme [Bases03.java] illustre ces différents points :

// les types primitifs : classe enveloppe et taille en octets
void main() {
    int ent = 2;
    float fl = 10.5F;
    double d = -4.6;
    String s = "essai";
    long l = 1000;
    byte octet = 5;
    short sh = -4;
    char c = 'A';
    boolean b = true;
    BigDecimal dec = new BigDecimal("10.67");
    // %s est remplacé par la valeur convertie en chaîne, %d par un entier
    IO.println("Type de ent[%s] : [%s,%d]".formatted(ent, nomType(ent), Integer.BYTES));
    IO.println("Type de fl[%s] : [%s,%d]".formatted(fl, nomType(fl), Float.BYTES));
    IO.println("Type de d[%s] : [%s,%d]".formatted(d, nomType(d), Double.BYTES));
    IO.println("Type de s[%s] : [%s]".formatted(s, nomType(s)));
    IO.println("Type de l[%s] : [%s,%d]".formatted(l, nomType(l), Long.BYTES));
    IO.println("Type de octet[%s] : [%s,%d]".formatted(octet, nomType(octet), Byte.BYTES));
    IO.println("Type de sh[%s] : [%s,%d]".formatted(sh, nomType(sh), Short.BYTES));
    IO.println("Type de c[%s] : [%s,%d]".formatted(c, nomType(c), Character.BYTES));
    IO.println("Type de b[%s] : [%s]".formatted(b, nomType(b)));
    IO.println("Type de dec[%s] : [%s]".formatted(dec, nomType(dec)));
    // le littéral 3 n'est pas un objet : il faut l'emballer dans un Integer
    IO.println("Type du littéral 3 emballé : " + Integer.valueOf(3).getClass().getName());
    // valeurs extrêmes
    IO.println("int : [" + Integer.MIN_VALUE + ", " + Integer.MAX_VALUE + "]");
    IO.println("long : [" + Long.MIN_VALUE + ", " + Long.MAX_VALUE + "]");
    IO.println("double : [" + Double.MIN_VALUE + ", " + Double.MAX_VALUE + "]");
    // pas d'entiers non signés, mais des méthodes qui interprètent les bits sans signe
    int moinsUn = -1;
    IO.println("-1 lu comme un entier non signé : " + Integer.toUnsignedString(moinsUn));
}

// rend le nom complet de la classe de l'objet reçu
String nomType(Object o) {
    return o.getClass().getName();
}

Résultats :

> java Bases03.java
Type de ent[2] : [java.lang.Integer,4]
Type de fl[10.5] : [java.lang.Float,4]
Type de d[-4.6] : [java.lang.Double,8]
Type de s[essai] : [java.lang.String]
Type de l[1000] : [java.lang.Long,8]
Type de octet[5] : [java.lang.Byte,1]
Type de sh[-4] : [java.lang.Short,2]
Type de c[A] : [java.lang.Character,2]
Type de b[true] : [java.lang.Boolean]
Type de dec[10.67] : [java.math.BigDecimal]
Type du littéral 3 emballé : java.lang.Integer
int : [-2147483648, 2147483647]
long : [-9223372036854775808, 9223372036854775807]
double : [4.9E-324, 1.7976931348623157E308]
-1 lu comme un entier non signé : 4294967295

Commentaires :

  • ligne 3 : déclaration d'un entier [ent] initialisé à 2 ;
  • ligne 4 : le suffixe F est obligatoire : le littéral 10.5 est de type [double], et le compilateur refuse [float fl = 10.5;], qui ferait perdre de la précision ;
  • ligne 7 : le littéral 1000 est de type [int] ; il est converti automatiquement en [long] ;
  • ligne 12 : un nombre [BigDecimal] est un objet ; on le crée ici à partir d'une chaîne de caractères, ce qui garantit une valeur exacte ;
  • ligne 14 : la méthode [formatted] (Java 15) s'applique à une chaîne de format : chaque spécificateur %s est remplacé par la valeur du paramètre suivant convertie en chaîne, chaque %d par la valeur d'un entier. Les paramètres sont pris dans l'ordre. Nous reviendrons sur les formats dans la section suivante ;
  • ligne 14 : la méthode [nomType] (lignes 36-38) attend un paramètre de type [Object]. La variable [ent], de type [int], est emballée automatiquement dans un objet [Integer], dont la classe est [java.lang.Integer] : c'est pourquoi l'affichage montre les classes enveloppes et non les noms des types primitifs. [Integer.BYTES] (Java 8) est la taille en octets d'un [int] ;
  • ligne 17 : [String] est une classe et non un type primitif : la taille en mémoire d'un objet n'est pas accessible en Java ;
  • ligne 22 : la taille d'un [boolean] n'est pas fixée par la spécification de Java : la classe [Boolean] n'a pas de constante [BYTES] ;
  • ligne 25 : le littéral 3 est emballé explicitement dans un [Integer] ;
  • lignes 27-29 : les constantes [MIN_VALUE] et [MAX_VALUE] des classes enveloppes donnent les bornes du domaine de valeurs. Attention : pour les réels, [Double.MIN_VALUE] est la plus petite valeur strictement positive (4,9 10⁻³²⁴) et non le plus grand nombre négatif, qui est [-Double.MAX_VALUE] ;
  • lignes 31-32 : -1 est codé par 32 bits à 1 ; interprétés comme un entier non signé, ces 32 bits valent 2³²-1 ;
  • lignes 36-38 : une méthode du fichier compact. Elle rend ([return]) une chaîne de caractères ([String]). Les méthodes sont présentées dans la section « Passage de paramètres à une fonction ».

On remarquera que les réels sont affichés avec un point décimal (10.5, -4.6), bien que le programme s'exécute en français. La conversion d'un nombre en chaîne par concaténation, par [String.valueOf], par [toString] ou par le spécificateur %s ne dépend pas de la langue de l'utilisateur : elle produit toujours le point décimal. Seuls les formats numériques comme %f, que nous allons voir, suivent les conventions de la langue.

3.3.2. Notation des données littérales

Donnée
Exemples de littéraux
entier [int] (32 bits)
145, -7, 0xFF (hexadécimal), 0b1010_1010 (binaire)
entier [long] (64 bits), suffixe L
100000L, 1_000_000_000_000L
réel [double], suffixe D facultatif
134.789, -45E-18 (-45 10⁻¹⁸), 10D
réel [float], suffixe F
134.789F, -45E-18F
nombre décimal [BigDecimal]
pas de littéral : [new BigDecimal("100000.50")]
caractère [char]
'A', 'b', '\n', '\u00e9'
chaîne [String]
"aujourd'hui", "c:\\chap1", bloc de texte """..."""
booléen [boolean]
[true], [false]
date
[LocalDate.of(1954, 10, 13)] (année, mois, jour) pour le 13/10/1954

Dans le code source, les réels s'écrivent toujours avec un point décimal. Depuis Java 7, on peut écrire les entiers en binaire (préfixe [0b]) et séparer les groupes de chiffres par le caractère _ pour améliorer la lisibilité : [1_000_000] est l'entier un million. Le suffixe L peut s'écrire en minuscule, mais l ressemble trop au chiffre 1. Attention enfin à un piège hérité du langage C : un entier écrit avec un 0 en tête est noté en octal (base 8) : 017 vaut 15.

Une chaîne littérale peut contenir des séquences d'échappement introduites par le caractère \ : "\n" représente la marque de fin de ligne et non la succession des deux caractères \ et n, "\t" une tabulation, "\"" un guillemet, "\\" le caractère \ lui-même. Pour écrire le chemin Windows c:\chap1\paragraph3, il faut donc écrire "c:\\chap1\\paragraph3". Java n'a pas de chaîne « brute » dans laquelle le caractère \ serait pris tel quel ; mais les classes de fichiers de Java acceptent aussi le séparateur / sous Windows : "c:/chap1/paragraph3" désigne le même fichier.

Pour construire une chaîne à partir de plusieurs valeurs, Java offre deux moyens :

  • la concaténation avec l'opérateur + : dès que l'un des deux opérandes est une chaîne, l'autre est converti en chaîne ;
  • les formats : [String.format(format, valeur1, valeur2, ...)], ou de façon équivalente [format.formatted(valeur1, valeur2, ...)] (Java 15), et pour un affichage direct [System.out.printf(format, valeur1, ...)]. La chaîne de format contient des spécificateurs remplacés dans l'ordre par les valeurs qui suivent : %s (une valeur quelconque convertie en chaîne), %d (un entier), %f (un réel), %.2f (un réel avec deux décimales), %x ou %X (un entier en hexadécimal), %,d (un entier avec séparateur des milliers), %e (notation scientifique), %n (une fin de ligne), %10s (une chaîne cadrée à droite sur 10 caractères), %-10s (cadrée à gauche)... Les formats numériques suivent les conventions de la langue de l'utilisateur.

Java n'a pas de chaînes « interpolées » contenant des expressions entre accolades : une telle fonctionnalité (les « string templates ») a été proposée en préversion dans Java 21 et 22, puis retirée. La concaténation et les formats restent les seuls moyens.

Depuis Java 15, on peut écrire une chaîne sur plusieurs lignes avec un bloc de texte (« text block ») : il commence par trois guillemets suivis d'une fin de ligne et se termine par trois guillemets. Il peut contenir des guillemets sans échappement. Les espaces communs en début de toutes les lignes (y compris la ligne des guillemets fermants) sont retirés, ce qui permet d'indenter le texte avec le code. En revanche, les séquences d'échappement y sont interprétées comme dans une chaîne ordinaire : il faut toujours écrire \\ pour obtenir \. Les blocs de texte sont très pratiques pour écrire du JSON, du SQL ou du HTML.

Le programme [Bases04.java] montre ces différentes notations :

// la notation des données littérales
void main() {
    // entiers : décimal, hexadécimal (0x), binaire (0b), séparateur _
    int i1 = 145, i2 = -7, i3 = 0xFF, i4 = 0b1010_1010;
    long l1 = 100000L, l2 = 1_000_000_000_000L;
    IO.println("entiers : " + i1 + " " + i2 + " " + i3 + " " + i4 + " " + l1 + " " + l2);
    // réels
    double d1 = 134.789, d2 = -45E-18, d3 = 10D;
    float f1 = 134.789F;
    BigDecimal m1 = new BigDecimal("100000.50");
    IO.println("réels : " + d1 + " " + d2 + " " + d3 + " " + f1 + " " + m1);
    // caractères et booléens
    char c1 = 'A', c2 = '\u00e9';
    boolean b1 = true;
    IO.println("caractères : " + c1 + " " + c2 + ", booléen : " + b1);
    // une date : (année, mois, jour)
    LocalDate naissance = LocalDate.of(1954, 10, 13);
    IO.println("date : " + naissance);

    // le caractère \ introduit une séquence d'échappement
    String chemin = "c:\\chap1\\paragraph3";
    IO.println(chemin);
    IO.println("ligne 1\nligne 2\t[tabulation]");

    // construction d'une chaîne : concaténation ou format
    String nom = "dupont";
    int age = 23;
    IO.println(nom + " a " + age + " ans, dans 10 ans il aura " + (age + 10) + " ans");
    IO.println(String.format("%s a %d ans, dans 10 ans il aura %d ans", nom, age, age + 10));
    // la méthode formatted (Java 15) s'applique à la chaîne de format elle-même
    IO.println("d1=%.1f, i3 en hexadécimal=%X, l2=%,d".formatted(d1, i3, l2));
    IO.println("i4 en binaire=" + Integer.toBinaryString(i4));
    IO.println("date=" + naissance.format(DateTimeFormatter.ofPattern("EEEE d MMMM yyyy")));

    // bloc de texte (Java 15) : les guillemets s'écrivent sans échappement
    String json = """
        {
          "nom": "dupont",
          "chemin": "c:\\chap1"
        }
        """;
    IO.print(json);
    // un bloc de texte est une chaîne comme une autre : il accepte formatted
    String json2 = """
        { "nom": "%s", "age": %d }""".formatted(nom, age);
    IO.println(json2);
}

Résultats :

> java Bases04.java
entiers : 145 -7 255 170 100000 1000000000000
réels : 134.789 -4.5E-17 10.0 134.789 100000.50
caractères : A é, booléen : true
date : 1954-10-13
c:\chap1\paragraph3
ligne 1
ligne 2 [tabulation]
dupont a 23 ans, dans 10 ans il aura 33 ans
dupont a 23 ans, dans 10 ans il aura 33 ans
d1=134,8, i3 en hexadécimal=FF, l2=1 000 000 000 000
i4 en binaire=10101010
date=mercredi 13 octobre 1954
{
  "nom": "dupont",
  "chemin": "c:\chap1"
}
{ "nom": "dupont", "age": 23 }

Commentaires :

  • ligne 4 : [0xFF] vaut 255 et [0b1010_1010] vaut 170 ;
  • ligne 5 : le suffixe L est obligatoire pour un littéral qui dépasse le domaine des [int]. Sans lui, le compilateur refuse la ligne avec le message [error: integer number too large] ;
  • ligne 8 : -45E-18 est affiché -4.5E-17 : c'est la même valeur, normalisée. Le réel 10D est affiché 10.0 : la conversion d'un [double] en chaîne produit toujours au moins une décimale ;
  • ligne 10 : un [BigDecimal] conserve le nombre de décimales avec lequel il a été écrit : 100000.50 et non 100000.5 ;
  • ligne 13 : la séquence \u00e9 désigne le caractère Unicode de code hexadécimal 00E9, c'est-à-dire é ;
  • ligne 17 : une date. La classe [LocalDate] (paquetage [java.time], Java 8) représente une date sans heure. Sa conversion en chaîne utilise le format international ISO 8601 (année-mois-jour), quelle que soit la langue. Nous retrouverons les dates au chapitre 5 ;
  • ligne 21 : chaque \\ représente un seul caractère \ ;
  • ligne 23 : une fin de ligne et une tabulation ;
  • ligne 28 : une concaténation. Les parenthèses de [(age + 10)] sont indispensables : sans elles, les opérateurs + seraient évalués de gauche à droite, la chaîne recevrait d'abord 23 puis 10, et l'on obtiendrait « il aura 2310 ans » ;
  • ligne 29 : le même texte construit par un format ;
  • ligne 31 : quelques spécificateurs : %.1f (une décimale), %X (hexadécimal en majuscules), %,d (séparateur des milliers). Ces formats suivent les conventions françaises : virgule décimale et, comme séparateur des milliers, une espace insécable fine ;
  • ligne 32 : il n'y a pas de spécificateur pour le binaire : la méthode [Integer.toBinaryString] fait la conversion ;
  • ligne 33 : un format de date personnalisé : jour de la semaine (EEEE), jour (d), mois en lettres (MMMM), année (yyyy). Les noms des jours et des mois sont en français, la langue de l'utilisateur ;
  • lignes 36-41 : un bloc de texte sur plusieurs lignes. Les guillemets y sont écrits tels quels. Les huit espaces communs à toutes les lignes ont été retirés. La séquence \\ a donné un seul \. Comme les guillemets fermants sont seuls sur leur ligne, le bloc se termine par une fin de ligne : on l'affiche donc avec [IO.print], qui ne passe pas à la ligne ;
  • lignes 44-45 : un bloc de texte dont les guillemets fermants suivent le texte : il ne se termine pas par une fin de ligne. La méthode [formatted] remplace %s et %d par les valeurs de [nom] et [age] : c'est la façon Java d'insérer des valeurs dans un texte.

3.3.3. Déclaration des données

3.3.3.1. Rôle des déclarations

Un programme manipule des données caractérisées par un nom et un type. Ces données sont stockées en mémoire. Au moment de la traduction du programme, le compilateur affecte à chaque donnée un emplacement en mémoire caractérisé par une adresse et une taille. Il le fait en s'aidant des déclarations faites par le programmeur. Par ailleurs, celles-ci permettent au compilateur de détecter des erreurs de programmation. Ainsi l'opération [x = x * 2;] sera déclarée erronée si [x] est une chaîne de caractères.

3.3.3.2. Déclaration des constantes

En Java, une constante est une variable déclarée avec le mot clé [final] : elle ne peut recevoir une valeur qu'une seule fois. La syntaxe est la suivante :

final type nom = valeur;

Une constante utilisée par tout le programme est déclarée en dehors des méthodes, comme un champ [static final] de la classe. Par exemple :

static final double TAUX_TVA = 0.2;

Pourquoi déclarer des constantes ?

  • la lecture du programme est plus aisée si l'on a donné à la constante un nom significatif ;
  • la modification du programme est plus aisée si la « constante » vient à changer. Ainsi, si le taux de TVA passe à 0,25, la seule modification à faire sera celle de l'instruction qui définit sa valeur : [static final double TAUX_TVA = 0.25;]. Si l'on avait utilisé 0.2 explicitement dans le programme, ce serait de nombreuses instructions qu'il faudrait modifier.

Par convention, en Java, le nom d'une constante [static final] s'écrit en majuscules, les mots étant séparés par le caractère _ : [TAUX_TVA], [MAX_VALUE]. Une variable locale peut elle aussi être déclarée [final] ; elle garde alors un nom ordinaire. Le mot [const] est réservé en Java, mais il n'est pas utilisé.

3.3.3.3. Déclaration des variables

Une variable est identifiée par un nom et se rapporte à un type de données. Java fait la différence entre majuscules et minuscules : les variables [FIN] et [fin] sont différentes. Par convention, le nom d'une variable commence par une minuscule, chaque mot suivant par une majuscule : [prixHt], [nbEnfants].

Les variables peuvent être initialisées lors de leur déclaration. La syntaxe de déclaration d'une ou plusieurs variables est :

type variable1 = valeur1, variable2 = valeur2, ...;

où [type] est un type prédéfini ou un type défini par le programmeur. L'initialisation est facultative ; en revanche, Java refuse qu'on utilise la valeur d'une variable locale qui n'a pas encore reçu de valeur (message [variable x might not have been initialized]).

Depuis Java 10, on peut aussi ne pas préciser le type d'une variable locale en utilisant le mot [var] à la place du type :

var variable1 = valeur1;

Le mot [var] ne veut pas dire que la variable n'a pas de type précis : elle a le type de la valeur qui lui est affectée, déterminé par le compilateur. L'initialisation est ici obligatoire, afin que le compilateur puisse en déduire le type. [var] n'est utilisable que pour les variables locales (pas pour les champs ni pour les paramètres des méthodes). On l'utilise lorsque le type est évident à la lecture de la valeur, en particulier lorsque son nom est long.

Le programme [Bases05.java] est le suivant :

// constantes et variables
// une constante de classe : static final, nom en majuscules
static final double TAUX_TVA = 0.2;

void main() {
    // une constante locale : final
    final String monnaie = "euros";
    double prixHt = 100;
    IO.println("Prix TTC : " + prixHt * (1 + TAUX_TVA) + " " + monnaie);

    // une donnée typée explicitement
    int i = 2;
    IO.println("Type de int i=2 : " + nomType(i));
    // des données typées implicitement avec var (Java 10)
    var j = 3;
    IO.println("Type de var j=3 : " + nomType(j));
    var aujourdhui = LocalDateTime.now();
    IO.println("Type de var aujourdhui : " + nomType(aujourdhui));
    var x = 3.5;
    var y = 3.5F;
    var z = 10L;
    var nom = "dupont";
    IO.println("x : " + nomType(x) + ", y : " + nomType(y));
    IO.println("z : " + nomType(z) + ", nom : " + nomType(nom));
    // plusieurs variables déclarées sur une même ligne
    int a = 1, b, c = 3;
    b = a + c;
    IO.println("a=" + a + ", b=" + b + ", c=" + c);
}

// rend le nom court et le nom complet de la classe de l'objet reçu
String nomType(Object o) {
    return o.getClass().getSimpleName() + "," + o.getClass().getName();
}

Résultats :

1
2
3
4
5
6
7
8
> java Bases05.java
Prix TTC : 120.0 euros
Type de int i=2 : Integer,java.lang.Integer
Type de var j=3 : Integer,java.lang.Integer
Type de var aujourdhui : LocalDateTime,java.time.LocalDateTime
x : Double,java.lang.Double, y : Float,java.lang.Float
z : Long,java.lang.Long, nom : String,java.lang.String
a=1, b=4, c=3

Commentaires :

  • ligne 3 : une constante de classe. Dans un fichier compact, une variable déclarée en dehors des méthodes est un champ de la classe implicite ;
  • ligne 7 : une constante locale ;
  • ligne 9 : le résultat est un [double], affiché avec au moins une décimale : 120.0 ;
  • ligne 12 : une donnée typée explicitement ;
  • ligne 13 : [getSimpleName()] (ligne 33) est le nom court de la classe, [getName()] son nom complet. Comme dans le programme précédent, l'[int] est emballé dans un [Integer] pour être passé à [nomType] ;
  • ligne 15 : une donnée typée implicitement. Parce que 3 est de type [int], [j] sera de type [int]. L'affichage montre [Integer] pour la même raison qu'à la ligne 13 ;
  • ligne 17 : parce que [LocalDateTime.now()] (la date et l'heure courantes) est de type [LocalDateTime], [aujourdhui] sera de type [LocalDateTime] ;
  • lignes 19-22 : le type déduit dépend de l'écriture du littéral : 3.5 est un [double], 3.5F un [float], 10L un [long] ;
  • ligne 26 : trois variables déclarées sur une même ligne ; seules [a] et [c] sont initialisées. [b] reçoit sa valeur à la ligne suivante.

Une variable typée implicitement par le mot [var] ne peut pas changer de type ensuite. Ainsi, après la ligne 17, l'instruction [aujourdhui = "aujourd'hui";] est refusée par le compilateur :

error: incompatible types: String cannot be converted to LocalDateTime

3.3.4. Les valeurs absentes : null, classes enveloppes et Optional

Une variable d'un type primitif, comme [int], contient toujours une valeur. Il est parfois utile de pouvoir exprimer l'absence de valeur : un âge inconnu, un champ non renseigné dans une base de données. On utilise alors la classe enveloppe : une variable de type [Integer] est une référence, qui peut valoir [null], c'est-à-dire « pas d'objet ».

Toute variable d'un type référence peut en effet valoir [null] : elle ne référence alors aucun objet. Utiliser un membre d'une telle variable, par exemple sa méthode [length()], provoque une erreur à l'exécution, une [NullPointerException] : c'est l'erreur la plus fréquente en Java. Depuis Java 15, son message indique précisément quelle expression valait [null]. Le compilateur Java ne fait pas la différence entre une référence qui peut valoir [null] et une référence qui ne le peut pas ; des annotations comme celles du projet JSpecify ([@Nullable], [@NonNull]), exploitées par des outils d'analyse, permettent de combler ce manque, mais nous ne les utiliserons pas.

Pour indiquer qu'une méthode peut ne rien rendre, Java 8 a ajouté la classe [Optional<T>] : un objet [Optional<String>] est une « boîte » qui contient une chaîne ou qui est vide. On le crée avec [Optional.of(valeur)], [Optional.empty()] ou [Optional.ofNullable(valeurOuNull)] ; on l'interroge avec [isPresent()], [isEmpty()] (Java 11), [get()], [orElse(valeurParDefaut)]... Les classes [OptionalInt], [OptionalLong] et [OptionalDouble] jouent le même rôle pour les types primitifs.

Nous rencontrerons la valeur [null] dès les premiers exemples, car la méthode [IO.readln()], qui lit une ligne tapée au clavier, rend [null] lorsqu'il n'y a plus rien à lire (fin du flux d'entrée). Le programme [Bases06.java] est le suivant :

// les valeurs absentes : null, classes enveloppes et Optional
void main() {
    // un int contient toujours une valeur ; un Integer peut valoir null
    Integer age = null;
    IO.println("age est null : " + (age == null));
    // emballage automatique (autoboxing) : l'int 23 devient un Integer
    age = 23;
    IO.println("age=" + age);
    // déballage automatique (unboxing) : l'Integer devient un int
    int n = age;
    IO.println("n=" + n);
    // le déballage d'un Integer qui vaut null lance une NullPointerException
    Integer inconnu = null;
    try {
        int m = inconnu;
        IO.println("m=" + m);
    } catch (NullPointerException e) {
        IO.println("Déballage de null : " + e.getClass().getName());
    }

    // une référence d'objet peut valoir null : elle ne désigne alors aucun objet
    String prenom = null;
    IO.println("prenom est null : " + (prenom == null));
    // Optional (Java 8) : une boîte qui contient une valeur ou qui est vide
    Optional<String> nom = Optional.of("dupont");
    Optional<String> surnom = Optional.empty();
    IO.println("nom présent : " + nom.isPresent() + ", nom a " + nom.get().length() + " caractères");
    IO.println("surnom présent : " + surnom.isPresent() + ", surnom : " + surnom.orElse("aucun"));

    // IO.readln (Java 25) rend null si le flux d'entrée est épuisé
    String ligne = IO.readln("Tapez une ligne : ");
    if (ligne == null) {
        IO.println();
        IO.println("Pas de ligne : le flux d'entrée est fermé");
    } else {
        IO.println("Vous avez tapé [" + ligne + "] : " + ligne.length() + " caractère(s)");
    }
}

Résultats avec une saisie au clavier :

> java Bases06.java
age est null : true
age=23
n=23
Déballage de null : java.lang.NullPointerException
prenom est null : true
nom présent : true, nom a 6 caractères
surnom présent : false, surnom : aucun
Tapez une ligne : bonjour
Vous avez tapé [bonjour] : 7 caractère(s)

Résultats lorsque l'utilisateur tape Ctrl-D (Ctrl-Z puis Entrée sous Windows), qui ferme le flux d'entrée :

> java Bases06.java
age est null : true
age=23
n=23
Déballage de null : java.lang.NullPointerException
prenom est null : true
nom présent : true, nom a 6 caractères
surnom présent : false, surnom : aucun
Tapez une ligne : 
Pas de ligne : le flux d'entrée est fermé

Commentaires :

  • ligne 4 : [age] ne référence aucun objet ;
  • ligne 7 : l'[int] 23 est emballé automatiquement : le compilateur a écrit pour nous [age = Integer.valueOf(23);] ;
  • ligne 10 : l'[Integer] est déballé automatiquement : le compilateur a écrit [int n = age.intValue();] ;
  • lignes 13-19 : déballer un [Integer] qui vaut [null] revient à appeler [intValue()] sur une référence nulle : une [NullPointerException] est lancée. Le piège est d'autant plus sournois qu'aucun appel de méthode n'est visible à la ligne 15. La structure [try / catch], qui intercepte l'erreur, est présentée plus loin ; on affiche ici le nom complet de la classe de l'exception ;
  • ligne 22 : une chaîne qui ne référence aucun objet ;
  • ligne 25 : un [Optional] qui contient la chaîne "dupont". La méthode [get()] (ligne 27) rend son contenu ; appliquée à un [Optional] vide, elle lancerait une exception [NoSuchElementException] ;
  • ligne 26 : un [Optional] vide ;
  • ligne 28 : [orElse] rend le contenu de l'[Optional], ou la valeur de remplacement passée en paramètre s'il est vide ;
  • ligne 31 : [IO.readln(invite)] (Java 25) affiche l'invite sans passer à la ligne, puis lit la ligne tapée au clavier. Elle rend [null] si le flux d'entrée est fermé ;
  • ligne 32 : on teste la valeur [null] ;
  • ligne 36 : dans la branche [else], [ligne] ne vaut pas [null] : on peut utiliser [ligne.length()] sans risque.

3.3.5. Les conversions entre nombres et chaînes de caractères

Conversion
Méthode
nombre -> chaîne
[String.valueOf(nombre)], [Integer.toString(nombre)], ["" + nombre] ou un format [String.format("%d", nombre)]
chaîne -> int
[Integer.parseInt(chaine)]
chaîne -> long
[Long.parseLong(chaine)]
chaîne -> double
[Double.parseDouble(chaine)]
chaîne -> float
[Float.parseFloat(chaine)]
chaîne -> BigDecimal
[new BigDecimal(chaine)]
chaîne -> boolean
[Boolean.parseBoolean(chaine)]

La conversion d'une chaîne vers un nombre peut échouer si la chaîne ne représente pas un nombre valide. La méthode de conversion génère alors une erreur appelée exception, ici de type [NumberFormatException], qui arrête le programme si elle n'est pas gérée. Cette erreur peut être gérée par la clause [try / catch] suivante :

1
2
3
4
5
6
try {
    appel de la méthode susceptible de générer l'exception
} catch (NumberFormatException e) {
    traiter l'exception e
}
instruction suivante

Si la méthode ne génère pas d'exception, on passe à l'instruction suivante ; sinon, on passe dans le corps de la clause [catch], puis à l'instruction suivante. Nous reviendrons sur la gestion des exceptions dans la section qui lui est consacrée.

Les méthodes [parseInt], [parseDouble]... acceptent un signe + ou - en tête, mais ni espaces ni séparateur des milliers : on retire les espaces de début et de fin d'une saisie avec la méthode [trim()] de la classe [String] avant de la convertir. La bibliothèque Java n'a pas de méthode qui tenterait une conversion et rendrait un indicateur de réussite au lieu de lancer une exception ; il est facile d'en écrire une, qui rend un [OptionalInt] vide en cas d'échec : c'est ce que fait la méthode [essayerParseInt] du programme suivant.

Le programme [Bases07.java] présente quelques techniques de conversion entre nombres et chaînes de caractères. La méthode [affiche] y écrit à l'écran la valeur de son paramètre :

// conversions entre nombres et chaînes de caractères
void main() {
    // données
    final int i = 10;
    final long l = 100000;
    final float f = 45.78F;
    double d = -14.98;

    // nombre --> chaîne
    affiche(String.valueOf(i));
    affiche(Long.toString(l));
    affiche(String.valueOf(f));
    affiche("" + d);

    // booléen --> chaîne
    final boolean b = false;
    affiche(String.valueOf(b));

    // chaîne --> int
    int i1 = Integer.parseInt("10");
    affiche(String.valueOf(i1));
    try {
        i1 = Integer.parseInt("10.67");
        affiche(String.valueOf(i1));
    } catch (NumberFormatException e) {
        affiche("Erreur : " + e.getMessage());
    }

    // chaîne --> long
    long l1 = Long.parseLong("100");
    affiche(String.valueOf(l1));
    try {
        l1 = Long.parseLong("10.675");
        affiche(String.valueOf(l1));
    } catch (NumberFormatException e) {
        affiche("Erreur : " + e.getMessage());
    }

    // chaîne --> double : le séparateur décimal est toujours le point
    double d1 = Double.parseDouble("100.87");
    affiche(String.valueOf(d1));
    try {
        d1 = Double.parseDouble("abcd");
        affiche(String.valueOf(d1));
    } catch (NumberFormatException e) {
        affiche("Erreur : " + e.getMessage());
    }

    // chaîne --> float
    float f1 = Float.parseFloat("100.87");
    affiche(String.valueOf(f1));
    try {
        f1 = Float.parseFloat("100,87");
        affiche(String.valueOf(f1));
    } catch (NumberFormatException e) {
        affiche("Erreur : " + e.getMessage());
    }

    // chaîne --> BigDecimal
    BigDecimal m1 = new BigDecimal("100.87");
    affiche(m1.toString());

    // une conversion sans exception : notre méthode rend un OptionalInt
    OptionalInt i2 = essayerParseInt("123");
    if (i2.isPresent()) {
        affiche("essayerParseInt(\"123\") a réussi : " + i2.getAsInt());
    }
    OptionalInt i3 = essayerParseInt("12x");
    if (i3.isEmpty()) {
        affiche("essayerParseInt(\"12x\") a échoué");
    }

    // la langue de l'utilisateur (la « locale ») n'intervient que dans les formats
    affiche("Locale courante : " + Locale.getDefault().toLanguageTag());
    affiche(String.format("%.2f", d1));
    affiche(String.format(Locale.ROOT, "%.2f", d1));
    // lire un nombre écrit à la française : la classe NumberFormat
    try {
        Number nombre = NumberFormat.getInstance(Locale.FRANCE).parse("100,87");
        affiche("NumberFormat : " + nombre.doubleValue());
    } catch (ParseException e) {
        affiche("Erreur : " + e.getMessage());
    }
}

// une méthode qui affiche son paramètre
void affiche(String s) {
    IO.println("S=" + s);
}

// convertit une chaîne en entier sans lancer d'exception
OptionalInt essayerParseInt(String texte) {
    try {
        return OptionalInt.of(Integer.parseInt(texte));
    } catch (NumberFormatException _) {
        return OptionalInt.empty();
    }
}

Résultats :

> java Bases07.java
S=10
S=100000
S=45.78
S=-14.98
S=false
S=10
S=Erreur : For input string: "10.67"
S=100
S=Erreur : For input string: "10.675"
S=100.87
S=Erreur : For input string: "abcd"
S=100.87
S=Erreur : For input string: "100,87"
S=100.87
S=essayerParseInt("123") a réussi : 123
S=essayerParseInt("12x") a échoué
S=Locale courante : fr-FR
S=100,87
S=100.87
S=NumberFormat : 100.87

Commentaires :

  • lignes 4-7 : les données à convertir ;
  • lignes 10-13 : quatre façons de convertir un nombre en chaîne. La dernière concatène une chaîne vide et le nombre ;
  • ligne 17 : un booléen s'affiche [true] ou [false] ;
  • lignes 22-27 : on gère l'exception que peut lancer [Integer.parseInt]. [e.getMessage()] est le message d'erreur lié à l'exception [e]. Les messages des exceptions du JDK sont en anglais : ils ne sont pas traduits ;
  • ligne 40 : [Double.parseDouble] attend toujours un point décimal, quelle que soit la langue de l'utilisateur : la chaîne "100.87" est lue de la même façon sur toutes les machines. C'est la bonne façon de lire des nombres dans des fichiers ou des échanges entre programmes ;
  • ligne 53 : en conséquence, la virgule décimale est refusée ;
  • ligne 60 : la conversion d'une chaîne en [BigDecimal] ; elle aussi attend un point décimal ;
  • lignes 64-71 : la méthode [essayerParseInt] (lignes 92-98) rend un [OptionalInt] qui contient l'entier si la conversion a réussi, et qui est vide sinon. [getAsInt()] rend l'entier contenu ;
  • ligne 74 : [Locale.getDefault()] rend la locale du programme, c'est-à-dire la langue et le pays de l'utilisateur, déduits des réglages du système : ici fr-FR ;
  • ligne 75 : un format utilise la locale courante : le réel est écrit avec une virgule décimale ;
  • ligne 76 : [Locale.ROOT] est une locale neutre qui utilise le point décimal, quelle que soit la machine. On l'utilise pour écrire des nombres formatés dans des fichiers ou des échanges entre programmes, qui ne doivent pas dépendre de la langue de l'utilisateur ;
  • ligne 79 : pour lire un nombre écrit selon les conventions françaises, on utilise un objet [NumberFormat] (paquetage [java.text]) obtenu pour la locale [Locale.FRANCE]. Sa méthode [parse] rend un objet [Number] et peut lancer une exception [ParseException]. Celle-ci est une exception contrôlée : le compilateur exige qu'on la gère, ce que nous expliquerons dans la section sur les exceptions ;
  • lignes 87-89 : une méthode du fichier compact. Les méthodes peuvent être écrites dans n'importe quel ordre, avant ou après [main] ;
  • lignes 92-98 : la méthode [essayerParseInt]. La variable de la clause [catch] s'appelle _ : depuis Java 22, ce caractère désigne une variable anonyme, que l'on déclare parce que la syntaxe l'exige mais que l'on n'utilise pas.

Retenons que les méthodes [parseXXX] et [toString] ignorent la langue de l'utilisateur (point décimal), alors que les formats et la classe [NumberFormat] la respectent. Un programme qui lit des nombres doit toujours savoir selon quelles conventions ils ont été écrits.

3.3.6. Les tableaux de données

Un tableau Java est un objet permettant de rassembler sous un même identificateur des données de même type. Sa déclaration est la suivante :

Type[] tableau = new Type[n];

[n] est le nombre de données que peut contenir le tableau. La syntaxe [tableau[i]] désigne la donnée n° i, où i appartient à l'intervalle [0, n-1]. Toute référence à la donnée [tableau[i]] où i n'appartient pas à l'intervalle [0, n-1] provoque une exception [ArrayIndexOutOfBoundsException]. À sa création, chaque élément du tableau reçoit la valeur par défaut de son type : 0 pour les nombres, [false] pour les booléens, [null] pour les types référence. Un tableau peut être initialisé en même temps que déclaré :

    int[] entiers = new int[]{0, 10, 20, 30};

ou plus simplement, dans une déclaration :

    int[] valeurs = {0, 10, 20, 30, 40};

Les tableaux ont un champ [length] (sans parenthèses) qui est leur nombre d'éléments. Un tableau ne peut pas changer de taille après sa création : pour une collection qui grandit, on utilise une liste (chapitre 5). On rencontre aussi la notation [int entiers[]], héritée du langage C : elle est acceptée mais déconseillée.

Java n'a pas de véritable tableau à deux dimensions : un tableau à deux indices est un tableau de tableaux. La déclaration

Type[][] tableau = new Type[n][m];

crée un tableau de [n] éléments, chacun étant une référence sur un tableau de [m] éléments : [n] lignes et [m] colonnes. La syntaxe [tableau[i][j]] désigne l'élément j de la ligne i. [tableau.length] est le nombre de lignes, [tableau[i].length] le nombre d'éléments de la ligne i. Le tableau peut lui aussi être initialisé en même temps qu'il est déclaré :

    double[][] reels = {{0.5, 1.7}, {8.4, -6}};

Puisque chaque ligne est un tableau indépendant, les lignes peuvent avoir des longueurs différentes (on parle de tableau en escalier). La déclaration

Type[][] tableau = new Type[n][];

crée un tableau de n éléments dont chacun est une référence de tableau à une dimension. Ces références ne sont pas initialisées par la déclaration ci-dessus : elles ont la valeur [null].

Java n'offre pas d'indice compté depuis la fin du tableau ni de notation pour extraire une plage d'éléments. Le dernier élément s'écrit [tableau[tableau.length - 1]], et la méthode [Arrays.copyOfRange(tableau, debut, fin)] rend un nouveau tableau formé des éléments d'indices [debut] (inclus) à [fin] (exclu). La classe [java.util.Arrays] offre de nombreuses autres méthodes utilitaires : [toString], [deepToString], [sort], [fill], [equals], [copyOf]... Les listes, plus souples, ont depuis Java 21 des méthodes [getFirst()] et [getLast()].

Le programme [Bases08.java] regroupe ces différents tableaux :

// les tableaux
void main() {
    // un tableau à 1 dimension initialisé
    int[] entiers = new int[]{0, 10, 20, 30};
    for (int i = 0; i < entiers.length; i++) {
        IO.println("entiers[" + i + "]=" + entiers[i]);
    }

    // un tableau à 2 dimensions : un tableau de tableaux de même longueur
    double[][] reels = {{0.5, 1.7}, {8.4, -6}};
    for (int i = 0; i < reels.length; i++) {
        for (int j = 0; j < reels[i].length; j++) {
            IO.println("reels[" + i + "][" + j + "]=" + reels[i][j]);
        }
    }
    IO.println("reels=" + Arrays.deepToString(reels));

    // un tableau de tableaux de longueurs différentes (tableau en escalier)
    String[][] noms = new String[3][];
    for (int i = 0; i < noms.length; i++) {
        noms[i] = new String[i + 1];
    }
    // initialisation
    for (int i = 0; i < noms.length; i++) {
        for (int j = 0; j < noms[i].length; j++) {
            noms[i][j] = "nom" + i + j;
        }
    }
    // affichage
    for (int i = 0; i < noms.length; i++) {
        for (int j = 0; j < noms[i].length; j++) {
            IO.println("noms[" + i + "][" + j + "]=" + noms[i][j]);
        }
    }

    // un tableau créé sans valeurs initiales : éléments à la valeur par défaut du type
    boolean[] drapeaux = new boolean[3];
    String[] chaines = new String[2];
    IO.println("drapeaux[0]=" + drapeaux[0] + ", chaines[0] est null : " + (chaines[0] == null));

    // la forme abrégée de l'initialisation
    int[] valeurs = {0, 10, 20, 30, 40};
    // Arrays.toString construit une chaîne avec les éléments du tableau
    IO.println("valeurs=" + Arrays.toString(valeurs));
    // le dernier et l'avant-dernier élément
    IO.println("dernier=" + valeurs[valeurs.length - 1] + ", avant-dernier=" + valeurs[valeurs.length - 2]);
    // une partie du tableau : les éléments d'indices 1 et 2 (la borne de fin est exclue)
    int[] extrait = Arrays.copyOfRange(valeurs, 1, 3);
    IO.println("copyOfRange(valeurs, 1, 3)=" + Arrays.toString(extrait));
    int[] fin = Arrays.copyOfRange(valeurs, 2, valeurs.length);
    IO.println("copyOfRange(valeurs, 2, length)=" + Arrays.toString(fin));
    // l'extrait est une copie : le tableau d'origine n'est pas modifié
    extrait[0] = 100;
    IO.println("extrait[0]=" + extrait[0] + ", valeurs[1]=" + valeurs[1]);
    // une liste (chapitre 5) offre getFirst et getLast (Java 21)
    List<String> amis = List.of("paul", "hélène", "jacques");
    IO.println("premier ami=" + amis.getFirst() + ", dernier ami=" + amis.getLast());
}

Résultats :

> java Bases08.java
entiers[0]=0
entiers[1]=10
entiers[2]=20
entiers[3]=30
reels[0][0]=0.5
reels[0][1]=1.7
reels[1][0]=8.4
reels[1][1]=-6.0
reels=[[0.5, 1.7], [8.4, -6.0]]
noms[0][0]=nom00
noms[1][0]=nom10
noms[1][1]=nom11
noms[2][0]=nom20
noms[2][1]=nom21
noms[2][2]=nom22
drapeaux[0]=false, chaines[0] est null : true
valeurs=[0, 10, 20, 30, 40]
dernier=40, avant-dernier=30
copyOfRange(valeurs, 1, 3)=[10, 20]
copyOfRange(valeurs, 2, length)=[20, 30, 40]
extrait[0]=100, valeurs[1]=10
premier ami=paul, dernier ami=jacques

Commentaires :

  • ligne 4 : un tableau à une dimension initialisé ;
  • ligne 5 : une boucle [for] parcourt les indices de 0 à [entiers.length - 1]. La boucle [for] est présentée plus loin ;
  • ligne 10 : un tableau à deux dimensions (2 lignes, 2 colonnes). L'entier -6 est converti en réel -6.0 ;
  • lignes 11-12 : [reels.length] est le nombre de lignes, [reels[i].length] le nombre de colonnes de la ligne i ;
  • ligne 16 : [Arrays.deepToString] affiche un tableau de tableaux ;
  • ligne 19 : un tableau [noms] de 3 éléments. Chaque élément est une référence sur un tableau de type [String[]], pour l'instant [null] ;
  • ligne 21 : les 3 éléments du tableau [noms] sont initialisés. [noms[i]] référence désormais un tableau de i+1 chaînes ;
  • ligne 26 : [noms[i][j]] est l'élément j du tableau référencé par [noms[i]]. L'expression "nom" + i + j est évaluée de gauche à droite : "nom" + i donne une chaîne, à laquelle on concatène j ;
  • lignes 37-38 : des tableaux créés sans valeurs initiales : leurs éléments ont la valeur par défaut de leur type ;
  • ligne 42 : la forme abrégée de l'initialisation ;
  • ligne 44 : la méthode [Arrays.toString] construit une chaîne avec les éléments du tableau. Afficher directement le tableau ([IO.println(valeurs)]) donnerait un texte comme [I@6328d34a (le type du tableau et un code numérique), et non son contenu ;
  • ligne 46 : le dernier et l'avant-dernier élément ;
  • ligne 48 : les éléments d'indices 1 et 2 ;
  • ligne 50 : les éléments de l'indice 2 jusqu'à la fin ;
  • lignes 53-54 : [copyOfRange] crée une copie : modifier [extrait] ne modifie pas [valeurs] ;
  • lignes 56-57 : une liste non modifiable créée par [List.of], et ses méthodes [getFirst()] et [getLast()] (Java 21).

3.4. Les instructions élémentaires de Java

On distingue :

  • les instructions élémentaires exécutées par l'ordinateur ;
  • les instructions de contrôle du déroulement du programme.

Les instructions élémentaires apparaissent clairement lorsqu'on considère la structure d'un micro-ordinateur et de ses périphériques :

Image

  • 1 : lecture d'informations provenant du clavier ;
  • 2 : traitement d'informations ;
  • 3 : écriture d'informations à l'écran.

3.4.1. Écriture sur écran

Il existe différentes instructions d'écriture à l'écran :

1
2
3
System.out.println(expression);
System.err.println(expression);
IO.println(expression);

où [expression] est toute donnée qui puisse être convertie en chaîne de caractères pour être affichée à l'écran. Tous les objets Java ont une méthode [toString()] qui est utilisée pour faire cette conversion ; les valeurs des types primitifs sont converties par [String.valueOf]. La méthode [print] fait la même chose que [println] sans passer à la ligne, et la méthode [printf(format, valeur1, ...)] écrit le résultat d'un format.

La classe [System] donne accès à deux flux d'écriture, ses champs statiques [out] et [err], de type [PrintStream] :

  • [System.out.println()] écrit sur le flux [out], la sortie standard, associée habituellement à l'écran ;
  • [System.err.println()] écrit sur le flux [err], la sortie d'erreur, habituellement associée elle aussi à l'écran.

La classe [IO] (Java 25) offre les méthodes [println] et [print], qui écrivent sur [System.out]. Les flux [out] et [err] peuvent être redirigés vers des fichiers au moment de l'exécution du programme, comme nous le verrons prochainement.

3.4.2. Lecture de données tapées au clavier

Le flux de données provenant du clavier est désigné par le champ [System.in], de type [InputStream]. C'est un flux d'octets, peu commode à utiliser directement. Pendant longtemps, pour lire une ligne de texte, il a fallu l'envelopper dans d'autres objets :

BufferedReader clavier = new BufferedReader(new InputStreamReader(System.in));
String ligne = clavier.readLine();

ou utiliser la classe [Scanner] (Java 5). Depuis Java 25, la classe [IO] offre une méthode [readln] beaucoup plus simple, sous deux formes :

String ligne = IO.readln();
String ligne = IO.readln("invite : ");

La ligne tapée au clavier, sans la marque de fin de ligne, est rangée dans la variable [ligne] et peut ensuite être exploitée par le programme. La seconde forme affiche d'abord une invite, sans passer à la ligne. Comme nous l'avons vu, [readln] rend [null] lorsque le flux d'entrée est épuisé. Le flux [System.in] peut être redirigé vers un fichier, comme les flux [out] et [err]. Signalons enfin la classe [Console], obtenue par [System.console()], dont la méthode [readPassword] lit un mot de passe sans l'afficher.

3.4.3. Exemple d'entrées-sorties

Le programme [Bases09.java] illustre les opérations d'entrées-sorties clavier / écran :

// entrées-sorties clavier / écran
void main() {
    // écriture sur le flux System.out
    Object obj = new Object();
    System.out.println(obj);

    // écriture sur le flux System.err
    int i = 10;
    System.err.println("i=" + i);
    System.err.printf("i=%d%n", i);

    // lecture d'une ligne saisie au clavier, après affichage d'une invite
    String ligne = IO.readln("Tapez une ligne : ");
    IO.println("ligne=" + ligne);
}

Résultats :

1
2
3
4
5
6
> java Bases09.java
java.lang.Object@32464a14
i=10
i=10
Tapez une ligne : je suis là
ligne=je suis là

Commentaires :

  • ligne 4 : [obj] est une référence d'objet ;
  • ligne 5 : [obj] est écrit à l'écran. C'est la méthode [obj.toString()] qui est appelée pour obtenir le texte à afficher ;
  • ligne 9 : on écrit sur le flux [err] ;
  • ligne 10 : la même chose avec un format. Le premier paramètre "i=%d%n" est le format d'affichage, les autres paramètres sont les valeurs à afficher : %d est remplacé par la valeur de [i], %n par la marque de fin de ligne du système (\r\n sous Windows, \n sous Linux et macOS) ;
  • ligne 13 : on affiche une invite sans passer à la ligne, puis on lit la ligne tapée au clavier ;
  • ligne 14 : si [ligne] valait [null], la concaténation produirait la chaîne "null".

Dans les résultats :

  • ligne 2 : l'affichage produit par la ligne 5 du code. La méthode [toString()] de la classe [Object] rend le nom complet de la classe de l'objet, suivi du caractère @ et d'un code numérique en hexadécimal (son « code de hachage »), qui peut changer d'une exécution ou d'une machine à l'autre. Les classes redéfinissent en général cette méthode pour afficher quelque chose de plus utile, comme nous le ferons au chapitre 4 ;
  • lignes 3-4 : les deux écritures sur le flux [err] apparaissent à l'écran comme celles du flux [out].

3.4.4. Redirection des entrées-sorties

Windows, Linux et macOS connaissent trois périphériques standard :

  • le périphérique d'entrée standard, qui désigne par défaut le clavier et porte le n° 0 ;
  • le périphérique de sortie standard, qui désigne par défaut l'écran et porte le n° 1 ;
  • le périphérique d'erreur standard, qui désigne par défaut l'écran et porte le n° 2.

En Java, le flux d'écriture [System.out] écrit sur le périphérique 1, le flux d'écriture [System.err] écrit sur le périphérique 2 et le flux de lecture [System.in] lit les données provenant du périphérique 0.

Lorsqu'on lance un programme dans une fenêtre de commandes, on peut fixer quels seront les périphériques 0, 1 et 2 pour le programme exécuté. Derrière les arguments du programme, on peut rediriger les périphériques d'entrées-sorties standard vers des fichiers :

Redirection
Signification
0<in.txt ou <in.txt
le flux d'entrée standard n° 0 est redirigé vers le fichier in.txt. Dans le programme, le flux [System.in] prendra donc ses données dans le fichier in.txt
1>out.txt ou >out.txt
redirige la sortie n° 1 vers le fichier out.txt. Dans le programme, le flux [System.out] écrira donc ses données dans le fichier out.txt
1>>out.txt ou >>out.txt
idem, mais les données écrites sont ajoutées au contenu actuel du fichier out.txt
2>err.txt
redirige la sortie n° 2 vers le fichier err.txt. Dans le programme, le flux [System.err] écrira donc ses données dans le fichier err.txt
2>>err.txt
idem, mais les données écrites sont ajoutées au contenu actuel du fichier err.txt
1>out.txt 2>err.txt
les périphériques 1 et 2 sont tous les deux redirigés vers des fichiers

On notera que pour rediriger les flux d'entrées-sorties d'un programme vers des fichiers, le programme n'a pas besoin d'être modifié : c'est le système d'exploitation qui fixe la nature des périphériques 0, 1 et 2. Considérons le programme [Bases10.java] suivant :

1
2
3
4
5
6
7
8
9
// redirections des flux d'entrée-sortie
void main() {
    // lecture flux System.in
    String data = IO.readln();
    // écriture flux System.out
    System.out.println("écriture dans flux out : " + data);
    // écriture flux System.err
    System.err.println("écriture dans flux err : " + data);
}

Les redirections s'appliquent au programme lancé par la commande [java]. Voici une session dans un terminal Linux (bash) :

> echo test > in.txt
> cat in.txt
test
> java Bases10.java < in.txt > out.txt 2> err.txt
> cat out.txt
écriture dans flux out : test
> cat err.txt
écriture dans flux err : test
> java Bases10.java < in.txt 2> err.txt
écriture dans flux out : test
> java Bases10.java < in.txt > out.txt
écriture dans flux err : test

Commentaires :

  • ligne 1 : on met la chaîne test dans le fichier in.txt ;
  • ligne 2 : on affiche le contenu du fichier in.txt pour vérification ;
  • ligne 4 : exécution du programme. Le flux [System.in] est redirigé vers le fichier in.txt, le flux [System.out] vers le fichier out.txt, le flux [System.err] vers le fichier err.txt. L'exécution ne provoque aucun affichage ;
  • lignes 5-6 : le contenu du fichier out.txt montre que le fichier in.txt a été lu et que l'écriture sur le flux [out] a été redirigée vers out.txt ;
  • lignes 7-8 : vérification analogue pour le fichier err.txt ;
  • lignes 9-10 : seul le flux [err] est redirigé : l'écran n'affiche que l'écriture sur le flux [out] ;
  • lignes 11-12 : seul le flux [out] est redirigé : l'écran n'affiche que l'écriture sur le flux [err].

On voit clairement que les flux [out] et [err] n'écrivent pas sur les mêmes périphériques puisqu'on a pu les rediriger séparément. Le flux [err] est fait pour les messages d'erreur : on peut ainsi rediriger les résultats d'un programme vers un fichier tout en continuant à voir ses messages d'erreur à l'écran.

Les opérateurs >, >> et 2> s'écrivent de la même façon dans l'invite de commandes Windows ([cmd]), dans PowerShell et dans bash. L'opérateur < est reconnu par [cmd] et bash, mais pas par PowerShell, qui le réserve pour un usage futur : on y envoie un fichier sur l'entrée standard d'un programme avec un tube (|). Les commandes suivantes, données à titre d'illustration (elles n'ont pas été exécutées pour ce cours), font sous Windows ce que fait la session bash ci-dessus :

rem invite de commandes Windows (cmd)
echo test>in.txt
java Bases10.java <in.txt >out.txt 2>err.txt
type out.txt
type err.txt

# PowerShell
"test" > in.txt
Get-Content in.txt | java Bases10.java > out.txt 2> err.txt
Get-Content out.txt
Get-Content err.txt

3.4.5. Affectation de la valeur d'une expression à une variable

On s'intéresse ici à l'opération [variable = expression;]. L'expression peut être de type arithmétique, relationnelle, booléenne, caractères, etc.

3.4.5.1. Interprétation de l'opération d'affectation

L'opération [variable = expression;] est elle-même une expression dont l'évaluation se déroule de la façon suivante :

  • la partie droite de l'affectation est évaluée : le résultat est une valeur V ;
  • la valeur V est affectée à la variable ;
  • la valeur V est aussi la valeur de l'affectation, vue cette fois en tant qu'expression.

C'est ainsi que l'opération [V1 = V2 = expression] est légale. L'opérateur = étant évalué de droite à gauche, on a [V1 = (V2 = expression)]. L'expression [V2 = expression] est évaluée et a pour valeur V. Son évaluation a provoqué l'affectation de V à V2. L'opérateur = suivant est alors évalué sous la forme [V1 = V]. La valeur de cette expression est encore V ; son évaluation provoque l'affectation de V à V1. Ainsi donc, l'opération [V1 = V2 = expression] est une expression dont l'évaluation :

  • provoque l'affectation de la valeur de [expression] aux variables V1 et V2 ;
  • rend comme résultat la valeur de [expression].

On peut généraliser à une expression du type [V1 = V2 = ... = Vn = expression].

3.4.5.2. Expressions arithmétiques

Les opérateurs des expressions arithmétiques sont les suivants :

Opérateur
Rôle
+
addition
-
soustraction
*
multiplication
/
division : le résultat est le quotient exact si l'un au moins des opérandes est réel. Si les deux opérandes sont entiers, le résultat est le quotient entier. Ainsi 5/2 vaut 2 et 5.0/2 vaut 2.5
%
reste de la division (modulo), quelle que soit la nature des opérandes. Le reste a le signe du dividende : -5 % 2 vaut -1. La méthode [Math.floorMod(-5, 2)] donne le reste au sens mathématique, du signe du diviseur : 1

Il existe diverses fonctions mathématiques. En voici quelques-unes :

Fonction
Rôle
[double sqrt(double x)]
racine carrée
[double cos(double x)], [sin], [tan]
cosinus, sinus, tangente (angle en radians)
[double pow(double x, double y)]
x à la puissance y
[double exp(double x)]
exponentielle
[double log(double x)], [log10]
logarithme népérien, décimal
[abs(x)], [max(x, y)], [min(x, y)]
valeur absolue, maximum, minimum (pour les types int, long, float, double)
[double floor(double x)], [ceil]
partie entière inférieure, supérieure
[long round(double x)], [double rint(double x)]
arrondi à l'entier le plus proche
[clamp(valeur, min, max)]
valeur ramenée dans l'intervalle [min, max] (Java 21)
[Math.PI], [Math.E]
les constantes π et e

Toutes ces fonctions sont des méthodes statiques d'une classe Java appelée [Math] (paquetage [java.lang]). Lorsqu'on les utilise, il faut les préfixer avec le nom de la classe où elles sont définies. Ainsi on écrira :

    double x, y = 4;
    x = Math.sqrt(y);

La description complète de la classe [Math] se trouve dans la documentation en ligne de Java, docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/Math.html.

Les réels [double] sont codés en binaire : la plupart des nombres décimaux, comme 0,1, n'y ont qu'une valeur approchée. C'est sans importance pour un calcul scientifique, mais inacceptable pour des montants. La classe [BigDecimal] (paquetage [java.math]) représente au contraire un nombre décimal de façon exacte, avec autant de chiffres que nécessaire. Java ne permettant pas de redéfinir les opérateurs, les calculs se font avec des méthodes : [add], [subtract], [multiply], [divide], [compareTo]... Un [BigDecimal] est immuable : une opération ne modifie pas l'objet sur lequel elle s'applique, elle rend un nouvel objet.

Le programme [Bases11.java] est le suivant :

// expressions arithmétiques
void main() {
    // division entière et division réelle
    IO.println("5 / 2 = " + 5 / 2 + ", 5.0 / 2 = " + 5.0 / 2 + ", 5 / 2.0 = " + 5 / 2.0);
    // reste de la division (modulo)
    IO.println("5 % 2 = " + 5 % 2 + ", -5 % 2 = " + -5 % 2 + ", 5.5 % 2 = " + 5.5 % 2);
    IO.println("Math.floorMod(-5, 2) = " + Math.floorMod(-5, 2));
    // division réelle par zéro : pas d'erreur, l'infini ou NaN (Not a Number)
    double zero = 0;
    IO.println("1 / 0.0 = " + 1 / zero + ", 0 / 0.0 = " + 0 / zero);
    // division entière par zéro : une exception
    int zeroEntier = 0;
    try {
        IO.println(1 / zeroEntier);
    } catch (ArithmeticException e) {
        IO.println("1 / 0 : " + e.getClass().getSimpleName() + " - " + e.getMessage());
    }

    // quelques méthodes de la classe Math
    double x, y = 4;
    x = Math.sqrt(y);
    IO.println("Math.sqrt(4) = " + x);
    IO.println("Math.pow(2, 10) = " + Math.pow(2, 10));
    IO.println("Math.abs(-3.5) = " + Math.abs(-3.5) + ", Math.max(3, 7) = " + Math.max(3, 7));
    IO.println("Math.PI = " + Math.PI + ", Math.cos(Math.PI) = " + Math.cos(Math.PI));
    IO.println("Math.exp(1) = " + Math.exp(1) + ", Math.log(Math.E) = " + Math.log(Math.E));
    IO.println("Math.floor(-2.5) = " + Math.floor(-2.5) + ", Math.ceil(-2.5) = " + Math.ceil(-2.5));
    // Math.round arrondit à l'entier le plus proche, les demis vers le haut ; il rend un long
    IO.println("Math.round(2.5) = " + Math.round(2.5) + ", Math.round(-2.5) = " + Math.round(-2.5));
    // Math.rint arrondit les demis au nombre pair le plus proche
    IO.println("Math.rint(2.5) = " + Math.rint(2.5) + ", Math.rint(3.5) = " + Math.rint(3.5));
    // Math.clamp (Java 21) ramène une valeur dans un intervalle
    IO.println("Math.clamp(150, 0, 100) = " + Math.clamp(150, 0, 100));

    // priorités : * / % avant + -
    IO.println("2 + 3 * 4 = " + (2 + 3 * 4) + ", (2 + 3) * 4 = " + (2 + 3) * 4);
    IO.println("10 - 4 - 3 = " + (10 - 4 - 3) + ", \"x\" + 1 + 2 = " + "x" + 1 + 2);

    // l'affectation est une expression : affectation multiple
    int v1, v2;
    v1 = v2 = 7 * 6;
    IO.println("v1=" + v1 + ", v2=" + v2);

    // les réels double sont des approximations binaires
    IO.println("0.1 + 0.2 = " + (0.1 + 0.2));
    // BigDecimal calcule de façon exacte en base 10
    BigDecimal a = new BigDecimal("0.1"), b = new BigDecimal("0.2");
    IO.println("BigDecimal : 0.1 + 0.2 = " + a.add(b));
    BigDecimal prix = new BigDecimal("19.99");
    BigDecimal total = prix.multiply(BigDecimal.valueOf(3));
    IO.println("3 x 19.99 = " + total + ", avec la TVA = " + total.multiply(new BigDecimal("1.2")));
    // arrondi à 2 décimales, les demis vers le haut (arrondi commercial)
    IO.println("2.565 arrondi = " + new BigDecimal("2.565").setScale(2, RoundingMode.HALF_UP));
    // une division qui ne tombe pas juste exige une précision
    IO.println("10 / 3 = " + BigDecimal.TEN.divide(BigDecimal.valueOf(3), MathContext.DECIMAL64));
}

Résultats :

> java Bases11.java
5 / 2 = 2, 5.0 / 2 = 2.5, 5 / 2.0 = 2.5
5 % 2 = 1, -5 % 2 = -1, 5.5 % 2 = 1.5
Math.floorMod(-5, 2) = 1
1 / 0.0 = Infinity, 0 / 0.0 = NaN
1 / 0 : ArithmeticException - / by zero
Math.sqrt(4) = 2.0
Math.pow(2, 10) = 1024.0
Math.abs(-3.5) = 3.5, Math.max(3, 7) = 7
Math.PI = 3.141592653589793, Math.cos(Math.PI) = -1.0
Math.exp(1) = 2.718281828459045, Math.log(Math.E) = 1.0
Math.floor(-2.5) = -3.0, Math.ceil(-2.5) = -2.0
Math.round(2.5) = 3, Math.round(-2.5) = -2
Math.rint(2.5) = 2.0, Math.rint(3.5) = 4.0
Math.clamp(150, 0, 100) = 100
2 + 3 * 4 = 14, (2 + 3) * 4 = 20
10 - 4 - 3 = 3, "x" + 1 + 2 = x12
v1=42, v2=42
0.1 + 0.2 = 0.30000000000000004
BigDecimal : 0.1 + 0.2 = 0.3
3 x 19.99 = 59.97, avec la TVA = 71.964
2.565 arrondi = 2.57
10 / 3 = 3.333333333333333

Commentaires :

  • ligne 4 : la division de deux entiers est une division entière. Dans cette concaténation, les divisions sont faites avant les + car l'opérateur / est prioritaire (voir plus loin) ;
  • ligne 6 : le reste a le signe du dividende ; l'opérateur % s'applique aussi aux réels ;
  • ligne 7 : [Math.floorMod] rend un reste du signe du diviseur ;
  • ligne 10 : la division d'un réel par zéro ne provoque pas d'erreur : elle donne l'infini ([Infinity]) ou la valeur spéciale [NaN] (« Not a Number ») pour 0/0 ;
  • lignes 12-17 : en revanche, la division d'un entier par zéro lance une exception [ArithmeticException] ;
  • lignes 20-21 : l'exemple ci-dessus. [Math.sqrt] rend un [double] : 2.0 ;
  • ligne 27 : [floor] arrondit vers le bas, [ceil] vers le haut. Pour tronquer un réel vers zéro, on le convertit en entier, comme nous le verrons plus loin ;
  • ligne 29 : [Math.round] arrondit à l'entier le plus proche ; une valeur située exactement à mi-chemin est arrondie vers le haut : 2,5 donne 3 et -2,5 donne -2. Le résultat est un [long] ;
  • ligne 31 : [Math.rint] arrondit une valeur située à mi-chemin vers l'entier pair le plus proche (arrondi « bancaire ») : 2,5 donne 2 et 3,5 donne 4. Le résultat est un [double] ;
  • ligne 33 : [Math.clamp] (Java 21) ramène 150 dans l'intervalle [0, 100] ;
  • ligne 36 : la priorité des opérateurs, présentée ci-dessous ;
  • ligne 37 : "x" + 1 + 2 est évalué de gauche à droite : "x" + 1 donne la chaîne "x1", à laquelle on concatène 2. Pour additionner d'abord, il faut écrire "x" + (1 + 2) ;
  • ligne 41 : une affectation multiple ;
  • ligne 45 : 0,1 et 0,2 n'ont pas de représentation binaire exacte : leur somme n'est pas exactement 0,3 ;
  • ligne 47 : deux [BigDecimal] créés à partir de chaînes. Il ne faut pas écrire [new BigDecimal(0.1)] : le paramètre serait le [double] 0.1, dont la valeur exacte est 0.1000000000000000055511151231257827021181583404541015625. [BigDecimal.valueOf(0.1)], qui passe par la chaîne "0.1", convient en revanche ;
  • ligne 48 : l'addition exacte ;
  • lignes 49-51 : [BigDecimal.valueOf(3)] convertit un entier. Un produit a autant de décimales que ses deux facteurs réunis : 59,97 × 1,2 = 71,964 ;
  • ligne 53 : [setScale(2, RoundingMode.HALF_UP)] arrondit à deux décimales, les valeurs situées à mi-chemin étant arrondies en s'éloignant de zéro : c'est l'arrondi commercial ;
  • ligne 55 : 10/3 n'a pas d'écriture décimale finie. La méthode [divide] exige alors une précision, donnée ici par [MathContext.DECIMAL64] (16 chiffres significatifs). Sans elle, la division lancerait une [ArithmeticException] : [Non-terminating decimal expansion; no exact representable decimal result.]

3.4.5.3. Priorités dans l'évaluation des expressions arithmétiques

La priorité des opérateurs lors de l'évaluation d'une expression arithmétique est la suivante (du plus prioritaire au moins prioritaire) :

[fonctions], [( )], [*, /, %], [+, -]

Les opérateurs d'un même bloc [ ] ont même priorité. À priorité égale, l'évaluation se fait de gauche à droite : 10 - 4 - 3 vaut (10 - 4) - 3 = 3.

3.4.5.4. Expressions relationnelles

Les opérateurs sont les suivants : <, <=, ==, !=, >, >=. Leurs priorités :

  • >, >=, <, <= ;
  • ==, !=.

Le résultat d'une expression relationnelle est le booléen [false] si l'expression est fausse, [true] sinon.

    int x = 5;
    boolean fin = x > 4;

Comparaison de deux caractères

Soient deux caractères C1 et C2. Il est possible de les comparer avec les opérateurs <, <=, ==, !=, >, >=. On compare alors leurs codes Unicode, qui sont des nombres. Selon l'ordre Unicode, on a les relations suivantes :

espace < .. < '0' < '1' < .. < '9' < .. < 'A' < 'B' < .. < 'Z' < .. < 'a' < 'b' < .. < 'z' < .. < 'é'

Comparaison de deux chaînes de caractères

Elles sont comparées caractère par caractère. La première inégalité rencontrée entre deux caractères induit une inégalité de même sens sur les chaînes. Soit à comparer les chaînes "Chat" et "Chien" :

1
2
3
4
5
"Chat"    "Chien"
---------------------
'C'   =   'C'
'h'   =   'h'
'a'   <   'i'

Cette dernière inégalité permet de dire que "Chat" < "Chien". Soit à comparer les chaînes "Chat" et "Chaton". Il y a égalité jusqu'à épuisement de la chaîne "Chat". Dans ce cas, la chaîne épuisée est déclarée la plus « petite ». On a donc la relation "Chat" < "Chaton".

Méthodes de comparaison de deux chaînes

Attention, c'est un piège classique en Java : appliqués à deux chaînes, comme à deux objets quelconques, les opérateurs == et != comparent les références et non les contenus. [s1 == s2] est vrai si les deux variables désignent le même objet en mémoire, pas si les deux chaînes contiennent les mêmes caractères. Pour tester l'égalité de deux chaînes, on utilise la méthode [equals] de la classe [String], ou [equalsIgnoreCase] pour ne pas tenir compte de la différence entre majuscules et minuscules. Les opérateurs < <= > >= ne s'appliquent pas aux chaînes : il faut utiliser la méthode [compareTo] (ou [compareToIgnoreCase]). Elle rend un entier :

  • nul si les deux chaînes sont égales ;
  • positif si la chaîne n° 1 est supérieure à la chaîne n° 2 ;
  • négatif si la chaîne n° 1 est inférieure à la chaîne n° 2.

[compareTo] compare les codes Unicode, caractère par caractère : son résultat est la différence des codes des deux premiers caractères différents, ou la différence des longueurs si l'une des chaînes est le début de l'autre. Ce n'est pas l'ordre d'un dictionnaire : comme le code de 'é' (233) est supérieur à celui de 'f' (102), "élan" se range après "face". Pour trier des textes destinés à l'utilisateur, on utilise un objet [Collator] (paquetage [java.text]), qui applique les règles de tri d'une langue. [compareTo] reste la bonne méthode pour les identifiants, les mots clés, les noms de fichiers.

Le programme [Bases12.java] est le suivant :

// expressions relationnelles et comparaison de chaînes
void main() {
    int x = 5;
    boolean fin = x > 4;
    IO.println("x > 4 : " + fin + ", x == 5 : " + (x == 5) + ", x != 5 : " + (x != 5));

    // comparaison de caractères : on compare leurs codes Unicode
    IO.println("'a' < 'b' : " + ('a' < 'b') + ", 'Z' < 'a' : " + ('Z' < 'a')
            + ", '9' < 'A' : " + ('9' < 'A'));
    IO.println("codes : 'A'=" + (int) 'A' + ", 'a'=" + (int) 'a' + ", 'é'=" + (int) 'é');

    // comparaison de chaînes avec equals et compareTo
    String chaine1 = "chat", chaine2 = "chien";
    int n = chaine1.compareTo(chaine2);
    boolean egal = chaine1.equals(chaine2);
    IO.println("n=" + n + ", egal=" + egal);
    IO.println("\"Chat\".compareTo(\"Chaton\") : " + "Chat".compareTo("Chaton"));

    // == compare les références, pas le contenu des chaînes
    String chaine3 = "cha".concat("t");
    IO.println("chaine3=" + chaine3 + ", chaine1 == chaine3 : " + (chaine1 == chaine3)
            + ", chaine1.equals(chaine3) : " + chaine1.equals(chaine3));

    // compareTo compare les codes Unicode, caractère par caractère
    IO.println("\"élan\".compareTo(\"face\") : " + "élan".compareTo("face"));
    IO.println("\"Chat\".compareTo(\"chat\") : " + "Chat".compareTo("chat"));
    // sans tenir compte de la casse (majuscules / minuscules)
    IO.println("\"Chat\".compareToIgnoreCase(\"chat\") : " + "Chat".compareToIgnoreCase("chat"));
    IO.println("\"Chat\".equals(\"chat\") : " + "Chat".equals("chat"));
    IO.println("\"Chat\".equalsIgnoreCase(\"chat\") : " + "Chat".equalsIgnoreCase("chat"));
    // un Collator compare selon les règles de tri d'une langue
    Collator collator = Collator.getInstance(Locale.FRENCH);
    IO.println("collator.compare(\"élan\", \"face\") : " + collator.compare("élan", "face"));
    IO.println("collator.compare(\"Chat\", \"chat\") : " + collator.compare("Chat", "chat"));
}

Résultats :

> java Bases12.java
x > 4 : true, x == 5 : true, x != 5 : false
'a' < 'b' : true, 'Z' < 'a' : true, '9' < 'A' : true
codes : 'A'=65, 'a'=97, 'é'=233
n=-8, egal=false
"Chat".compareTo("Chaton") : -2
chaine3=chat, chaine1 == chaine3 : false, chaine1.equals(chaine3) : true
"élan".compareTo("face") : 131
"Chat".compareTo("chat") : -32
"Chat".compareToIgnoreCase("chat") : 0
"Chat".equals("chat") : false
"Chat".equalsIgnoreCase("chat") : true
collator.compare("élan", "face") : -1
collator.compare("Chat", "chat") : 1

Commentaires :

  • ligne 4 : une expression relationnelle ;
  • lignes 8-9 : la comparaison de caractères ;
  • ligne 10 : la conversion [(int)] d'un caractère donne son code Unicode ;
  • ligne 14 : la variable [n] reçoit un entier négatif puisque "chat" < "chien" : la différence des codes de 'a' (97) et de 'i' (105) ;
  • ligne 15 : la variable [egal] a la valeur [true] si les deux chaînes sont égales, [false] sinon ;
  • ligne 17 : la chaîne épuisée la première est la plus petite ; le résultat est la différence des longueurs : 4 - 6. Dans une chaîne littérale, \" désigne un guillemet ;
  • ligne 20 : la méthode [concat] construit, à l'exécution, un nouvel objet chaîne qui contient "chat" ;
  • lignes 21-22 : [chaine1] et [chaine3] ont le même contenu mais désignent deux objets différents : == rend [false], [equals] rend [true]. Le piège est d'autant plus sournois que deux chaînes littérales identiques sont rangées une seule fois en mémoire, si bien que ["chat" == "chat"] est vrai : un test avec == peut sembler fonctionner pendant les essais. On utilisera toujours [equals] ;
  • ligne 25 : 233 - 102 = 131 : pour [compareTo], "élan" est après "face" ;
  • ligne 26 : 'C' (67) - 'c' (99) = -32 ;
  • lignes 28-30 : en ignorant la casse, "Chat" et "chat" sont égales ;
  • lignes 32-34 : un [Collator] pour le français : "élan" précède "face", et "Chat" suit "chat" (à lettres égales, la minuscule passe avant la majuscule). La méthode [compare] rend un entier négatif, nul ou positif, comme [compareTo].

3.4.5.5. Expressions booléennes

Les opérateurs utilisables sont ET (&&), OU (||) et NON (!). Le résultat d'une expression booléenne est un booléen. Priorités des opérateurs :

  • ! ;
  • && ;
  • ||.
    double x = 3.5;
    boolean valide = x > 2 && x < 4;

Les opérateurs relationnels ont priorité sur les opérateurs && et ||. Ces deux opérateurs n'évaluent leur opérande droit que si c'est nécessaire : dans [a && b], b n'est pas évalué si a est faux ; dans [a || b], b n'est pas évalué si a est vrai. On dit qu'ils sont « court-circuités ». On peut ainsi écrire sans risque [texte != null && texte.length() > 0]. Les opérateurs & et |, appliqués à deux booléens, font le même calcul mais évaluent toujours leurs deux opérandes.

3.4.5.6. Traitement de bits

Soient [i] et [j] deux entiers :

Opération
Rôle
i << n
décale i de n bits sur la gauche. Les bits entrants sont des zéros
i >> n
décale i de n bits sur la droite. Le bit de signe est préservé
i >>> n
décalage à droite non signé : les bits entrants sont des zéros, même si i est négatif
i & j
fait le ET logique de i et j bit à bit
i|j
fait le OU logique de i et j bit à bit
~i
complémente i à 1
i ^ j
fait le OU EXCLUSIF de i et j bit à bit

Le programme [Bases13.java] est le suivant :

// traitement de bits
void main() {
    short i = 100, j = -13;
    // char est le seul type entier non signé de Java (16 bits)
    char k = 0xF123;
    IO.println(String.format("i=0x%04x, j=0x%04x, k=0x%04x", i, j, (int) k));
    IO.println(String.format("i<<4=0x%04x, i>>4=0x%04x, k>>4=0x%04x", i << 4, i >> 4, k >> 4));
    IO.println(String.format("i&j=0x%04x, i|j=0x%04x, i^j=0x%04x, ~i=0x%04x",
            (short) (i & j), (short) (i | j), (short) (i ^ j), (short) ~i));
    IO.println(String.format("j<<2=0x%04x, j>>2=0x%04x", (short) (j << 2), (short) (j >> 2)));
    // les mêmes valeurs en binaire
    IO.println("i=" + binaire(i, 16) + ", j=" + binaire(j, 16) + ", i&j=" + binaire(i & j, 16));
    // décalage à droite non signé >>> : les bits entrants sont des zéros
    int m = -16;
    IO.println("m=" + binaire(m, 32) + ", m>>2=" + binaire(m >> 2, 32) + ", m>>>2=" + binaire(m >>> 2, 32));
}

// les n bits de poids faible de valeur, sous forme de chaîne de 0 et de 1
String binaire(int valeur, int n) {
    StringBuilder bits = new StringBuilder();
    for (int rang = n - 1; rang >= 0; rang--) {
        bits.append((valeur >>> rang) & 1);
    }
    return bits.toString();
}

Résultats :

1
2
3
4
5
6
7
> java Bases13.java
i=0x0064, j=0xfff3, k=0xf123
i<<4=0x0640, i>>4=0x0006, k>>4=0x0f12
i&j=0x0060, i|j=0xfff7, i^j=0xff97, ~i=0xff9b
j<<2=0xffcc, j>>2=0xfffc
i=0000000001100100, j=1111111111110011, i&j=0000000001100000
m=11111111111111111111111111110000, m>>2=11111111111111111111111111111100, m>>>2=00111111111111111111111111111100

Commentaires :

  • ligne 3 : deux entiers [short] ;
  • ligne 5 : la valeur 0xF123 (61731) ne tient pas dans un [short], dont le maximum est 32767. Le type [char], entier non signé sur 16 bits, peut la contenir ;
  • ligne 6 : le spécificateur %04x affiche un entier en hexadécimal (x) sur 4 chiffres complétés par des zéros (04). Appliqué à un [short], il montre ses 16 bits : -13 donne fff3. Le spécificateur %x n'accepte pas un [char] : on le convertit en [int] ;
  • ligne 7 : les opérations sur des [short] ou des [char] se font en [int] : les opérandes sont d'abord convertis en [int] (on parle de promotion), et le résultat est un [int] ;
  • ligne 9 : on reconvertit donc les résultats en [short] pour afficher 4 chiffres hexadécimaux. Sans conversion, ~i serait affiché sur 8 chiffres (ffffff9b) ;
  • ligne 12 : la méthode [binaire] (lignes 19-25) affiche les n bits de poids faible d'un entier. On vérifie bit à bit le ET logique de i et j ;
  • ligne 15 : le décalage [>>] préserve le bit de signe de m, qui est négatif ; le décalage [>>>] fait entrer des zéros ;
  • ligne 22 : [(valeur >>> rang) & 1] amène le bit n° [rang] en position 0 puis le sépare des autres. La classe [StringBuilder], présentée au chapitre 5, construit une chaîne par ajouts successifs.

3.4.5.7. Combinaison d'opérateurs

[a = a + b] peut s'écrire [a += b] ; [a = a - b] peut s'écrire [a -= b]. Il en est de même avec les opérateurs /, %, *, <<, >>, >>>, &, |, ^. Ainsi [a = a / 2;] peut s'écrire [a /= 2;]. Une petite différence : l'opérateur combiné contient une conversion implicite vers le type de la variable. Si [s] est un [short], [s = s + 1;] est refusé (le résultat de l'addition est un [int]), alors que [s += 1;] est accepté.

3.4.5.8. Opérateurs d'incrémentation et de décrémentation

La notation [variable++] signifie [variable = variable + 1] ou encore [variable += 1]. La notation [variable--] signifie [variable = variable - 1] ou encore [variable -= 1]. Utilisés dans une expression, ces opérateurs ont deux formes :

  • [i++] (postfixée) : la valeur de l'expression est la valeur de i avant l'incrémentation ;
  • [++i] (préfixée) : la valeur de l'expression est la valeur de i après l'incrémentation.

3.4.5.9. L'opérateur ternaire ?

L'expression [expr_cond ? expr1 : expr2] est évaluée de la façon suivante :

  • l'expression [expr_cond] est évaluée. C'est une expression booléenne ;
  • si elle est vraie, la valeur de l'expression est celle de [expr1], et [expr2] n'est pas évaluée ;
  • si elle est fausse, c'est l'inverse qui se produit : la valeur de l'expression est celle de [expr2], et [expr1] n'est pas évaluée.

L'opération [i = (j > 4 ? j + 1 : j - 1);] affectera à la variable i : j+1 si j>4, j-1 sinon. C'est la même chose que d'écrire [if (j > 4) i = j + 1; else i = j - 1;], mais c'est plus concis.

3.4.5.10. Les valeurs null : pas d'opérateurs spécialisés

Contrairement à d'autres langages comme C# ou Kotlin, Java n'a pas d'opérateurs dédiés à la valeur [null] (valeur de remplacement, accès conditionnel à un membre). On utilise à la place :

  • l'opérateur ternaire, [a != null ? a : b], ou la méthode [Objects.requireNonNullElse(a, b)] (Java 9) qui rend a s'il ne vaut pas [null], b sinon. On l'utilise pour donner une valeur de remplacement : [String nom = Objects.requireNonNullElse(IO.readln(), "");] ;
  • un simple test, [if (a == null) a = b;], pour n'affecter une variable que si elle vaut [null] ;
  • la classe [Optional] et sa méthode [map] pour n'accéder à un membre que si l'objet existe : [Optional.ofNullable(texte).map(String::length)] rend un [Optional] vide si [texte] vaut [null], et un [Optional] contenant la longueur du texte sinon.

Le programme [Bases14.java] réunit ces différents opérateurs :

// autres opérateurs
void main() {
    // expressions booléennes
    double x = 3.5;
    boolean valide = x > 2 && x < 4;
    IO.println("valide=" + valide + ", !valide=" + !valide + ", valide || x > 10 : " + (valide || x > 10));

    // opérateurs combinés
    int a = 10;
    a += 5;
    IO.print("a += 5 : " + a);
    a *= 2;
    IO.print(", a *= 2 : " + a);
    a /= 4;
    IO.print(", a /= 4 : " + a);
    a %= 4;
    IO.print(", a %= 4 : " + a);
    a <<= 2;
    IO.println(", a <<= 2 : " + a);

    // incrémentation et décrémentation
    int i = 5;
    int j = i++;    // j reçoit l'ancienne valeur de i, puis i est incrémenté
    int k = ++i;    // i est incrémenté, puis k reçoit la nouvelle valeur de i
    IO.println("i=" + i + ", j=" + j + ", k=" + k);
    i--;
    IO.println("i=" + i);

    // opérateur ternaire
    int n = 5;
    int p = n > 4 ? n + 1 : n - 1;
    String parite = n % 2 == 0 ? "pair" : "impair";
    IO.println("p=" + p + ", " + n + " est " + parite);

    // valeur de remplacement si une référence vaut null
    String saisie = null;
    String nom = Objects.requireNonNullElse(saisie, "inconnu");
    Integer age = null;
    int ageAffiche = age != null ? age : -1;
    IO.println("nom=" + nom + ", ageAffiche=" + ageAffiche);
    // affectation seulement si la variable vaut null
    String titre = null;
    if (titre == null) {
        titre = "Sans titre";
    }
    IO.println("titre=" + titre);
    // accès à un membre seulement si l'objet n'est pas null : Optional et map
    String texte = null;
    Optional<Integer> longueur = Optional.ofNullable(texte).map(String::length);
    IO.println("longueur=" + longueur.map(String::valueOf).orElse("null"));
    texte = "bonjour";
    IO.println("longueur=" + Optional.ofNullable(texte).map(String::length).orElse(-1));
}

Résultats :

> java Bases14.java
valide=true, !valide=false, valide || x > 10 : true
a += 5 : 15, a *= 2 : 30, a /= 4 : 7, a %= 4 : 3, a <<= 2 : 12
i=7, j=5, k=7
i=6
p=6, 5 est impair
nom=inconnu, ageAffiche=-1
titre=Sans titre
longueur=null
longueur=7

Commentaires :

  • ligne 5 : une expression booléenne ;
  • lignes 9-19 : les opérateurs combinés : 10+5=15, 15*2=30, 30/4=7 (division entière), 7%4=3, 3<<2=12 ;
  • ligne 23 : [j] reçoit 5, puis [i] passe à 6 ;
  • ligne 24 : [i] passe à 7, puis [k] reçoit 7 ;
  • lignes 31-32 : deux opérateurs ternaires ;
  • ligne 37 : [saisie] vaut [null] : [nom] reçoit "inconnu" ;
  • ligne 39 : l'opérateur ternaire permet de passer d'un [Integer] à un [int] sans risque d'exception : [age] n'est déballé que s'il ne vaut pas [null] ;
  • lignes 43-45 : l'affectation n'a lieu que si [titre] vaut [null] ;
  • ligne 49 : [texte] vaut [null] : [Optional.ofNullable(texte)] est vide, et [map] rend un [Optional] vide sans appeler [length()]. La notation [String::length] est une référence de méthode : elle désigne la méthode [length] de la classe [String]. Nous l'étudierons au chapitre 7 ;
  • ligne 50 : si [longueur] est vide, on affiche la chaîne "null" ;
  • ligne 52 : [texte] vaut "bonjour" : [map] applique [length()] et l'on obtient 7.

3.4.5.11. Priorité générale des opérateurs

Le tableau suivant donne la priorité des opérateurs, du plus prioritaire au moins prioritaire :

Opérateurs
Associativité
[x.y] [f(x)] [a[i]] [x++] [x--] [new]
gd
[+x] [-x] [!x] [~x] [++x] [--x] [(T)x] (changement de type)
dg
[*] [/] [%]
gd
[+] [-]
gd
[<<] [>>] [>>>]
gd
[<] [<=] [>] [>=] [instanceof]
gd
[==] [!=]
gd
[&]
gd
[^]
gd
[|]
gd
[&&]
gd
[||]
gd
[? :] (ternaire)
dg
[=] [+=] [-=] etc., [->] (lambda)
dg

gd indique qu'à priorité égale, c'est la priorité gauche-droite qui est observée : lorsque dans une expression on a des opérateurs de même priorité, c'est l'opérateur le plus à gauche qui est évalué en premier. dg indique une priorité droite-gauche. En cas de doute, on n'hésitera pas à mettre des parenthèses : elles rendent aussi l'expression plus lisible.

3.4.5.12. Les changements de type

Il est possible, dans une expression, de changer momentanément le codage d'une valeur. On appelle cela changer le type d'une donnée, ou transtyper (en anglais « type casting »). La syntaxe du changement du type d'une valeur dans une expression est la suivante :

(type) valeur

La valeur prend alors le type indiqué. Cela entraîne un changement de codage de la valeur. On distingue :

  • les conversions élargissantes (« widening »), faites automatiquement par le compilateur : [byte] vers [short], [short] vers [int], [char] vers [int], [int] vers [long], [long] vers [float] ou [double], [float] vers [double]... Elles conservent l'ordre de grandeur de la valeur. Celles qui vont d'un [int] vers un [float], ou d'un [long] vers un [float] ou un [double], peuvent cependant perdre en précision : un [float] n'a que 7 chiffres significatifs, un [double] 15 à 16 ;
  • les conversions rétrécissantes (« narrowing »), qui peuvent perdre de l'information et que le programmeur doit demander avec la notation (type) : d'un [double] vers un [int] (la partie décimale est perdue), d'un [long] vers un [int] (les bits de poids fort sont perdus), etc.

Un dépassement de capacité dans un calcul entier ou une conversion entre entiers n'est jamais signalé : le résultat est simplement faux. Java n'a pas de mot clé pour demander une vérification. Lorsque le risque existe, on utilise les méthodes [addExact], [subtractExact], [multiplyExact], [incrementExact], [toIntExact]... de la classe [Math] (Java 8), qui lancent une exception [ArithmeticException] en cas de dépassement.

Le programme [Bases15.java] est le suivant :

// les changements de type
void main() {
    int i = 3, j = 4;
    float f1 = i / j;
    float f2 = (float) i / j;
    IO.println("f1=" + f1 + ", f2=" + f2);

    // conversions implicites (élargissement) : sans perte d'information
    long l = i;
    double d = l;
    IO.println("l=" + l + ", d=" + d);

    // conversions explicites (transtypage) : perte d'information possible
    double reel = 3.99;
    int tronque = (int) reel;
    int arrondi = (int) Math.round(reel);
    IO.println("(int) 3.99=" + tronque + ", (int) Math.round(3.99)=" + arrondi);
    long grand = 3_000_000_000L;
    int deborde = (int) grand;
    IO.println("(int) 3000000000L=" + deborde);
    // un réel trop grand est ramené à la plus grande valeur entière
    IO.println("(int) 1e10=" + (int) 1e10 + ", (int) Double.NaN=" + (int) Double.NaN);

    // caractères et entiers
    char c = (char) 65;
    int code = 'é';
    IO.println("(char) 65=" + c + ", code de 'é'=" + code);

    // dépassement de capacité : silencieux
    int max = Integer.MAX_VALUE;
    int suivant = max + 1;
    IO.println("Integer.MAX_VALUE + 1 = " + suivant);
    // les méthodes ...Exact de Math signalent le dépassement par une exception
    try {
        int verifie = Math.addExact(max, 1);
        IO.println("verifie=" + verifie);
    } catch (ArithmeticException e) {
        IO.println("Math.addExact(max, 1) : " + e.getClass().getSimpleName() + " - " + e.getMessage());
    }
    try {
        int conversion = Math.toIntExact(grand);
        IO.println("conversion=" + conversion);
    } catch (ArithmeticException e) {
        IO.println("Math.toIntExact(grand) : " + e.getClass().getSimpleName() + " - " + e.getMessage());
    }
}

Résultats :

> java Bases15.java
f1=0.0, f2=0.75
l=3, d=3.0
(int) 3.99=3, (int) Math.round(3.99)=4
(int) 3000000000L=-1294967296
(int) 1e10=2147483647, (int) Double.NaN=0
(char) 65=A, code de 'é'=233
Integer.MAX_VALUE + 1 = -2147483648
Math.addExact(max, 1) : ArithmeticException - integer overflow
Math.toIntExact(grand) : ArithmeticException - integer overflow

Commentaires :

  • ligne 4 : [f1] aura la valeur 0.0. La division 3/4 est une division entière puisque les deux opérandes sont de type [int] ; son résultat 0 est ensuite converti en [float] ;
  • ligne 5 : [(float) i] est la valeur de [i] transformée en [float]. On a maintenant une division entre un réel de type [float] et un entier de type [int] : la valeur de [j] est elle aussi transformée en [float], puis la division des deux réels est faite. [f2] a la valeur 0.75. Dans l'opération [(float) i], [i] est une valeur codée de façon exacte sur 4 octets, [(float) i] est la même valeur codée de façon approchée en réel sur 4 octets. Ce transcodage n'a lieu que le temps du calcul : la variable [i] conserve toujours son type [int] ;
  • lignes 9-10 : des conversions élargissantes ;
  • ligne 15 : la conversion d'un réel en entier tronque la partie décimale (vers zéro) ;
  • ligne 16 : pour arrondir, on utilise [Math.round], qui rend un [long], puis on convertit en [int] ;
  • ligne 18 : le suffixe L est indispensable : 3 milliards dépasse le domaine des [int] ;
  • ligne 19 : 3 milliards ne tient pas dans un [int] : seuls les 32 bits de poids faible sont conservés et le résultat n'a plus de sens ;
  • ligne 22 : un réel trop grand pour un [int] est ramené à [Integer.MAX_VALUE] ; la valeur [NaN] devient 0 ;
  • lignes 25-26 : conversions entre caractères et codes Unicode ; la seconde est implicite ;
  • ligne 31 : le dépassement de capacité passe inaperçu : on obtient le plus petit [int] ;
  • ligne 35 : avec [Math.addExact], le dépassement lance une exception [ArithmeticException] ;
  • ligne 41 : [Math.toIntExact] convertit un [long] en [int] et lance elle aussi une exception si la valeur ne tient pas dans un [int].

Même lorsque le dépassement a lieu dans une expression formée uniquement de constantes, comme [Integer.MAX_VALUE + 1], le compilateur l'accepte sans avertissement : il calcule lui-même la valeur, -2147483648.

3.5. Les instructions de contrôle du déroulement du programme

3.5.1. Arrêt

La méthode [exit] de la classe [System] permet d'arrêter l'exécution d'un programme :

System.exit(int status);

[exit] provoque la fin du processus en cours et rend la main au processus appelant, en général l'interpréteur de commandes. La valeur de [status], appelée code de retour, peut être utilisée par celui-ci. Par convention, 0 signifie que le programme s'est bien déroulé et une autre valeur signale une erreur. Sous Windows, le code de retour est disponible dans la variable [%ERRORLEVEL%] de l'invite de commandes et dans la variable [$LASTEXITCODE] de PowerShell ; sous Linux et macOS, dans la variable [$?] du shell. Ainsi [System.exit(0);] arrêtera l'exécution du programme avec un code de retour égal à 0.

Sans appel à [System.exit], un programme se termine avec le code de retour 0 lorsque sa méthode [main] se termine, éventuellement par une instruction [return]. S'il est arrêté par une exception que personne n'a interceptée, il affiche cette exception et se termine avec le code de retour 1. En Java, la méthode [main] ne peut pas rendre d'entier : [System.exit] est le seul moyen de choisir le code de retour.

Le programme [Bases16.java] reçoit son code de retour en argument :

// arrêt du programme avec un code de retour
void main(String[] args) {
    IO.println("Début du programme");
    // le code de retour est passé en argument (voir la section sur les arguments du programme)
    int code;
    try {
        // sans argument, on convertit la chaîne vide, ce qui échoue
        code = Integer.parseInt(args.length > 0 ? args[0] : "");
    } catch (NumberFormatException e) {
        System.err.println("Syntaxe : java Bases16.java code_de_retour");
        System.exit(1);
        return;
    }
    IO.println("Arrêt avec le code de retour " + code);
    System.exit(code);
    IO.println("Cette ligne n'est jamais exécutée");
}

Résultats dans un terminal bash :

> java Bases16.java 3
Début du programme
Arrêt avec le code de retour 3
> echo $?
3
> java Bases16.java
Début du programme
Syntaxe : java Bases16.java code_de_retour
> echo $?
1

Commentaires :

  • ligne 2 : la méthode [main] reçoit les arguments de la ligne de commande dans le tableau [args]. Les arguments du programme sont présentés plus loin ;
  • ligne 8 : s'il y a au moins un argument, on convertit le premier en entier ; sinon, on convertit la chaîne vide, ce qui échoue. Ainsi, l'absence d'argument et un argument non numérique sont traités par la même clause [catch] ;
  • ligne 11 : on arrête le programme avec le code de retour 1 ;
  • ligne 12 : cette instruction ne sera jamais exécutée, mais le compilateur ne sait pas que [System.exit] ne rend jamais la main. Sans elle, il considérerait que l'on peut arriver à la ligne 14 avec une variable [code] non initialisée, et refuserait le programme : [error: variable code might not have been initialized] ;
  • ligne 15 : arrêt avec le code de retour passé en argument. La ligne qui suit n'est pas exécutée ;
  • lignes 4-5 et 9-10 des résultats : la commande [echo $?] affiche le code de retour du dernier programme exécuté : 3 puis 1.

3.5.2. Structure de choix simple

1
2
3
4
5
if (condition) {
    actions_condition_vraie;
} else {
    actions_condition_fausse;
}

Notes :

  • la condition est entourée de parenthèses. Elle doit être de type [boolean] : contrairement au langage C, Java refuse [if (n)] lorsque [n] est un entier ;
  • chaque action est terminée par un point-virgule ;
  • les accolades ne sont pas suivies d'un point-virgule ;
  • les accolades ne sont nécessaires que s'il y a plus d'une action ; on les met cependant souvent pour une seule action, par lisibilité et pour éviter les erreurs lors de l'ajout d'une action. En Java, l'usage est de placer l'accolade ouvrante en fin de ligne ;
  • la clause [else] peut être absente ;
  • il n'y a pas de mot clé [then].

L'équivalent algorithmique de cette structure est la structure si .. alors .. sinon :

1
2
3
4
si condition
    alors actions_condition_vraie
    sinon actions_condition_fausse
finsi

Exemple :

if (x > 0) { nx = nx + 1; sx = sx + x; } else dx = dx - x;

On peut imbriquer les structures de choix. Se pose alors parfois le problème illustré par le programme [Bases17.java] :

// structure de choix simple if
void main() {
    int n = 5;
    // un else se rapporte toujours au if le plus proche
    if (n > 1)
        if (n > 6)
            IO.println(">6");
        else
            IO.println("<=6");

    // avec des accolades, on rattache le else au premier if
    int n2 = 0;
    if (n2 > 1) {
        if (n2 > 6) {
            IO.println(">6");
        }
    } else {
        IO.println("<=1");
    }

    // une suite de choix : else if
    int note = 13;
    String mention;
    if (note < 10) {
        mention = "ajourné";
    } else if (note < 12) {
        mention = "passable";
    } else if (note < 14) {
        mention = "assez bien";
    } else {
        mention = "bien";
    }
    IO.println("note=" + note + ", mention=" + mention);
}

Résultats :

1
2
3
4
> java Bases17.java
<=6
<=1
note=13, mention=assez bien

Commentaires :

  • lignes 5-9 : le [else] de la ligne 8 se rapporte à quel [if] ? La règle est qu'un [else] se rapporte toujours au [if] le plus proche : [if (n > 6)], ligne 6. L'indentation du code, qui n'a aucun effet sur le compilateur, a été choisie pour le montrer ;
  • lignes 13-19 : ici, nous voulions mettre un [else] au [if (n2 > 1)] et pas de [else] au [if (n2 > 6)]. À cause de la règle précédente, nous sommes obligés de mettre des accolades au [if (n2 > 1) { ... }] ;
  • lignes 24-32 : une suite de choix exclusifs s'écrit avec des [else if] successifs. Les conditions sont testées dans l'ordre ; la première vraie détermine l'action exécutée. Le compilateur vérifie que la variable [mention], déclarée sans valeur ligne 23, reçoit une valeur dans toutes les branches avant d'être utilisée ligne 33.

3.5.3. Structure de cas

3.5.3.1. L'instruction switch

La syntaxe historique est la suivante :

switch (expression) {
    case v1:
        actions1;
        break;
    case v2:
        actions2;
        break;
    ...
    default:
        actions_sinon;
        break;
}

Notes :

  • la valeur de l'expression de contrôle du [switch] peut être un entier ([int], [short], [byte], [char] ou leurs classes enveloppes), une chaîne de caractères (Java 7), une valeur d'énumération et, depuis Java 21, n'importe quel objet (voir plus loin le filtrage par motif). Les types [long], [float], [double] et [boolean] ne sont pas acceptés ;
  • l'expression de contrôle est entourée de parenthèses ;
  • la clause [default] peut être absente ;
  • les valeurs vi sont des valeurs possibles de l'expression. Si l'expression a pour valeur vi, les actions derrière la clause [case vi] sont exécutées. Depuis Java 14, une clause peut énumérer plusieurs valeurs : [case "samedi", "dimanche":] ;
  • l'instruction [break] fait sortir de la structure de cas. Attention : si elle est absente, l'exécution continue avec les actions de la clause [case] suivante (« fall-through »). Le compilateur l'accepte ; avec l'option [-Xlint:all], il émet seulement l'avertissement [warning: [fallthrough] possible fall-through into case]. Cet oubli est une erreur fréquente, que la forme avec flèche présentée ci-dessous rend impossible.

Exemple en algorithmique :

selon la valeur de choix
    cas 0
        fin du module
    cas 1
        exécuter module M1
    cas 2
        exécuter module M2
    sinon
        erreur <- vrai
findescas

3.5.3.2. La forme avec flèche et l'expression switch

Java 14 a ajouté une seconde forme de clause, [case v1, v2 -> action], où la flèche est suivie d'une seule instruction ou d'un bloc entre accolades. Seule l'action de la clause retenue est exécutée : il n'y a ni [break] à écrire, ni enchaînement d'une clause à la suivante.

Très souvent, une structure de cas ne sert qu'à choisir une valeur. Le [switch] peut pour cela être utilisé comme une expression (Java 14) :

1
2
3
4
5
6
resultat = switch (expression) {
    case v1 -> valeur1;
    case v2, v3 -> valeur2;
    ...
    default -> valeur_sinon;
};

L'expression switch doit être exhaustive : elle doit prévoir une valeur pour toutes les valeurs possibles de l'expression de contrôle, ce qui impose en général une clause [default]. Lorsqu'une clause a besoin de plusieurs instructions, on écrit un bloc, qui rend sa valeur avec l'instruction [yield].

Depuis Java 21, une clause [case] peut contenir un motif (« pattern ») au lieu d'une constante. Un motif de type, [case Integer i], est retenu si l'objet examiné est de type [Integer] ; l'objet est alors disponible, avec ce type, dans la variable [i]. Une garde [when condition] ajoute une condition : [case Integer i when i > 0] n'est retenue que pour un entier positif. Une clause [case null] traite la valeur [null], qui sans elle provoquerait une [NullPointerException]. Les clauses sont examinées dans l'ordre : la première qui convient est retenue. Le même filtrage s'utilise avec l'opérateur [instanceof] (Java 16) : [if (o instanceof String s && s.length() > 3)].

Java 25 ne permet pas encore d'écrire de tels motifs pour les types primitifs ([case int i when i > 0]) : cette possibilité existe en préversion, que nous n'utiliserons pas. Pour classer un [int] avec des gardes, on fait porter le [switch] sur un [Integer].

Le programme [Bases18.java] est le suivant :

// structure de cas : instruction switch et expression switch
void main() {
    int choix = 2;
    boolean erreur = false;
    switch (choix) {
        case 0:
            return;
        case 1:
            m1();
            break;
        case 2:
            m2();
            break;
        default:
            erreur = true;
            break;
    }
    IO.println("erreur=" + erreur);

    // plusieurs valeurs pour un même cas, expression de type String
    String jour = "samedi";
    switch (jour) {
        case "samedi", "dimanche":
            IO.println(jour + " : week-end");
            break;
        default:
            IO.println(jour + " : semaine");
            break;
    }
    // la forme avec flèche (Java 14) : pas de break, pas d'enchaînement des cas
    switch (jour) {
        case "samedi", "dimanche" -> IO.println(jour + " : week-end");
        default -> IO.println(jour + " : semaine");
    }

    // expression switch (Java 14) : elle rend une valeur
    String typeJour = switch (jour) {
        case "samedi", "dimanche" -> "week-end";
        case "lundi", "mardi", "mercredi", "jeudi", "vendredi" -> "semaine";
        default -> "jour inconnu";
    };
    IO.println(jour + " : " + typeJour);
    // un bloc d'instructions rend sa valeur avec yield
    int nbHeures = switch (jour) {
        case "samedi" -> 4;
        case "dimanche" -> 0;
        default -> {
            int heures = 7;
            yield heures + 1;
        }
    };
    IO.println(jour + " : " + nbHeures + " heure(s) d'ouverture");

    // filtrage par motif avec gardes when (Java 21)
    IO.println("-5 : " + classer(-5));
    IO.println("0 : " + classer(0));
    IO.println("7 : " + classer(7));
    IO.println("42 : " + classer(42));
    IO.println("1000 : " + classer(1000));
    // filtrage selon le type de l'objet
    Object[] objets = {12, -3, "java", 2.5, null};
    for (Object o : objets) {
        IO.println(o + " : " + decrire(o));
    }
}

// méthodes utilisées ci-dessus
void m1() {
    IO.println("M1");
}

void m2() {
    IO.println("M2");
}

String classer(Integer n) {
    return switch (n) {
        case Integer i when i < 0 -> "négatif";
        case 0 -> "nul";
        case Integer i when i <= 10 -> "petit";
        case Integer i when i < 100 -> "moyen";
        default -> "grand";
    };
}

String decrire(Object o) {
    return switch (o) {
        case null -> "aucun objet";
        case Integer i when i > 0 -> "entier positif";
        case Integer i -> "entier négatif ou nul";
        case String s -> "chaîne de " + s.length() + " caractères";
        default -> "objet de type " + o.getClass().getSimpleName();
    };
}

Résultats :

> java Bases18.java
M2
erreur=false
samedi : week-end
samedi : week-end
samedi : week-end
samedi : 4 heure(s) d'ouverture
-5 : négatif
0 : nul
7 : petit
42 : moyen
1000 : grand
12 : entier positif
-3 : entier négatif ou nul
java : chaîne de 4 caractères
2.5 : objet de type Double
null : aucun objet

Commentaires :

  • lignes 5-17 : la traduction en Java de l'algorithme ci-dessus ;
  • ligne 7 : [return] termine la méthode [main], donc le programme ;
  • ligne 23 : deux valeurs pour les mêmes actions. Avant Java 14, on écrivait deux clauses successives [case "samedi":] et [case "dimanche":], la première, vide, « tombant » dans la seconde ;
  • lignes 31-34 : la même structure de cas avec des flèches : plus de [break] ;
  • lignes 37-41 : une expression switch sur une chaîne. Elle se termine par un point-virgule, comme toute instruction d'affectation. La clause [default] la rend exhaustive ;
  • lignes 44-51 : une clause dont l'action est un bloc : l'instruction [yield] (ligne 49) donne la valeur de l'expression ;
  • lignes 76-84 : la méthode [classer]. Son paramètre [Integer] reçoit les entiers -5, 0, 7... emballés automatiquement (lignes 55-59). Les gardes [when] classent l'entier, et la constante 0 se mêle aux motifs. La clause [default] traite les valeurs restantes, ici les entiers supérieurs ou égaux à 100 ;
  • ligne 61 : un tableau d'objets de types différents : les nombres 12, -3 et 2.5 sont emballés en [Integer] et en [Double] ;
  • lignes 86-94 : la méthode [decrire] choisit selon le type de l'objet. À la ligne 91, la variable [s] est de type [String] : on peut appeler sa méthode [length()] sans conversion. La clause sans garde [case Integer i] (ligne 90) doit être placée après la clause avec garde (ligne 89), qu'elle masquerait sinon : le compilateur refuserait le programme ;
  • lignes 68-74 : deux méthodes appelées par le premier [switch].

3.5.4. Structures de répétition

3.5.4.1. Nombre de répétitions connu

Structure for

La syntaxe est la suivante :

1
2
3
for (i = id; i <= if; i = i + ip) {
    actions;
}

Notes :

  • les 3 arguments du [for] sont à l'intérieur d'une parenthèse et séparés par des points-virgules ;
  • chaque action du [for] est terminée par un point-virgule ;
  • l'accolade n'est nécessaire que s'il y a plus d'une action ;
  • l'accolade n'est pas suivie d'un point-virgule.

L'équivalent algorithmique est la structure pour :

1
2
3
pour i variant de id à if avec un pas de ip
    actions
finpour

qu'on peut traduire par une structure tantque :

1
2
3
4
5
i <- id
tantque i <= if
    actions
    i <- i + ip
fintantque

Structure for-each

La syntaxe est la suivante :

1
2
3
for (Type variable : collection) {
    instructions;
}

Notes :

  • [collection] est un tableau ou une collection d'objets parcourable (qui implémente l'interface [Iterable]). Nous verrons les collections ([List], [Set], [Map]...) au chapitre 5 ;
  • [Type] est le type des éléments de la collection. Pour un tableau, ce serait le type des éléments du tableau. On peut écrire [var] à la place ;
  • [variable] est une variable locale à la boucle qui va prendre successivement pour valeur toutes les valeurs de la collection. Lui affecter une autre valeur dans la boucle ne modifie pas la collection ; on peut la déclarer [final] pour l'interdire.

3.5.4.2. Nombre de répétitions inconnu

Structure tantque (while)

1
2
3
while (condition) {
    actions;
}

On boucle tant que la condition est vérifiée. La boucle peut ne jamais être exécutée. Les notes sont les mêmes que pour le [for]. La structure algorithmique correspondante est la structure tantque :

1
2
3
tantque condition
    actions
fintantque

Structure répéter jusqu'à (do while)

La syntaxe est la suivante :

1
2
3
do {
    instructions;
} while (condition);

On boucle jusqu'à ce que la condition devienne fausse. Ici, la boucle est faite au moins une fois. La structure algorithmique correspondante est la structure répéter ... jusqu'à :

1
2
3
répéter
    actions
jusqu'à non condition

Structure pour générale (for)

La syntaxe est la suivante :

1
2
3
for (instructions_depart; condition; instructions_fin_boucle) {
    instructions;
}

On boucle tant que la condition est vraie (évaluée avant chaque tour de boucle). Les [instructions_depart] sont effectuées avant d'entrer dans la boucle pour la première fois. Les [instructions_fin_boucle] sont exécutées après chaque tour de boucle. Les différentes instructions de [instructions_depart] et de [instructions_fin_boucle] sont séparées par des virgules. La structure algorithmique correspondante est la suivante :

1
2
3
4
5
instructions_depart
tantque condition
    actions
    instructions_fin_boucle
fintantque

3.5.4.3. Instructions de gestion de boucle

Instruction
Rôle
[break]
fait sortir de la boucle [for], [while], [do ... while]
[continue]
fait passer à l'itération suivante des boucles [for], [while], [do ... while]
[break etiquette]
fait sortir de la boucle précédée de l'étiquette [etiquette:], même si l'instruction se trouve dans une boucle imbriquée
[continue etiquette]
fait passer à l'itération suivante de la boucle précédée de l'étiquette [etiquette:]

3.5.4.4. Exemples

Le programme [Bases19.java] est le suivant :

// les structures de répétition
void main() {
    // quatre façons de calculer la somme des 10 premiers entiers
    int i, somme, n = 10;
    for (i = 1, somme = 0; i <= n; i = i + 1) {
        somme = somme + i;
    }
    IO.println("for (1) : somme=" + somme);

    for (i = 1, somme = 0; i <= n; somme = somme + i, i = i + 1) {
    }
    IO.println("for (2) : somme=" + somme);

    i = 1;
    somme = 0;
    while (i <= n) {
        somme += i;
        i++;
    }
    IO.println("while : somme=" + somme);

    i = 1;
    somme = 0;
    do {
        somme += i++;
    } while (i <= n);
    IO.println("do while : somme=" + somme);

    // la variable de boucle peut être déclarée dans le for : elle n'existe que dans la boucle
    int produit = 1;
    for (int k = 1; k <= 5; k++) {
        produit *= k;
    }
    IO.println("5! = " + produit);

    // for-each : parcours de tous les éléments d'un tableau ou d'une collection
    String[] amis = {"paul", "hélène", "jacques", "sylvie"};
    for (String nom : amis) {
        IO.println(nom);
    }

    // break et continue
    for (int k = 1; k <= 10; k++) {
        // les nombres pairs sont ignorés
        if (k % 2 == 0) {
            continue;
        }
        // on s'arrête après 7
        if (k > 7) {
            break;
        }
        IO.print(k + " ");
    }
    IO.println();

    // boucle infinie dont on sort par break : le premier multiple de 7 supérieur à 50
    int multiple = 50;
    while (true) {
        multiple++;
        if (multiple % 7 == 0) {
            break;
        }
    }
    IO.println("premier multiple de 7 supérieur à 50 : " + multiple);

    // break avec étiquette : on sort des deux boucles à la fois
    int[][] grille = {{1, 2, 3}, {4, -5, 6}, {7, 8, 9}};
    recherche:
    for (int ligne = 0; ligne < grille.length; ligne++) {
        for (int colonne = 0; colonne < grille[ligne].length; colonne++) {
            if (grille[ligne][colonne] < 0) {
                IO.println("premier négatif en [" + ligne + "][" + colonne + "]");
                break recherche;
            }
        }
    }
}

Résultats :

> java Bases19.java
for (1) : somme=55
for (2) : somme=55
while : somme=55
do while : somme=55
5! = 120
paul
hélène
jacques
sylvie
1 3 5 7 
premier multiple de 7 supérieur à 50 : 56
premier négatif en [1][1]

Commentaires :

  • lignes 3-27 : quatre façons de calculer la somme des 10 premiers nombres entiers ;
  • lignes 5-7 : un [for] classique ;
  • lignes 10-11 : tout le travail est fait dans les instructions de fin de boucle : le corps de la boucle est vide. Java accepte aussi l'écriture [for (...);], sans corps, mais un point-virgule placé par erreur derrière un [for] est une faute classique, difficile à repérer. Un bloc vide { } exprime la même chose sans ambiguïté ;
  • ligne 25 : [i++] rend la valeur de [i] avant son incrémentation : on ajoute [i] à [somme], puis on incrémente [i] ;
  • ligne 31 : la forme la plus courante du [for] : la variable de boucle est déclarée dans le [for] et n'existe que dans la boucle ;
  • lignes 38-40 : la boucle for-each parcourt les éléments du tableau [amis] ;
  • lignes 43-53 : [continue] passe à l'itération suivante pour les nombres pairs, [break] arrête la boucle dès que [k] dépasse 7 ;
  • lignes 58-63 : une boucle infinie [while (true)] dont on sort par [break]. Cette forme est commode lorsque la condition d'arrêt se calcule au milieu de la boucle. Nous l'utiliserons pour les saisies au clavier ;
  • lignes 67-76 : la recherche du premier nombre négatif d'un tableau à deux dimensions. L'étiquette [recherche:] (ligne 68) nomme la boucle extérieure ; l'instruction [break recherche;] (ligne 73) fait sortir des deux boucles à la fois. Un simple [break] ne ferait sortir que de la boucle intérieure. Java n'a pas d'instruction [goto] (le mot est réservé mais inutilisé) : les étiquettes en tiennent lieu dans ce cas précis.

3.6. La gestion des exceptions

De nombreuses méthodes Java sont susceptibles de générer des exceptions, c'est-à-dire des erreurs. Lorsqu'une méthode est susceptible de générer une exception, le programmeur devrait la gérer dans le but d'obtenir des programmes plus résistants aux erreurs : il faut toujours éviter l'arrêt brutal d'une application.

3.6.1. La structure try / catch / finally

La gestion d'une exception se fait selon le schéma suivant :

1
2
3
4
5
6
try {
    code susceptible de générer une exception
} catch (Exception e) {
    traiter l'exception e
}
instruction suivante

Si le code de la clause [try] ne génère pas d'exception, on passe à l'instruction suivante ; sinon, on passe dans le corps de la clause [catch], puis à l'instruction suivante. Notons les points suivants :

  • [e] est un objet de type [Exception] ou d'un type dérivé. Il existe de nombreux types d'exceptions : [ArrayIndexOutOfBoundsException], [NumberFormatException], [ArithmeticException], [IllegalArgumentException], [NullPointerException], [IOException], etc. En écrivant [catch (Exception e)], on indique qu'on veut gérer tous les types d'exceptions. Si le code de la clause [try] est susceptible de générer plusieurs types d'exceptions, on peut vouloir être plus précis en gérant l'exception avec plusieurs clauses [catch] :
1
2
3
4
5
6
7
8
try {
    code susceptible de générer les exceptions
} catch (ArrayIndexOutOfBoundsException e1) {
    traiter l'exception e1
} catch (NumberFormatException e2) {
    traiter l'exception e2
}
instruction suivante

Les clauses [catch] sont examinées dans l'ordre ; la première dont le type correspond à l'exception est exécutée. Une clause qui gère un type d'exception doit donc précéder celle qui gère un type plus général : le compilateur refuse une clause qui ne pourrait jamais être atteinte ;

  • on peut ajouter aux clauses [try / catch] une clause [finally] :
1
2
3
4
5
6
7
8
try {
    code susceptible de générer une exception
} catch (Exception e) {
    traiter l'exception e
} finally {
    code exécuté après try ou catch
}
instruction suivante

Qu'il y ait exception ou pas, le code de la clause [finally] est toujours exécuté. On y plaçait autrefois la fermeture d'un fichier ; nous verrons qu'il existe aujourd'hui une forme plus simple, le [try] avec ressources ;

  • dans la clause [catch], on peut ne pas vouloir utiliser l'objet exception. Il faut cependant lui donner un nom : depuis Java 22, on peut écrire [catch (NumberFormatException _)], le caractère _ désignant une variable anonyme qui ne sera pas utilisée ;
  • la classe [Exception] a une méthode [getMessage()] qui rend le message détaillant l'erreur qui s'est produite. Si on veut l'afficher, on écrira :
1
2
3
4
} catch (Exception ex) {
    System.err.println("L'erreur suivante s'est produite : " + ex.getMessage());
    ...
}
  • la méthode [toString()] d'une exception rend le nom complet de sa classe suivi de son message ; sa méthode [printStackTrace()] affiche en plus la pile des appels, c'est-à-dire la liste des méthodes en cours d'exécution au moment de l'erreur, avec le nom du fichier source et le numéro de ligne. C'est l'information la plus utile pour le développeur, mais elle n'est pas destinée à l'utilisateur. Dans ce cours, nous afficherons le plus souvent le nom de la classe de l'exception, [ex.getClass().getName()] ou [ex.getClass().getSimpleName()], et son message.

3.6.2. Un premier exemple

Le programme [Bases20.java] montre une exception générée par l'utilisation d'un élément de tableau inexistant :

// une exception due à un indice de tableau incorrect
void main() {
    int[] tab = {0, 1, 2, 3};
    // affichage du tableau avec un for
    for (int i = 0; i < tab.length; i++) {
        IO.println("tab[" + i + "]=" + tab[i]);
    }
    // affichage du tableau avec un for-each
    for (int elmt : tab) {
        IO.println(elmt);
    }
    // génération d'une exception
    try {
        tab[100] = 6;
    } catch (Exception e) {
        System.err.println("L'erreur suivante s'est produite : "
                + e.getClass().getName() + " - " + e.getMessage());
        return;
    } finally {
        IO.println("finally ...");
    }
    IO.println("Cette ligne n'est pas exécutée");
}

Résultats :

> java Bases20.java
tab[0]=0
tab[1]=1
tab[2]=2
tab[3]=3
0
1
2
3
L'erreur suivante s'est produite : java.lang.ArrayIndexOutOfBoundsException - Index 100 out of bounds for length 4
finally ...

Commentaires :

  • ligne 14 : cette instruction génère une exception parce que le tableau [tab] n'a pas d'élément n° 100 ;
  • ligne 15 : la clause [catch (Exception e)] intercepte toutes les exceptions ;
  • lignes 16-17 : l'exception est de type [java.lang.ArrayIndexOutOfBoundsException], et son message indique l'indice fautif et la taille du tableau (ligne 10 des résultats) ;
  • ligne 18 : on quitte la méthode [main], donc le programme ;
  • lignes 19-20 : la clause [finally] a été exécutée (ligne 11 des résultats), alors même que la clause [catch] contenait une instruction [return]. On retiendra que la clause [finally] est toujours exécutée ;
  • ligne 22 : à cause du [return], cette ligne n'est pas exécutée.

3.6.3. Une saisie contrôlée

Voici un autre exemple, [Bases21.java], où l'on gère l'exception provoquée par la conversion en entier d'une chaîne qui ne représente pas un nombre entier :

// saisie d'un âge avec gestion de l'exception de conversion
void main() {
    // on demande le nom
    String nom = Objects.requireNonNullElse(IO.readln("Nom : "), "");
    // on demande l'âge
    int age = 0;
    boolean ageOK = false;
    while (!ageOK) {
        // question, lecture-vérification réponse
        try {
            age = Integer.parseInt(IO.readln("âge : "));
            ageOK = age >= 1;
        } catch (NumberFormatException _) {
            // ageOK reste à false
        }
        if (!ageOK) {
            IO.println("Âge incorrect, recommencez...");
        }
    }
    // affichage final
    IO.println("Vous vous appelez " + nom + " et vous avez " + age + " an(s)");
}

Résultats :

1
2
3
4
> java Bases21.java
Nom : dupont
âge : 23
Vous vous appelez dupont et vous avez 23 an(s)
> java Bases21.java
Nom : durand
âge : x
Âge incorrect, recommencez...
âge : -4
Âge incorrect, recommencez...
âge : 99999999999
Âge incorrect, recommencez...
âge : 12
Vous vous appelez durand et vous avez 12 an(s)

Commentaires :

  • ligne 4 : si le flux d'entrée est fermé, [IO.readln] rend [null] : [Objects.requireNonNullElse] le remplace par une chaîne vide ;
  • lignes 8-19 : la boucle de saisie de l'âge ;
  • ligne 11 : la ligne tapée au clavier est transformée en nombre entier par la méthode [Integer.parseInt]. Cette méthode lance une exception [NumberFormatException] si la conversion n'est pas possible, y compris lorsque la chaîne vaut [null]. C'est pourquoi l'opération a été placée dans un [try / catch] ;
  • ligne 12 : si on arrive à cette ligne, c'est que la conversion a réussi. On vérifie cependant que l'entier obtenu est bien supérieur ou égal à 1 ;
  • ligne 13 : si une exception [NumberFormatException] est lancée, on va dans le [catch] où rien n'est fait. Ainsi, le booléen [ageOK], initialisé à [false], reste à [false]. La variable de l'exception, inutilisée, est la variable anonyme _. On n'intercepte que l'exception attendue : une clause [catch (Exception _)] masquerait aussi les erreurs que l'on n'a pas prévues ;
  • lignes 16-18 : un message d'erreur est émis si l'âge est incorrect.

Un nombre trop grand pour un [int], comme 99999999999, provoque lui aussi une [NumberFormatException] : il est donc traité comme les autres saisies incorrectes (deuxième exécution).

3.6.4. Plusieurs clauses catch et lancement d'exceptions

Le programme peut lui-même lancer une exception avec l'instruction [throw] :

throw new IllegalArgumentException("message d'erreur");

L'exécution de la méthode en cours s'arrête et l'exception remonte vers les méthodes appelantes jusqu'à ce qu'une clause [catch] la gère ; si aucune ne le fait, le programme s'arrête en affichant l'exception et la pile des appels. Dans une clause [catch], l'instruction [throw e;] relance l'exception en cours, par exemple après l'avoir journalisée.

Java n'a pas de filtre sur les clauses [catch] : pour ne traiter une exception que dans certains cas, on teste la condition dans la clause et l'on relance l'exception si nécessaire. Pour vérifier un paramètre qui ne doit pas valoir [null], on utilise la méthode [Objects.requireNonNull(valeur, message)] : elle rend la valeur si elle n'est pas nulle, et lance sinon une [NullPointerException] avec le message indiqué.

Le programme [Bases22.java] illustre ces possibilités :

// plusieurs clauses catch, lancement d'exceptions
void main() {
    String[] saisies = {"12", "abc", "", "99999999999", "-3", "150"};
    for (String saisie : saisies) {
        try {
            int age = lireAge(saisie);
            IO.println("[" + saisie + "] : âge=" + age);
        } catch (NumberFormatException e) {
            // pas de filtre sur une clause catch : on teste dans la clause
            if (saisie.isEmpty()) {
                IO.println("[" + saisie + "] : saisie vide");
            } else {
                IO.println("[" + saisie + "] : " + e.getClass().getSimpleName() + " - " + e.getMessage());
            }
        } catch (IllegalArgumentException e) {
            IO.println("[" + saisie + "] : " + e.getClass().getSimpleName() + " - " + e.getMessage());
        }
    }

    // Objects.requireNonNull lance une NullPointerException si la référence vaut null
    String[] noms = {"dupont", null};
    for (String nom : noms) {
        try {
            String valide = Objects.requireNonNull(nom, "le nom est obligatoire");
            IO.println("nom=" + valide);
        } catch (NullPointerException e) {
            IO.println(e.getClass().getSimpleName() + " - " + e.getMessage());
        }
    }
}

// une méthode qui lance des exceptions
int lireAge(String texte) {
    // Integer.parseInt lance NumberFormatException
    int age = Integer.parseInt(texte);
    // nous lançons nous-mêmes une exception si l'âge est hors limites
    if (age < 0 || age > 130) {
        throw new IllegalArgumentException("âge hors limites [0,130] : " + age);
    }
    return age;
}

Résultats :

1
2
3
4
5
6
7
8
9
> java Bases22.java
[12] : âge=12
[abc] : NumberFormatException - For input string: "abc"
[] : saisie vide
[99999999999] : NumberFormatException - For input string: "99999999999"
[-3] : IllegalArgumentException - âge hors limites [0,130] : -3
[150] : IllegalArgumentException - âge hors limites [0,130] : 150
nom=dupont
NullPointerException - le nom est obligatoire

Commentaires :

  • ligne 3 : des saisies simulées, valides ou non ;
  • ligne 6 : la méthode [lireAge] (lignes 33-41) peut lancer plusieurs types d'exceptions ;
  • ligne 8 : les saisies "abc", "" et "99999999999" provoquent une [NumberFormatException] ;
  • lignes 10-14 : faute de filtre, on distingue la saisie vide à l'intérieur de la clause [catch] ;
  • ligne 15 : l'exception lancée par [lireAge] elle-même ;
  • ligne 24 : [Objects.requireNonNull] lance une [NullPointerException] si [nom] vaut [null] ; son message est celui passé en second paramètre (ligne 9 des résultats) ;
  • ligne 38 : la méthode [lireAge] lance une exception si l'âge est hors limites.

L'ordre des clauses [catch] des lignes 8 et 15 n'est pas indifférent : la classe [NumberFormatException] dérive de la classe [IllegalArgumentException] (une [NumberFormatException] est un cas particulier d'argument incorrect). Si l'on inverse les deux clauses, la clause [IllegalArgumentException] intercepte aussi les [NumberFormatException], et le compilateur refuse la clause devenue inaccessible :

1
2
3
4
Bases22.java:10: error: exception NumberFormatException has already been caught
        } catch (NumberFormatException e) {
          ^
1 error

3.6.5. Exceptions contrôlées et non contrôlées

Voici une particularité importante de Java. Les exceptions sont des objets dont les classes forment une hiérarchie :

Image

  • la classe racine est [Throwable] ;
  • la classe [Error] et ses dérivées signalent des problèmes graves de la machine virtuelle ([OutOfMemoryError], [StackOverflowError]...), qu'un programme ne cherche normalement pas à intercepter ;
  • la classe [RuntimeException] et ses dérivées ([NullPointerException], [ArithmeticException], [ArrayIndexOutOfBoundsException], [IllegalArgumentException], [NumberFormatException]...) signalent en général des erreurs de programmation ou des données incorrectes ;
  • les autres classes dérivées d'[Exception] ([IOException], [InterruptedException], [ParseException], [SQLException]...) signalent des incidents dus à l'environnement du programme (fichier absent, réseau coupé, base de données arrêtée) qu'un programme correct doit prévoir.

Les exceptions de cette dernière catégorie sont dites contrôlées (« checked exceptions ») : le compilateur vérifie qu'une méthode qui peut en lancer une, directement ou en appelant une autre méthode, soit l'intercepte par une clause [catch], soit la déclare dans sa signature avec le mot clé [throws] :

String lireFichier(String nom) throws IOException { ... }

L'obligation est alors reportée sur les méthodes appelantes. Les exceptions dérivées de [RuntimeException] et d'[Error] sont dites non contrôlées (« unchecked ») : on peut les intercepter, mais le compilateur ne l'exige pas, et il est inutile de les déclarer. Beaucoup d'autres langages, comme C# ou Kotlin, n'ont que des exceptions non contrôlées.

Le programme [Bases23.java] montre ces différents cas :

// exceptions contrôlées et non contrôlées
void main() {
    // 1 - lireFichier déclare une exception contrôlée (IOException) : il faut l'intercepter
    try {
        String contenu = lireFichier("inexistant.txt");
        IO.println(contenu);
    } catch (IOException e) {
        IO.println("lireFichier : " + e.getClass().getName() + " - " + e.getMessage());
    }
    // 2 - même chose avec InterruptedException, déclarée par attendre
    try {
        attendre(100);
        IO.println("attente de 100 ms terminée");
    } catch (InterruptedException e) {
        IO.println("attente interrompue");
    }
    // 3 - une exception non contrôlée n'a pas à être déclarée ni interceptée
    try {
        IO.println("diviser(1, 0) = " + diviser(1, 0));
    } catch (ArithmeticException e) {
        IO.println("diviser : " + e.getClass().getName() + " - " + e.getMessage());
    }
    // 4 - une exception contrôlée enveloppée dans une exception non contrôlée
    try {
        IO.println(lireConfiguration("absent.properties"));
    } catch (UncheckedIOException e) {
        IO.println(e.getMessage() + " ; cause : " + e.getCause().getClass().getSimpleName());
    }
    // 5 - la place de quelques exceptions dans la hiérarchie des classes
    IO.println(ancetres(new NoSuchFileException("x")));
    IO.println(ancetres(new NumberFormatException()));
    IO.println(ancetres(new OutOfMemoryError()));
}

// Files.readString lance une IOException (contrôlée) : on la déclare
String lireFichier(String nom) throws IOException {
    return Files.readString(Path.of(nom));
}

// Thread.sleep lance une InterruptedException (contrôlée)
void attendre(long millisecondes) throws InterruptedException {
    Thread.sleep(millisecondes);
}

// une ArithmeticException est non contrôlée : pas de clause throws
int diviser(int a, int b) {
    return a / b;
}

// l'IOException est interceptée puis relancée sous la forme d'une exception non contrôlée
String lireConfiguration(String nom) {
    try {
        return Files.readString(Path.of(nom));
    } catch (IOException e) {
        throw new UncheckedIOException("configuration illisible : " + nom, e);
    }
}

// la chaîne des classes parentes de la classe d'un objet
String ancetres(Object o) {
    StringBuilder chaine = new StringBuilder();
    for (Class<?> classe = o.getClass(); classe != null; classe = classe.getSuperclass()) {
        if (!chaine.isEmpty()) {
            chaine.append(" -> ");
        }
        chaine.append(classe.getSimpleName());
    }
    return chaine.toString();
}

Résultats :

1
2
3
4
5
6
7
8
> java Bases23.java
lireFichier : java.nio.file.NoSuchFileException - inexistant.txt
attente de 100 ms terminée
diviser : java.lang.ArithmeticException - / by zero
configuration illisible : absent.properties ; cause : NoSuchFileException
NoSuchFileException -> FileSystemException -> IOException -> Exception -> Throwable -> Object
NumberFormatException -> IllegalArgumentException -> RuntimeException -> Exception -> Throwable -> Object
OutOfMemoryError -> VirtualMachineError -> Error -> Throwable -> Object

Commentaires :

  • lignes 36-38 : la méthode [Files.readString] (chapitre 5) lit tout le contenu d'un fichier ; elle peut lancer une [IOException], qui est contrôlée. La méthode [lireFichier] ne l'intercepte pas : elle doit donc la déclarer avec [throws IOException] ;
  • lignes 4-9 : à son tour, la méthode [main] qui appelle [lireFichier] doit gérer l'[IOException]. Le fichier n'existant pas, l'exception lancée est une [NoSuchFileException], classe dérivée d'[IOException], dont le message est le nom du fichier ;
  • lignes 41-43 : [Thread.sleep] suspend le programme pendant la durée indiquée en millisecondes ; elle peut lancer une [InterruptedException] (contrôlée), que nous retrouverons au chapitre 8 ;
  • lignes 11-16 : l'appel de [attendre] doit donc lui aussi être placé dans un [try / catch] ;
  • lignes 46-48 : une division entière peut lancer une [ArithmeticException], non contrôlée : aucune clause [throws] n'est nécessaire ;
  • lignes 18-22 : l'appelant intercepte quand même l'exception, parce qu'il le souhaite ;
  • lignes 51-57 : une technique fréquente lorsqu'une méthode ne peut ou ne veut pas déclarer une exception contrôlée : on l'intercepte et l'on lance à la place une exception non contrôlée, ici une [UncheckedIOException] (Java 8), en lui passant l'exception d'origine comme cause ;
  • ligne 27 : [getCause()] rend l'exception d'origine ;
  • lignes 60-69 : la méthode [ancetres] remonte la chaîne des classes parentes d'un objet ([getSuperclass()]) jusqu'à la classe [Object], racine de toutes les classes ;
  • lignes 30-32 : on retrouve dans les résultats (lignes 6-8) la hiérarchie décrite plus haut.

Si l'on retire la clause [throws IOException] de la ligne 36, le compilateur refuse le programme. Il signale à la fois l'exception non gérée dans [lireFichier] et la clause [catch] devenue inutile dans [main], puisque plus aucune méthode appelée ne déclare d'[IOException] :

1
2
3
4
5
6
7
Bases23.java:7: error: exception IOException is never thrown in body of corresponding try statement
    } catch (IOException e) {
      ^
Bases23.java:37: error: unreported exception IOException; must be caught or declared to be thrown
    return Files.readString(Path.of(nom));
                           ^
2 errors

La méthode [main] elle-même peut déclarer des exceptions : [void main() throws IOException]. Une exception non interceptée arrête alors le programme, qui affiche l'exception et la pile des appels et se termine avec le code de retour 1. C'est acceptable pour un petit programme d'essai, pas pour une application. Les exceptions contrôlées sont parfois jugées trop contraignantes, et beaucoup de bibliothèques récentes (Spring, Hibernate, que nous utiliserons plus tard) n'utilisent que des exceptions non contrôlées. Dans ce cours, nous interceptons une exception contrôlée là où l'on sait la traiter, et nous l'enveloppons dans une exception non contrôlée sinon.

3.6.6. La libération des ressources : le try avec ressources

Une ressource (un fichier ouvert, une connexion réseau, une connexion à une base de données) doit être fermée après usage, même si une erreur se produit pendant son utilisation. Avant Java 7, on la fermait dans une clause [finally], ce qui demandait plusieurs lignes délicates. Java 7 a introduit le try avec ressources (« try-with-resources ») :

1
2
3
try (Type ressource = ...) {
    utilisation de la ressource
}

La ressource déclarée entre parenthèses est fermée automatiquement (par un appel à sa méthode [close()]) à la sortie du bloc, que celui-ci se termine normalement ou par une exception. Le type de la ressource doit implémenter l'interface [AutoCloseable], qui ne contient que la méthode [close()] ; c'est le cas de toutes les classes de fichiers, de réseau et de bases de données de Java. Plusieurs ressources peuvent être déclarées, séparées par des points-virgules : elles sont fermées dans l'ordre inverse de leur ouverture. Un tel [try] peut avoir des clauses [catch] et [finally], exécutées après la fermeture des ressources.

Java 7 a aussi introduit le multi-catch : une même clause [catch] peut traiter plusieurs types d'exceptions séparés par le caractère | : [catch (NumberFormatException | ArithmeticException e)]. Les types énumérés ne doivent pas dériver l'un de l'autre.

Le programme [Bases24.java] est le suivant :

// try-with-resources et multi-catch
void main() throws IOException {
    // 1 - une ressource est fermée automatiquement à la sortie du try
    try (Ressource r = ouvrir("R1")) {
        IO.println("utilisation de " + r.nom());
    }
    // 2 - plusieurs ressources : elles sont fermées dans l'ordre inverse de leur ouverture,
    // même si une exception se produit, et avant l'exécution des clauses catch et finally
    try (Ressource r2 = ouvrir("R2"); Ressource r3 = ouvrir("R3")) {
        IO.println("utilisation de " + r2.nom() + " et " + r3.nom());
        throw new IllegalStateException("incident pendant l'utilisation");
    } catch (IllegalStateException e) {
        IO.println("catch : " + e.getMessage());
    } finally {
        IO.println("finally");
    }
    // 3 - un BufferedReader est une ressource ; readLine lance une IOException (contrôlée)
    try (BufferedReader lecteur = new BufferedReader(new StringReader("ligne 1\nligne 2"))) {
        String ligne;
        while ((ligne = lecteur.readLine()) != null) {
            IO.println("lu : " + ligne);
        }
    }
    // 4 - multi-catch (Java 7) : un même traitement pour plusieurs types d'exceptions
    String[][] operations = {{"10", "2"}, {"10", "zéro"}, {"10", "0"}};
    for (String[] operation : operations) {
        try {
            int a = Integer.parseInt(operation[0]);
            int b = Integer.parseInt(operation[1]);
            IO.println(a + " / " + b + " = " + a / b);
        } catch (NumberFormatException | ArithmeticException e) {
            IO.println(Arrays.toString(operation) + " : "
                    + e.getClass().getSimpleName() + " - " + e.getMessage());
        }
    }
}

// une ressource : un objet qui implémente l'interface AutoCloseable (chapitre 4)
record Ressource(String nom) implements AutoCloseable {
    @Override
    public void close() {
        IO.println("fermeture de " + nom);
    }
}

Ressource ouvrir(String nom) {
    IO.println("ouverture de " + nom);
    return new Ressource(nom);
}

Résultats :

> java Bases24.java
ouverture de R1
utilisation de R1
fermeture de R1
ouverture de R2
ouverture de R3
utilisation de R2 et R3
fermeture de R3
fermeture de R2
catch : incident pendant l'utilisation
finally
lu : ligne 1
lu : ligne 2
10 / 2 = 5
[10, zéro] : NumberFormatException - For input string: "zéro"
[10, 0] : ArithmeticException - / by zero

Commentaires :

  • ligne 2 : la méthode [main] déclare l'exception contrôlée [IOException], que peut lancer la méthode [readLine] de la ligne 20 et que nous n'interceptons pas ;
  • lignes 39-44 : une ressource de démonstration. C'est un record (chapitre 4), une classe très simple dont chaque objet porte ici un nom ; elle implémente l'interface [AutoCloseable] en écrivant la méthode [close()], qui affiche un message ;
  • lignes 46-49 : la méthode [ouvrir] affiche un message et crée une ressource ;
  • lignes 4-6 : la ressource [r] est fermée automatiquement à la fin du bloc (ligne 4 des résultats) ;
  • lignes 9-16 : deux ressources. Une exception est lancée pendant leur utilisation (ligne 11). Les résultats (lignes 5-11) montrent que R3 est fermée avant R2, et que les deux sont fermées avant l'exécution des clauses [catch] et [finally] ;
  • ligne 18 : un [BufferedReader] qui lit ici une chaîne de caractères comme s'il s'agissait d'un fichier. Nous l'utiliserons au chapitre 5 pour lire de vrais fichiers ;
  • ligne 20 : [readLine()] rend la ligne suivante, ou [null] à la fin du texte. L'affectation est faite à l'intérieur de la condition du [while] : la valeur de l'expression [(ligne = lecteur.readLine())] est la valeur affectée ;
  • ligne 31 : un multi-catch : la même action pour une [NumberFormatException] (ligne 15 des résultats) et pour une [ArithmeticException] (ligne 16).

3.7. Application exemple - V1

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. Les chiffres sont ceux du barème 2004 sur les revenus 2003, conservés tels quels : c'est un exercice de programmation, pas un calcul de l'impôt actuel. Cette application nous servira de fil rouge : nous la reprendrons dans les chapitres suivants avec des classes, des fichiers, une base de données, puis sur le réseau.

  • 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 3 champs. Pour calculer l'impôt I, on recherche la première ligne telle que 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, tronqué à l'euro. Si QF est tel que la relation QF <= limite n'est jamais vérifiée, ce sont les coefficients de la dernière ligne qui sont utilisés : I = 0,481 * R - 9505,54 * nbParts. La limite 0 de la dernière ligne n'est donc jamais utilisée.

Les calculs portent sur des montants et des coefficients décimaux : nous les faisons avec la classe [BigDecimal]. Le programme Java correspondant [Impots1.java] est le suivant :

// calcul de l'impôt - version 1
// barème 2004 (revenus 2003) : c'est un exercice, pas le barème en vigueur
void main() {
    // tableaux de données nécessaires au calcul de l'impôt
    BigDecimal[] limites = decimaux("4962", "8382", "14753", "23888", "38868", "47932", "0");
    BigDecimal[] coeffR = decimaux("0", "0.068", "0.191", "0.283", "0.374", "0.426", "0.481");
    BigDecimal[] coeffN = decimaux("0", "291.09", "1322.92", "2668.39", "4846.98", "6883.66", "9505.54");

    // statut marital
    String reponse;
    while (true) {
        reponse = lire("Êtes-vous marié(e) (O/N) ? ").toLowerCase();
        if (reponse.equals("o") || reponse.equals("n")) {
            break;
        }
        System.err.println("Réponse incorrecte. Recommencez");
    }
    boolean marie = reponse.equals("o");

    // nombre d'enfants
    int nbEnfants;
    while (true) {
        try {
            nbEnfants = Integer.parseInt(lire("Nombre d'enfants : "));
            if (nbEnfants >= 0) {
                break;
            }
        } catch (NumberFormatException _) {
            // saisie non numérique : on recommence
        }
        System.err.println("Réponse incorrecte. Recommencez");
    }

    // salaire annuel
    int salaire;
    while (true) {
        try {
            salaire = Integer.parseInt(lire("Salaire annuel : "));
            if (salaire >= 0) {
                break;
            }
        } catch (NumberFormatException _) {
            // saisie non numérique : on recommence
        }
        System.err.println("Réponse incorrecte. Recommencez");
    }

    // nombre de parts
    BigDecimal nbParts = BigDecimal.valueOf(nbEnfants).divide(BigDecimal.TWO)
            .add(BigDecimal.valueOf(marie ? 2 : 1));
    if (nbEnfants >= 3) {
        nbParts = nbParts.add(new BigDecimal("0.5"));
    }

    // revenu imposable
    BigDecimal revenu = new BigDecimal("0.72").multiply(BigDecimal.valueOf(salaire));

    // quotient familial (la division peut ne pas tomber juste : on fixe une précision)
    BigDecimal quotient = revenu.divide(nbParts, MathContext.DECIMAL128);

    // recherche de la tranche d'impôt : la première telle que quotient <= limite,
    // la dernière si aucune ne convient
    int i = 0;
    while (i < limites.length - 1 && quotient.compareTo(limites[i]) > 0) {
        i++;
    }

    // l'impôt (tronqué à l'euro)
    int impot = coeffR[i].multiply(revenu).subtract(coeffN[i].multiply(nbParts)).intValue();

    // affichage du résultat
    IO.println("Impôt à payer : " + impot + " euros");
}

// affiche une invite, lit une ligne et la rend sans les espaces de début et de fin
// (chaîne vide si le flux d'entrée est épuisé)
String lire(String invite) {
    return Objects.requireNonNullElse(IO.readln(invite), "").trim();
}

// convertit des chaînes en nombres BigDecimal exacts
BigDecimal[] decimaux(String... valeurs) {
    BigDecimal[] nombres = new BigDecimal[valeurs.length];
    for (int i = 0; i < valeurs.length; i++) {
        nombres[i] = new BigDecimal(valeurs[i]);
    }
    return nombres;
}

Commentaires :

  • lignes 5-7 : les trois tableaux de [BigDecimal] sont construits par la méthode [decimaux] (lignes 82-88) à partir de chaînes de caractères : chaque valeur, comme 0,068, est ainsi exacte. Avec un [double], 0,068 ne serait qu'une approximation binaire. La méthode [decimaux] accepte un nombre quelconque de paramètres ([String... valeurs]), qu'elle reçoit dans un tableau ; cette notation est présentée dans la section « Passage de paramètres à une fonction » ;
  • lignes 10-18 : la saisie du statut marital, dans une boucle [while (true)] dont on sort par [break] lorsque la réponse est correcte ;
  • ligne 12 : la méthode [lire] (lignes 77-79) affiche l'invite et lit la réponse ; si le flux d'entrée est fermé, la valeur [null] est remplacée par une chaîne vide. La méthode [trim()] enlève les espaces de début et de fin de la chaîne lue, [toLowerCase()] la transforme en minuscules. On accepte ainsi O et o ;
  • ligne 13 : les chaînes se comparent avec [equals], jamais avec == ;
  • ligne 16 : les messages d'erreur sont écrits sur le flux [System.err] ;
  • ligne 18 : le booléen [marie] reçoit la valeur [true] ou [false] de l'expression [reponse.equals("o")] ;
  • lignes 21-32 : la saisie du nombre d'enfants. [Integer.parseInt] lance une [NumberFormatException] si la saisie n'est pas un entier : on passe alors dans la clause [catch] (ligne 28), qui ne fait rien, puis on affiche le message d'erreur et l'on recommence. Si la conversion réussit et que le nombre est positif ou nul, on sort de la boucle (ligne 26). La variable [nbEnfants], déclarée sans valeur à la ligne 21, a toujours reçu une valeur lorsqu'on sort de la boucle : le compilateur le vérifie ;
  • lignes 35-46 : même chose pour le salaire ;
  • lignes 49-50 : le nombre de parts est calculé en [BigDecimal]. On ne peut pas écrire simplement [nbEnfants / 2] : si nbEnfants vaut 3, on aurait 3/2, une division entière qui donnerait 1 et non 1,5. [BigDecimal.valueOf(nbEnfants)] convertit l'entier, [divide(BigDecimal.TWO)] le divise par deux (la constante [TWO] existe depuis Java 19), et [add] ajoute 2 ou 1. Les appels de méthodes s'enchaînent : chacun s'applique au résultat du précédent ;
  • ligne 52 : un [BigDecimal] est immuable : [add] ne modifie pas [nbParts] mais rend un nouvel objet, qu'il faut affecter à la variable. Écrire seulement [nbParts.add(new BigDecimal("0.5"));] n'aurait aucun effet : c'est un piège classique ;
  • ligne 56 : le revenu imposable ;
  • ligne 59 : le quotient familial. La division d'un revenu par 1,5 ou par 3,5 parts n'a en général pas d'écriture décimale finie : on fixe une précision, ici [MathContext.DECIMAL128] (34 chiffres significatifs), largement suffisante ;
  • lignes 63-66 : la recherche de la tranche. On s'arrête à la première limite supérieure ou égale au quotient familial, et au plus tard sur la dernière tranche grâce à la condition [i < limites.length - 1]. Les opérateurs < et > ne s'appliquent pas aux [BigDecimal] : [quotient.compareTo(limites[i]) > 0] signifie « quotient > limites[i] ». On ne compare pas deux [BigDecimal] avec [equals], qui tient compte du nombre de décimales : 2.0 et 2.00 ne sont pas « égaux » pour [equals] ;
  • ligne 69 : l'impôt est tronqué à l'euro par la méthode [intValue()], qui abandonne la partie décimale.

Voici des exemples d'exécution :

1
2
3
4
5
> java Impots1.java
Êtes-vous marié(e) (O/N) ? o
Nombre d'enfants : 2
Salaire annuel : 60000
Impôt à payer : 4282 euros
> java Impots1.java
Êtes-vous marié(e) (O/N) ? oui
Réponse incorrecte. Recommencez
Êtes-vous marié(e) (O/N) ? o
Nombre d'enfants : trois
Réponse incorrecte. Recommencez
Nombre d'enfants : 3
Salaire annuel : 60000 euros
Réponse incorrecte. Recommencez
Salaire annuel : 60000
Impôt à payer : 2959 euros
> java Impots1.java
Êtes-vous marié(e) (O/N) ? n
Nombre d'enfants : 0
Salaire annuel : 60000
Impôt à payer : 11519 euros

Pour un salaire annuel de 60000 euros, un contribuable marié avec 2 enfants paie 4282 euros, avec 3 enfants 2959 euros, et un célibataire sans enfant 11519 euros. Dans la deuxième exécution, la saisie « 60000 euros » est refusée : [Integer.parseInt] n'accepte que les chaînes représentant exactement un entier (les espaces au début et à la fin ont été retirés par [trim()]).

3.8. Arguments du programme principal

La méthode principale [main] peut admettre comme paramètre un tableau de chaînes [String[]]. Ce tableau contient les arguments de la ligne de commande utilisée pour lancer l'application. Ainsi, si on lance le programme P avec la commande :

java P arg0 arg1 ... argn

et si la méthode [main] est déclarée comme suit :

    public static void main(String[] args) {

on aura args[0]="arg0", args[1]="arg1", etc. Dans un fichier compact, on obtient le même tableau en déclarant [void main(String[] args)]. Avec le lanceur de fichiers source, les arguments destinés au programme se placent après le nom du fichier ; les options de la commande [java] elle-même (comme [-cp] ou [-D]) se placent avant :

java Prog.java a0 a1 a2

Voici un exemple, [Bases25.java] :

1
2
3
4
5
6
7
// les arguments de la ligne de commande dans un fichier source compact
void main(String[] args) {
    IO.println("Il y a " + args.length + " argument(s)");
    for (int i = 0; i < args.length; i++) {
        IO.println("args[" + i + "]=" + args[i]);
    }
}

Résultats :

1
2
3
4
5
6
> java Bases25.java a0 a1 a2 a3
Il y a 4 argument(s)
args[0]=a0
args[1]=a1
args[2]=a2
args[3]=a3

Un argument qui contient des espaces doit être entouré de guillemets :

1
2
3
4
> java Bases25.java "jean dupont" 23
Il y a 2 argument(s)
args[0]=jean dupont
args[1]=23

Le même programme écrit avec une classe et une méthode [main] classique [Bases26.java] :

1
2
3
4
5
6
7
8
9
// les arguments de la ligne de commande avec une méthode main classique
public class Bases26 {
    public static void main(String[] args) {
        System.out.println("Il y a " + args.length + " argument(s)");
        for (int i = 0; i < args.length; i++) {
            System.out.println("args[" + i + "]=" + args[i]);
        }
    }
}

Résultats :

1
2
3
4
5
6
> java Bases26.java a0 a1 a2 a3
Il y a 4 argument(s)
args[0]=a0
args[1]=a1
args[2]=a2
args[3]=a3

Commentaires :

  • ligne 2 : une classe publique, dans le fichier [Bases26.java] ;
  • ligne 3 : le point d'entrée du programme, sous sa forme classique ;
  • lignes 4-7 : le reste est identique à [Bases25.java], [System.out.println] remplaçant [IO.println].

Une application organisée en paquetages place ses classes dans des dossiers qui reproduisent le nom des paquetages (chapitre 4) ; c'est aussi l'organisation des projets Maven, que nous utiliserons à partir du chapitre 5. Rappelons enfin qu'en Java, la méthode [main] ne rend pas de valeur : le code de retour du programme se fixe avec [System.exit].

3.9. Les énumérations

Une énumération est un type de données dont le domaine de valeurs est un ensemble de constantes nommées. Considérons un programme qui a à gérer des mentions à un examen. Il y en aurait cinq : Passable, Assez bien, Bien, Très bien, Excellent. On peut alors définir une énumération pour ces cinq constantes :

enum Mentions { PASSABLE, ASSEZ_BIEN, BIEN, TRES_BIEN, EXCELLENT }

Par convention, les noms des constantes s'écrivent en majuscules. Une variable peut être déclarée comme prenant ses valeurs dans l'énumération :

    Mentions maMention = Mentions.PASSABLE;

On peut comparer une variable aux différentes valeurs possibles de l'énumération avec l'opérateur == :

1
2
3
    if (maMention == Mentions.PASSABLE) {
        IO.println("Peut mieux faire");
    }

Contrairement à d'autres langages où une énumération n'est qu'un ensemble d'entiers nommés, une énumération Java est une classe, dérivée de la classe [java.lang.Enum], dont les seuls objets sont les constantes déclarées. Chaque constante est un objet unique, créé une fois pour toutes : c'est pourquoi on peut les comparer avec ==. Chaque constante connaît son nom ([name()], ou [toString()]) et son numéro d'ordre dans la déclaration ([ordinal()], à partir de 0). Le compilateur ajoute à toute énumération deux méthodes statiques :

  • [Mentions.values()] rend le tableau des constantes de l'énumération [Mentions], dans l'ordre de leur déclaration ;
  • [Mentions.valueOf(chaine)] rend la constante dont le nom est [chaine], et lance une [IllegalArgumentException] si aucune constante ne porte ce nom.

Une énumération ne se convertit pas en entier ni un entier en énumération : on passe par [ordinal()] et [values()].

Le programme [Bases27.java] met en lumière ce qui vient d'être écrit :

// les énumérations
enum Mentions { PASSABLE, ASSEZ_BIEN, BIEN, TRES_BIEN, EXCELLENT }

void main() {
    // une variable qui prend ses valeurs dans l'énumération Mentions
    Mentions maMention = Mentions.PASSABLE;
    // affichage de la valeur de la variable
    IO.println("mention=" + maMention);
    // test avec une valeur de l'énumération
    if (maMention == Mentions.PASSABLE) {
        IO.println("Peut mieux faire");
    }
    // liste des mentions : nom et numéro d'ordre
    for (Mentions m : Mentions.values()) {
        IO.println(m + " = " + m.ordinal());
    }
    // numéro d'ordre --> énumération
    Mentions m3 = Mentions.values()[3];
    IO.println("Mentions.values()[3] = " + m3);
    // chaîne --> énumération
    Mentions m2 = Mentions.valueOf("BIEN");
    IO.println("m2=" + m2 + ", m2 après PASSABLE : " + (m2.compareTo(Mentions.PASSABLE) > 0));
    try {
        Mentions.valueOf("Bien");
    } catch (IllegalArgumentException e) {
        IO.println("valueOf(\"Bien\") : " + e.getMessage());
    }
    // une énumération dans une expression switch : pas besoin de default si tous les cas sont traités
    String appreciation = switch (maMention) {
        case PASSABLE, ASSEZ_BIEN -> "correct";
        case BIEN, TRES_BIEN -> "très correct";
        case EXCELLENT -> "remarquable";
    };
    IO.println("appréciation : " + appreciation);
    IO.println("type : " + Mentions.class.getName());
    IO.println("classe parente : " + Mentions.class.getSuperclass().getName());
}

Résultats :

> java Bases27.java
mention=PASSABLE
Peut mieux faire
PASSABLE = 0
ASSEZ_BIEN = 1
BIEN = 2
TRES_BIEN = 3
EXCELLENT = 4
Mentions.values()[3] = TRES_BIEN
m2=BIEN, m2 après PASSABLE : true
valueOf("Bien") : No enum constant Bases27.Mentions.Bien
appréciation : correct
type : Bases27$Mentions
classe parente : java.lang.Enum

Commentaires :

  • ligne 2 : la déclaration de l'énumération. Dans un fichier compact, elle se place à côté des méthodes ; dans un programme organisé en classes, on la déclare, comme une classe (chapitre 4), dans son propre fichier [Mentions.java] ;
  • ligne 6 : une variable de type [Mentions] ;
  • ligne 8 : une constante d'énumération est affichée par son nom ;
  • lignes 14-16 : la liste des constantes et leurs numéros d'ordre ;
  • ligne 18 : la conversion d'un numéro d'ordre en constante ;
  • ligne 21 : la conversion d'une chaîne en constante de l'énumération ;
  • ligne 22 : les constantes se comparent avec [compareTo] selon leur ordre de déclaration : BIEN est après PASSABLE ;
  • lignes 23-27 : [valueOf] distingue majuscules et minuscules : "Bien" n'est pas un nom de constante ;
  • lignes 29-33 : les énumérations se prêtent bien aux expressions switch. Les clauses [case] utilisent les noms des constantes sans les préfixer par [Mentions]. Comme toutes les constantes sont traitées, l'expression est exhaustive sans clause [default] ; si l'on ajoute plus tard une constante à l'énumération, le compilateur signalera l'expression switch devenue incomplète ;
  • ligne 35 : le nom complet de l'énumération est [Bases27$Mentions] : dans un fichier compact, l'énumération est un type membre de la classe implicite [Bases27], et le caractère $ sépare les deux noms ;
  • ligne 36 : la classe parente de toute énumération est [java.lang.Enum].

3.9.1. Des énumérations enrichies

Puisqu'une énumération est une classe, elle peut avoir des champs, un constructeur et des méthodes. Chaque constante est alors créée en appelant le constructeur avec les paramètres écrits entre parenthèses après son nom. C'est une possibilité très utilisée de Java, qui évite de gérer à part des tableaux de valeurs associées aux constantes.

Pour représenter un ensemble d'options, comme les droits d'accès à un fichier (lecture, écriture, exécution), on combinait autrefois des puissances de 2 avec l'opérateur | (OU bit à bit). En Java, on utilise une énumération et la classe [EnumSet], un ensemble de constantes d'une énumération, aussi efficace qu'une combinaison de bits (c'en est une, en interne) mais beaucoup plus lisible. La classe [EnumMap] est de même un dictionnaire dont les clés sont les constantes d'une énumération.

Le programme [Bases28.java] est le suivant :

// des énumérations enrichies : champs, constructeur, méthodes ; EnumSet et EnumMap
// chaque mention a une note minimale et un libellé
enum Mention {
    PASSABLE(10, "Passable"),
    ASSEZ_BIEN(12, "Assez bien"),
    BIEN(14, "Bien"),
    TRES_BIEN(16, "Très bien"),
    EXCELLENT(18, "Excellent");

    // les champs de chaque constante
    private final int noteMinimale;
    private final String libelle;

    // le constructeur est appelé une fois pour chaque constante
    Mention(int noteMinimale, String libelle) {
        this.noteMinimale = noteMinimale;
        this.libelle = libelle;
    }

    int getNoteMinimale() {
        return noteMinimale;
    }

    String getLibelle() {
        return libelle;
    }

    // une méthode statique : la mention correspondant à une note (vide si note < 10)
    static Optional<Mention> pour(double note) {
        Mention resultat = null;
        for (Mention m : values()) {
            if (note >= m.noteMinimale) {
                resultat = m;
            }
        }
        return Optional.ofNullable(resultat);
    }
}

// des droits d'accès : un ensemble de valeurs sera représenté par un EnumSet
enum Droit { LECTURE, ECRITURE, EXECUTION }

void main() {
    for (Mention m : Mention.values()) {
        IO.println(m + " : « " + m.getLibelle() + " » à partir de " + m.getNoteMinimale());
    }
    double[] notes = {8.5, 12, 15.5, 19};
    for (double note : notes) {
        String libelle = Mention.pour(note).map(Mention::getLibelle).orElse("aucune mention");
        IO.println("note " + note + " : " + libelle);
    }

    // EnumSet : un ensemble de valeurs d'une énumération (remplace les combinaisons de bits)
    EnumSet<Droit> droits = EnumSet.of(Droit.LECTURE, Droit.ECRITURE);
    IO.println("droits=" + droits + ", lecture : " + droits.contains(Droit.LECTURE)
            + ", exécution : " + droits.contains(Droit.EXECUTION));
    droits.add(Droit.EXECUTION);
    droits.remove(Droit.ECRITURE);
    IO.println("droits=" + droits);
    IO.println("tous=" + EnumSet.allOf(Droit.class) + ", aucun=" + EnumSet.noneOf(Droit.class)
            + ", complément de [LECTURE]=" + EnumSet.complementOf(EnumSet.of(Droit.LECTURE)));
    IO.println("de ASSEZ_BIEN à TRES_BIEN : " + EnumSet.range(Mention.ASSEZ_BIEN, Mention.TRES_BIEN));

    // EnumMap : un dictionnaire dont les clés sont les valeurs d'une énumération (chapitre 5)
    EnumMap<Mention, Integer> effectifs = new EnumMap<>(Mention.class);
    effectifs.put(Mention.BIEN, 7);
    effectifs.put(Mention.PASSABLE, 12);
    IO.println("effectifs=" + effectifs);
}

Résultats :

> java Bases28.java
PASSABLE : « Passable » à partir de 10
ASSEZ_BIEN : « Assez bien » à partir de 12
BIEN : « Bien » à partir de 14
TRES_BIEN : « Très bien » à partir de 16
EXCELLENT : « Excellent » à partir de 18
note 8.5 : aucune mention
note 12.0 : Assez bien
note 15.5 : Bien
note 19.0 : Excellent
droits=[LECTURE, ECRITURE], lecture : true, exécution : false
droits=[LECTURE, EXECUTION]
tous=[LECTURE, ECRITURE, EXECUTION], aucun=[], complément de [LECTURE]=[ECRITURE, EXECUTION]
de ASSEZ_BIEN à TRES_BIEN : [ASSEZ_BIEN, BIEN, TRES_BIEN]
effectifs={PASSABLE=12, BIEN=7}

Commentaires :

  • lignes 4-8 : les constantes de l'énumération, chacune avec ses deux paramètres. Le point-virgule après la dernière constante est obligatoire lorsque des membres la suivent ;
  • lignes 11-12 : les champs de chaque constante. Ils sont [private] (accessibles seulement dans l'énumération) et [final] (fixés une fois pour toutes) ;
  • lignes 15-18 : le constructeur, appelé une fois pour chaque constante lors du premier usage de l'énumération. Le mot clé [this] désigne l'objet en cours de construction : [this.noteMinimale] est le champ, [noteMinimale] le paramètre. Le constructeur d'une énumération est implicitement privé : on ne peut pas créer d'autres constantes ;
  • lignes 20-26 : deux méthodes qui rendent la valeur des champs. Les noms [getNoteMinimale] et [getLibelle] suivent la convention des accesseurs Java, présentée au chapitre 4 ;
  • lignes 29-37 : une méthode statique, qui s'appelle sur l'énumération elle-même ([Mention.pour(note)]). Les constantes étant déclarées par notes croissantes, la dernière dont la note minimale est atteinte est la bonne. Le résultat est un [Optional], vide si la note est inférieure à 10 ;
  • ligne 41 : une énumération simple, pour les droits d'accès ;
  • lignes 44-46 : la liste des mentions avec leurs champs ;
  • ligne 49 : [map(Mention::getLibelle)] transforme l'[Optional<Mention>] en [Optional<String>] contenant le libellé ; [orElse] fournit un texte lorsque l'[Optional] est vide ;
  • ligne 54 : un ensemble de deux droits ;
  • lignes 55-56 : la méthode [contains] teste l'appartenance d'une constante à l'ensemble, comme on testait un bit ;
  • lignes 57-58 : on ajoute et on retire des constantes ;
  • lignes 60-61 : l'ensemble de toutes les constantes, l'ensemble vide et le complémentaire d'un ensemble ;
  • ligne 62 : les constantes comprises entre deux constantes, dans l'ordre de déclaration ;
  • lignes 65-68 : un [EnumMap] associe un effectif à certaines mentions. Ses clés sont toujours parcourues dans l'ordre de déclaration des constantes : PASSABLE apparaît avant BIEN, bien qu'elle ait été ajoutée après. Les dictionnaires sont étudiés au chapitre 5.

3.10. Passage de paramètres à une fonction

En Java, les fonctions sont appelées méthodes : elles appartiennent toujours à une classe, éventuellement à la classe implicite d'un fichier compact. Nous nous intéressons ici au mode de passage des paramètres d'une méthode. Considérons la méthode suivante :

1
2
3
4
void changeInt(int a) {
    a = 30;
    IO.println("Paramètre formel a=" + a);
}

Dans la définition de la méthode, ligne 1, [a] est appelé un paramètre formel. Il n'est là que pour les besoins de la définition de la méthode [changeInt]. Il aurait tout aussi bien pu s'appeler [b]. Considérons maintenant une utilisation de cette méthode :

1
2
3
    int age = 20;
    changeInt(age);
    IO.println("Paramètre effectif age=" + age);

Ici, dans l'instruction de la ligne 2, [age] est le paramètre effectif qui va transmettre sa valeur au paramètre formel [a]. Nous nous intéressons à la façon dont un paramètre formel récupère la valeur d'un paramètre effectif.

3.10.1. Le passage par valeur, seul mode de Java

Java n'a qu'un mode de passage des paramètres : le passage par valeur. La valeur du paramètre effectif est recopiée dans le paramètre formel correspondant. On a deux entités distinctes : si la méthode modifie le paramètre formel, le paramètre effectif n'est en rien modifié.

Lorsque le paramètre est d'un type référence (un tableau, un objet), la valeur recopiée est la référence : le paramètre formel et le paramètre effectif désignent alors le même objet. La méthode peut donc modifier cet objet, s'il est modifiable, et l'appelant verra la modification ; en revanche, elle ne peut pas faire désigner un autre objet à la variable de l'appelant.

Contrairement à d'autres langages, Java n'a pas de passage par référence, dans lequel paramètre formel et paramètre effectif seraient une seule et même variable : une méthode ne peut pas modifier une variable locale de son appelant, ni lui « rendre » des résultats par ses paramètres. On utilise à la place :

  • la valeur de retour de la méthode, que l'appelant affecte à sa variable ;
  • un record lorsque la méthode doit rendre plusieurs valeurs ;
  • un tableau ou un objet modifiable passé en paramètre, dans lequel la méthode dépose ses résultats.

Par ailleurs, un paramètre déclaré [final] ne peut pas recevoir de nouvelle valeur dans la méthode : le compilateur garantit ainsi qu'il garde la valeur transmise par l'appelant.

Le programme [Bases29.java] illustre ces différents cas :

// le passage des paramètres : toujours par valeur
void main() {
    // 1 - passage par valeur : le paramètre effectif n'est pas modifié
    int age = 20;
    changeInt(age);
    IO.println("Paramètre effectif age=" + age);

    // 2 - pour modifier une variable de l'appelant : la méthode rend la nouvelle valeur
    int age2 = 20;
    age2 = changeInt2(age2);
    IO.println("Paramètre effectif age2=" + age2);

    // 3 - pour rendre plusieurs valeurs : un record
    Conversion c1 = convertir("123");
    Conversion c2 = convertir("12x");
    IO.println("c1=" + c1 + ", c2=" + c2);
    if (c1.reussie()) {
        IO.println("valeur de c1 : " + c1.valeur());
    }

    // 4 - un échange de valeurs : les valeurs sont rangées dans un tableau
    int[] xy = {1, 2};
    echanger(xy);
    IO.println("après échange : x=" + xy[0] + ", y=" + xy[1]);

    // 5 - un tableau passé par valeur : c'est sa référence qui est copiée
    int[] tableau = {1, 2, 3};
    modifierTableau(tableau);
    IO.println("tableau=" + Arrays.toString(tableau));

    // 6 - un objet modifiable (mutable) : la méthode modifie l'objet référencé
    StringBuilder message = new StringBuilder("Bonjour");
    completer(message);
    IO.println("message=" + message);

    // 7 - un paramètre final ne peut pas recevoir de nouvelle valeur dans la méthode
    BigDecimal prix = new BigDecimal("100");
    IO.println("TTC(" + prix + ")=" + ttc(prix));
}

// le résultat d'une conversion : réussite et valeur obtenue
record Conversion(boolean reussie, int valeur) {}

void changeInt(int a) {
    a = 30;
    IO.println("Paramètre formel a=" + a);
}

int changeInt2(int a2) {
    a2 = 30;
    IO.println("Paramètre formel a2=" + a2);
    return a2;
}

Conversion convertir(String texte) {
    try {
        return new Conversion(true, Integer.parseInt(texte));
    } catch (NumberFormatException _) {
        return new Conversion(false, 0);
    }
}

void echanger(int[] t) {
    int temp = t[0];
    t[0] = t[1];
    t[1] = temp;
}

void modifierTableau(int[] t) {
    // on modifie le tableau référencé : l'appelant voit la modification
    t[0] = 100;
    // on fait pointer t sur un autre tableau : l'appelant ne le voit pas
    t = new int[]{7, 8, 9};
    IO.println("dans modifierTableau : t=" + Arrays.toString(t));
}

void completer(StringBuilder texte) {
    texte.append(" tout le monde");
}

BigDecimal ttc(final BigDecimal montant) {
    // montant = BigDecimal.ZERO; serait refusé : le paramètre est final
    return montant.multiply(new BigDecimal("1.2"));
}

Résultats :

> java Bases29.java
Paramètre formel a=30
Paramètre effectif age=20
Paramètre formel a2=30
Paramètre effectif age2=30
c1=Conversion[reussie=true, valeur=123], c2=Conversion[reussie=false, valeur=0]
valeur de c1 : 123
après échange : x=2, y=1
dans modifierTableau : t=[7, 8, 9]
tableau=[100, 2, 3]
message=Bonjour tout le monde
TTC(100)=120.0

Commentaires :

  • lignes 4-6 : la valeur 20 du paramètre effectif [age] a été recopiée dans le paramètre formel [a] (ligne 44). Celui-ci a ensuite été modifié (ligne 45). Le paramètre effectif est resté inchangé ;
  • lignes 9-11 : la méthode [changeInt2] (lignes 49-53) rend sa nouvelle valeur, que l'appelant affecte lui-même à sa variable [age2] ;
  • ligne 42 : un record (Java 16) déclare en une ligne une classe dont les objets regroupent deux valeurs non modifiables, [reussie] et [valeur]. Le compilateur lui ajoute un constructeur, une méthode d'accès pour chacune des deux valeurs ([reussie()], [valeur()]) et une méthode [toString()]. Les records sont étudiés au chapitre 4 ;
  • lignes 14-19 : la méthode [convertir] (lignes 55-61) rend deux informations, la réussite de la conversion et la valeur obtenue, réunies dans un objet [Conversion]. Sa méthode [toString()] affiche ses composants ;
  • lignes 22-24 : l'échange de deux valeurs. Une méthode [echanger(int a, int b)] échangerait des copies sans effet pour l'appelant ; on range donc les deux valeurs dans un tableau, que la méthode [echanger] (lignes 63-67) modifie ;
  • lignes 27-29 : c'est la référence du tableau qui est passée par valeur. La méthode [modifierTableau] travaille donc sur le même tableau que l'appelant : la modification de [t[0]] (ligne 71) est visible de l'appelant. En revanche, faire pointer [t] sur un autre tableau (ligne 73) ne modifie que la copie locale de la référence ;
  • lignes 32-34 : un objet [StringBuilder] est une chaîne modifiable : la méthode [completer] (lignes 77-79) lui ajoute du texte, et l'appelant voit le résultat. Avec une [String], c'eût été impossible : les objets [String] sont immuables ;
  • lignes 37-38 : la méthode [ttc] (lignes 81-84) a un paramètre [final]. Le produit de deux [BigDecimal] a autant de décimales que ses deux facteurs réunis : 100 × 1,2 = 120,0, affiché 120.0.

Si l'on remplace le commentaire de la ligne 82 par l'instruction [montant = BigDecimal.ZERO;], le compilateur refuse le programme :

1
2
3
4
Bases29.java:82: error: final parameter montant may not be assigned
    montant = BigDecimal.ZERO;
    ^
1 error

3.10.2. Surcharge de méthodes, nombre variable de paramètres

Java n'a pas de paramètres optionnels, ayant une valeur par défaut, ni d'arguments nommés. On obtient l'équivalent des paramètres optionnels par la surcharge : plusieurs méthodes peuvent porter le même nom si leurs listes de paramètres diffèrent par le nombre ou le type des paramètres ; le compilateur choisit, d'après les paramètres effectifs, celle qui convient. Le plus souvent, les versions courtes appellent la version complète en lui passant des valeurs par défaut. Lorsque les paramètres optionnels sont nombreux, on préfère un objet de configuration ou un « monteur » (« builder »), dont nous verrons des exemples dans la suite du cours.

Java 5 a ajouté les méthodes à nombre variable de paramètres (« varargs ») : le dernier paramètre formel est déclaré avec trois points, [int... nombres]. L'appelant peut passer soit un tableau, soit une liste de valeurs (éventuellement vide), que le compilateur range dans un tableau ; dans la méthode, le paramètre est un tableau.

Le programme [Bases30.java] est le suivant :

// surcharge de méthodes, nombre variable de paramètres
void main() {
    // pas de paramètres optionnels en Java : on surcharge la méthode
    IO.println(saluer("Pierre"));
    IO.println(saluer("Pierre", "Bonsoir"));
    IO.println(saluer("Pierre", "Bonjour", true));
    IO.println(saluer("Paul", "Salut", true));

    // varargs : nombre variable de paramètres
    IO.println("somme()=" + somme());
    IO.println("somme(1, 2, 3)=" + somme(1, 2, 3));
    int[] valeurs = {10, 20, 30};
    IO.println("somme(valeurs)=" + somme(valeurs));
    // des méthodes du JDK à nombre variable de paramètres
    IO.println(String.join(" - ", "a", "b", "c"));
    IO.println(String.format("%s, %s et %s", "un", "deux", "trois"));
    IO.println(List.of(1, 2, 3, 4));
}

// trois méthodes de même nom qui diffèrent par leurs paramètres
String saluer(String nom) {
    return saluer(nom, "Bonjour");
}

String saluer(String nom, String formule) {
    return saluer(nom, formule, false);
}

String saluer(String nom, String formule, boolean majuscules) {
    String message = formule + " " + nom;
    return majuscules ? message.toUpperCase() : message;
}

int somme(int... nombres) {
    // nombres est un tableau
    int somme = 0;
    for (int nombre : nombres) {
        somme += nombre;
    }
    return somme;
}

Résultats :

> java Bases30.java
Bonjour Pierre
Bonsoir Pierre
BONJOUR PIERRE
SALUT PAUL
somme()=0
somme(1, 2, 3)=6
somme(valeurs)=60
a - b - c
un, deux et trois
[1, 2, 3, 4]

Commentaires :

  • lignes 21-32 : trois méthodes [saluer] qui diffèrent par leur nombre de paramètres. Seule la dernière fait le travail ; les deux autres l'appellent avec des valeurs par défaut pour les paramètres absents ;
  • ligne 4 : [saluer("Pierre")] appelle la version à un paramètre (ligne 21), qui appelle la version à deux paramètres avec la formule "Bonjour", qui appelle à son tour la version complète avec [false] ;
  • lignes 6-7 : sans arguments nommés, pour passer [majuscules], il faut aussi passer [formule] ;
  • lignes 34-41 : [somme] accepte un nombre quelconque d'entiers, reçus dans le tableau [nombres] ;
  • lignes 10-13 : on appelle [somme] sans paramètre, avec trois entiers, puis avec un tableau ;
  • lignes 15-17 : de nombreuses méthodes du JDK ont un nombre variable de paramètres : [String.join], [String.format], [List.of]...

3.11. Fonctions locales et tuples : les solutions de Java

3.11.1. Méthodes, lambdas et classes locales

Certains langages permettent de définir une fonction locale, c'est-à-dire une fonction écrite à l'intérieur d'une autre et utilisable seulement à cet endroit. Java ne le permet pas. Dans nos fichiers compacts, les méthodes du fichier en tiennent lieu ; dans une classe, on écrit des méthodes privées. À l'intérieur d'une méthode, on dispose de deux outils :

  • une lambda (Java 8), petite fonction anonyme que l'on range dans une variable d'un type adapté, appelé interface fonctionnelle : [IntUnaryOperator cube = x -> x * x * x;]. Les lambdas sont étudiées au chapitre 7 « Interfaces fonctionnelles, lambdas et événements ». Une lambda peut lire les variables locales qui l'entourent, mais seulement si elles ne sont jamais modifiées (variables [final] ou « effectivement finales ») ; elle ne peut pas les modifier ;
  • une classe locale, déclarée à l'intérieur de la méthode et utilisable seulement dans celle-ci.

3.11.2. Les records à la place des tuples

D'autres langages ont des tuples, qui regroupent plusieurs valeurs sans qu'il soit besoin de déclarer un type. Java n'en a pas : il utilise des records (Java 16). La déclaration [record Couple(int numero, String nom) {}] crée en une ligne une classe dont chaque objet regroupe un entier et une chaîne, avec un constructeur, les méthodes d'accès [numero()] et [nom()], et les méthodes [toString()], [equals()] et [hashCode()]. Un record peut être déclaré à l'intérieur d'une méthode (record local). Ses composants peuvent être extraits par un motif d'enregistrement (« record pattern », Java 21), avec l'opérateur [instanceof] ou dans un [switch]. Les records sont le moyen naturel de faire rendre plusieurs valeurs à une méthode ; leurs composants ayant des noms, ils sont plus lisibles que des tuples.

Le programme [Bases31.java] est le suivant :

// à la place des fonctions locales et des tuples : méthodes, lambdas, classes et records locaux
void main() {
    // une méthode privée du fichier compact
    IO.println("carre(7)=" + carre(7));
    // une lambda rangée dans une variable (chapitre 7)
    IntUnaryOperator cube = x -> x * x * x;
    IO.println("cube(3)=" + cube.applyAsInt(3));

    // une lambda ne peut pas modifier une variable locale : on passe par un objet
    int[] compteur = {0};
    Runnable incrementer = () -> compteur[0]++;
    incrementer.run();
    incrementer.run();
    IO.println("compteur=" + compteur[0]);

    // une classe locale : déclarée dans la méthode, elle n'est utilisable que là
    class Accumulateur {
        private int total;

        void ajouter(int valeur) {
            total += valeur;
        }
    }
    Accumulateur acc = new Accumulateur();
    acc.ajouter(5);
    acc.ajouter(8);
    IO.println("total=" + acc.total);

    // un record local regroupe plusieurs valeurs nommées
    record Couple(int numero, String nom) {}
    Couple t1 = new Couple(1, "un");
    IO.println("t1=" + t1 + ", t1.numero()=" + t1.numero() + ", t1.nom()=" + t1.nom());
    // déconstruction par un motif d'enregistrement (Java 21)
    Object objet = new Couple(3, "trois");
    if (objet instanceof Couple(int numero, String nom)) {
        IO.println("numero=" + numero + ", nom=" + nom);
    }
    // une paire toute faite : Map.entry
    Map.Entry<Integer, String> paire = Map.entry(2, "deux");
    IO.println("paire=" + paire + ", clé=" + paire.getKey() + ", valeur=" + paire.getValue());

    // une méthode qui rend plusieurs valeurs : un record
    DivisionEntiere d = diviser(17, 5);
    IO.println("17 = 5 * " + d.quotient() + " + " + d.reste());
    MinMax mm = minMax(new int[]{12, -4, 35, 7});
    IO.println("min=" + mm.min() + ", max=" + mm.max());

    // échange de deux variables : une variable temporaire
    int a = 1, b = 2;
    int temp = a;
    a = b;
    b = temp;
    IO.println("a=" + a + ", b=" + b);
}

int carre(int x) {
    return x * x;
}

record DivisionEntiere(int quotient, int reste) {}

record MinMax(int min, int max) {}

DivisionEntiere diviser(int a, int b) {
    return new DivisionEntiere(a / b, a % b);
}

MinMax minMax(int[] valeurs) {
    int min = valeurs[0], max = valeurs[0];
    for (int v : valeurs) {
        if (v < min) min = v;
        if (v > max) max = v;
    }
    return new MinMax(min, max);
}

Résultats :

> java Bases31.java
carre(7)=49
cube(3)=27
compteur=2
total=13
t1=Couple[numero=1, nom=un], t1.numero()=1, t1.nom()=un
numero=3, nom=trois
paire=2=deux, clé=2, valeur=deux
17 = 5 * 3 + 2
min=-4, max=35
a=2, b=1

Commentaires :

  • ligne 4 : la méthode [carre] (lignes 56-58) du fichier compact ;
  • ligne 6 : une lambda. [x -> x * x * x] se lit « la fonction qui à x associe x * x * x ». Elle est rangée dans une variable de type [IntUnaryOperator], qui représente une fonction d'un [int] vers un [int] (paquetage [java.util.function]) ;
  • ligne 7 : la lambda est appelée par la méthode [applyAsInt] de l'interface [IntUnaryOperator] ;
  • lignes 10-14 : une lambda de type [Runnable] (sans paramètre ni résultat), appelée par [run()]. Elle ne peut pas modifier une variable locale [int] ; elle peut en revanche modifier le contenu d'un objet, ici un tableau d'un élément. Avec [int compteur = 0;] et [() -> compteur++], le compilateur aurait refusé le programme : [error: local variables referenced from a lambda expression must be final or effectively final] ;
  • lignes 17-23 : une classe locale [Accumulateur], avec un champ [total] et une méthode [ajouter]. Les classes sont présentées au chapitre 4 ;
  • lignes 24-27 : on crée un objet [Accumulateur] avec l'opérateur [new], puis on appelle sa méthode. Le champ privé [total] reste accessible au code de la méthode qui contient la classe ;
  • ligne 30 : un record local ;
  • ligne 32 : la méthode [toString()] d'un record affiche le nom du record et ses composants ; les méthodes [numero()] et [nom()] rendent les composants ;
  • lignes 34-37 : un motif d'enregistrement : si [objet] est un [Couple], ses deux composants sont rangés dans les variables [numero] et [nom], déclarées dans le motif. Java 25 n'a pas d'instruction qui éclaterait directement un record dans plusieurs variables ;
  • lignes 39-40 : pour un simple couple clé-valeur, la bibliothèque offre [Map.entry(cle, valeur)], dont les composants s'obtiennent par [getKey()] et [getValue()] ;
  • lignes 43-46 : les méthodes [diviser] et [minMax] (lignes 64-75) rendent chacune deux valeurs, réunies dans un record déclaré aux lignes 60 et 62. Les noms des composants ([quotient()], [reste()], [min()], [max()]) rendent le code appelant explicite ;
  • lignes 49-53 : sans tuples, l'échange de deux variables se fait avec une variable temporaire ;
  • lignes 71-72 : une instruction [if] dont l'action, unique, est écrite sur la même ligne, sans accolades.

Dans un programme plus important, on écrit des classes, qui regroupent des données et les méthodes qui les manipulent : c'est l'objet du chapitre suivant.