Cours Génie logiciel


Introduction

Présentation de l’UE

Présentation de partie conception

L’Unité d’Enseignement Initiation Génie Logiciel est consacrée à ce que l’on nomme en programmation la conception logicielle. La conception logicielle consiste à donner à un projet de programmation une organisation souple mais structurante. Une telle organisation permettra de garder un code clair au cours des différentes évolutions que le code subira. Ces évolutions devront pouvoir être faites facilement, et le code pourra à tout moment être testé facilement. La modularité intelligente est une clé de voûte de la conception. Les principes de cette modularité sont énoncés dans les principes SOLID. Le savoir-faire de cette modularité est résumé dans les patrons de conceptions qui permettent d’éviter les pièges habituels de conception en suivant des modèles sous la forme de diagrammes UML.

Les différentes points abordés durant ce cours sont :

  • Gestion de version
  • Tests
  • Documentation
  • Diagrammes de classes UML
  • Principes SOLID : SRP, OCP, LSP, ISP, DIP
  • Présentation et utilisation de patrons de conceptions sur des exemples concrets

Bibliographie

Ce cours sur la conception s’appuie en autre sur les ouvrages suivants :

  • Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides (trad. Jean-Marie Lasvergères), Design Patterns - Catalogue de modèles de conceptions réutilisables, France, Vuibert, 1999.
  • Robert C. Martin, Agile Software Development : Principles, Patterns, and Practices, Upper Saddle River, NJ, Pearson Education, 2002.
  • Robert C. Martin, Coder proprement, Pearson France, 2019.
  • J B Rainsberger & Scott Stirling, JUnit Recipes - Practical Methods for Programmer Testing, Manning publishing

Déroulement des enseignements

Les cours, exercices et devoirs seront publiés tout au long du semestre. La note finale (\(NF\)) de cette UE est simplement la note à l’examen final.

Langage de programmation

Le langage utilisé durant le cours sera le Java. Il est tout à fait possible d’appliquer les bonnes pratiques de programmation dont principes SOLID ainsi que les patrons de conceptions avec d’autres langages de programmation permettant le paradigme de programmation orientée objet comme Python, C#, C++, PHP, Swift, Kotlin, … Normalement, vous devriez avoir suivi deux unités d’enseignement sur la programmation orientée objet nommées Programmation 1 et Programmation 2. Si vous avez besoin de réviser le fonctionnement de Java et les concepts de base de la programmation orientée objet, vous pouvez consulter les supports de cours de ces deux unités d’enseignements aux liens suivants :

Logiciels à installer

Contrairement à d’autres cours, il n’y a pas d’obligation d’utiliser un système d’exploitation UNIX/Linux pour pouvoir travailler sur la partie conception car Java a été conçu pour être exécutable facilement sur n’importe quel système d’exploitation. Néanmoins, l’utilisation d’un tel système est bien sûr possible et peut faciliter l’utilisation de certains outils (comme la configuration de l’authentification via une clé SSH). Vous avez la possibilité d’utiliser la machine virtuelle Lunix disponible au lien suivant : système de virtualisation. Si jamais vous avez des problèmes pour accéder à ces machines virtuelles vous pouvez posez des questions dans le forum général d’échanges du cours ou bien formuler une demande d’aide personnalisée à la DIRNUM (DIRection du NUMérique) via le service de télé-demandes accessible depuis votre ENT.

Kit de développement Java

Afin de pouvoir compiler et exécuter du code Java, il vous faudra installer un Java Development Kit (JDK). Nous vous conseillons la version 17 de Java qui peut être téléchargée sur le site d’Oracle au lien suivant.

Sous Ubuntu ou tout système linux utilisant le gestionnaire de paquet apt (c’est-à-dire généralement une distribution debian), vous pouvez installer le JDK 17 avec la ligne de commande suivante : apt install openjdk-17-jdk openjdk-17-jre.

Environnement de développement

