logo le blog invivoo blanc

L’art du « clean code » en environnement Java

1 décembre 2022 | Design & Code, Java | 0 comments

I. Introduction

L’ouvrage “Clean Code“ écrit par Robert C. Martin, aussi connu comme « Uncle Bob » (Oncle Bob) est une référence incontournable sur les bonnes pratiques du « clean code ». Nous allons dans cet article résumer certaines de ces bonnes pratiques avec quelques exemples en environnement Java.

Avant de rentrer dans les détails du « clean code », expliquons à quoi il correspond. Le terme « clean code » peut-être résumé par un ensemble de pratiques à appliquer pour qu’un code soit plus facile à maintenir et à comprendre par les autres développeurs. On retrouve ces pratiques à tous les niveaux dans le développement informatique, certaines sont très simples à mettre en place et d’autres nécessitent un peu plus d’expérience.

Partout où nous entendons parler de code propre, nous rencontrons peut-être une référence à Martin Fowler, Martin Fowler auteur de “Refactoring: Improving the Design of Existing Code”. Voici comment il décrit le code propre dans ses œuvres :

“Any fool can write code that a computer can understand. Good programmers write code that humans can understand.”

Ce qui se traduit par : N’importe quel imbécile peut écrire du code qu’un ordinateur peut comprendre. Les bons programmeurs écrivent du code que les humains peuvent comprendre.

II. Pourquoi écrire du “clean code” ?

Écrire du « clean code » est une question de compétences, mais avant tout une habitude à prendre lorsque nous développons. Plus nous acquérons de l’expérience plus nous améliorons la qualité de nos livrables. Mais pourquoi investir du temps dans l’apprentissage et l’implémentation de « clean code » :

  • Le vrai coût d’une application c’est la maintenance (plus de 60 % du coût de l’application d’après une étude effectuée par le cabinet de conseil Accenture et publiée dans “How Software Maintenance Fees Are Siphoning Away Your IT Budget – and How to Stop It“)
  • On passe 10 fois plus de temps à lire le code qu’en écrire (Robert C. Martin dans son ouvrage : « Clean Code: A Handbook of Agile Software Craftsmanship »)
  • Pour s’adapter plus facilement au changement.
  • Pour avoir moins de bugs à cause d’incompréhensions du code.
  • Solution testable plus facilement, nous le verrons plus tard dans l’article, le code est plus découpé et plus testable.
  • Et enfin une source de motivation pour les développeurs de travailler sur une application bien pensée et bien codée.

III. Les indicateurs d’un mauvais code ou “code smells”

Le terme de « codes smells » a été inventé par Martin Fowler et Kent Beck, les avis divergent entre les développeurs, non pas sur les indicateurs d’un mauvais code, mais sur la limite à partir de laquelle celui-ci est considéré comme mauvais. Par exemple sur la taille d’une classe ou d’une méthode, ou alors sur le nombre de paramètres d’une méthode, certains considèreront que cette limite jusqu’à six paramètres reste convenable, d’autres seront plus strictes et fixeront cette limite à deux ou trois paramètres.

Ici ce n’est pas l’esthétique qui est recherchée mais plutôt de construire une application beaucoup plus robuste et maintenable et ainsi éviter au maximum les bugs.

Parmi ces indicateurs on retrouve :

  • Code dupliqué ;
  • Méthode trop longue ;
  • Méthode avec plusieurs intentions ;
  • Noms non parlants ;
  • Nombres magiques ;
  • Non utilisation de constantes ;
  • Classe trop longue ;
  • Méthode avec trop de paramètres en entrée ;

IV. Les principes de base et règles du ” clean code “

1. DRY : Don’t Repeat Yourself (Ne vous répétez pas)

La duplication du code est un des fléaux dans le développement des logiciels et pose beaucoup de problèmes dans la maintenance de celui-ci. Ces duplications de code sont à l’origine de beaucoup de bugs qui sont difficilement identifiables et rendent également les évolutions plus compliquées à mettre en place.

