Mise en place

ReCHor – étape 1

1. Introduction

Le but de cette première étape est d'écrire des classes et interfaces représentant quelques concepts importants du projet — les arrêts de transport public, les voyages (relations), etc. — ainsi que le code permettant de les représenter sous forme textuelle.

Comme toutes les descriptions d'étapes, celle-ci commence par une introduction aux concepts nécessaires à sa réalisation (§2), suivie d'une présentation de leur mise en œuvre en Java (§3).

Si vous travaillez en groupe, vous êtes toutefois priés de lire, avant de continuer, les guides Travailler en groupe et Synchroniser son travail, qui vous aideront à bien vous organiser.

2. Concepts

2.1. Arrêt

Un arrêt (stop en anglais) est un endroit où un véhicule de transport public peut s'arrêter afin de laisser monter et/ou descendre des passagers. Dans ce projet, nous en distinguerons deux types :

  1. les gares (stations), qui correspondent soit à ce que l'on entend effectivement par « gare » dans le langage courant, comme la gare de Lausanne, soit à des arrêts assez petits pour que la distinction entre les différentes voies/quais ne soit pas jugée nécessaire,
  2. les voies ou quais (platforms), qui correspondent aux différentes voies ou quais d'une gare ferroviaire ou routière.

Les arrêts possèdent un nom (p.ex. Lausanne) et une position géographique, donnée par un couple longitude/latitude (p.ex. 6.62909°, 46.51679°). Les voies ou quais possèdent de plus un nom de voie (ou de quai). En Suisse, ce nom est généralement — mais pas toujours — un nombre pour les voies ferroviaires, éventuellement accompagné d'une désignation de secteur (1, 3BC, etc.) et une lettre pour les quais de gare routière (A, B, etc.).

Il n'est pas forcément évident de décider ce qui constitue un arrêt, et des choix qui peuvent sembler surprenants ont parfois été faits en Suisse.

Par exemple, dans les données d'horaires officielles que nous utiliserons, l'arrêt du métro m1 à l'EPFL est représenté par un unique arrêt — une gare —, malgré le fait qu'il y ait deux voies.

À l'inverse, de nombreux arrêts existent pour la gare de Lausanne. Non seulement l'arrêt nommé Lausanne, qui correspond à la gare elle-même, ainsi que des arrêts correspondants aux différentes voies ferroviaires (1, 3 à 8 et 70), mais aussi l'arrêt de bus nommé Lausanne, gare, qui n'est représenté que par un seul arrêt alors que les différents bus s'arrêtent à plusieurs endroits assez distants les uns des autres.

2.2. Ligne

Un véhicule de transport public se déplace toujours en suivant une ligne (route), p. ex. la ligne de métro m1, ou alors la ligne IR 15 des CFF qui relie Genève-Aéroport à Lucerne en desservant une dizaine d'arrêts intermédiaires entre les deux.

Le sens dans lequel un véhicule se déplace sur une ligne est généralement indiqué par sa destination finale — Genève-Aéroport ou Lucerne dans le cas de la ligne IR 15.

2.3. Voyage

Nous nommerons voyage (journey) un déplacement entre deux arrêts de transports publics. Un voyage est ce que nous avons appelé une relation dans l'introduction au projet, terme que nous n'utiliserons plus par la suite.

Un voyage est constitué d'une ou plusieurs étapes (legs), chacune d'entre elles partant d'un arrêt de départ à un instant donné et arrivant à un arrêt d'arrivée à un instant ultérieur. Il existe deux types différents d'étapes :

  1. celles effectuées à pied, soit entre deux arrêts voisins ou alors au sein d'un même arrêt lors d'un changement,
  2. celles effectuées en transport public — train, bus, métro, etc.

Les étapes effectuées en transport public peuvent éventuellement comporter des arrêts intermédiaires entre l'arrêt de départ de l'étape et celui d'arrivée. De plus, elles sont toujours effectuées à bord d'un unique véhicule qui suit une ligne donnée, dans une direction donnée.

Un voyage est valide si et seulement si ses étapes satisfont les conditions suivantes :

  1. l'instant de départ d'une étape ne précède pas l'instant d'arrivée de l'étape précédente, s'il y en a une,
  2. l'arrêt de départ d'une étape est identique à l'arrêt d'arrivée de l'étape précédente, s'il y en a une,
  3. les étapes à pied alternent avec les étapes en transport public.

Un exemple de voyage valide pourrait être celui présenté dans l'introduction au projet qui va de l'arrêt Ecublens VD, EPFL (départ à 16h13 le 18 février 2025) à l'arrêt Gruyères (arrivée à 17h57). Il est constitué de neuf étapes, les cinq premières étant :

  1. de Ecublens VD, EPFL (dép. à 16h13) à Renens VD, gare (arr. à 16h19), avec le métro de la ligne m1 en direction de Renens VD, gare,
  2. de Renens VD, gare (dép. à 16h19) à Renens VD, voie 4 (arr. à 16h22), à pied (trajet entre deux arrêts voisins),
  3. de Renens VD, voie 4 (dép. à 16h26) à Lausanne, voie 5 (arr. à 16h33) avec le train de la ligne R 4 en direction de Bex,
  4. de Lausanne, voie 5 (dép. à 16h33) à Lausanne, voie 1 (arr. à 16h38), à pied (changement au sein de la même gare),
  5. de Lausanne, voie 1 (dép. à 16h40) à Romont FR, voie 2 (arr. à 17h13) avec le train de la ligne IR 15 en direction de Luzern (Lucerne).