Pour coder proprement, il est souvent utile de disposer d’un environnement de développement (IDE pour Integrated development environment). Un IDE est un ensemble d’outils comportant un éditeur de texte dédiée à la programmation (avec une autocomplétion intelligente), des fonctions et menus qui permettent, de compiler et exécuter du code, d’accéder à des outils pour déboguer, de gérer la gestion de version, fournir des outils de refactoring efficaces, …

Il existe de nombreux IDE adaptés au langage de programmation Java. Les plus utilisés sont les suivants :

  • IntelliJ IDEA de JetBrains qui est l’IDE qui va être mis en avant dans les supports et qui est disponible sur les machines virtuelles fournies par AMU dans sa version ultimate;
  • Eclipse qui est très similaire à IntelliJ IDEA;
  • Visual Studio Code qui est plus léger que les deux IDE précédents et pour lequel il faut installer les extensions java pour avoir le même type de fonctionnalités.

Il est recommandé d’installer un de ces logiciels ou équivalent pour cette partie car utilisé un bon environnement de développement est une première étape pour construire proprement un projet logiciel d’une taille un peu conséquente.

Moteur de production

Un moteur de production est un logiciel dont la fonction principale consiste à automatiser le processus de création d’un logiciel à partir d’un code source. Cela comprend la compilation du code source, le packaging de l’executable (création d’installateur ou d’exécutable), la gestion des dépendances (liens avec des bibliothèques externes, téléchargements, …), l’exécution automatisés de tests, …

Pour les projets de ce cours, nous utiliserons gradle comme moteur de production. Il permet en autre de rendre facile la mise en place de tests unitaires via les bibliothèques JUnit 5 et AssertJ.

Pour installer Gradle il suffit de suivre les instructions d’installation en téléchargeant la version 7.5.1 de Gradle.

Gestion de version

Lorsqu’on travaille sur le code un peu conséquent, il est utile d’utiliser un outil de gestion de version qui permet en autre de :

  • conserver les différentes versions du code: conservation de l’historique des changements sous la forme d’un dépôt qui permet de revenir à n’importe quelle version;
  • stocker le code à des endroits différents (machines personnelles des développeurs, serveur permettant gérer le code, …) avec des outils de synchronisation entre les différents dépôts;
  • travailler en équipe en conservant l’origine de toutes les modifications (on sait donc qui blamer lorsqu’il y a des erreurs).

Dans ce cours, vous utiliserez git que vous pouvez télécharger au lien suivant : https://git-scm.com/download. Afin d’héberger votre code, vous avez accès à une instance gitlab à l’adresse suivante : https://etulab.univ-amu.fr/.

Bonnes pratiques de programmation

Avant de présenter les principes SOLID qui sont cinq principes de conception visant à produire des architecture logicielles qui sont plus compréhensibles et maintenables, il est important de faire un rappel sur les bonnes pratiques de programmation qui permettent de rendre le code plus lisible. Une des choses les plus importantes lorsqu’on écrit du code est de bien nommer les éléments du code. Nous allons donc commencer par expliquer les principes à respecter pour avoir un nommage efficace.

Une méthodologie pour bien nommer

Trouver des noms appropriés peut prendre beaucoup de temps sur le moment mais va vous permettre de gagner du temps par la suite. Les conventions de nommage sont extrêmement importante pour la maintenance et la lisibilité d’un programme. Il est donc important de :

  • utiliser des noms cohérents pour tous les symboles:
  • choisir un nom qui correspond au but/rôle du symbole (Le nom doit révéler le rôle de l’élément);
  • utiliser l’anglais pour nommer vos éléments;
  • choisir un nom en regardant le code source des librairies sérieuses comme celles fournies par la JDK.

Les raisons de l’importance du nommage sont les suivantes.

  • Un nom bien choisi rend plus facile la lecture du programme.
  • Lorsqu’on n’arrive pas à choisir un nom adapté, c’est souvent parce que son rôle n’est pas bien défini.
  • Un programmeur passe 80% de son temps à lire le programme : il faut lui faciliter ce travail surtout qu’on peut passer beaucoup de temps à relire son propre code.
  • Des incohérences logiques évidentes peuvent sauter aux yeux avec un bon nommage.
  • Toutes les librairies sont codées en anglais.