Il est assez simple d’appliquer ce principe, contrairement à d’autres patrons de conception avancés, il faut diviser son système en morceaux, diviser le code et la logique en unités les plus petites possibles, des méthodes concises avec des responsabilités identifiées.

Une longue méthode risque d’embarquer des logiques exportables dans d’autres cas d’utilisation, la maintenance de cette logique dans les autres cas d’utilisation sera alors beaucoup plus compliquée.

2. KISS : Keep It Simple Stupid (Rester le plus simple possible)

Ce principe est simple, il faut aller à l’essentiel, ne pas développer des fonctionnalités superflues s’il n’y a pas le besoin à l’instant présent, si ce besoin existe un jour, il faudra surement l’adapter pour y répondre ce qui entrainera un coût supérieur.

Il faut également répondre au besoin de la façon la plus logique et simple, ne pas passer par des abstractions trop compliquées que vous-même ne comprendrez plus quelques mois plus tard.

On identifiera également au niveau du coût de la maintenance du logiciel, car il sera plus simple aux autres développeurs de s’imprégner votre code.

3. YAGNI : You Ain’t Gonna Neet It : (Tu n’en auras pas besoin)

Ce principe rejoint le principe précédent sur l’utilité d’une fonctionnalité, il ne faut pas développer une fonctionnalité s’il n’y a pas de besoin fonctionnel réel.

Beaucoup de développeurs aiment produire des fonctionnalités « bonus », mais dans la majorité des cas ces fonctionnalités ne seront jamais utilisées et si c’est le cas elles nécessiteront tout de même des adaptations.

Les désavantages de cette pratique peuvent sembler évident mais en voici un listing :

  • Temps consacré à la réalisation d’une fonctionnalité non prévue au dépend de fonctionnalités prévues dans la roadmap
  • Temps de tests/revue par l’équipe, mise en recette/production et ainsi de suite
  • Temps de maintenance
  • Risque d’être une pratique courante de l’équipe par la suite

D’après « Extreme Programming » (XP), méthode Agile orientée sur l’aspect réalisation d’une application, il est même préférable de miser sur le « refactoring » de code existant plutôt que de prévoir une future mise en place de fonctionnalités.

4. SOLID : 5 lettres pour 5 principes :

S : Single Responsability (Chaque classe/fonction… ne doit avoir qu’une seule responsabilité) (Uncle Bob)
  • Nombre de lignes pour une fonction ? Une classe ?
  • Fonction : 5 à 10 lignes max
  • Classe : De la taille d’un écran (éviter d’avoir à scroller)
O : Open/Closed principle :

Chaque logiciel doit être ouvert à l’extension et fermé à la modification, par exemple ici nous avons un client qui utilise une imprimante, l’imprimante utilisée est Printer1, le client connait donc l’implémentation de cette imprimante, si cette implémentation change il devra alors modifier son appel à cette imprimante pour l’adapter :

Ici un moyen simple de corriger ce problème de design, c’est d’utiliser une abstraction que toutes les imprimantes implémenteront, le client fera alors appel uniquement aux fonctions qui seront décrites dans l’abstraction « Printer ».

Ici notre logiciel est fermé à la modification, à savoir l’abstraction ne sera pas modifiée, mais par contre il est ouvert à l’extension, nous pourrons ajouter autant d’implémentations que nous souhaitons d’imprimantes :

L : Liskov substitution principle (LSP)

Si on travaille avec une classe abstraite, le fait d’utiliser telle ou telle implémentation n’est pas censé changer le contrat.

Mathématiquement parlant, un carré est un rectangle (du moins, un cas particulier). Cependant, dans notre cas, ce n’est pas vrai.

En effet, la classe Square a un invariant particulier, à savoir qu’à tout instant t, sa hauteur et sa largeur doivent être égales (en soi, la notion de hauteur ou de largeur n’a sémantiquement pas de sens dans le cas d’un carré).