Il va sans dire que les voyages jouent un rôle central dans ce projet, dont le but principal est de calculer l'ensemble des voyages permettant de relier de manière optimale deux arrêts, à une date et une heure donnée.

2.4. Représentation textuelle

Plusieurs des concepts décrits ci-dessus, par exemple les étapes d'un voyage, doivent pouvoir être représentés sous forme de texte afin d'être affichés à l'écran ou alors inclus dans un événement de calendrier. Les sections qui suivent décrivent la manière dont nous représenterons textuellement ces concepts — et d'autres — dans ce projet.

2.4.1. Durée

Une durée — p. ex. d'un voyage ou d'une de ses étapes — est représentée textuellement sous la forme d'un nombre d'heures et de minutes, ou de minutes seulement pour les durées inférieures à une heure. Les secondes ne sont jamais incluses, car toutes les durées que nous manipulons ne sont précises qu'à la minute près.

Ainsi, une durée de douze minutes est représentée textuellement par la chaîne :

12 min

tandis qu'une durée d'une heure et trois minutes l'est par la chaîne :

1 h 3 min

Notez la présence d'espaces entre les nombres et les unités.

Toutes les durées, y compris celles plus longues qu'un jour — qui devraient être rares voire inexistantes dans ce projet — sont représentées de cette manière. Ainsi, une durée de deux jours exactement est représentée textuellement par la chaîne :

48 h 0 min

2.4.2. Heure

Une heure — p. ex. de départ ou d'arrivée — est représentée textuellement en séparant par la lettre h le nombre d'heures pleines, sur un ou deux chiffres (de 0 à 23), du nombre de minutes dans l'heure, sur deux chiffres (de 00 à 59).

Ainsi, midi et demi est représenté textuellement par la chaîne :

12h30

tandis que 5 minutes après minuit l'est par la chaîne :

0h05

Notez l'absence d'espaces dans cette représentation, la lettre h jouant ici plutôt un rôle de séparateur que d'unité.

2.4.3. Voie ou quai

Comme nous l'avons vu, la convention veut qu'en Suisse les voies de chemin de fer soient identifiées par des nombres, tandis que les quais des gares routières le soient par des lettres.

Dès lors, une voie/quai dont le nom commence par un chiffre est représentée textuellement par son nom précédé de « voie », par exemple :

voie 1AB

tandis qu'une voie/quai dont le nom ne commence pas par un chiffre est représentée textuellement par son nom précédé de « quai », par exemple :

quai A

2.4.4. Étape à pied

Une étape à pied est représentée par une description de son type (changement ou trajet entre deux arrêts voisins) et sa durée, entre parenthèses.

Ainsi, un changement d'une durée de 5 minutes est représenté par la chaîne :

changement (5 min)

tandis qu'un trajet entre deux arrêts voisins, d'une durée de 3 minutes, l'est par la chaîne :

trajet à pied (3 min)

Bien entendu, la durée entre parenthèses est représentée de la manière décrite à la §2.4.1 plus haut.

2.4.5. Étape en transport public

Une étape en transport public a une représentation textuelle relativement complexe, due au grand nombre d'attributs qu'elle possède. Dans l'ordre, les attributs qui font partie de sa représentation textuelle sont :

  • l'heure de départ,
  • le nom de la gare de départ,
  • le nom de la voie/quai de départ (s'il existe),
  • le nom de la gare d'arrivée,
  • l'heure d'arrivée,
  • le nom de la voie/quai d'arrivée (s'il existe).

Les noms des voies ou quais sont placés entre parenthèses, de même que l'heure d'arrivée précédée de « arr. ». De plus, une flèche (→) sépare les informations concernant le départ de celles concernant l'arrivée.

Par exemple, la troisième étape du voyage donné en exemple à la §2.3 est représentée par la chaîne suivante :

16h26 Renens VD (voie 4) → Lausanne (arr. 16h33 voie 5)

Lorsqu'un des arrêts est une gare et pas une voie (ou un quai), le nom de la voie (ou du quai) n'apparaît bien entendu pas, et si, en conséquence, la première paire de parenthèses devait être vide, elle n'apparaît simplement pas. Ainsi, la première étape du voyage d'exemple est représentée par la chaîne suivante :

16h13 Ecublens VD, EPFL → Renens VD, gare (arr. 16h19)

Il faut noter que cette représentation textuelle des étapes en transport public n'est pas celle qui est utilisée dans l'interface graphique. Dans cette interface, une étape en transport public est représentée de manière partiellement graphique.

2.4.6. Ligne et sens de parcours