Nommage des variables/arguments/attributs

Il y a certains écueils à éviter lorsqu’on cherche à trouver des noms pour des variables. Vous ne devez pas avoir des variables avec des :

  • noms en une lettre (même pour les indices) : i, j, …
  • noms numérotés : a1, a2, a3, …
  • abréviations ayant plusieurs interprétations : rec, res, …
  • noms ne donnant pas le sens précis : temporary, result, …
  • noms trompeurs : par exemple un accountList doit être une List (et pas un array ou un autre type)
  • types de l’objet au singulier pour une collection d’objet : une liste de personnes doit s’appeler persons et non person
  • noms imprononçables : genymdhms, …

Comme toute les bonnes pratiques, ce ne sont pas des règles absolues et on peut évidemment y déroger. Par exemple, il est généralement accepté d’utiliser x et y pour les coordonnées d’un Point comme cela est fait pour la classe Point2D de JavaFX. Néanmoins, comme pour toutes les règles qui ne sont pas absolues, il est nécessaire de se poser la question de savoir si on fait bien de ne pas les respecter.

Le Java et la quasi-totalité des langages de programmation utilise l’anglais (dans les mot-clés et les librairies standards). Par conséquent, on se doit de programmer en anglais pour avoir la cohérence du code. Utiliser l’anglais permet aussi d’augmenter le nombre de personnes pouvant lire le code et donc d’avoir de nombreux exemples existants pour s’en inspirer.

Une autre règle importante pour le nommage est de respecter le Code Style de Java pour le nommage des variables/arguments/attributs. Pour les noms composés d’un seul mot, on écrit tout simplement le mot en minuscule. Pour un nom composé de plusieurs mots, on n’utilise ni espace ni ponctuation, et on sépare les mots en mettant en capitale la première lettre de chaque mot. Par exemple, cela donne : flaggedCells, gameBoard, … Si le nom comportait des lettres en dehors des 26 caractères non-accentué classique de l’anglais (de a à z), on les remplace par des caractères inclus dans les 26 caractères de base. Par exemple, “root computed by Müller’s method” devient rootComputedByMuellersMethod.

Nommage des méthodes

Comme pour les variables, il est important de bien nommer les méthodes. Le nom d’une méthode doit décrire le service rendu à celui qui l’appelle, et non pas comment elle le fait. La convention de nommage est la même que pour les variables (capitale pour la première lettre de chaque mot sauf le premier). Dans une très grande majorité des cas, la méthode se trouve dans une des catégories suivantes :

  • Ordre : méthodes exécutant une action avec comme sujet l’objet avec lequel la méthode est appelée. Dans ce cas, on utilise le groupe verbal à l’infinitif. Cela donne par exemple : connection.open(), list.sort(comparator), comparator.compare(object1, object2).
  • Requête booléenne : méthodes testant un prédicat sur l’objet. Dans ce cas, on utilise un groupe verbal au présent. Cela donne par exemple : connection.isClosed(), list.isEmpty(), list.contains(object), node.hasNext(), frame.canClose().
  • Requête non-booléenne : méthodes renvoyant une partie de l’état de l’objet. Dans ce cas, on utilise un groupe nominal ou bien un getter. Cela donne par exemple : list.size(), connection.getMetaData().
  • Conversion : méthodes convertissant l’objet en un objet d’un autre type. Dans ce cas, on utilise to suivi du type ciblé. Cela donne par exemple : list.toArray(...), object.toString().

Comme pour le nommage des variables, ces règles ne sont pas absolues mais juste des conventions qui peuvent avoir des exceptions. En général le plus simple est de s’inspirer de l’existant : par exemple la JDK et de bien réfléchir lorsqu’on souhaite déroger aux règles.

