Bonnes pratiques tirées d’« Effective Java (3rd Edition) »

Extrait d’« Effective Java (3rd Edition)  » de Joshua Bloch

1. Renvoyer des collections ou des tableaux vides, pas null

Si la méthode renvoie un Set, n’écrivez pas return null; pour un résultat vide – écrivez plutôt return Set.of();.

2. Envisager des méthodes de fabrique statiques plutôt que des constructeurs

public static Boolean valueOf(boolean b) {
    return b ? Boolean.TRUE : Boolean.FALSE;
}

Ce n’est pas toujours mieux, mais dans certains cas d’usage, elles offrent de nombreux avantages :

Inconvénients :

3. Envisager un builder face à de nombreux paramètres de constructeur

Les fabriques statiques et les constructeurs partagent une limite : ils s’adaptent mal à un grand nombre de paramètres optionnels. Ce problème a durement frappé certains projets sur lesquels j’ai travaillé.

4. Garantir la propriété de singleton avec un constructeur privé ou un type enum

Tout ce qui n’a pas besoin de plusieurs instances ne devrait être instancié qu’une seule fois – que ce soit avec @ApplicationScoped ou un autre pattern singleton.

5. Empêcher l’instanciation avec un constructeur privé

Il arrive que l’on veuille écrire une classe qui n’est qu’un regroupement de méthodes et de champs statiques. Ces classes ont mauvaise réputation, car certains en abusent pour éviter de penser en termes d’objets, mais elles ont des usages légitimes.

private MyStaticClass() {
    // private constructor to prevent instantiation
}

6. Éviter de créer des objets inutiles

String s = new String("bikini");  // DON'T DO THIS!

Ou bien :

// Hideously slow! Can you spot the object creation?
private static long sum() {
    Long sum = 0L;
    for (long i = 0; i <= Integer.MAX_VALUE; i++)
        sum += i;
    return sum;
}

Solution : ce programme donne le bon résultat, mais il est beaucoup plus lent qu’il ne devrait l’être, à cause d’une faute de frappe d’un seul caractère. La variable sum est déclarée comme Long au lieu de long, ce qui signifie que le programme construit environ 2^31 instances de Long inutiles (à peu près une à chaque fois que le long i est ajouté au Long sum).

7. Éviter les finaliseurs et les cleaners

Les finaliseurs sont imprévisibles, souvent dangereux et généralement inutiles. Leur utilisation peut entraîner un comportement erratique, de mauvaises performances et des problèmes de portabilité.

8. Préférer try-with-resources à try-finally

// try-with-resources - the best way to close resources!
try (BufferedReader br = new BufferedReader(new FileReader(path))) {
    return br.readLine();
}

9. Envisager d’implémenter Comparable

En implémentant Comparable, vous permettez à votre classe de fonctionner avec les nombreux algorithmes génériques et implémentations de collections qui dépendent de cette interface.

10. Réduire au minimum l’accessibilité des classes et des membres

Le facteur le plus important qui distingue un composant bien conçu d’un composant mal conçu est la mesure dans laquelle il cache ses données internes et ses autres détails d’implémentation aux autres composants. Un composant bien conçu cache tous ses détails d’implémentation et sépare proprement son API de son implémentation. Les composants ne communiquent alors qu’à travers leurs API et ignorent tout du fonctionnement interne des autres. Ce concept, appelé masquage de l’information ou encapsulation, est un principe fondamental de la conception logicielle.

11. Dans les classes publiques, utiliser des méthodes d’accès, pas des champs publics

Vous pourriez parfois être tenté d’écrire des classes dégénérées qui ne servent qu’à regrouper des champs d’instance :

// Degenerate classes like this should not be public!
class Point {
    public double x;
    public double y;
}

Comme on accède directement aux champs de données de ces classes, elles n’offrent pas les avantages de l’encapsulation. Impossible de changer la représentation sans changer l’API, impossible de garantir des invariants, et impossible de déclencher une action annexe lors de l’accès à un champ. Pour les tenants stricts de la programmation orientée objet, ces classes sont une hérésie et devraient toujours être remplacées par des classes avec des champs privés et des méthodes d’accès publiques (getters) et, pour les classes mutables, des mutateurs (setters) :