Toute étape en transport public se fait à bord d'un véhicule qui parcourt une ligne dans un sens donné. Cette information est représentée textuellement par le nom de la ligne et le nom de la destination finale précédée de « Direction ».

Par exemple, le train parcourant la ligne IR 15 de Genève-Aéroport à Luzern est représenté textuellement par la chaîne :

IR 15 Direction Luzern

3. Mise en œuvre Java

Les concepts importants pour cette étape ayant été introduits, il est temps de décrire leur mise en œuvre en Java. En plus des classes spécifiques à cette étape, une classe « utilitaire » est à réaliser : Preconditions, qui offre une méthode de validation d'argument.

Toutes les classes et interfaces de ce projet appartiendront au paquetage ch.epfl.rechor — qui sera désigné par le terme paquetage principal dans les descriptions d'étapes — ou à l'un de ses sous-paquetages.

Attention : jusqu'à l'étape 6 du projet (incluse), vous devez suivre à la lettre les instructions qui vous sont données dans l'énoncé, et vous n'avez pas le droit d'apporter la moindre modification à l'interface publique des classes, interfaces et types énumérés décrits.

En d'autres termes, vous ne pouvez pas ajouter des classes, interfaces ou types énumérés publics à votre projet, ni ajouter des attributs ou méthodes publics aux classes, interfaces et types énumérés décrits dans l'énoncé. Vous pouvez par contre définir des méthodes et attributs privés si cela vous semble judicieux.

Cette restriction sera levée dès l'étape 7.

3.1. Installation de Java et d'IntelliJ

Avant de commencer à programmer, il faut vous assurer que la version 21 de Java est bien installée sur votre ordinateur, car c'est celle qui sera utilisée pour ce projet.

Si vous ne l'avez pas encore installée, rendez-vous sur le site Web du projet Adoptium, téléchargez le programme d'installation correspondant à votre système d'exploitation (macOS, Linux ou Windows), et exécutez-le.

Cela fait, si vous n'avez pas encore installé IntelliJ, téléchargez la version Community Edition — et pas la version Ultimate qui apparaît au sommet de la page — depuis le site de JetBrains et installez-la.

3.2. Importation du squelette

Une fois Java et IntelliJ installés, vous pouvez télécharger le squelette de projet que nous mettons à votre disposition. Il s'agit d'une archive Zip dont vous devrez tout d'abord extraire le contenu à un emplacement de votre choix sur votre ordinateur. (Notez que certains navigateurs comme Safari extraient automatiquement le contenu de telles archives.)

Une fois le contenu de l'archive extrait, vous constaterez qu'il se trouve en totalité dans un dossier nommé ReCHor. Lancez IntelliJ, choisissez Open, sélectionnez ce dossier, et finalement cliquez Ok.

Le dossier ReCHor contient les sous-dossiers suivants :

  • resources, destiné à contenir les « ressources » utiles au projet, en l'occurrence des icônes représentant les différents moyens de transport,
  • src, destiné à contenir le code source de votre projet, ainsi que divers fichiers que nous mettrons à votre disposition, et qui contient pour l'instant :
    • SignatureChecks_1.java, un fichier de vérification de signatures pour l'étape 1, qui ne devrait plus contenir d'erreur lorsque vous aurez terminé la rédaction de cette étape,
    • Submit.java, un programme qui vous permettra de rendre votre projet à la fin de chaque semaine,
  • test, destiné à contenir le code des tests unitaires de votre projet que nous vous fournirons ou que vous écrirez vous-même, et qui contient pour l'instant les tests de l'étape 1 — fournis exceptionnellement pour faciliter votre démarrage.

Le dossier resources doit être marqué comme « dossier de ressources » pour qu'il soit correctement géré par IntelliJ. Pour cela, faites un clic droit sur lui puis sélectionnez Mark Directory As puis Resources Root. Vérifiez ensuite que le panneau Project d'IntelliJ ressemble à l'image ci-dessous.

intellij-project-skeleton;32.png
Figure 1 : Projet IntelliJ après importation du squelette

3.3. Classe Preconditions

Fréquemment, les méthodes d'un programme exigent que leurs arguments satisfassent certaines conditions. Par exemple, une méthode déterminant la valeur maximale d'un tableau d'entiers exige que ce tableau contienne au moins un élément.

De telles conditions sont souvent appelées préconditions (preconditions) car elles doivent être satisfaites avant l'appel d'une méthode : c'est à l'appelant de s'assurer qu'il n'appelle la méthode qu'avec des arguments valides.

En Java, la convention veut que chaque méthode vérifie, autant que possible, ses préconditions et lève une exception — souvent IllegalArgumentException — si l'une d'entre elles n'est pas satisfaite. Par exemple, une méthode max calculant la valeur maximale d'un tableau d'entiers, et exigeant logiquement que celui-ci contienne au moins un élément, pourrait commencer ainsi :

int max(int[] array) {
  if (! (array.length > 0))
    throw new IllegalArgumentException();
  // … reste du code
}

(Notez au passage que la méthode max ne déclare pas qu'elle lève potentiellement IllegalArgumentException au moyen d'une clause throws, car cette exception est de type unchecked.)