Si vous avez des difficultés à nommer vos méthodes, c’est sans doute qu’elles font trop d’actions. Une méthode devrait avoir au maximum une dizaine de lignes de code. Il est toujours possible de satisfaire à cette contrainte en extrayant le plus possible les parties du code d’une méthode à d’autres méthodes. Il est donc important de :

  • réfléchir avant de coder au rôle de la méthode ;
  • se demander ce qui peut être confié à d’autres méthodes.

Une fonction ne doit donc faire qu’une seule chose. Pour cela, elle ne doit réaliser que des étapes de même niveau d’abstraction. Nous allons illustrer cela sur l’action de cuisiner. Pour faire la cuisine on doit (premier niveau d’abstraction) :

  • choisir une recette ;
  • réunir les ingrédients ;
  • suivre la recette.

Pour choisir une recette, on doit (deuxième niveau d’abstraction):

  • réfléchir à ce que j’ai envie de manger ;
  • chercher sur marmiton.

Considérons le code suivant pour une méthode cook :

void cook(){
    // On choisit la recette
    Food wantToEat = thinkAboutFood();
    Recipe recipe = lookOnMarmiton(wantToEat);
    // On réunit les ingrédients
    openFridge();
    for(Ingredient ingredient : 
        recipe.getFreshIngredients()){
        takeInFrige(ingredient);
    }
    closeFridge();
    openCupboard();
    ...
    // On suit la recette
    ...
}

Dans le code de la méthode cook, on utilise un niveau d’abstraction trop bas. Cette approche n’est pas la bonne et on a été obligé de rajouter des commentaires pour indiquer les étapes du premier niveau d’abstraction. La bonne approche consiste à définir des méthodes correspondant aux étapes du premier niveau d’abstraction et de les appeler dans la méthode cook. Cela nous donne le code suivant :

    void cook(){
        Recipe recipe = chooseRecipe();
        gatherIngredients(recipe);
        followRecipe(recipe);
    }

    Recipe chooseRecipe(){
        Food wantToEat = thinkAboutFood();
        Recipe recipe = lookOnMarmiton(wantToEat);
        return recipe;
    }

    ...

Un autre écueil à éviter est de mentir dans le nom d’une méthode. Par exemple, si on considère la méthode checkPassword dans la classe ci-dessous :

class User {
    private boolean authenticated;
    private String password;

    public boolean checkPassword(String password) {
        if (password.equals(this.password)) {
            authenticated = true;
            return true;
        }
        return false;
    }
}

Cette méthode authentifie l’utilisateur alors qu’elle ne devrait que vérifier la validité du mot de passe d’après son nom. Il y a donc un mensonge (la méthode fait plus que ce qu’elle dit) ce qui complique fortement la compréhension du code. C’est donc un comportement à éviter.

Nommage des classes/interfaces/records/enums

En Java, pour tous les éléments du code qui correspondent à des types (c’est-à-dire des classes, interfaces, records ou enums), on utilise une majuscule pour la première lettre de chaque mot composant le nom du type (y compris le premier mot contrairement aux variables et méthodes). Cela donne par exemple : Shape, Rectangle, ArrayList, MountainBike. Un type définissant généralement une catégorie d’objet, le nom d’une classe/interface/record/enum correspond dans la plupart des cas à un groupe nominal au singulier. Cela vaut en particulier pour les enum de java qui doivent être au singulier. L’enum DayOfWeek qui définit les sept jours de la semaine est au singulier car un objet de type DayOfWeek correspond à un jour de la semaine.

Parfois, on a besoin de regrouper un certain nombre de fonctions static ensemble. C’est par exemple ce qui se passe pour Math qui contient des fonctions mathématiques de base ou Collections qui contient des méthodes s’appliquant sur des collections. Dans ce cas très particuliers, la classe ne permet pas de créer des objets (par exemple le constructeur de Math est privé et n’est pas appelé dans la classe) et donc la convention de nommage ne s’applique pas vraiment. Le nom de la classe doit juste donner les liens entre les différentes méthodes statiques qu’on a regroupées dans celle-ci.