Or, si initialiser un rectangle avec une largeur de 5 et une hauteur de 2 (par exemple) fait sens, cela devient aberrant lorsque l’on parle d’un carré. De fait, le comportement d’une classe Square va à l’encontre de celui de notre classe Rectangle. Le LSP n’est alors pas respecté.

@Test
void should_return_6_when_getting_area_with_3_height_and_2_width() {
    calculateArea(new LiskovSubstitutionPrinciple.Rectangle());
    calculateArea(new LiskovSubstitutionPrinciple.Square());
}

void calculateArea(LiskovSubstitutionPrinciple.Rectangle rectangle) {
    // given
    rectangle.setHeight(3);
    rectangle.setWidth(2);

    // when
    double area = rectangle.getArea();

    // then
    assertEquals(area, 6);
}




I : Interface segregation principle

Un client ne doit jamais être forcé de dépendre d’une interface qu’il n’utilise pas (Robert C. Martin).

Ici imaginons cette interface « IWorker », qui déclare deux signatures de méthode, work et eat :

public interface IWorker {
    void work();

    void eat();
}

Voici deux implémentations possibles « Worker » et « GreatWorker », ici les salariés vont travailler pendant leurs heures de travail et manger pendant leurs pauses, aucun problème jusque-là. Nos deux méthodes sont bien alimentées et différemment entre les deux implémentations.

public class Worker implements IWorker {
    @Override
    public void work() {
        // working
    }

    @Override
    public void eat() {
        // eating in break
    }
}




public class GreatWorker implements IWorker {
    @Override
    public void work() {
        // working much more
    }

    @Override
    public void eat() {
        // eating in break
    }
}




Par contre imaginons cette troisième implémentation, celle de « RobotWorker », ici ce n’est plus un salarié mais un robot qui va travailler, le robot n’a nul besoin de s’alimenter, donc la méthode « eat » a été ignorée.

Ici il y a eu donc violation du principe et nous dépendons donc d’une interface que nous n’utilisons pas complètement.

public class RobotWorker implements IWorker {

    @Override

    public void work() {

        // working much more

    }



    @Override

    public void eat() {

    }

}




D : Dependency inversion principle
  • Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau. Les deux doivent dépendre d’abstractions.
  • Les abstractions ne doivent pas dépendre des détails. Les détails doivent dépendre des abstractions.

Exemple ici avec l’inversion de dépendances nous passons par une interface qui ne va pas évoluer en fonction de l’implémentation au lieu d’appeler directement la couche d’accès aux données par l’implémentation spécifique :

V. Quelques règles à appliquer dans la vie de tous les jours d’un développeur

1. Nommage

Le nommage est un des éléments les plus importants dans notre code, le code que nous écrivons sera lu et maintenu par d’autres humains.

Il faut qu’à la lecture d’une variable un développeur puisse comprendre son utilité, qu’à la lecture d’une fonction il sache ce qu’elle fera :

  • Les noms doivent révéler l’intention
  • Les classes doivent être des noms, éviter également les informations redondantes dans leurs noms
    • UserInfo : ici le terme Info est inutile, dans la classe User nous aurons des attributs qui correspondront aux informations de l’utilisateur (nom/prénom etc…)
  • Les fonctions doivent être des verbes : getXXX(), setXXX(), createXXX(), validateXXX()
  • Les booléens doivent répondre à Yes/No : isXXX(), areXXXX()
  • Utiliser des mots qui sont facilement recherchés dans le code et qui ont du sens
  • Les noms doivent être prononçables
    • Ne pas abuser des abréviations (sauf pour les concepts métiers basiques…), par exemple getInvsttBnkRt
  • DRY :
    • User.getUserName, User.getUserCity : ici on répète le terme User alors que nous sommes dans la classe user, cela est contraire au principe « DRY : don’t repeat yourself »
  • Ne pas hésiter à changer un nom, en effet les IDE permettent de changer des noms très facilement, si vous trouvez un nom qui correspond mieux à l’intention voulue.
  • Évitez les nombres magiques, un nombre magique est un nombre utilisé sans avoir été nommé en révélant son utilité.