La première classe à réaliser dans le cadre de ce projet a pour but de faciliter l'écriture de telles préconditions. En l'utilisant, la méthode ci-dessus pourrait être simplifiée ainsi :

int max(int[] array) {
  Preconditions.checkArgument(array.length > 0);
  // … reste du code
}

Cette classe est nommée Preconditions et appartient au paquetage principal. Elle est publique et finale et n'offre rien d'autre que la méthode checkArgument décrite plus bas. Elle a toutefois la particularité d'avoir un constructeur par défaut privé :

public final class Preconditions {
  private Preconditions() {}
  // … méthode(s)
}

Le but de ce constructeur privé est de rendre impossible la création d'instances de la classe, puisque cela n'a clairement aucun sens — elle ne sert que de conteneur à une méthode statique. Dans la suite du projet, nous définirons plusieurs autres classes du même type, que nous appellerons dès maintenant classes non instanciables.

La méthode publique (et statique) offerte par la classe Preconditions est :

void checkArgument(boolean shouldBeTrue)
qui lève l'exception IllegalArgumentException si son argument est faux, et ne fait rien sinon.

3.4. Type énuméré Vehicle

Le type énuméré Vehicle du sous-paquetage journey — donc du paquetage ch.epfl.rechor.journey — représente les différents types de véhicules de transport public en Suisse. Il comporte les sept valeurs suivantes, qui sont données ici dans l'ordre dans lequel elles doivent apparaître dans le type énuméré :

TRAM
qui représente un tram,
METRO
qui représente un métro,
TRAIN
qui représente un train,
BUS
qui représente un bus ou un car,
FERRY
qui représente un bac ou un autre type de bateau,
AERIAL_LIFT
qui représente une télécabine ou un autre type de transport aérien à câble,
FUNICULAR
qui représente un funiculaire.

En plus de ces valeurs, le type énuméré Vehicle offre l'attribut public, statique et final suivant :

List<Vehicle> ALL
une liste immuable contenant la totalité des valeurs du type énuméré, dans leur ordre de définition (voir les conseils de programmation ci-dessous).

3.4.1. Conseils de programmation

La définition de l'attribut ALL peut vous poser quelques problèmes, car le concept de liste, représenté en Java par l'interface List, n'a pas encore été vu au cours. Toutefois, vous avez vu au premier semestre le concept de tableau dynamique (ArrayList), qui est une sorte de liste. Pour l'instant, vous pouvez donc admettre que le type List est synonyme du type ArrayList, et que les méthodes offertes par une valeur de type ArrayList le sont aussi par une valeur de type List.

Sachant cela, il n'est pas très difficile de définir l'attribut ALL en s'aidant des deux méthodes statiques suivantes :

  1. la méthode values définie pour tous les types énumérés et retournant un tableau contenant les éléments du type énuméré dans leur ordre de définition,
  2. la méthode of de l'interface List, qui retourne une liste immuable ayant les mêmes éléments que le tableau qu'on lui passe en argument.

L'extrait de programme ci-dessous, dont vous pouvez vous inspirer, montre comment la méthode of de List peut être utilisée pour obtenir une liste immuable (seasons) des noms de saisons stockés dans un tableau (seasonsArray) :

String[] seasonsArray = new String[] {
  "printemps", "été", "automne", "hiver"
};
List<String> seasons = List.of(seasonsArray);

3.5. Enregistrements Java

Avant de décrire Stop, la prochaine classe à mettre en œuvre pour cette étape, il convient de décrire le concept de classe enregistrement (record class), ou simplement enregistrement (record), introduit dans la version 17 de Java.

Un enregistrement est un type particulier de classe qui peut se définir au moyen d'une syntaxe plus concise qu'une classe normale. Dans cette syntaxe, le mot-clef class est remplacé par record et les attributs de la classe sont donnés entre parenthèses, après son nom.

Par exemple, un enregistrement nommé Complex représentant un nombre complexe dont les attributs sont sa partie réelle re et sa partie imaginaire im — tous deux de type double — peut se définir ainsi :

public record Complex(double re, double im) { }

Cette définition est équivalente à celle d'une classe finale (!) dotée de :

  • deux attributs privés et finaux nommés re et im de type double,
  • un constructeur prenant en argument la valeur de ces attributs et les initialisant,
  • des méthodes d'accès (getters) publics nommés re() et im() pour ces attributs,
  • une méthode equals retournant vrai si et seulement si l'objet qu'on lui passe est aussi une instance de Complex et que ses attributs sont égaux à ceux de this,
  • une méthode hashCode compatible avec la méthode equals — le but de cette méthode et la signification de sa compatibilité avec equals seront examinés ultérieurement dans le cours,
  • une méthode toString retournant une chaîne composée du nom de la classe et du nom et de la valeur des attributs de l'instance, p. ex. Complex[re=1.0, im=2.0].

En d'autres termes, la définition plus haut, qui tient sur une ligne, est équivalente à la définition suivante, qui n'utilise que des concepts que vous connaissez déjà :

public final class Complex {
  private final double re;
  private final double im;