// Encapsulation of data by accessor methods and mutators
class Point {

    private double x;
    private double y;

    public Point(double x, double y) {
        this.x = x;
        this.y = y;
    }

    public double getX() { return x; }
    public double getY() { return y; }

    public void setX(double x) { this.x = x; }
    public void setY(double y) { this.y = y; }
}

Les puristes ont certainement raison en ce qui concerne les classes publiques : si une classe est accessible en dehors de son package, fournissez des méthodes d’accès pour préserver la possibilité de modifier sa représentation interne. Si une classe publique expose ses champs de données, tout espoir de changer sa représentation est perdu, car le code client peut être disséminé un peu partout.

En revanche, si une classe est package-private ou s’il s’agit d’une classe imbriquée privée, il n’y a rien de fondamentalement mal à exposer ses champs de données – à condition qu’ils décrivent correctement l’abstraction fournie par la classe.

12. Réduire au minimum la mutabilité

Une classe immuable est simplement une classe dont les instances ne peuvent pas être modifiées. Toutes les informations contenues dans chaque instance sont figées pour toute la durée de vie de l’objet, si bien qu’aucun changement ne peut jamais être observé. Les bibliothèques de la plateforme Java contiennent de nombreuses classes immuables, dont String, les classes des primitifs encapsulés, BigInteger et BigDecimal. Les bonnes raisons ne manquent pas : les classes immuables sont plus faciles à concevoir, à implémenter et à utiliser que les classes mutables. Elles sont moins sujettes aux erreurs et plus sûres. Pour rendre une classe immuable, suivez ces cinq règles :

  1. Ne fournissez pas de méthodes qui modifient l’état de l’objet (appelées mutateurs).
  2. Assurez-vous que la classe ne peut pas être étendue. Cela empêche des sous-classes négligentes ou malveillantes de compromettre le comportement immuable de la classe en se comportant comme si l’état de l’objet avait changé. On empêche généralement le sous-classement en déclarant la classe final, par exemple.
  3. Déclarez tous les champs final. Cela exprime clairement votre intention, d’une manière que le système fait respecter. C’est aussi nécessaire pour garantir un comportement correct lorsqu’une référence à une instance nouvellement créée est passée d’un thread à un autre sans synchronisation, comme le précise le modèle mémoire.
  4. Déclarez tous les champs private. Cela empêche les clients d’accéder aux objets mutables référencés par les champs et de modifier ces objets directement. Même s’il est techniquement permis à des classes immuables d’avoir des champs public final contenant des valeurs primitives ou des références à des objets immuables, ce n’est pas recommandé, car cela empêche de modifier la représentation interne dans une version ultérieure.
  5. Garantissez un accès exclusif à tout composant mutable. Si votre classe possède des champs qui référencent des objets mutables, assurez-vous que ses clients ne peuvent pas obtenir de références à ces objets. N’initialisez jamais un tel champ avec une référence d’objet fournie par un client et ne renvoyez jamais ce champ depuis une méthode d’accès. Faites des copies défensives dans les constructeurs, les méthodes d’accès et les méthodes readObject.

13. Préférer la composition à l’héritage

L’héritage est un moyen puissant de réutiliser du code, mais ce n’est pas toujours le meilleur outil. Utilisé à mauvais escient, il produit des logiciels fragiles. L’héritage est sûr au sein d’un même package, où les implémentations de la sous-classe et de la superclasse sont sous le contrôle des mêmes programmeurs. Il est également sûr d’hériter de classes spécialement conçues et documentées pour être étendues. En revanche, hériter de classes concrètes ordinaires au-delà des frontières d’un package est dangereux.