2. Éviter les fonctions à effets de bord (Side Effects)

Les effets de bord sont mauvais et cachent souvent des bugs, c’est une pratique qu’on retrouve souvent dans le code. Il arrive souvent qu’on passe en paramètre d’une fonction un objet et qu’on le mette à jour par référence, on risque d’ajouter encore plus de responsabilité à cette fonction.

La fonction nous ment car elle n’indique pas dans son nom ce qu’elle fait réellement. Ceci est source de bugs et de comportements non souhaités surtout quand ces mises à jour sont noyées dans un gros volume de code.

3. Early return principle :

D’après ce principe il faut écrire ses fonctions de manière à ce que le retour positif attendu d’une fonction soit retourné à la toute fin de celle-ci et que tout le reste du code termine l’exécution lorsque les conditions ne sont pas remplies.

Prenons l’exemple du code ci-dessous, nous avons ici une imbrication de vérifications les unes dans les autres, ceci complique énormément la lecture et la compréhension de ce code. Le traitement qui nous intéresse dans cette fonction est caché au milieu de la fonction, là où est positionné le commentaire : // Action if allowed

public int confusingFonction(String name, int value, AuthenticationInfo permissions) {
    int retval = SUCCESS;
    if (globalCondition) {
        if (name != null && !name.equals("")) {
            if (value != 0) {
                if (permissions.allow(name)) {
                    // Action if allowed
                } else {
                    retval = DENY;
                }
            } else {
                retval = BAD_VALUE;
            }
        } else {
            retval = INVALID_NAME;
        }
    } else {
        retval = BAD_COND;
    }
    return retval;
}

Essayons d’améliorer ce code en appliquant le principe de retour rapide, il faut que les vérifications, les exceptions, les mauvais paramètres soient tous vérifiés avant de faire le traitement de notre fonction :

public int lessConfusingFonction(String name, int value, AuthenticationInfo perms) {
    if (!globalCondition) {
        return BAD_COND;
    }

    if (name == null || name.equals("")) {
        return BAD_NAME;
    }

    if (value == 0) {
        return BAD_VALUE;
    }

    if (!perms.allow(name)) {
        return DENY;
    }

    // Action if allowed
    return SUCCESS;
}

a. Avoid return null :

Tony Hoare, qui a inventé le null en 1965, regrette son invention : “I call it my billion-dollar mistake“.

Son objectif était de s’assurer que toute utilisation de références devait être absolument sûre, avec une vérification effectuée automatiquement par le compilateur. Mais il n’a pas pu résister à la tentation de mettre une référence nulle, simplement parce que c’était facile à mettre en œuvre.

Cela a conduit à d’innombrables erreurs, vulnérabilités et pannes du système, qui ont probablement causé un milliard de dollars de souffrances et de dommages au cours des quarante dernières années.

Plusieurs solutions sont possibles pour appliquer ce principe, pour une méthode qui retourne une collection, au lieu de retourner un null, il est possible de le remplacer par une collection vide. À partir de Java 5 nous avons la méthode « Collections.emptyList() » par exemple ou en Java 9 avec « List.of() », ces deux méthodes retournent des listes immutables, l’appelant ne les modifiera donc pas.

private List<Integer> getMovieYears2(List<Movie> movies) {
    if (movies == null) {
        return Collections.emptyList();
    }
    List<Integer> years = new ArrayList<>();
    for (Movie movie : movies) {
        years.add(movie.getYear());
    }
    return years;
}

