Mercredi 2 novembre 2022
Ce didacticiel vous permet de commencer à programmer en Java, partant du principe que vous savez déjà vous débrouiller avec un autre langage de programmation tel que C ou Python. Vous pouvez regarder la documentation de Java 19 : soit celle d’OpenJDK, soit celle d’Oracle.
Le logiciel fondamental est le JDK. Sous GNU/Linux, il est bien sûr disponible parmi les paquets officiels de votre distribution. Si vous êtes sous un autre système, vous pouvez télécharger OpenJDK depuis son site web. Il existe aujourd’hui Java 19, mais si vous n’avez que la version 11 du JDK, c’est amplement suffisant.
Ensuite, il faut un environnement de développement puissant. Programmer en Java avec un simple éditeur de texte est possible mais généralement très laborieux, car il y a énormément de composants et leurs noms sont souvent très longs, c’est pourquoi l’EDI doit être capable de parcourir vos fichiers Java et les bibliothèques que vous utilisez afin de vous permettre de saisir tous ces noms très rapidement quel que soit le paquetage dans lequel ils se trouvent. L’EDI peut aussi vous signaler vos erreurs en temps réel, renommer un composant en deux temps et trois mouvements, vous indiquer les endroits où vous utilisez un composant, vous indiquer quels sont les types de paramètres possibles pour une fonction ainsi que les noms de ces paramètres, etc.
En Licence 3 Informatique, au premier semestre, au CREMI, on nous a proposé IntelliJ Idea, qui bien sûr fait complètement l’affaire. Sous GNU/Linux, vous le trouverez normalement dans les paquets officiels de votre distribution. Cet EDI est plutôt lourd : il fait énormément ramer mon ordinateur, car ce dernier n’a que 4 Gio de mémoire vive, et IntelliJ Idea en utilise apparemment 3… Il existe d’autres EDI spécialisés dans le Java, tels que Eclipse et NetBeans, qui ne sont probablement pas moins lourds.
Au deuxième semestre, on nous a plutôt incités à utiliser VS Codium, qui n’est pas spécialisé dans le Java mais le prend bien en charge. Cet EDI est également lourd.
Le développement en Java se couple souvent avec l’utilisation d’un moteur de production : la faculté nous a fait utiliser Gradle puis Maven.
La syntaxe est globalement très inspirée de celle du C++. Elle est cependant nettement plus simple : Java est moins riche que C++, mais plus facile à appréhender.
Copiez-collez ce code dans votre EDI et mettez-le d’un côté de l’écran, avec de l’autre côté les explications ci-dessous.
package
et un point-virgule. Cette ligne peut être omise,
mais alors les composants (classes, interfaces et énumérations) du fichier ne sont pas accessibles depuis les composants
des autres dossiers. Les composants ne sont pas non plus accessibles si le dossier ne correspond pas
au nom de paquetage indiqué. En l’occurrence, ici, le fichier doit être dans un dossier fr/ubx/hello.
Les dossiers racines sont souvent com
, org
ou fr
comme dans les noms des sites web, sauf que l’ordre est inversé
(au lieu d’écrire hello.ubx.fr
, on écrit fr.ubx.hello
).
import
au début du code
(avec un point-virgule à la fin).
Mettre un astérisque à la place d’un nom de classe permet d’importer toutes les classes du paquetage.
Ci-dessus, j’ai utilisé un astérisque pour importer l’interface List
et les classes
ArrayList
, Scanner
et InputMismatchException
du paquetage java.util
.
L’astérisque ne peut pas être utilisée pour importer plusieurs paquetages d’un coup : l’astérisque symbolise
les classes, les interfaces et les énumérations, pas les paquetages.
import static
(pas utilisée ci-dessus). L’astérisque permet d’importer tous les membres statiques d’un coup.
Par exemple, ci-dessus, on aurait pu écrire :
import static java.lang.Math.*;pour pouvoir écrire
toRadians
, sin
et cos
sans avoir à les préfixer de Math.
.
java.util.function.Consumer
n’a rien à voir avec celle de
java.util.*
.
java.lang
(tels que la classe System
ou encore la classe Math
)
sont importés automatiquement, pas besoin de lignes d’importation pour celles-là.
Les conventions décrites dans cette section ne sont certes pas obligatoires, mais elles sont normalement suivies par toutes et tous en Java.
int
et double
ci-dessus) sont tout en minuscules.
Tout comme en Python, les identifiants peuvent contenir des caractères spéciaux tels que les lettres avec diacritiques (éàôçï) et les ligatures (œ).
Elles sont définies avec le mot-clé class
. Ce mot-clé est précédé par le niveau de visibilité de la classe :
public
(c’est ce qu’on met en général) : n’importe quel autre composant est censé pouvoir y accéder ;
package private
(ce qui s’applique si on ne met rien) :
seuls les composants du même paquetage de la classe pourront y accéder.
Si la classe est publique, elle doit avoir le même nom que le fichier qui la contient. L’extension des fichiers source en Java est .java ; ici le fichier doit donc s’appeler Hello.java.
Le contenu de la classe est spécifié entre accolades. Bien sûr, à chaque fois qu’on ouvre une accolade, on indente le code. Habituellement, l’EDI s’occupe de l’indentation pratiquement tout seul.
Contrairement à ce qu’on fait en C++, il faut indiquer le niveau de visibilité
pour chaque membre de la classe, sinon par défaut il est package private
.
Pour les membres, il y a deux niveaux de visibilité en plus :
protected
: les composants du même paquetage que la classe, mais aussi les classes dérivées
(abordées dans la section Le polymorphisme), peuvent accéder à l’élément ;
private
: seule la classe elle-même peut accéder à l’élément.
Ensuite, il faut distinguer les membres statiques de ceux qui ne sont pas statiques.
static
) existent une fois pour toutes,
indépendamment de toute instance de la classe. Ci-dessus, il n’y a que la méthode main
,
que l’on peut donc désigner en écrivant Hello.main
,
ou simplement main
si on est à l’intérieur de la classe.
new
) pour pouvoir les utiliser. Par exemple :
Hello hello = new Hello(); hello.linesOfText();Si on est déjà dans une méthode d’instance, on dispose du mot-clé
this
pour désigner l’instance actuelle.
Le constructeur s’écrit comme en C++ : c’est une méthode sans type de retour, qui a le même nom que la classe.
On l’appelle avec le mot-clé new
, suivi entre parenthèses des paramètres correspondant
au constructeur qu’on veut appeler.
Il n’y a pas de destructeurs en Java : il y a au lieu de ça un ramasse-miette
(garbage collector en anglais), censé étudier les données créées par votre programme
et les détruire (pour libérer la mémoire) lorsqu’elles ne sont plus accessibles d’où que ce soit.
De nombreux langages modernes utilisent ce concept du ramasse-miette.
Il n’y a pas de destructeur, par contre il peut exister une méthode close
nécessaire pour décharger immédiatement des ressources (telles qu’un fichier ouvert),
la lenteur du ramasse-miette étant souvent inadaptée.
Ouvrez une invite de commandes, rendez-vous dans le dossier racine de vos paquetages (celui qui contient le dossier « fr »), et exécutez javac en lui fournissant le chemin vers le fichier Java que vous voulez compiler :
javac fr/ubx/hello/Hello.java
Vous verrez un fichier Hello.class apparaitre à côté de votre fichier Hello.java
(ainsi qu’un autre fichier pour l’interface DoubleConsumer
).
Les fichiers en Java ne sont pas compilés en vrais programmes exécutables ; au lieu de ça,
ils sont compilés en ce qu’on appelle le bytecode Java, ou encore code portable Java.
Ce bytecode est le même quelle que soit la plateforme (Windows, OS X, GNU/Linux…) que vous utilisez,
c’est pourquoi il est dit portable.
Pour exécuter le programme, il faut faire appel à la machine virtuelle Java, la JVM. Le nom du programme est java. Cette fois, il ne faut pas indiquer le chemin vers un fichier, mais le nom de la classe avec son paquetage (toujours depuis le dossier racine) :
java fr.ubx.hello.Hello
Le système de complétion automatique de votre interpréteur de commandes (invoqué par pression de la touche Tab) devrait vous permettre de saisir ce chemin très aisément.
Une classe ne peut être exécutée par la JVM que si elle comporte une méthode statique main
qui prend en paramètre un tableau de chaines de caractères (ce tableau doit faire partie des paramètres
même s’il n’est pas utilisé). Ci-dessus, elle est définie tout en haut de la classe.
Essayez de supprimer la méthode main
, ou de modifier sa signature ou son type de retour,
puis de recompiler et réexécuter votre code : selon le cas, le compilateur ou la JVM déclarera qu’il y a une erreur.
Il n’y a pas de fonctions externes en Java, seulement des méthodes de classes/interfaces/énumérations.
Tout comme en C++, il faut déclarer leur type de retour, leur nom, puis entre parenthèses les types et les noms
des paramètres qu’il faut fournir pour les appeler (ci-dessus, toutes les méthodes,
à l’exception de la méthode statique main
, sont sans paramètre, donc les parenthèses sont vides).
Tout comme en C++, il est possible de déclarer plusieurs méthodes du même nom dans la même classe, avec des types de paramètres différents. Cela s’appelle la surcharge (overloading en anglais). Le nom de la méthode et les types de ses paramètres constituent la signature de la fonction. Le type de retour ne fait pas partie de la signature ; changer le type de retour ne suffit pas pour surcharger, il est impossible que deux méthodes aient la même signature même si elles ont un type de retour différent.
Pour appeler une méthode, on écrit son nom, puis entre parenthèses, les valeurs des paramètres séparées par des virgules
(comme dans la plupart des langages de programmations impératifs).
Par défaut, cela appelle une méthode de l’objet en cours : this
(exemples lignes 16 et 18 : on appelle les méthodes linesOfText
et theConsumers
).
Pour appeler une méthode d’un autre objet, il faut précéder le nom de la méthode par le nom de l’objet suivi par un point
(les nombreux System.out.println
en sont des exemples : on appelle la méthode println
de l’objet System.out
, qui est un membre statique de la classe System
).
Dans chaque méthode, on renvoie une valeur avec le mot-clé return
.
Si une fonction risque de ne pas retourner de valeur, javac refusera de compiler.
Ci-dessus, toutes les méthodes sont de type void
, c’est-à-dire qu’elles ne renvoient aucune valeur.
Semblablement à ce qui se passe en Python, il existe deux sortes de types de données : les types primitifs et les types objets.
La liste des types primitifs est la suivante (elle ressemble à celle du C++) :
int
(entier, sur 4 octets) ;long
(entier long, sur 8 octets) ;short
(entier court, sur 2 octets) ;byte
(entier sur 1 octet) ;char
(caractère, sur 2 octets, utilisant donc UTF-16) ;float
(flottant, sur 4 octets) ;double
(flottant à double précision, sur 8 octets) ;boolean
(booléen).
Il n’y a pas de types numériques non signés.
Les données de type primitif n’ont aucun membre : on ne peut pas écrire variable.quelqueChose
.
D’ailleurs, à chacun de ces types primitifs correspond un type objet dans le paquetage java.lang
:
ces types objets ont simplement le même nom que le type primitif, avec une majuscule au début
(deux de ces types ont un nom plus long cependant : Integer
et Character
).
Ces classes fournissent des méthodes utiles pour gérer les types primitifs.
Elles sont d’ailleurs exceptionnelles : leurs instances (les objets ayant ces types) peuvent être utilisées
comme les variables ayant le type primitif, donc avec les opérateurs arithmétiques, etc.
Du reste, contrairement au C++ et au Python, pour des raisons de simplicité Java ne permet pas la surcharge des opérateurs.
Même pour les opérateurs de comparaison, la logique est d’utiliser des méthodes equals
et compareTo
plutôt que les opérateurs tels que ==
.
Tous les types non primitifs sont des types objets, y compris les tableaux et les chaines de caractères.
Les variables de type objet contiennent toujours une référence vers une instance d’objet (comme en Python),
ou la valeur spéciale null
. Par comparaison au C++, c’est comme si toutes ces variables étaient des pointeurs.
Aucune copie n’est faite lorsqu’on affecte la variable de type objet à une autre variable, ou encore lorsqu’on passe
une telle variable à une fonction : c’est juste la référence qui est dupliquée, l’objet pointé est toujours le même.
Ainsi, l’opérateur ==
, utilisé avec des variables de type objet, vérifie si les références sont identiques,
autrement dit si les deux variables pointent vers le même objet.
Pour pouvoir vraiment dupliquer un objet, il faut créer un nouvel objet qui a les mêmes propriétés
(cela peut être facilité avec un constructeur de recopie comme en C++, ou encore avec une méthode clone()
).
Lorsqu’une variable est initialisée à l’endroit de sa déclaration, on peut utiliser le mot-clé var
pour que son type soit auto-déduit. Par exemple :
var nb1 = 0; // variable de type int var nb2 = 5d; // variable de type double List<Integer> list = new ArrayList<>(); var iter = list.iterator(); // variable de type Iterator<Integer> for (var nb: list) {} // variable de type Integer
Le type correspondant est String
. Tout comme en Python, les chaines de caractères ayant ce type
sont immutables. Pour avoir des chaines de caractères mutables, il faut utiliser le type StringBuilder
.
Tout comme en JavaScript (c’est l’un des quelques points communs entre Java et JavaScript),
toute variable peut être facilement convertie en chaine de caractères,
en l’additionnant avec une chaine de caractères (dans le code ci-dessus, cela est utilisé aux lignes 31, 41, 44…).
Dans le cas où la variable est de type objet, c’est en fait la méthode toString
de l’objet qui est appelée.
Tout objet possède cette méthode ; par défaut, elle renvoie le nom de la classe suivi de l’adresse de l’objet,
avec une arobase entre les deux (essayez d’afficher toString()
ou encore "" + this
pour voir).
Par ailleurs, les méthodes print
et println
, utilisées ci-dessus pour écrire du texte
dans la console, acceptent n’importe quel type en paramètre, et dans le cas d’un objet,
appellent sa méthode toString
(essayez d’afficher this
pour voir).
Note : en tapant simplement sout
dans l’éditeur de votre EDI, souvent l’outil d’autocomplétion vous propose
directement System.out.println
.
Pour parcourir les chaines de caractères, la classe String
offre principalement trois méthodes :
isEmpty()
, length()
(utilisées ci-dessus) et charAt(index)
(on ne peut pas utiliser les crochets pour accéder à un caractère, il faut utiliser .charAt(index)
).
Une variable est soit un champ de classe, soit locale à une méthode. Les champs de classe sont initialisés
soit à 0 (pour les types primitifs) soit à null
(pour les types objets).
Les variables locales à une méthode doivent recevoir une valeur avant d’être utilisées
(sans quoi le compilateur refusera de compiler, signalant une erreur).
Une variable locale peut avoir le même nom qu’un champ de la classe de la méthode dans laquelle elle est définie.
Dans ce cas, il devient nécessaire d’utiliser le préfixe this.
pour accéder au champ.
Souvent, certains paramètres du constructeur ont le même nom que les champs de classe auxquels ils correspondent,
et on écrit alors des this.variable = variable;
pour faire les initialisations.
Comme en C++11, on peut initialiser les champs à l’endroit de leur déclaration.
Ci-dessus, le champ stdinScanner
ne reçoit jamais d’autre valeur que celle qu’on lui affecte au départ,
c’est pourquoi nous l’avons déclaré constant avec final
.
Les champs sont généralement marqués private
. Couramment, en POO, on encapsule le traitement des champs,
avec des méthodes nommées getVariable()
et setVariable(newValue)
,
qui contrôlent la façon dont on les modifie.
Ce sont les mêmes qu’en C++ : if
, else
, while
, for
,
switch
(non utilisé ci-dessus ; tout comme en C++, il faut mettre des break
dedans),
case
, try
, catch
et finally
. Après chacun de ces mots-clés,
on indique entre parenthèses les informations principales les concernant, le cas échéant.
Puis on met les instructions contenues entre accolades. Elles peuvent être omises s’il n’y a qu’une seule instruction
(sauf pour try
, catch
et finally
),
mais je vous recommande de les mettre à chaque fois : si jamais vous devez passer à plusieurs instructions
ou une seule instruction dans le bloc, ça évite d’avoir à faire des modifications, ce qui peut éviter les erreurs
et génère aussi deux lignes de différence en moins dans le cas où vous gérez les versions.
D’ailleurs, comme en JavaScript, la syntaxe Kernighan & Ritchie pour les accolades est recommandée partout
(c’est-à-dire qu’il faut mettre l’accolade ouvrante en fin de ligne, non seule sur une ligne).
Il n’y a pas d’instruction goto
, mais il y a les deux autres instructions de saut inconditionnel habituelles :
continue
(passe immédiatement au tour de boucle suivant) et break
(quitte la boucle ou
le switch
). Il est cependant possible de spécifier des étiquettes pour les break
,
pour quitter plusieurs boucles d’un coup. Mais il faut généralement chercher à éviter d’utiliser ces instructions.
Les structures de contrôle sont un type d’instruction. Les autres types d’instructions sont :
break
, return
…) ;Chacune de ces instructions se termine par un point-virgule (;).
Il est par exemple impossible de compiler une instruction faite uniquement d’un calcul qui ne fait rien : le compilateur indiquera que c’est une erreur.
Ci-dessus, il y a un tableau, déclaré ligne 40. Tout comme en C++, on peut mettre les crochets après le nom de la variable lors de la déclaration, mais c’est déconseillé : il vaut mieux les coller au nom du type. En aucun cas on ne précise à cet endroit la taille du tableau : les crochets sont toujours vides. Il y a alors deux manières de construire le tableau :
int[] numbers = { 1, 2, 3, 4, 5, 6 }; Hello[] hellos = { new Hello(), new Hello(), new Hello() }; String[] strs; strs = new String[] { "abc", "def", "ghi", "j" };
new
et le nombre de cases :
boolean[] bools = new boolean[100]; char[][] grid; grid = new char[100][100]; int[][] manyInts = new int[][] { new int[1000], new int[1000] };
Les éléments sont initialisés à 0 pour les types primitifs et à null
pour les types objets.
On peut bien sûr accéder aux éléments avec des crochets, et comme dans la plupart des langages de programmation,
les indices commencent à zéro et se terminent à un de moins que la taille du tableau.
Les tableaux possèdent un seul champ spécifique : length
, qui indique la taille du tableau.
Attention : c’est une méthode pour le type String
, et un champ pour les tableaux
(donc dans un cas il faut mettre des parenthèses, dans l’autre non) :
System.out.println("strs, tableau de taille " + strs.length); System.out.println("strs[0], chaine de caractères de longueur " + strs[0].length());
La longueur des tableaux ne peut pas facilement être changée. Pour la changer, il faut créer un tout nouveau tableau :
manyInts[0] = new int[10000];
Les valeurs initiales sont alors perdues (mais on peut les copier facilement avec Arrays.copyOf
, voir plus bas).
Pour changer plus facilement la taille d’un tableau, il est recommandé d’utiliser les classes génériques
qui implémentent l’interface java.util.List
. Ces classes ont notamment les méthodes suivantes :
isEmpty()
;size()
(et non length()
) ;get(index)
(lent pour une LinkedList
) ;set(index, elem)
(lent pour une LinkedList
) ;add(elem)
et add(index, elem)
(la deuxième variante est lente pour une
ArrayList
) ;
remove(index)
et remove(elem)
(toujours lent, mais avec les LinkedList
,
on peut utiliser un itérateur pour supprimer plusieurs éléments rapidement) ;
clear()
;
iterator()
.
Il n’y a pas de méthode ni de constructeur pour ajouter directement plusieurs éléments,
sauf s’ils font partie d’une autre collection
(et on peut convertir un tableau en collection, voir plus bas) : addAll(collection)
(les List
sont des collections, mais il y en a d’autres, notamment Deque
et Set
).
Tout comme en C++11, les tableaux et les collections peuvent être aisément parcourus avec une boucle for
utilisant la notation avec un deux-points. Il faut alors forcément déclarer une variable qui va prendre la valeur
de chacun des éléments, successivement. Ci-dessus, cela est fait ligne 32.
Tout comme en C++11 avec les initializer_list
, on peut créer des tableaux un peu partout :
spécialement pour une boucle for
, ou encore pour un return
.
Par contre, si ce tableau n’est pas fourni lors de la déclaration d’une variable de type tableau,
il faut préciser avant l’accolade ouvrante le type du tableau, précédé de new
. Exemple :
for (int nb: new int[] { 1, 2, 3 }) { System.out.print(nb + ", "); } System.out.println("nous irons au bois.");
Tout comme en C++, les collections sont des types génériques, paramétrés : on indique entre chevrons
le type de leurs éléments. Et lorsqu’on les instancie avec new
, on remet les chevrons
(mais on peut les laisser vides, car le compilateur déduit tout seul leur contenu).
Dans le code ci-dessus, il y a un exemple de cela ligne 22.
Mais contrairement au C++, les types paramétrés ne peuvent pas accepter de types primitifs :
seuls les types objets sont acceptés. En effet, la technique de C++ pour la généricité est de générer du code
pour chaque liste de paramètres utilisés. En Java, la technique est bien moins lourde :
elle consiste à utiliser en permanence le type objet le plus générique, qui s’appelle Object
(voir la section Le polymorphisme, plus bas). La conséquence est que les types primitifs
ne sont pas acceptés, mais on peut toujours utiliser les classes de java.lang
qui les encapsulent.
Il existe des noms de classes au pluriel : Arrays
, Collections
, Objects
,
dans le paquetage java.util
, qui fournissent des méthodes statiques utilitaires
pour les tableaux, les collections et les objets. Voici un exemple d’utilisation de méthodes de la classe Arrays
(essayez donc d’afficher le contenu de la liste après ces opérations) :
String[] strs = { "abc", "def", "ghi" }; strs = Arrays.copyOf(strs, 4); strs[3] = "jkl"; List<String> listOfStrs = new ArrayList<>(); listOfStrs.addAll(Arrays.asList(strs)); listOfStrs.addAll(Arrays.asList("mno", "pqr"));
Il est fréquent en Java de créer un objet juste pour qu’une seule méthode soit appelée :
on utilise pour cela la notation lambda, qui s’écrit avec une flèche vers la droite
(un trait d’union et d’un chevron : ->
) :
return
peut être omis en l’absence d’accolades).
Dans le programme ci-dessus, 4 méthodes lambda sont créées, toujours avec un paramètre nb
de type Double
, car les objets sont tous de type DoubleConsumer
.
Note : Java n’autorise pas l’instanciation de tableaux dont le type est paramétré,
c’est pourquoi j’ai créé DoubleConsumer
comme quasi-synonyme de Consumer<Double>
.
L’interface générique java.util.function.Consumer
permet simplement de créer des méthodes lambda
qui prennent un paramètre et ne renvoient aucune valeur (il y a d’autres interfaces semblables,
par exemple java.lang.Runnable
, dont la méthode ne prend aucun paramètre et ne renvoie rien non plus).
Les interfaces permettant ainsi de créer des méthodes lambda sont appelées « interfaces fonctionnelles » ;
leur définition est précédée du décorateur @FunctionalInterface
.
Ce tableau d’objets de type DoubleConsumer
me permet de gérer facilement les réponses au menu
de la boucle while
ligne 55. On voit que la méthode de l’interface Consumer
s’appelle accept
.
Un programme Java ne plante jamais (ou presque). Au lieu de déclarer des erreurs de segmentation
qui bousillent tout, la JVM lance des exceptions, exactement comme en Python.
Ces exceptions peuvent être attrapées par le code Java et traitées de la façon qu’on souhaite ;
à défaut, le programme est arrêté et la fonction printStackTrace()
de l’exception lancée est appelée,
et donc l’état de la pile au moment du lancement de l’exception est affiché, ainsi que le nom de l’exception.
Si cette exception n’était pas voulue par le développeur, cela permet de déterminer facilement
quelles parties du code ont causé le problème.
En C++, on peut lancer des exceptions de n’importe quel type ; en Java, toutes les exceptions lancées
doivent être d’un type classe qui descend de la classe Throwable
.
En général, quand on crée un type d’exception, on le fait plutôt dériver de RuntimeException
(qui dérive d’Exception
, qui dérive de Throwable
).
Pour créer une exception, on utilise le mot-clé new
comme avec n’importe quelle classe,
et pour la lancer, on utilise le mot-clé throw
. Ainsi, ligne 59, une exception de type
java.util.InputMismatchException
est créée, lorsqu’on entre un nombre invalide
(cette exception indique que l’entrée est invalide).
Le lancement de l’exception conduit à sortir du bloc try
(et donc de la boucle while
)
pour aller dans le bloc catch
qui comporte entre parenthèses le type de l’exception lancée
(ou un type ascendant ; par exemple, en spécifiant le type Throwable
, on attrape toutes les exceptions).
Une exception de type InputMismatchException
peut également être lancée par stdinScanner.nextInt()
et stdinScanner.nextDouble()
si les caractères fournis en entrée ne sont pas numériques ;
le comportement est alors le même.
On peut spécifier plusieurs blocs catch
à la suite pour attraper plusieurs types d’exceptions différents
et les traiter de façon appropriée. À la suite des blocs catch
, on peut mettre un bloc finally
,
dont le contenu sera exécuté quoi qu’il arrive, même si une exception cherche encore un catch
qui lui correspond : ce bloc finally
permet de libérer les ressources ouvertes malgré l’exception.
Attention : si une exception est levée dans le bloc finally
, l’exception qui était restée en attente est perdue.
Maintenant que nous avons fait le tour de notre premier programme, abordons une notion qu’on utilise presque en permanence en Java : le polymorphisme, c’est-à-dire l’aptitude d’un élément à adopter plusieurs formes. Ce concept fait partie du paradigme de la POO et est donc pris en charge par tous les langages de programmation qui le supportent.
Pour faire usage du polymorphisme, il faut déclarer qu’une classe hérite d’une autre, ou encore qu’elle implétemente telle ou telle interface.
Si on omet les interfaces, alors les classes sont organisées sous la forme d’une arborescence, dont la racine est la classe
java.lang.Object
. Toutes les classes héritent par défaut de Object
, et autrement,
elles en descendent. La classe Object
dispose notamment des méthodes getClass()
,
equals(otherObj)
et toString()
(cette dernière a déjà été évoquée plus haut).
La méthode getClass
ne peut pas être redéfinie, et la méthode equals
de la classe Object
ne fait que comparer les références.
Dans la classe qui hérite d’une autre (on dit que c’est une classe dérivée de l’autre), on peut redéfinir les fonctions, pour leur donner un autre sens. Et alors, il est possible de traiter une instance de la classe dérivée comme une instance de la classe de base. Étudions donc un exemple.
En compilant ce fichier, vous verrez que vous obtiendrez un fichier .class pour chacune des cinq classes qu’il contient.
Il faut bien entendu exécuter la classe Eating
avec la JVM.
Comme on le voit dans le code ci-dessus, les classes Carrot
et Beef
héritent
de la classe Food
(grâce au mot-clé extend
) et redéfinissent la méthode eat
.
Ainsi, dans Eating.main
, on peut créer un tableau de type Food[]
,
et y mettre une carotte et une pièce de bœuf. On les mange : l’une ajoute des fibres, l’autre ajoute du fer.
Il est utile de mettre le décorateur @Override
lorsque l’on redéfinit une fonction :
cela permet d’assurer qu’il s’agit bien d’une redéfinition (tout comme le mot-clé override
de C++11).
Si ce n’est pas une redéfinition, une erreur sera relevée à la compilation.
Pour redéfinir la méthode dans une classe dérivée, il faut écrire une méthode avec la même signature et
un type de retour compatible (c’est-à-dire soit le même type de retour, soit un type objet qui en descend).
Elle doit avoir un niveau de visibilité au moins aussi important que la méthode d’origine
(s’il était protected
, il peut devenir public
).
Note : en C++, pour que le polymorphisme fonctionne pour une méthode, il faut y adjoindre le mot-clé virtual
.
En Java, les méthodes sont virtuelles par défaut. Pour échapper à ce comportement, on peut leur adjoindre
le mot-clé final
, qui empêche la redéfinition.
Dans les méthodes eat
des classes dérivées, j’ai écrit super.eat
pour appeler
la méthode eat
de la classe mère.
La classe Food
est abstraite, cela signifie que l’on ne peut pas instancier d’objet de cette classe.
Le mot-clé abstract
caractérise cela.
On peut aussi définir des méthodes abstraites, par exemple :
public abstract void printNutritiveData();
Une classe comportant une méthode abstraite doit être déclarée abstraite (en mettant le mot-clé abstract
devant le mot-clé class
). Les classes dérivées héritent bien sûr des méthodes abstraites,
et sont donc elles aussi abstraites, à moins de définir la méthode. En C++, l’équivalent de la méthode abstraite
est la méthode virtuelle pure, à laquelle on affecte la valeur 0.
Les interfaces sont globalement comme des classes abstraites qui n’ont que des méthodes publiques abstraites.
L’intérêt des interfaces est qu’elles permettent l’héritage multiple : en Java, une classe ne peut hériter
que d’une seule autre classe, mais elle peut implémenter (mot-clé implements
) plusieurs interfaces
(et une interface peut hériter de plusieurs interfaces, avec le mot-clé extends
comme pour l’héritage de classes). C’est ainsi que les classes forment une arborescence,
tandis que les interfaces forment un graphe orienté sans cycle. Un petit exemple :
Vous pouvez appeler le constructeur de la classe mère avec le mot-clé super
,
qui doit être la première instruction dans le constructeur de la classe fille.
C’est indispensable si la classe mère ne propose pas de constructeur sans paramètre
(si elle en possède un et que la classe fille n’appelle pas explicitement de constructeur de la classe mère,
le constructeur sans paramètre, dit aussi constructeur par défaut, de la classe mère est appelé implicitement).
On peut aussi appeler un autre constructeur de la classe avec le mot-clé this
. Exemple :
abstract class Anything { Anything(int nb, String str) {} } class ThisOrThat extends Anything { ThisOrThat() { this(8, "okay"); } ThisOrThat(int nb, String str) { super(nb, str); } }
Pour voir le graphe complet d’héritage entre classes et interfaces d’un projet, vous pouvez utiliser cette application de ma création : Générateur de graphe d’héritage.
Les opérateurs du Java sont à peu près les mêmes que ceux du C ; il y en a quelques-uns en plus et quelques-uns en moins. En voici la liste, des plus prioritaires aux moins prioritaires, avec l’indication de l’ordre d’évaluation entre opérateurs de même priorité (qui ne fait pas sens pour les opérateurs unaires) :
Type | Opérateurs | Ordre |
---|---|---|
accès ou appel | . :: [] () | → |
postfixe | ++ -- | |
préfixe | ++ -- + - ~ ! (type) | |
multiplicatif | * / % | → |
additif | + - | → |
décalage | << >> >>> | → |
relationel | < > <= >= instanceof | → |
égalité | == != | → |
« et » bit à bit | & | → |
« ou exclusif » bit à bit | ^ | → |
« ou inclusif » bit à bit | | | → |
« et » logique | && | → |
« ou » logique | || | → |
ternaire | ?: | → |
affectation | = += -= *= /= %= &= ^= |= <<= >>= >>>= | ← |
Comparaison avec le Python :
&&
, ||
et !
prennent la place de and
, or
et not
;
cond ? valIfTrue : valIfFalse
prend la place de valIfTrue if cond else valIfFalse
(cet opérateur ?:
est appelé l’opérateur ternaire, car c’est le seul auquel il faut fournir
trois opérandes ; les autres opérateurs sont dits unaires et binaires) ;
(type)expression
et non pas type(expression)
;
++
et --
pour incrémenter et décrémenter
les variables de type entier de 1 ;
Pour voir la différence entre les opérateurs postfixes et préfixes, compilez et exécutez un code de ce genre :
int nb = 0; System.out.println(nb); System.out.println("++nb : " + ++nb); System.out.println(nb); System.out.println("nb++ : " + nb++); System.out.println(nb); System.out.println("nb-- : " + nb--); System.out.println(nb); System.out.println("--nb : " + --nb); System.out.println(nb);
Personnellement, je n’aime pas utiliser la valeur des opérations d’incrémentation et de décrémentation (je laisse toujours une telle opération seule dans son instruction), et j’utilise toujours l’opérateur préfixe (car l’opérateur postfixe procède théoriquement à une sauvegarde de la valeur de départ).
Comparaison avec le C :
*
et ->
et l’opérateur de référencement &
sont bien entendu absents ;
,
(virgule) est lui aussi absent, on peut toutefois utiliser la virgule
pour spécifier plusieurs instructions d’initialisation et de mise à jour dans l’entête des boucles for
;
sizeof
, mais on a un opérateur instanceof
(à deux opérandes),
qui permet de tester si la classe d’un objet descend d’une certaine classe ou est cette certaine classe ;
::
du C++ existe, il permet de spécifier une méthode
de classe ou d’objet en lieu et place d’une méthode lambda (lorsque c’est une classe et non un objet qui est indiqué,
la méthode lambda utilise son premier paramètre comme objet), y compris un constructeur (écrire ::new
) ;
%
(modulo, reste de la division) peut être utilisé avec les nombres à virgule
(alors qu’en C, il faut utiliser une fonction fmod
) ;
>>>
et >>>=
pour faire un décalage binaire vers la droite
qui ajoute forcément des 0 en début, pour compenser le fait qu’il n’y a pas de type non signé
(l’opérateur >>
classique ajoute des 1 au début lorsque le nombre est strictement négatif).
Pour écrire des commentaires, tout comme en C++ :
// commentaire
;/* commentaire */
.
L’imbrication de commentaires n’est pas prise en charge.
Pour qu’un bloc de code ne soit plus exécuté, on peut toujours le mettre dans un bloc if (false)
.
Mais, de façon plus commode, votre EDI propose certainement un raccourci clavier pour commenter et décommenter
automatiquement des lignes de code.
À l’aide de l’opérateur de transtypage, on peut tenter de convertir un objet dans un type d’une classe descendante. Par exemple, en reprenant les classes et les variables du fichier Delirium.java ci-dessus :
((Beef)animal[0]).printMyStrength();
L’opérateur de transtypage a certes une forte priorité, mais elle est moins importante que celle de l’opérateur
d’accès aux membres .
, c’est pourquoi les parenthèses sont nécessaires autour de (Beef)animal[0]
.
Une fois la pièce de bœuf obtenue, on appelle sa méthode printMyStrength()
.
Si la conversion est impossible, une exception sera lancée, comme d’habitude.
Tout comme en Python et en JavaScript, tout « plantage » du programme peut être intercepté par des blocs
try
/catch
.
L’opérateur de transtypage a aussi deux intérêts avec les types primitifs :
(int)aCharacter
et
(char)codeAsInt
) ;
trunc
pour tronquer les nombres à virgule).
Les constantes se déclarent avec le mot-clé final
. La différence majeure avec les constantes
de la plupart des autres langages de programmation est que la constante n’est pas obligée de recevoir sa valeur
au moment de sa définition : elle peut la recevoir plus tard. C’est pourquoi le langage dit qu’elle est « finale » :
parce qu’elle reçoit une seule valeur, pas forcément immédiatement, et après elle n’en change plus.
Si la variable finale risque de ne pas recevoir de valeur, ou encore qu’elle est utilisée avant d’avoir reçu une valeur,
le compilateur refusera de compiler pour cause d’erreur. Ainsi, notamment, si la variable finale reçoit une valeur
dans un if
, il faut aussi qu’elle reçoive une valeur dans le else
correspondant.
Tout comme pour vérifier que les méthodes non void
renvoient toujours une valeur,
le compilateur analyse les différentes manières dont le programme peut s’exécuter pour vérifier
qu’aucun cas n’échappe aux bonnes règles.
Lorsqu’une variable d’un type objet est déclarée finale, c’est juste la référence de l’objet qui ne peut pas être modifiée.
En C++, on peut renvoyer des références d’objets qui ne permettent pas de modifier les objets référencés
(on ne peut alors appeler que les méthodes de l’objet qui ne l’altèrent pas, déclarées avec le mot-clé const
).
En Java, ce n’est pas aussi simple. Pour permettre l’accès à un objet sans que celui-ci soit modifié, il y a deux méthodes :
List.of
et
List.copyOf
).
Lorsqu’un champ est déclaré final
, il doit être initialisé soit à l’endroit de sa déclaration,
soit dans tous les constructeurs (il ne peut pas être initialisé dans une méthode appelée par un constructeur).
Généralement, on ne prend pas la peine de marquer final
toutes les variables qui pourraient l’être
(notamment les paramètres des méthodes). Le mot-clé sert plutôt à empêcher une autre classe de modifier
un champ public, ou encore qualifie une variable dont la valeur est fixée par le développeur et va être utilisée
de nombreuses fois, de sorte que le développeur puisse changer facilement cette valeur quand il recompile le programme.
On les déclare avec le mot-clé enum
. Elles sont comme des classes, sauf que leur constructeur est privé,
et toutes les instances de la classe sont créées au début de la définition de la classe, en tant que
membres publics statiques de la classe. Par exemple :
enum Color { RED(255, 0, 0), GREEN(0, 255, 0), BLUE(0, 0, 255); public final byte r, g, b; Color(byte r, byte g, byte b) { this.r = r; this.g = g; this.b = b; } }
La méthode toString()
de ces objets renvoie le nom de la constante énumérée,
par exemple Color.RED.toString()
renvoie "RED"
.
La méthode statique values()
renvoie un tableau contenant la liste des constantes énumérées.
Ainsi Color.values()
renvoie new Color[] { Color.RED, Color.GREEN, Color.BLUE }
.
La méthode ordinal()
renvoie la position de la constante énumérée
dans le tableau renvoyé par values()
.
C’est la fin de ce cours d’introduction au Java.