Introduction au langage Java

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.

Logiciels nécessaires

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.

Premier programme


   

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.

Les paquetages

Les différents types de noms

Les conventions décrites dans cette section ne sont certes pas obligatoires, mais elles sont normalement suivies par toutes et tous en Java.

Tout comme en Python, les identifiants peuvent contenir des caractères spéciaux tels que les lettres avec diacritiques (éàôçï) et les ligatures (œ).

Les classes

Elles sont définies avec le mot-clé class. Ce mot-clé est précédé par le niveau de visibilité de la classe :

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 :

Ensuite, il faut distinguer les membres statiques de ceux qui ne sont pas statiques.

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.

Compiler et exécuter le programme

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.

Le point d’entrée du programme

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.

Les méthodes

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.

Les types : différence entre types primitifs et types objets

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++) :

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
   

Les chaines de caractères

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)).

Les variables

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.

Les structures de contrôle

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 autres instructions

Les structures de contrôle sont un type d’instruction. Les autres types d’instructions sont :

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.

Les tableaux et les collections

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 :

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 :

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"));
   

Les expressions lambda

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 : ->) :

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.

Les exceptions

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.

Le polymorphisme

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.

La redéfinition de méthode

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.

Les classes et méthodes abstraites

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 :



   

Les constructeurs

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);
    }
}
   

Graphe d’héritage

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

Liste et ordre d’évaluation

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) :

TypeOpérateursOrdre
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 :

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 :

Les commentaires

Pour écrire des commentaires, tout comme en C++ :

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.

Le transtypage

À 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 :

Les constantes et les énumérations

Les constantes

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 :

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.

Les énumérations

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.