:: Enseignements :: Master :: M1 :: 2024-2025 :: Java Avancé ::
[LOGO]

Sed, the stream editor


Programmation fonctionnelle vs. programmation orientée objet, interface, record, lambda, gestion des entrées/sorties.
Le but de ce TP est d'implanter une petite partie des commandes de l'outil sed.

Exercice 1 - Maven

Nous allons utiliser Maven avec la configuration, le pom.xml, suivante
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>fr.uge.sed</groupId>
    <artifactId>sed</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>5.11.0</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.13.0</version>
                <configuration>
                    <release>23</release>
                    <compilerArgs>
                        <compilerArg>--enable-preview</compilerArg>
                    </compilerArgs>
                </configuration>
            </plugin>

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.5.0</version>
                <configuration>
                    <argLine>--enable-preview</argLine>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>
   
Créer un projet Maven (pas un projet Java) puis cocher create simple project au niveau du premier écran, puis passer à l'écran suivant en indiquant Next. Pour ce TP, le groupId est fr.uge.sed , l'artefactId est sed et la version est 0.0.1-SNAPSHOT. Pour finir, cliquer sur Finish.

Exercice 2 - Astra inclinant, sed non obligant

Le but de cet exercice est de créer un petit éditeur comme sed.
Pour ceux qui ne connaîtraient pas sed, c'est un utilitaire en ligne de commande qui prend en entrée un fichier et génère en sortie un nouveau fichier en effectuant des transformations ligne à ligne. sed permet facilement de supprimer une ligne soit spécifiée par son numéro, soit si elle contient une expression régulière ou de remplacer un mot (en fait une regex) par un mot.
L'utilitaire sed traite le fichier ligne à ligne, il ne stocke pas tout le fichier en mémoire. Ce n'était pas une solution viable à la création de sed en 1974, et ce n'est toujours pas une solution viable maintenant que l'on peut avoir des fichiers de plusieurs centaines de giga-octets.
On parle de traitement en flux, en stream en Anglais, d'où le nom de Stream EDitor, sed.

