Packages et modules

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.

Les packages :

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.

Les fichiers d'un projet

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 :

SUV.java
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 :

SUV.java
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 :

SUV.java
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
}

Les modules :

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

La création d'un projet avec IntelliJ

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.

Création d'une nouvelle classe

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.

SUV.java
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.

Le main et les méthodes statiques

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.

SUV.java
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 ».

Les classes imbriquées

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 :

SUV.java
 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 :

SUV.java
 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}

Visibilité des classes, attributs et méthodes

La visibilité des classes :

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.

La visibilité des attributs et des méthodes :

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

Exercice 1 : La classe à Dallas   

Dans un fichier MyArray.java, écrivez une classe MyArray qui représente un tableau d’entiers. Cette classe possède :

Dans un fichier Main.java, écrivez un programme qui teste votre classe MyArray.

Indice 1 

Afin que notre classe puisse stocker des éléments, on lui affecte les attributs suivants :

private int[] array;   // contiendra les éléments
private int size = 0;  // le nombre d'éléments stockés

Notez que les deux attributs sont private. On ne veut surtout pas exposer à l’extérieur de la classe comment les éléments sont stockés. En effet, cela permettra, d’une part, de pouvoir changer ces types ultérieurement si on le souhaite sans que l’utilisateur de la classe MyArray ait à modifier quoi que ce soit dans son code. Par ailleurs, si on déclarait public int size, rien n’interdirait d’écrire le code suivant, qui produirait des résultats erronés :

1  MyArray myArray = new MyArray(5);
2  myArray.pushBack(1);
3  myArray.size = -10;
4  System.out.println(myArray.getElement(0));
5  myArray.pushBack(10);

En effet, la ligne 4 devrait certainement indiquer que 0 est « out of bounds » alors qu’il y a bien un élément dans le tableau et la ligne 6 devrait certainement provoquer une exception.

Indice 2 

N’oubliez pas, quand vous rajoutez des éléments dans l’attribut array de mettre également à jour la valeur de size.

Dans le premier constructeur, celui qui prend en argument capacity. Il peut être utile de vérifier que capacity est supérieure strictement à 0. Sinon, il faut lui donner une valeur strictement positive, par exemple 2 ou 4.

Exercice 2 : La malédiction de la dimension   

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.

Indice 1 

On peut créer une méthode realloc ou _realloc qui permet de redimensionner le tableau d’entiers stocké en tant qu’attribut de MyArray. Notez que cette méthode n’a vocation à être appelée que par les méthode pushBack() et pushFront(). Elle devrait donc être déclarée en private.

Les méthodes pushBack() et pushFront() testent d’abord si l’on peut insérer le nouvel élément. Si ce n’est pas le cas, on peut augmenter la capacité du tableau en utilisant la méthode realloc puis insérer le nouvel élément. Traditionnellement (c’est ce qui se fait dans les vector du C++), on double la capacité du tableau.

Indice 2 

Dans la méthode realloc, il faut allouer un nouveau tableau, recopier tous les éléments actuels de array (cf. l’indice 1) dedans, puis affecter ce nouveau tableau à array. N’oubliez pas que int[] est un type référence. Donc array = tab permet de modifier array simplement.

Getters and Setters

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;
  }
}
 
© C.G. 2007 - 2024