Contrairement à l’appel de méthode, l’héritage viole l’encapsulation. On pourrait penser qu’étendre une classe est sans danger si l’on se contente d’ajouter de nouvelles méthodes sans redéfinir les méthodes existantes. Ce type d’extension est certes bien plus sûr, mais il n’est pas sans risque. Si la superclasse reçoit une nouvelle méthode dans une version ultérieure et que vous avez eu la malchance de donner à la sous-classe une méthode de même signature avec un type de retour différent, votre sous-classe ne compilera plus. Si vous avez donné à la sous-classe une méthode de même signature et de même type de retour que la nouvelle méthode de la superclasse, vous la redéfinissez désormais, et vous êtes donc exposé aux problèmes décrits plus haut. De plus, il est peu probable que votre méthode respecte le contrat de la nouvelle méthode de la superclasse, puisque ce contrat n’était pas encore écrit lorsque vous avez écrit la méthode de la sous-classe.

Heureusement, il existe un moyen d’éviter tous les problèmes décrits ci-dessus. Au lieu d’étendre une classe existante, donnez à votre nouvelle classe un champ privé qui référence une instance de la classe existante. Cette conception s’appelle la composition, car la classe existante devient un composant de la nouvelle. Chaque méthode d’instance de la nouvelle classe appelle la méthode correspondante sur l’instance contenue de la classe existante et renvoie le résultat. C’est ce qu’on appelle la délégation (forwarding), et les méthodes de la nouvelle classe sont appelées méthodes de délégation. La classe obtenue est solide comme le roc, sans aucune dépendance aux détails d’implémentation de la classe existante. Même l’ajout de nouvelles méthodes à la classe existante n’a aucun impact sur la nouvelle classe.

14. Préférer les interfaces aux classes abstraites

Java dispose de deux mécanismes pour définir un type qui admet plusieurs implémentations : les interfaces et les classes abstraites. Depuis l’introduction des méthodes par défaut dans les interfaces avec Java 8, les deux mécanismes permettent de fournir des implémentations pour certaines méthodes d’instance.

Une différence majeure est que, pour implémenter le type défini par une classe abstraite, une classe doit être une sous-classe de cette classe abstraite. Comme Java n’autorise que l’héritage simple, cette restriction limite fortement l’usage des classes abstraites comme définitions de types. Toute classe qui définit toutes les méthodes requises et respecte le contrat général peut implémenter une interface, où qu’elle se trouve dans la hiérarchie des classes. Les classes existantes peuvent facilement être adaptées pour implémenter une nouvelle interface.

15. Génériques : ne pas utiliser de types bruts

Vous ne découvrez l’erreur qu’à l’exécution, longtemps après qu’elle s’est produite, et dans du code qui peut être très éloigné du code fautif. Une fois la ClassCastException sous les yeux, vous devez fouiller toute la base de code à la recherche de l’appel de méthode qui a glissé la pièce de monnaie dans la collection de timbres. Le compilateur ne peut pas vous aider.

16. Génériques : éliminer les avertissements unchecked

Quand vous programmez avec des génériques, vous voyez apparaître de nombreux avertissements du compilateur :

Plus vous gagnez en expérience avec les génériques, moins vous aurez d’avertissements, mais ne vous attendez pas à ce que du code fraîchement écrit compile sans aucun avertissement. Beaucoup d’avertissements unchecked sont faciles à éliminer. Supposons par exemple que vous écriviez par mégarde cette déclaration :

Set<Lark> exaltation = new HashSet();

Remplacez-la par :

Set<Lark> exaltation = new HashSet<>();

Certains avertissements seront bien plus difficiles à éliminer. Quand un avertissement demande réflexion, persévérez ! Éliminez tous les avertissements unchecked que vous pouvez.

Si vous éliminez tous les avertissements, vous avez la garantie que votre code est typesafe, ce qui est une très bonne chose. Cela signifie que vous n’aurez pas de ClassCastException à l’exécution, et cela renforce votre confiance dans le fait que votre programme se comportera comme prévu. Si vous ne pouvez pas éliminer un avertissement, mais pouvez prouver que le code qui l’a provoqué est typesafe, alors (et seulement alors) supprimez l’avertissement avec une annotation @SuppressWarnings("unchecked"). Si vous supprimez des avertissements sans avoir d’abord prouvé que le code est typesafe, vous vous donnez un faux sentiment de sécurité. Le code compilera peut-être sans le moindre avertissement, mais il pourra toujours lever une ClassCastException à l’exécution.