Il existe également d’autres solutions, par exemple en Java 8 nous avons la nouvelle classe « Optional » qui a été ajoutée. Cette classe est une sorte de conteneur qui soit est vide soit contient la valeur non null.

Brian Goetz, architecte Java chez Oracle, explique les raisons de l’ajout de cette nouvelle classe :

“Our intention was to provide a limited mechanism for library method return types where there needed to be a clear way to represent “no result”, and using null for such was overwhelmingly likely to cause errors.”

Notre intention était de fournir un mécanisme limité pour les types de retour de méthode où il devait y avoir une manière claire de représenter «aucun résultat», et l’utilisation de null pour ce type était extrêmement susceptible de provoquer des erreurs.

Petite parenthèse, il est à noter que le but est d’utiliser la classe Optional uniquement dans les retours de fonctions. Si nous avons besoin d’un Optional dans les paramètres d’une fonction, c’est que celle-ci aura deux fonctions, une si l’Optional est présent, une seconde si pas présent, il faut donc créer deux fonctions et conditionner l’appel en fonction de l’optional. Il ne faut pas non plus l’utiliser en attribut d’une classe (Optional n’est d’ailleurs par sérialisable).

Nous pouvons encore améliorer cette fonction en utilisant des api qui facilitent la lecture (ici l’api « stream » et le découpage en pipeline facilite la lecture, une action = une ligne).

private List<Integer> getMovieYears3(List<Movie> movies) {
    if (movies == null) {
        return Collections.emptyList();
    }
    return movies.stream()
                 .map(Movie::getYear)
                 .collect(Collectors.toList());
}

Ou encore en simplifiant la vérification du null en autorisant dans le « stream » l’utilisation d’une collection potentiellement null :

private List<Integer> getMovieYears5(List<Movie> movies) {
    return Stream.ofNullable(movies)
                 .flatMap(Collection::stream)
                 .map(Movie::getYear)
                 .collect(Collectors.toList());
}

4. Les commentaires

Ne pas commenter un code incompréhensible, le rendre compréhensible

Uncle Bob : « Comments are always failure »,

  • Ils mentent, vieillissent mal, ne sont pas refactorables
  • Montrent l’échec de :
    • L’utilisation d’un bon nom
    • La création d’une abstraction
    • Le découpage en méthodes à intention unique
  • Sauf pour explication (Javadoc, algorithmes mathématiques, contournement de bugs, légal, copyright…)

VI. Astuces

  • Simplifier les méthodes trop longues en faisant des extractions de méthodes à intention unique
    • Améliore également les performances, HOT methods pour le JIT (Just in time compiler)
  • Corriger le code dupliqué en utilisant des classes utilitaires ou utilisation de l’héritage
  • Éviter un trop grand nombre de paramètres passés à une méthode (2 ou 3 paramètres au maximum), sinon penser à la transformer en un objet ou revoir la conception.
    • Si la fonction fait trop de choses (Single Responsability Principle), alors la découper.
    • Éviter les combinaisons de booléens dans les fonctions
  • Pas de paramètres null, si nous avons deux cas d’usages de la méthode il faut donc créer deux méthodes différentes avec l’intention bien identifiée dans le nom.

VII. Conclusion

Je comprends qu’il est difficile d’écrire de bons programmes compte tenu parfois du calendrier ou des différents jalons de votre projet. Mais jusqu’à quand allez-vous le retarder ?

Votre code peut faire des merveilles pour vous et surtout pour les autres. En tant que développeur nous nous améliorons en continu. En regardant un code développé par soit même il y a quelques années vous trouverez certainement des axes d’améliorations.

Se conformer à ces règles simples du ” clean code ” n’est pas difficile et vous évitera bien des maux de tête à l’avenir, vous créerez un système qui est intrinsèquement testable, avec tous les avantages que cela implique.

Lorsque l’une des parties externes du système devient obsolète, comme la base de données ou le framework Web, vous pourrez remplacer ces éléments obsolètes avec un minimum de tracas.