Dans une application ou librairie de taille conséquente, il est important de structurer son code. Cela passe bien évidemment par un découpage en classes (en programmation objet) mais également par une structuration des fichiers. Les packages et modules Java sont les outils qui permettent cela.
Traditionnellement, avant Java 9, si l’on souhaitait distribuer du code, on le plaçait dans des packages Java. Cela correspondait à la notion de librairie en C++. Toutefois, une différence notable est qu’un package peut contenir des sous-packages, ce qui permet une structuration plus fine qu’en C++. Voyons un exemple : ci-dessous les fichiers d’un projet Java ouvert avec IntelliJ.
En haut, on observe que la racine du projet est le répertoire java. On verra la signification du [fr.polytech.aco] dans la sous-section suivante. L’arborescence de fichiers correspond à celle ci-dessous :
java
└── src
└── fr
└── polytech
└── aco
└── vehicule
├── deuxRoues
│ ├── Bicyclette.java
│ └── Moto.java
├── Vehicule.java
└── voiture
├── F1.java
└── SUV.java
À l’intérieur du répertoire src, chaque sous-répertoire correspond à un package (ou sous-package s’il est lui-même inclus dans un répertoire). Ici, comme fr n’a qu’un enfant polytech, qui n’a qu’un enfant aco, qui n’a qu’un enfant vehicule, on peut considérer que le package racine, celui que l’on souhaiterait distribuer, est fr.polytech.aco.vehicule. Notez que l’on sépare les sous-répertoires par des « . », comme en Python.
En C++, lorsque vous souhaitez utiliser le code d’une librairie, vous faites de #include <…>. En Java, on fait des import, comme en Python. Il faut donc savoir quel est le nom du package/de la librairie que l’on souhaite importer. Ici, c’est très simple : il suffit d’utiliser le chemin à partir de src jusqu’à la classe Java qui vous intéresse. Par exemple, voici un code basique pour les SUV : un SUV est un véhicule et donc il doit hériter de la classe Vehicule, qui est dans le package fr.polytech.aco.vehicule. On l’importe donc comme indiqué sur la ligne 3 ci-dessous :
1package fr.polytech.aco.vehicule.voiture;
2
3import fr.polytech.aco.vehicule.Vehicule;
4
5public class SUV extends Vehicule {
6}
Notez sur la première ligne de ce code, que l’on indique, via l’instruction package, que SUV est une classe du package fr.polytech.aco.vehicule.voiture. Vous voyez donc qu’il est très simple d’importer des classes que vous avez écrites. Pour faire importer des classes que vous n’avez pas écrites vous-même, c’est le même principe et on l’a déjà vu avec la classe Scanner :
package fr.polytech.aco.vehicule.voiture;
import java.util.Scanner;
import fr.polytech.aco.vehicule.Vehicule;
public class SUV extends Vehicule {
Scanner scanner = new Scanner(System.in);
}
Attention
Il est important d’éviter les clashs de noms : si l’on doit utiliser dans une même classe Java des instances de deux classes Sqrt de deux packages se nommant tous les deux math, on va avoir des problèmes : comment les différencier ? Pour éviter cela, il est d’usage de préfixer les noms des packages que l’on écrit avec son nom de domaine internet inversé. C’est pourquoi j’ai débuté mes noms de package par fr.polytech.
Notez que, dans le cas de deux classes ayant le même nom, on n’importe pas ces classes, on les utilise en spécifiant leur nom complet, au moins pour l’une d’elles :
package fr.polytech.aco.vehicule.voiture;
import java.util.Scanner;
// import fr.polytech.aco.vehicule.Scanner; on n'importe pas
public class SUV {
Scanner scanner1 = new Scanner(System.in); // Scanner de java.util (importé)
fr.polytech.aco.vehicule.Scanner scanner2 =
new fr.polytech.aco.vehicule.Scanner(); // notre Scanner
}
Depuis Java 9, en plus des packages, on peut structurer notre code avec des modules. Un module contient un ou plusieurs packages. La différence réside essentiellement dans la visibilité des objets contenus dans le module : on a beaucoup plus de latitude pour cacher du code. Imaginons que l’on ait développé deux packages a et b et que l’on souhaite distribuer b mais pas a. Malheureusement, b dépend de a. Avec la notion de module, on incluera a et b dans le module, mais on n’exportera que b. Ainsi, l’utilisateur pourra accéder au package b, qui utilisera le package a, mais l’utilisateur ne pourra pas accéder directement au package a.
Dans Analyse et conception objets, nous n’exploiterons pas les modules. Mais si vous souhaitez en savoir plus, vous pouvez vous reporter au site web Java de Jean-Michel Doudoux qui est très bien documenté.
On peut toutefois noter que le JDK que vous avez téléchargé contient un certain nombre de modules, que vous pouvez visualiser avec la commande :
java --list-modules
Voici un extrait de ce qu’elle produit :
java.base@22.0.2
java.compiler@22.0.2
java.sql@22.0.2
java.sql.rowset@22.0.2
java.transaction.xa@22.0.2
jdk.accessibility@22.0.2
jdk.compiler@22.0.2
jdk.internal.ed@22.0.2
jdk.internal.jvmstat@22.0.2
jdk.internal.le@22.0.2
jdk.internal.opt@22.0.2
jdk.internal.vm.ci@22.0.2
jdk.jartool@22.0.2
jdk.javadoc@22.0.2
Pour terminer cette brève introduction aux modules, revenons sur le [fr.polytech.aco] vu dans la sous-section sur les packages. Lorsque l’on crée un projet avec IntelliJ, on nous demande, évidemment, le nom du projet (ici java comme indiqué tout en haut de la capture d’écran ci-dessous). Si l’on clique sur advanced settings, on peut voir que le projet est associé à un module. Par défaut, celui-ci porte le même nom que le projet mais on peut mettre un nom différent, ce que j’ai fait ici avec fr.polytech.aco. C’est donc avec ce nom que l’on pourra accéder à mon code si celui-ci est distribué.
Attention
Comme pour les packages, il est d’usage de préfixer les noms de modules par son nom de domaine internet inversé. Il est également d’usage que le package racine contenu dans le module porte le même nom que le module.
Chaque classe correspond à un fichier du même nom. La déclaration d’une classe est assez similaire à celle que l’on aurait faite en C++. Notez toutefois le mot-clef public avant celui de class. On reviendra dessus plus tard.
package fr.polytech.aco.vehicule.voiture;
import java.util.Date;
public class SUV {
// attributs des instances
double qtiteCarburant = 0;
String couleur;
Date derniereRevision;
// attributs de classe
static int nbRoues = 4;
// il peut y avoir plusieurs constructeurs
public SUV() {
this.couleur = "rouge";
this.derniereRevision = new Date();
}
public SUV(String couleur, Date derniereRevision) {
this.couleur = couleur;
this.derniereRevision = derniereRevision;
}
public SUV(String couleur, Date derniereRevision, double qtiteCarburant) {
this.couleur = couleur;
this.derniereRevision = derniereRevision;
this.qtiteCarburant = qtiteCarburant;
}
// méthodes
public double getQtiteCarburant() { return this.qtiteCarburant; }
public void setQtiteCarburant(double qtite) { this.qtiteCarburant = qtite; }
}
Attention
Notez que, contrairement au C++, il n’y a pas de destructeur en Java.
Rien n’interdit que le main soit inclus dans la classe SUV même si ce n’est pas une pratique souhaitable car, dans un gros projet, cela complexifie la localisation du fichier où se trouve le main. Donc, mieux vaut créer une classe spécifique Main qui ne contiendra que le main.
package fr.polytech.aco.vehicule.voiture;
import java.util.Date;
public class SUV {
double qtiteCarburant = 0;
String couleur;
Date derniereRevision;
static int nbRoues = 4;
// il peut y avoir plusieurs constructeurs
public SUV() {
this.couleur = "rouge";
this.derniereRevision = new Date();
}
// on peut créer des méthodes statiques, y compris le main:
public static void main(String args[]) {
System.out.println("main de SUV");
}
}
Si vous vous déplacez dans le répertoire src et que vous exécutez les commandes suivantes :
javac -classpath . fr/polytech/aco/vehicule/voiture/SUV.java
java -classpath . fr.polytech.aco.vehicule.voiture.SUV
javac produira un fichier SUV.class dans le répertoire java/src/fr/polytech/aco/vehicule/voiture et la commande java exécutera le main de ce fichier SUV.class, c’est-à-dire qu’elle affichera « main de SUV ».
Comme en C++, il est possible de déclarer des classes à l’intérieur d’autres classes. Cela permet de bien structurer le code (et donc de le rendre plus facilement maintenable) en séparant différentes parties d’une classe en sous-parties bien identifiées. Par exemple, dans une voiture, le système électronique conserve des informations utiles pour diagnostiquer ses pannes (elles-ci sont accessibles par ce que les mécaniciens appellent la valise). Voici un code qui sépare donc, dans notre SUV, ce système du reste :
1package fr.polytech.aco.vehicule.voiture;
2
3public class SUV {
4 public class Diagnostic {
5 int capteurCO2 = 0;
6 boolean voyantMoteur = false;
7
8 public Diagnostic() {}
9 public void printDiagnostic() {
10 System.out.println("capteur CO2 = " + this.capteurCO2 +
11 " voyant moteur = " + this.voyantMoteur);
12 }
13 }
14
15 String nom;
16 Diagnostic diag;
17
18 public SUV(String nom) {
19 this.nom = nom;
20 this.diag = new Diagnostic();
21 }
22
23 public void printSUV() {
24 System.out.println("Nom = " + this.nom + " CO2 = " + this.diag.capteurCO2);
25 }
26
27 public static void main(String args[]) {
28 SUV suv1 = new SUV("suv 1");
29 suv1.printSUV();
30 SUV.Diagnostic diagnostic = suv1.new Diagnostic();
31 }
32}
On voit sur la ligne 16 qu’un SUV possède un attribut diag de type Diagnostic. Étant donné que c’est un type référence, il ne faut pas oublier, dans le constructeur du SUV, d’allouer diag via un new (cf. la ligne 20).
Comme le type Diagnostic est public, on peut créer des instances de ce type en dehors d’une instance de SUV. C’est ce que montre la ligne 30. Attention: ici, Diagnostic est déclaré en public class, ce qui signifie qu’il est, par défaut, lié à une instance. Il faut donc utiliser suv1.new et non juste new pour créer notre instance de Diagnostic. Si l’on souhaitait utiliser cette dernière option, nous aurions dû déclarer :
1package fr.polytech.aco.vehicule.voiture;
2
3public class SUV {
4 public static class Diagnostic {
5 int capteurCO2 = 0;
6 boolean voyantMoteur = false;
7
8 public Diagnostic() {}
9 public void printDiagnostic() {
10 System.out.println("capteur CO2 = " + this.capteurCO2 +
11 " voyant moteur = " + this.voyantMoteur);
12 }
13 }
14
15 String nom;
16 Diagnostic diag;
17
18 public SUV(String nom) {
19 this.nom = nom;
20 this.diag = new Diagnostic();
21 }
22
23 public void printSUV() {
24 System.out.println("Nom = " + this.nom + " CO2 = " + this.diag.capteurCO2);
25 }
26
27 public static void main(String args[]) {
28 SUV suv1 = new SUV("suv 1");
29 suv1.printSUV();
30 SUV.Diagnostic diagnostic = new SUV.Diagnostic();
31 }
32}
Jusqu’à maintenant, pour déclarer une classe, on a préfixé le mot-clef class avec le mot-clef public. Cela permettait de déclarer une classe visible de tout le monde. Mais on peut limiter cette visibilité.
Attention
Limiter l’exposition de vos classes le plus possible est important car, dans une application de « grande » taille, moins vous exposez du code qui ne concerne qu’une petite partie de l’application, c’est-à-dire moins votre code est visible/accessible, plus votre code sera maintenable.
En Java, les préfixes possibles sont les suivants :
Préfixe | Signification |
---|---|
public | Classe visible partout, y compris si celle-ci est déclarée à l'intérieur d'une autre. |
Lorsqu'il n'y a pas de préfixe, on parle de classe "package-private". Cela signifie que la classe est visible partout dans son package et uniquement dans le package. La classe peut éventuellement être déclarée à l'intérieur d'une autre classe. | |
private | Ce mot-clef ne concerne qu'une classe déclarée à l'intérieur d'une autre. Dans ce cas, seule cette dernière peut la voir. |
Attention
Dans la mesure du possible, essayez de déclarer vos classes private. Si ce n’est pas possible, essayez le « package-private ». Dans le pire des cas, rendez votre classe public. L’idée est de faire en sorte que le moins de classes possibles dépendent de vos propres classes. Cela améliore très significativement la maintenance et le débogage de vos programmes.
Pour les attributs et les méthodes, Java fournit les mêmes mots-clefs de visibilité que pour les classes. En voici la signification :
Préfixe | visible de la même classe |
visible partout dans le même package |
visible des sous-classes définies dans un autre package |
visible partout dans un package différent |
---|---|---|---|---|
public | ✔ | ✔ | ✔ | ✔ |
protected | ✔ | ✔ | ✔ | |
✔ | ✔ | |||
private | ✔ |
Notez en particulier le mot-clef protected qui a une signification différente de celle du C++.
Dans un fichier MyArray.java, écrivez une classe MyArray qui représente un tableau d’entiers. Cette classe possède :
un constructeur prenant en paramètre un entier capacity et qui permet de créer une instance pouvant contenir jusqu’à capacity entiers;
un constructeur prenant en paramètre un tableau d’entiers (int[]) array et qui recopie le contenu de array dans l’instance de MyArray. La capacité de cette dernière est alors égale au nombre d’éléments dans array.
une méthode getSize() qui retourne le nombre d’éléments actuellement stockés dans l’instance de MyArray.
une méthode getElement(int index) qui, si l’index passé en argument correspond à un élément du tableau, retourne la valeur de l’élément correspondant (comme d’habitude, l’index 0 est le premier du tableau). Si, en revanche, l’index ne correspond pas à un élément du tableau, la méthode affiche « out of bounds » et retourne la valeur -1 (on verra plus loin comment lever des exceptions).
une méthode pushBack et une méthode pushFront qui permettent d’ajouter respectivement en fin et en début de tableau un nouvel élément. Si le tableau était déjà plein, la méthode affiche « tableau plein » et ne fait rien de plus.
Dans un fichier Main.java, écrivez un programme qui teste votre classe MyArray.
On souhaite maintenant que la classe MyArray de l’exercice précédent puisse stocker un nombre arbitraire d’éléments (comme un vector du C++ ou un ArrayList de Java). Modifiez votre code pour rendre cela possible.
Il est d’usage, en Java, de proposer des méthodes dites getters et setters afin d’accéder ou d’apporter des modifications aux attributs des classes. La convention de nommage est de préfixer le nom de l’attribut par get ou set :
class MyArray {
private int size;
public int getSize() {
return this.size;
}
public void setSize(int size) {
this.size = size;
}
}
Attention
Comme déjà indiqué, il est important de privilégier au maximum les attributs private afin de rendre le code plus facilement maintenable. Dans la même logique, il ne faut proposer des getters et/ou des setters que lorsque c’est nécessaire. Dans le cas de MyArray, par exemple, le setSize() n’est pas utile. On ne proposera donc pas de setter.
Notez que, si on décide que nos tableaux sont toujours de petite taille et qu’un int est trop grand pour stocker la taille du tableau, on peut très bien redéfinir size comme un short sans toucher à la méthode publique getSize(). On aura ainsi pu modifier la classe MyArray sans que l’utilisateur de cette classe ait quoi que ce soit à modifier dans son code. On a donc un code maintenable.
class MyArray {
private short size;
public int getSize() {
return this.size;
}
}