  public Complex(double re, double im) {
    this.re = re;
    this.im = im;
  }

  public double re() { return re; }
  public double im() { return im; }

  @Override
  public boolean equals(Object that) {
    // … vrai ssi that :
    // 1. est aussi une instance de Complex, et
    // 2. ses attributs re et im sont identiques.
  }

  @Override
  public int hashCode() {
    // … code omis car peu important
  }

  @Override
  public String toString() {
    return "Complex[re=" + re + ", im=" + im + "]";
  }
}

Comme cet exemple l'illustre, les enregistrements permettent d'éviter d'écrire beaucoup de code répétitif, ce que les anglophones appellent du boilerplate code. Il faut toutefois bien comprendre qu'en dehors d'une syntaxe très concise, les enregistrements n'apportent — pour l'instant en tout cas — rien de nouveau à Java, dans le sens où il est toujours possible de récrire un enregistrement en une classe Java équivalente, comme ci-dessus. En cela, les enregistrements sont similaires aux types énumérés.

Il est bien entendu possible de définir des méthodes dans un enregistrement, qui viennent s'ajouter à celles définies automatiquement. Par exemple, pour doter l'enregistrement Complex d'une méthode modulus retournant son module, il suffit de l'ajouter entre les accolades, ainsi :

public record Complex(double re, double im) {
  public double modulus() { return Math.hypot(re, im); }
}

(La méthode Math.hypot(x,y) retourne \(\sqrt{x^2 + y^2}\)).

Finalement, il est aussi possible de définir ce que l'on nomme un constructeur compact (compact constructor), qui augmente le constructeur que Java ajoute par défaut aux enregistrements. Un constructeur compact doit son nom au fait qu'il semble ne prendre aucun argument, et n'initialise pas explicitement les attributs. En réalité, il prend des arguments qui sont les mêmes que ceux de l'enregistrement (re et im dans notre exemple), et Java lui ajoute automatiquement des affectations de ces arguments aux attributs correspondants.

Par exemple, on pourrait vouloir ajouter un constructeur compact à la classe Complex pour lever une exception si l'un des arguments passés au constructeur était une valeur NaN (not a number, une valeur invalide). On pourrait le faire ainsi :

public record Complex(double re, double im) {
  public Complex {  // constructeur compact
    if (Double.isNaN(re) || Double.isNaN(im))
      throw new IllegalArgumentException();
  }
  // … méthode modulus
}

Ce constructeur compact serait automatiquement traduit en :

public final class Complex {
  // … attributs re et im

  public Complex(double re, double im) {
    if (Double.isNaN(re) || Double.isNaN(im))
      throw new IllegalArgumentException();
    this.re = re;  // ajouté automatiquement
    this.im = im;  // ajouté automatiquement
  }

  // … méthodes modulus, re, im, hashCode, etc.
}

Les enregistrements ne seront pas décrits en détail dans le cours, mais seront introduits au moyen d'exemples similaires à ceux ci-dessus dans la suite du projet. Les personnes intéressées par les détails de leur fonctionnement pourront se rapporter à la §8.10 (Record Classes) de la spécification du langage.

3.6. Enregistrement Stop

L'enregistrement Stop du sous-paquetage journey, public, représente un arrêt de transport public. Cet enregistrement est utilisé à la fois pour les gares et pour les voies ou quais. Il possède les attributs suivants :

String name
le nom de l'arrêt, ou le nom de la gare à laquelle appartient l'arrêt s'il représente une voie ou un quai,
String platformName
le nom de la voie ou du quai de l'arrêt, ou null si l'arrêt correspond à une gare,
double longitude
la longitude de la position de l'arrêt, en degrés,
double latitude
la latitude de la position de l'arrêt, en degrés.

Par exemple, l'arrêt correspondant à la gare de Lausanne pourrait être construit ainsi :

Stop l = new Stop("Lausanne", null, 6.62909, 46.51679);

tandis que celui correspondant à la voie 70 de cette même gare pourrait l'être ainsi :

Stop l70 = new Stop("Lausanne", "70", 6.62909, 46.51679);

Stop possède un constructeur compact chargé de valider les arguments et qui :

Stop ne possède aucune autre méthode que celles ajoutées automatiquement aux enregistrements par Java.

3.6.1. Conseils de programmation

Dans le constructeur compact, utilisez la méthode requireNonNull de Objects pour lever une NullPointerException lorsque name est null.

Pour valider la longitude et la latitude, utilisez bien entendu la méthode checkArgument de Preconditions.

3.7. Enregistrement Journey

L'enregistrement Journey, du sous-paquetage journey, public et immuable, représente un voyage. Il possède un unique attribut :

List<Leg> legs
les étapes du voyage.

Le type Leg est celui d'une interface imbriquée à l'intérieur de l'enregistrement Journey et décrite à la §3.8. Comme expliqué dans cette section et celles qui suivent, trois enregistrements (IntermediateStop, Transport et Foot) sont imbriqués dans Leg. Cela signifie que Journey a la structure suivante :