Chaque fois que vous utilisez une annotation @SuppressWarnings("unchecked"), ajoutez un commentaire expliquant pourquoi c’est sans danger.

17. Génériques : quand cela a du sens

Aïe – une classe qui aurait cruellement besoin de génériques :

Exemple d’une classe qui aurait besoin de génériques

Ce genre de chose devrait être repéré au plus tard lors de la merge request !

18. Génériques : ce qu’il ne faut pas faire

Désolé, inutile d’en dire plus. S’il vous plaît, ne mergez JAMAIS ceci :

public abstract class JpaLearningCardBaseContentService<LC extends LearningCardBaseContent<L, LC, AC>, J extends JpaLearningCardBaseContent<L, LC, AC>, L extends
        LearningCardBase<LC, A>, A extends AnswerOptionBase<L, AC>, AC extends AnswerOptionBaseContent<LC, AC, A>, AS extends AnswerOptionBaseService<A, AC, L, LC>, ACS extends
        AnswerOptionBaseContentService<AC, A, L, LC>, LS extends LearningCardBaseService<L>>
    extends JpaLightPersistenceService<LC, J, Long> implements LearningCardBaseContentService<LC, L, A, AC> {
    // ...
}

Une simple déclaration de classe ne devrait jamais demander 5 minutes de réflexion.

19. Préférer les listes aux tableaux

Les tableaux diffèrent des types génériques sur deux points importants. D’abord, les tableaux sont covariants. Ce mot effrayant signifie simplement que si Sub est un sous-type de Super, alors le type tableau Sub[] est un sous-type du type tableau Super[]. Les génériques, eux, sont invariants : pour deux types distincts quelconques Type1 et Type2, List<Type1> n’est ni un sous-type ni un supertype de List<Type2>.

20. Préférer les lambdas aux classes anonymes

// Anonymous class instance as a function object - obsolete!
Collections.sort(words, new Comparator<String>() {
    public int compare(String s1, String s2) {
        return Integer.compare(s1.length(), s2.length());
    }
});

Faites plutôt ceci :

// Lambda expression as function object (replaces anonymous class)
Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));

21. Réduire au minimum la portée des variables locales

La technique la plus efficace pour réduire la portée d’une variable locale est de la déclarer là où elle est utilisée pour la première fois…

22. Préférer les boucles for-each aux boucles for traditionnelles

// The preferred idiom for iterating over collections and arrays
for (Element e : elements) {
    ... // Do something with e
}

23. Connaître et utiliser les bibliothèques

Supposons que vous vouliez générer des entiers aléatoires entre zéro et une borne supérieure. Face à cette tâche courante, beaucoup de programmeurs écriraient une petite méthode ressemblant à ceci :

// Common but deeply flawed!
static Random rnd = new Random();

static int random(int n) {
    return Math.abs(rnd.nextInt()) % n;
}

Cette méthode a l’air correcte, mais elle a trois défauts. Le premier : si n est une petite puissance de deux, la suite de nombres aléatoires se répète après une période assez courte. Le deuxième défaut : si n n’est pas une puissance de deux, certains nombres seront en moyenne renvoyés plus souvent que d’autres. Si n est grand, cet effet peut être très prononcé.

Le troisième défaut de la méthode random est qu’elle peut, en de rares occasions, échouer de façon catastrophique en renvoyant un nombre hors de l’intervalle spécifié. En effet, la méthode tente de ramener la valeur renvoyée par rnd.nextInt() à un int non négatif en appelant Math.abs. Si nextInt() renvoie Integer.MIN_VALUE, alors Math.abs renvoie aussi Integer.MIN_VALUE, et l’opérateur de reste (%) renvoie un nombre négatif, en supposant que n ne soit pas une puissance de deux. Cela fera presque certainement échouer votre programme, et l’échec risque d’être difficile à reproduire.