Le stream editor que nous allons créer prend un ensemble de règles (rules) et transforme chaque ligne du fichier suivant les règles.
Juste pour rire, nous avons demandé à ChatGPT de compléter le fichier en donnant un main() contenant les premières lignes du fichier (celles qui contiennent l'aide). Voilà le résultat obtenu :
  public static void main(String[] args) {
    if (args.length != 3) {
      System.err.println(
          """
          Usage: <commands> <input.txt> <output.txt>

          examples of command:
          u          Convert the line to uppercase
          l          Convert the line to lowercase
          *9         Replace every occurrence of one * with nine *
          *4u        Replace every occurrence of one * with four * and convert the result to uppercase
          d          Delete the entire line
          """);
      System.exit(1);
      return;
    }

    // complété par ChatGPT 
    String commands = args[0];
    String inputFile = args[1];
    String outputFile = args[2];

    try (BufferedReader reader = new BufferedReader(new FileReader(inputFile));
         PrintWriter writer = new PrintWriter(new FileWriter(outputFile))) {

      String line;
      while ((line = reader.readLine()) != null) {
        boolean modified = false;
        for (String command : commands.split(",")) {
          switch (command) {
            case "u":
            line = line.toUpperCase();
            modified = true;
            break;
          case "l":
            line = line.toLowerCase();
            modified = true;
            break;
          case "*9":
            line = line.replace("*", "********");
            modified = true;
            break;
          case "*4u":
            line = line.replace("*", "****").toUpperCase();
            modified = true;
            break;
          case "d":
            line = "";
            modified = true;
            break;
          }
          if (modified) {
            writer.write(line);
            writer.newLine();
            modified = false; // Reset flag after processing a command
          }
        }
      }
    } catch (IOException e) {
       System.out.println("Error reading/writing files: " + e.getMessage());
    }
  }
   

Si ce n'est pas évident pour vous, le code ne fait pas ce qui est demandé et est aussi farci de bugs, donc nous allons plutôt développer le code à la main :)
Si cela vous intéresse de voir tous les bugs :
  public static void main(String[] args) {
    if (args.length != 3) {
      System.err.println(
          """
          ...
          """);
      System.exit(1);
      return;
    }

    // complété par ChatGPT
    String commands = args[0];   // cela serait mieux avec des "var"
    String inputFile = args[1];  // devrait être un Path
    String outputFile = args[2]; // devrait être un Path

    //try (BufferedReader reader = new BufferedReader(new FileReader(inputFile));
    //     PrintWriter writer = new PrintWriter(new FileWriter(outputFile))) {
    // devrait être
    try(BufferedReader reader = Files.newBufferedReader(inputFile);
        BufferedWriter writer = Files.newBufferedWriter(outputFile)) {

      // conceptuellement, il y a un problème car le re-parse les commandes pour chaque ligne
      String line;
      while ((line = reader.readLine()) != null) {
        boolean modified = false;   // pas vraiment nécessaire voir plus tard
        for (String command : commands.split(",")) {  // le split est n'importe quoi
          switch (command) {  // devrait utiliser un switch expression
            case "u":                   // case "u" -> {
              line = line.toUpperCase();  // manque un argument
              modified = true;            // yield le résultat
              break;                      // }
            case "l":
              line = line.toLowerCase();  // manque un argument
              modified = true;
              break;
            case "*9":                               // devrait détecter seulement '*' et regarder le caractère suivant
              line = line.replace("*", "********");  // 8 étoiles au lieu de 9
              modified = true;
              break;
            case "*4u":
              line = line.replace("*", "****").toUpperCase();  // pas compris que l'on peut composer les commandes
              modified = true;
              break;
            case "d":
              line = "";         // sert à rien
              modified = true;   // bah, non justement, faire un "continue" sur le while semble plus simple
              break;
            }       // manque une autre accolade fermante ici, car là on affiche plusieurs fois la même ligne
            if (modified) {
              writer.write(line);
              writer.newLine();  // pas le même résultat suivant l'OS, chiant à tester (Windows est différent !)
              modified = false;
            }
          }
        }
      } catch (IOException e) {
        System.out.println("Error reading/writing files: " + e.getMessage());  // devrait être System.err
                                                                               // manque le System.exit(2)
      }
    }
    

Les tests JUnit 5 de cet exercice sont StreamEditorTest.java.

  1. Dans un premier temps, on va prendre une commande (une chaîne de caractères comme "u" ou "*4") et créer l'objet correspondant pour éviter de re-parser la commande à chaque ligne. On se propose de nommer cette objet Transformer car son rôle est de transformer une ligne du fichier en une nouvelle ligne.
    En plus de la méthode createTransformer qui créé un transformer pour une commande, on a besoin d'une autre méthode rewrite qui prend un reader, un writer et un transformer. Elle lit chaque ligne du reader, la transforme avec le transformer et l'écrit sur le writer.
            var transformer = StreamEditor.createTransformer("u");  // exemple avec "u" comme commande
            try(BufferedReader reader = ...;
                BufferedWriter writer = ...) {
              StreamEditor.rewrite(reader, writer, transformer);
            }
        

    Pour l'instant, on ne s'intéresse qu'aux commandes "u", "l" et "*" suivie d'un chiffre (pas un nombre !) indiquant la répétition ("*4", "*7", etc), et on veut une classe par type de commande. Déclarer Transformer ainsi que 3 implantations UpperCaseTransformer, LowerCaseTransformer et StarTransformer.
    Puis créer la méthode createTransformer qui prend une commande, regarde le premier caractère et créé une instance de Transformer avec la bonne implantation.
    Enfin, écrire la méthode rewrite qui boucle sur chaque ligne du reader, envoie la ligne au transformer et écrit la ligne résultante dans le writer. Pour le retour à la ligne on utilisera un '\n', ainsi le programme fonctionnera de la même façon quelque soit l'OS.
    Vérifier que les tests marqués "Q1" passent.
    Note : il existe une méthode replace() sur la classe String. En fait, il en existe plusieurs, à vous de prendre la bonne.

  2. On veut que notre programme fonctionne de la même façon, quelle que soit la machine or la norme Unicode. On demande que les méthodes String.toUpperCase() et String.toLowerCase() aient un comportement spécifique en fonction de la langue (Locale) par défaut de l'OS.
    Si vous ne l'avez pas déjà fait, changer votre code pour que la mise en majuscule/minuscule soit fait de façon indépendante de l'OS. Si vous ne vous souvenez plus de comment on fait, vous pouvez relire la javadoc de toUpperCase() ou toLowerCase()
    Modifier, si nécessaire, votre code et vérifier que les tests marqués "Q2" passent.

  3. Vous avez sûrement utilisé le polymorphisme pour implanter Transformer alors que votre programme contrôle toutes les implantations possibles. C'est MAL, vous auriez du utiliser le pattern matching.
    Commenter votre code puis changer votre implantation pour utiliser le pattern matching plutôt que le polymorphisme.
    Vérifier que les tests marqués "Q3" passent.
    Rappel : Ne pas utiliser le polymorphisme veut dire ne pas avoir de méthode abstract dans Transformer. Et pattern matching, signifie implanter en faisant un switch sur toutes les implantations.

  4. En fait, les transformations sont des actions, donc pour chaque transformation, on pourrait utiliser une lambda, ainsi le code serait plus simple à écrire et donc à maintenir.
    Comment indiquer que Transformer est implantée en utilisant des lambdas ?
    Comment faire pour que l'on empêche d'autre code de fournir une autre implantation ?
    Vérifier que les tests marqués "Q4" passent.

  5. On veut maintenant pouvoir gérer une commande qui est elle-même composée de plusieurs commandes comme lu ou *4u (qui respectivement mette la ligne en minuscule puis majuscule, remplace les étoiles par quatre étoiles puis mette la ligne en majuscule).
    Cela veut dire qu'il va falloir décomposer une commande en plusieurs commandes, pour cela, on se propose d'écrire une méthode parse(command, transformer, index) qui renvoie un tuple composé de la commande à l'index index ainsi que de l'index de la prochaine commande (pour la mise en majuscule/miniscule la prochaine commande est à l'index suivante mais pour les étoiles il faut sauter deux cases).
    Comment représente-t-on des tuples en Java ? Dans notre cas, quel est la représentation d'un tuple qui contient un Transformer et un index
    Écrire la méthode parse(command, transformer, index) et changer le code de la méthode createTransformer(command) en conséquence.
    Vérifier que les tests marqués "Q5" passent.

  6. En fait, le code précédent est la façon fonctionnelle de voir la décomposition en transformer, on peut aussi écrire une version plus objet des choses. En POO, on va encapsuler les mutations, ici, la mutation est l'index qui nous indique là où décoder/parser le prochain transformer dans la commande. Encapsuler la mutation revient donc à déclarer une classe Parser avec un champ mutable qui va être modifié à chaque fois que l'on décode une transformation.
    On a de plus besoin en plus d'une méthode pour savoir si on est arrivé à la fin de la chaîne de caractères (appelée ici, canParse())
      static final class Parser {
        private final String commands;
        private int index;
    
        Parser(String commands) {
          this.commands = commands;
        }
    
        boolean canParse() {
          return index < commands.length;
        }
    
        Transformer parse(Transformer t) {
          // TODO
        }
      }
        

    Écrire la classe Parser et modifier la méthode createTransformer(commande) pour utiliser le parser (l'idée est de faire une boucle tant que canParse renvoie vrai et envoyer le précédent transformer à parse() qui retourne le nouveau transformer.
    Vérifier que les tests marqués "Q6" passent.
    Note : on peut remarquer qu'il y a un modificateur static devant le nom de la classe Parser, on verra la semaine prochaine en cours pourquoi.

  7. [Revision] Pour les plus balèzes, en fait, le code précédent peut être simplifié car Parser.parse() fait trop de choses. Au lieu d'appeler la méthode Parser.parse() avec la transformation précédente, on peut ne rien envoyer et faire la composition entre la transformation précédente et la nouvelle transformation en dehors de l'appel à parse().
    On peut aussi remarquer l'on compose les transformers les un à la suite des autres, donc c'est comme une réduction fonctionnelle et on peut utiliser la méthode Stream.reduce() pour cela.
    Vérifier que les tests marqués "Q6" passent toujours.
    Note : si vous ne voyez pas comment faire, ce n'est pas grave, vous pourrez revenir sur cette question pendant vos révisions.

  8. [Revision] Enfin pour les ultra-balèzes, il manque deux choses
    • Il faut écrire un main ainsi que la méthode rewrite(Path input, Path output, Transformer transformer)
    • Il faut implanter la commande "d" qui supprime une ligne. Le plus simple consiste à ce que la méthode dans Transformer renvoie un Optional<String> au lieu de renvoyer juste un String comme cela, on peut encoder le fait que la ligne peut ne plus exister.

    Faite les deux modifications proposées ci-dessus.
    Vérifier que les tests marqués "Q8" passent.