public record Journey(/* … attributs */) {
  public interface Leg {
    public record IntermediateStop(/* … attributs */) {
      /* … constructeur et/ou méthodes */
    }
    /* … autres enregistrements imbriqués, méthodes */
  }
  /* … constructeur et/ou méthodes */
}

En raison de cette imbrication, la notation pointée doit être utilisée depuis l'extérieur pour accéder à l'interface Leg et aux enregistrements IntermediateStop, Transport et Foot. Par exemple, pour créer une instance de l'enregistrement IntermediateStop, on écrira :

new Journey.Leg.IntermediateStop(/* … arguments */);

Journey possède un constructeur compact qui valide la liste des étapes reçue en vérifiant que :

  • elle n'est pas vide,
  • les étapes à pied alternent avec celles en transport,
  • pour toutes les étapes sauf la première, l'instant de départ ne précède pas celui d'arrivée de la précédente,
  • pour toutes les étapes sauf la première, l'arrêt de départ est identique à l'arrêt d'arrivée de la précédente.

Si l'une de ces conditions n'est pas satisfaite, le constructeur compact lève une IllegalArgumentException. Sinon, dans le but de garantir l'immuabilité de la classe, il copie la liste des étapes en utilisant la technique décrite dans les conseils de programmation plus bas.

Journey offre les méthodes publiques suivantes :

Stop depStop()
qui retourne l'arrêt de départ du voyage, c.-à-d. celui de sa première étape,
Stop arrStop()
qui retourne l'arrêt d'arrivée du voyage, c.-à-d. celui de sa dernière étape,
LocalDateTime depTime()
qui retourne la date/heure de début du voyage, c.-à-d. celle de sa première étape,
LocalDateTime arrTime()
qui retourne la date/heure de fin du voyage, c.-à-d. celle de sa dernière étape,
Duration duration()
qui retourne la durée totale du voyage, c.-à-d. celle séparant la date/heure de fin de celle de début.

La classe LocalDateTime, qui représente un couple date/heure sans indication de fuseau horaire, fait partie de la bibliothèque Java. Cette classe jouera un rôle très important dans ce projet, et il est donc conseillé de prendre un moment pour lire sa documentation.

3.7.1. Conseils de programmation

  1. Copie des étapes à la construction

    Pour que la classe Journey soit immuable, son constructeur compact doit copier la liste des étapes reçue en argument (legs) dans une liste immuable.

    Cette copie garantit d'une part que, même si le contenu de la liste passée au constructeur change après la création d'une instance, ces changements n'affecteront pas l'instance ; et d'autre part que la liste stockée dans l'enregistrement ne peut pas être modifiée.

    La copie peut se faire au moyen de la méthode copyOf de List, dont le résultat doit être stocké à nouveau dans l'argument legs afin d'y remplacer la liste initiale, dont le contenu est susceptible de changer :

    legs = List.copyOf(legs);
    
  2. Accès à la première et dernière étape

    Pour déterminer les arrêts et dates/heures de départ et d'arrivée du voyage, il est nécessaire d'accéder à la première et à la dernière de ses étapes, ce qui peut se faire facilement au moyen des méthodes getFirst et getLast de List.

3.8. Interface Journey.Leg

L'interface Leg, publique et imbriquée dans l'enregistrement Journey, représente une étape d'un voyage. Cette interface est implémentée par deux enregistrements, Transport (décrit à la §3.10) et Foot (décrit à la §3.11), qui correspondent aux deux types d'étapes — en transport et à pied.

L'interface Leg offre les méthodes suivantes, toutes abstraites sauf la dernière :

Stop depStop()
qui retourne l'arrêt de départ de l'étape,
LocalDateTime depTime()
qui retourne la date/heure de départ de l'étape,
Stop arrStop()
qui retourne l'arrêt d'arrivée de l'étape,
LocalDateTime arrTime()
qui retourne la date/heure d'arrivée de l'étape,
List<IntermediateStop> intermediateStops()
qui retourne la liste des arrêts intermédiaires de l'étape, le type IntermediateStop étant décrit à la §3.9,
default Duration duration()
qui retourne la durée de l'étape.

La classe Duration, qui représente une durée, est une classe de la bibliothèque Java qui provient du même paquetage que LocalDateTime (java.time). La lecture de sa documentation est aussi fortement recommandée.

3.8.1. Conseils de programmation

La durée d'une étape est bien entendu celle qui sépare sa date/heure d'arrivée de sa date/heure de départ. La méthode statique between de Duration est utile pour la déterminer.

3.9. Enregistrement Journey.Leg.IntermediateStop

L'enregistrement IntermediateStop, public et imbriqué dans l'interface Leg, représente un arrêt intermédiaire d'une étape. Il s'agit d'un arrêt auquel le moyen de transport utilisé s'arrête effectivement, mais qui se trouve entre l'arrêt de départ et l'arrêt d'arrivée de l'étape. Cet enregistrement possède les attributs suivants :

Stop stop
l'arrêt intermédiaire en question,
LocalDateTime arrTime
la date/heure d'arrivée à l'arrêt,
LocalDateTime depTime
la date/heure de départ de l'arrêt.