24. Éviter float et double si des résultats exacts sont requis

Utilisez BigDecimal pour les montants monétaires. float et double produisent des erreurs d’arrondi.

Supposons par exemple que vous ayez 1,03 $ en poche et que vous dépensiez 42 ¢. Combien d’argent vous reste-t-il ? Voici un fragment de programme naïf qui tente de répondre à cette question :

System.out.println(1.03 - 0.42);

Malheureusement, il affiche 0.6100000000000001. Ce n’est pas un cas isolé. Supposons que vous ayez un dollar en poche et que vous achetiez neuf rondelles à dix cents pièce. Combien de monnaie vous rend-on ?

System.out.println(1.00 - 9 * 0.10);

D’après ce fragment de programme, on vous rend $0.09999999999999998.

Vous pourriez penser qu’il suffit d’arrondir les résultats avant de les afficher, mais malheureusement, cela ne fonctionne pas toujours. Supposons par exemple que vous ayez un dollar en poche et que vous voyiez une étagère avec une rangée de délicieux bonbons à 10 ¢, 20 ¢, 30 ¢, et ainsi de suite jusqu’à un dollar. Vous achetez un bonbon de chaque sorte, en commençant par celui à 10 ¢, jusqu’à ce que vous ne puissiez plus vous offrir le bonbon suivant sur l’étagère.

25. Préférer les types primitifs aux primitifs encapsulés

// Broken comparator - can you spot the flaw?
Comparator<Integer> naturalOrder = (i, j) -> (i < j) ? -1 : (i == j ? 0 : 1);

26. Désigner les objets par leurs interfaces

Bon exemple :

Set<Son> sonSet = new LinkedHashSet<>();

Mauvais exemple :

LinkedHashSet<Son> sonSet = new LinkedHashSet<>();

27. Préférer les interfaces à la réflexion

Sinon :

28. Respecter les conventions de nommage généralement admises

La plateforme Java dispose d’un ensemble bien établi de conventions de nommage, dont beaucoup figurent dans The Java Language Specification [JLS, 6.1].

29. N’utiliser les exceptions que pour des situations exceptionnelles

// Horrible abuse of exceptions. Don't ever do this!
try {
    int i = 0;
    while (true) {
        range[i++].climb();
    }
} catch (ArrayIndexOutOfBoundsException e) {
    // catch it
}

Que fait ce code ? Ce n’est pas du tout évident à la lecture, et c’est une raison suffisante pour ne pas l’utiliser. Il s’avère qu’il s’agit d’un idiome atrocement mal pensé pour parcourir les éléments d’un tableau. La boucle infinie se termine en levant, en attrapant et en ignorant une ArrayIndexOutOfBoundsException lorsqu’elle tente d’accéder au premier élément situé hors des limites du tableau. Il est censé être équivalent à l’idiome standard pour parcourir un tableau, que tout programmeur Java reconnaît instantanément :

for (Mountain m : range) {
    m.climb();
}

Les exceptions, comme leur nom l’indique, ne doivent être utilisées que pour des situations exceptionnelles ; elles ne doivent jamais servir au flux de contrôle ordinaire.

30. Utiliser des exceptions vérifiées pour les situations récupérables et des exceptions d’exécution pour les erreurs de programmation

Utilisez des exceptions vérifiées (checked) pour les situations dont on peut raisonnablement attendre de l’appelant qu’il se remette. En confrontant l’utilisateur à une exception vérifiée, le concepteur de l’API lui impose de gérer la situation. L’utilisateur peut passer outre en attrapant l’exception et en l’ignorant, mais c’est généralement une mauvaise idée.

Utilisez des exceptions d’exécution (runtime) pour signaler des erreurs de programmation. La grande majorité des exceptions d’exécution signalent des violations de préconditions. Une violation de précondition, c’est simplement le client d’une API qui ne respecte pas le contrat établi par la spécification de l’API. Par exemple, le contrat d’accès à un tableau précise que l’indice doit être compris entre zéro et la longueur du tableau moins un, inclus. Une ArrayIndexOutOfBoundsException indique que cette précondition a été violée.