IntermediateStop possède un constructeur compact qui valide les arguments en vérifiant que :

  • stop n'est pas null (avec requireNonNull),
  • la date/heure de départ n'est pas antérieure à celle d'arrivée (attention, les deux peuvent être égales !).

Notez que, comme il s'agit d'un arrêt intermédiaire, le véhicule y arrive avant d'en repartir, raison pour laquelle la date/heure d'arrivée doit précéder (ou être identique à) celle de départ.

Comme d'habitude, une NullPointerException est levée si la première vérification échoue, et une IllegalArgumentException si la seconde échoue.

3.9.1. Conseils de programmation

La méthode isBefore de LocalDateTime est utile pour valider les dates/heures de départ et d'arrivée.

3.10. Enregistrement Journey.Leg.Transport

L'enregistrement Transport, public et imbriqué dans l'interface Leg, représente une étape effectuée en transport public. Il implémente donc Leg et possède les attributs suivants :

Stop depStop
l'arrêt de départ de l'étape,
LocalDateTime depTime
la date/heure de départ de l'étape,
Stop arrStop
l'arrêt d'arrivée de l'étape,
LocalDateTime arrTime
la date/heure d'arrivée de l'étape,
List<IntermediateStop> intermediateStops
les éventuels arrêts intermédiaires de l'étape,
Vehicle vehicle
le type de véhicule utilisé pour cette étape,
String route
le nom de la ligne sur laquelle circule le véhicule utilisé pour cette étape,
String destination
le nom de la destination finale du véhicule utilisé pour cette étape.

Le constructeur compact de Transport valide les arguments en vérifiant que :

  • aucun d'entre eux n'est null,
  • la date/heure d'arrivée n'est pas antérieure à celle de départ.

De plus, il copie la liste des arrêts intermédiaires afin de garantir l'immuabilité de la classe.

Transport ne possède aucune autre méthode que celles ajoutées automatiquement aux enregistrements par Java. Notez que ces méthodes ajoutées par Java sont celles qui implémentent les méthodes abstraites de l'interface Leg. Par exemple, la méthode d'accès pour l'attribut depStop, ajoutée automatiquement, implémente la méthode depStop() de Leg.

3.10.1. Conseils de programmation

Notez bien que des appels à requireNonNull ne sont nécessaires dans le constructeur que pour les arguments qui ne sont pas manipulés d'une manière ou d'une autre avant d'être stockés dans l'enregistrement. Par exemple, il n'est pas nécessaire d'utiliser requireNonNull pour intermediateStops puisque cet argument est passé à copyOf, qui lèvera elle-même une exception si cet argument est null.

De manière générale, requireNonNull n'est vraiment utile que dans les constructeurs, et uniquement lorsqu'on désire s'assurer qu'un argument qu'on se contente de stocker dans un attribut de la classe n'est pas null.

3.11. Enregistrement Journey.Leg.Foot

L'enregistrement Foot, public et imbriqué dans l'interface Leg, représente une étape effectuée à pied. Il implémente donc Leg et possède les attributs suivants :

Stop depStop
l'arrêt de départ de l'étape,
LocalDateTime depTime
la date/heure de départ de l'étape,
Stop arrStop
l'arrêt d'arrivée de l'étape,
LocalDateTime arrTime
la date/heure d'arrivée de l'étape.

Le constructeur compact de Foot fait les mêmes vérifications que celui de Transport. En plus de ce constructeur compact, Foot possède deux méthodes publiques :

List<IntermediateStop> intermediateStops()
qui implémente la méthode abstraite correspondante de l'interface Leg et retourne une liste vide, car une étape à pied ne comporte jamais d'arrêts intermédiaires,
boolean isTransfer()
qui retourne vrai ssi l'étape est un changement au sein de la même gare, c.-à-d. si le nom (!) de l'arrêt de départ est identique à celui de l'arrêt d'arrivée.

Soyez attentifs au fait qu'une étape à pied entre deux arrêts différents peut être considérée comme un changement si ces arrêts ont le même nom. Par exemple, une étape entre la voie 70 et la voie 1 de la gare de Lausanne est un changement, même si les arrêts sont différents. Seul le fait que le nom de (la gare de) ces deux arrêts soit identique compte.

3.12. Interfaces scellées en Java

En Java, une interface peut normalement être implémentée par n'importe quelle classe. Par exemple, l'interface Leg déclarée ci-dessus pourrait très bien être implémentée par d'autres classes que celles déclarées à l'intérieur de Leg (Transport et Foot).

Dans ce cas particulier, cela n'est toutefois pas souhaitable, car nous savons que les seuls types d'étapes qui existent dans le projet sont ces deux-là. Il serait donc bien de pouvoir communiquer cela à Java, afin d'interdire la définition d'autres classes implémentant l'interface Leg.

Cela peut se faire en scellant (seal) l'interface Leg, simplement en ajoutant le mot-clef sealed à l'interface, ainsi :

public sealed interface Leg { … }

Lorsqu'une interface est ainsi scellée, les seules classes qui ont le droit de l'implémenter sont celles se trouvant dans la même « unité de compilation », c.-à-d. le même fichier Java.

3.13. Classe FormatterFr

La classe FormatterFr du paquetage principal, publique, finale et non instanciable, contient des méthodes statiques permettant d'obtenir la représentation textuelle de différents types de données, au format décrit à la §2.4. Il s'agit de :

String formatDuration(Duration duration)
qui retourne la représentation textuelle, décrite à la §2.4.1, de la durée donnée,
String formatTime(LocalDateTime dateTime)
qui retourne la représentation textuelle, décrite à la §2.4.2, de l'heure de la date/heure donnée,
String formatPlatformName(Stop stop)
qui retourne la représentation textuelle, décrite à la §2.4.3, de la voie ou quai de l'arrêt donné — ou une chaîne vide si l'arrêt en question est une gare, ou si son nom de voie/quai est vide,
String formatLeg(Journey.Leg.Foot footLeg)
qui retourne la représentation textuelle, décrite à la §2.4.4, de l'étape à pied donnée,
String formatLeg(Journey.Leg.Transport leg)
qui retourne la représentation textuelle, décrite à la §2.4.5, de l'étape en transport donnée,
String formatRouteDestination(Journey.Leg.Transport transportLeg)
qui retourne la représentation textuelle, décrite à la §2.4.6, de la ligne et du sens de parcours du véhicule emprunté pour effectuer l'étape donnée.

3.13.1. Conseils de programmation

  1. Méthode formatTime

    Pour formater une date/heure dans formatTime, il faut utiliser une instance de DateTimeFormatter configurée correctement. La manière la plus simple et la plus propre d'en obtenir une consiste à utiliser le bâtisseur prévu pour cela, à savoir la classe DateTimeFormatterBuilder. Par exemple, pour formater une date selon la convention utilisée en suisse (jour/mois/année), on pourrait créer un formateur ainsi :

    DateTimeFormatter fmt = new DateTimeFormatterBuilder()
      .appendValue(ChronoField.DAY_OF_MONTH)
      .appendLiteral('/')
      .appendValue(ChronoField.MONTH_OF_YEAR)
      .appendLiteral('/')
      .appendValue(ChronoField.YEAR)
      .toFormatter();
    

    qu'on pourrait ensuite utiliser ainsi :

    fmt.format(LocalDate.of(2025, Month.FEBRUARY, 18));
    

    pour obtenir la chaîne 18/2/2025.

  2. Méthode formatLeg

    La manière la plus simple de construire la représentation textuelle d'une étape effectuée en transport consiste à le faire petit à petit, en y ajoutant progressivement les parties requises — nom de l'arrêt de départ, voie ou quai de ce même arrêt, etc. La classe StringBuilder de la bibliothèque Java est idéale pour construire de manière progressive une chaîne de caractères, et est plus efficace que l'opérateur de concaténation (+).

3.14. Tests

Pour vous aider à démarrer ce projet, des tests unitaires JUnit vous sont exceptionnellement fournis pour cette étape, et se trouvent dans le dossier test du squelette. Une fois que vous aurez terminé la rédaction des classes de cette étape, vous pourrez les exécuter en :

  • ajoutant JUnit à votre projet, comme expliqué dans notre guide à ce sujet,
  • effectuant un clic droit sur le dossier test du projet et sélectionnant Run 'All Tests'.

3.15. Documentation

Une fois les tests exécutés avec succès, il vous reste à documenter la totalité des entités publiques (classes, attributs et méthodes) définies dans cette étape, au moyen de commentaires Javadoc, comme décrit dans le guide consacré à ce sujet. Vous pouvez écrire ces commentaires en français ou en anglais, en fonction de votre préférence, mais vous ne devez utiliser qu'une seule langue pour tout le projet.

4. Résumé

Pour cette étape, vous devez :

  • installer la version 21 (et ni une plus récente, ni une plus ancienne !) de Java sur votre ordinateur, ainsi que la dernière version d'IntelliJ IDEA Community Edition (attention : n'installez pas la version Ultimate) ,
  • écrire les classes et interfaces Preconditions, Vehicle, Stop, Journey, Journey.Leg, Journey.Leg.IntermediateStop, Journey.Leg.Transport, Journey.Leg.Foot et FormatterFr selon les indications ci-dessus,
  • vérifier que les tests que nous vous fournissons s'exécutent sans erreur, et dans le cas contraire, corriger votre code,
  • documenter la totalité des entités publiques que vous avez définies,
  • (optionnel mais fortement recommandé) rendre votre code au plus tard le 21 février 2025 à 18h00, en exécutant le programme Submit fourni dans le squelette, après avoir modifié les définitions des variables TOKEN_1 et TOKEN_2 pour qu'elles contiennent les jetons individuels des deux membres du groupe, disponibles sur la page privée de chacun d'eux. (Les personnes travaillant seules doivent mettre leur jeton individuel dans les deux variables.)

Ce premier rendu n'est pas noté, mais celui de la prochaine étape le sera. Dès lors, il est vivement conseillé de faire un rendu de test cette semaine afin de se familiariser avec la procédure à suivre.