31. Éviter l’usage inutile des exceptions vérifiées

En résumé, utilisées avec parcimonie, les exceptions vérifiées peuvent accroître la fiabilité des programmes ; utilisées à l’excès, elles rendent les API pénibles à utiliser.

Pair<Boolean, LearningCardGroup> answerLearningCard(@NotNull LearningCardGroup learningCardGroup,
                                                      LearningCardAssignment learningCardAssignment,
                                                      @NotNull Collection<AnswerOption> answers)
      throws InvalidTrainingStateException, PersistenceException, LearningCardGroupAlreadyCompletedException,
             ConsecutiveCorrectCountExceededException,
             PreviousLearningCardGroupUncompletedException, LearningCardGroupCooldownException,
             LearningCardGroupHasUncompletedLearningCardsException, InvalidParticipantException, TrainingNotSignedException; // but why ???
     // this is a mistake seen a lot with junior devs
     // exceptions are exceptionally slow and should be avoided for mere validation
     // the performance gets even worse if a stacktrace is printed, since logging and printing is one of the slowest possible actions

32. Privilégier les exceptions standard

Ce qui distingue les programmeurs experts des moins expérimentés, c’est que les experts recherchent et atteignent généralement un haut degré de réutilisation du code.

Les exceptions ne font pas exception à la règle selon laquelle la réutilisation du code est une bonne chose. Les bibliothèques Java fournissent un ensemble d’exceptions qui couvre la plupart des besoins de la plupart des API. Réutiliser les exceptions standard présente plusieurs avantages. Le principal : votre API devient plus facile à apprendre et à utiliser, car elle suit des conventions établies que les programmeurs connaissent déjà. Juste derrière : les programmes qui utilisent votre API sont plus faciles à lire, car ils ne sont pas encombrés d’exceptions inconnues. Enfin (et c’est le moins important), moins de classes d’exception signifie une empreinte mémoire plus réduite et moins de temps passé à charger des classes.

Le type d’exception le plus souvent réutilisé est IllegalArgumentException. C’est généralement l’exception à lever lorsque l’appelant passe un argument dont la valeur est inappropriée. Par exemple, c’est celle qu’il faudrait lever si l’appelant passait un nombre négatif dans un paramètre représentant le nombre de répétitions d’une action. Une autre exception souvent réutilisée est IllegalStateException. C’est généralement l’exception à lever si l’appel est illégal en raison de l’état de l’objet récepteur. Par exemple, c’est celle qu’il faudrait lever si l’appelant tentait d’utiliser un objet avant qu’il ait été correctement initialisé.

On peut soutenir que tout appel de méthode erroné se ramène à un argument ou à un état illégal, mais d’autres exceptions sont utilisées par convention pour certains types d’arguments et d’états illégaux. Si un appelant passe null dans un paramètre pour lequel les valeurs null sont interdites, la convention veut que l’on lève une NullPointerException plutôt qu’une IllegalArgumentException. De même, si un appelant passe une valeur hors limites dans un paramètre représentant un indice dans une séquence, il faut lever une IndexOutOfBoundsException plutôt qu’une IllegalArgumentException.

Ne réutilisez pas directement Exception, RuntimeException, Throwable ou Error. Traitez ces classes comme si elles étaient abstraites. RuntimeException peut toutefois être utilisée si vous voulez convertir une exception vérifiée en exception d’exécution et que vous savez vraiment ce que vous faites.

33. Ne pas ignorer les exceptions

// Empty catch block ignores exception - Highly suspect!
try {
    ...
} catch (SomeException e) {
}

Un bloc catch vide va à l’encontre de la raison d’être des exceptions, qui est de vous obliger à gérer les situations exceptionnelles. Ignorer une exception revient à ignorer une alarme incendie – et à l’éteindre pour que personne d’autre ne puisse vérifier s’il y a vraiment le feu.