Best practices uit “Effective Java (3rd Edition)”

Fragment uit “Effective Java (3rd Edition) ” van Joshua Bloch

1. Retourneer lege collections of arrays, geen nulls

Als de methode een Set retourneert, gebruik dan geen return null; voor een leeg resultaat – maar return Set.of();.

2. Overweeg static factory methods in plaats van constructors

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

Niet altijd beter, maar in sommige use cases bieden ze talrijke voordelen:

Nadelen:

3. Overweeg een builder bij veel constructorparameters

Static factories en constructors delen een beperking: ze schalen slecht bij grote aantallen optionele parameters. Dit heeft sommige projecten waaraan ik heb gewerkt hard geraakt.

4. Dwing de singleton-eigenschap af met een private constructor of een enumtype

Alles wat geen meerdere instanties nodig heeft, zou maar één keer geïnstantieerd moeten worden – met @ApplicationScoped of met een ander singletonpatroon.

5. Voorkom instantiëring met een private constructor

Af en toe wil je een klasse schrijven die alleen een groepering is van statische methoden en statische velden. Zulke klassen hebben een slechte naam gekregen omdat sommige mensen ze misbruiken om niet in objecten te hoeven denken, maar ze hebben wel degelijk zinvolle toepassingen.

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

6. Vermijd het maken van onnodige objecten

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

Of:

// 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;
}

Oplossing: dit programma geeft het juiste antwoord, maar is veel trager dan nodig, door een typfout van één teken. De variabele sum is gedeclareerd als Long in plaats van long, waardoor het programma ongeveer 2^31 onnodige Long-instanties aanmaakt (ruwweg één voor elke keer dat de long i bij de Long sum wordt opgeteld).

7. Vermijd finalizers en cleaners

Finalizers zijn onvoorspelbaar, vaak gevaarlijk en meestal overbodig. Ze kunnen leiden tot grillig gedrag, slechte performance en portabiliteitsproblemen.

8. Geef de voorkeur aan try-with-resources boven try-finally

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

9. Overweeg Comparable te implementeren

Door Comparable te implementeren laat je je klasse samenwerken met alle generieke algoritmen en collection-implementaties die van deze interface afhankelijk zijn.

10. Beperk de toegankelijkheid van klassen en members zo veel mogelijk

De allerbelangrijkste factor die een goed ontworpen component onderscheidt van een slecht ontworpen component, is de mate waarin het component zijn interne data en andere implementatiedetails verbergt voor andere componenten. Een goed ontworpen component verbergt al zijn implementatiedetails en scheidt zijn API netjes van zijn implementatie. Componenten communiceren dan alleen via hun API’s en weten niets van elkaars interne werking. Dit concept, bekend als information hiding of inkapseling, is een fundamenteel principe van softwareontwerp.

11. Gebruik in publieke klassen accessormethoden, geen publieke velden

Soms kom je in de verleiding om gedegenereerde klassen te schrijven die geen ander doel hebben dan instantievelden te groeperen:

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

Omdat de datavelden van zulke klassen rechtstreeks worden benaderd, bieden deze klassen niet de voordelen van inkapseling. Je kunt de representatie niet veranderen zonder de API te veranderen, je kunt geen invarianten afdwingen en je kunt geen aanvullende actie uitvoeren wanneer een veld wordt benaderd. Rechtlijnige objectgeoriënteerde programmeurs vinden zulke klassen uit den boze en vinden dat ze altijd vervangen moeten worden door klassen met private velden en publieke accessormethoden (getters) en, voor mutable klassen, mutators (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; }
}

De hardliners hebben zeker gelijk als het om publieke klassen gaat: als een klasse buiten haar package toegankelijk is, bied dan accessormethoden aan om de flexibiliteit te behouden om de interne representatie van de klasse te veranderen. Als een publieke klasse haar datavelden blootstelt, is alle hoop op het veranderen van haar representatie vervlogen, omdat clientcode wijd en zijd verspreid kan zijn.

Als een klasse echter package-private is of een private geneste klasse, is er niets mis met het blootstellen van haar datavelden – op voorwaarde dat ze de abstractie die de klasse biedt goed genoeg beschrijven.

12. Beperk mutabiliteit zo veel mogelijk

Een immutable klasse is simpelweg een klasse waarvan de instanties niet gewijzigd kunnen worden. Alle informatie in een instantie ligt vast voor de hele levensduur van het object, zodat er nooit wijzigingen waar te nemen zijn. De Java-platformbibliotheken bevatten veel immutable klassen, waaronder String, de boxed primitive classes en BigInteger en BigDecimal. Daar zijn veel goede redenen voor: immutable klassen zijn makkelijker te ontwerpen, te implementeren en te gebruiken dan mutable klassen. Ze zijn minder foutgevoelig en veiliger. Volg deze vijf regels om een klasse immutable te maken:

  1. Bied geen methoden aan die de state van het object wijzigen (bekend als mutators).
  2. Zorg ervoor dat de klasse niet kan worden uitgebreid. Dat voorkomt dat onzorgvuldige of kwaadwillige subklassen het immutable gedrag van de klasse ondermijnen door zich te gedragen alsof de state van het object is veranderd. Subklassen voorkomen doe je meestal door de klasse bijvoorbeeld final te maken.
  3. Maak alle velden final. Zo druk je je bedoeling duidelijk uit op een manier die door het systeem wordt afgedwongen. Het is bovendien nodig om correct gedrag te garanderen als een referentie naar een nieuw aangemaakte instantie zonder synchronisatie van de ene thread naar de andere wordt doorgegeven, zoals beschreven in het geheugenmodel.
  4. Maak alle velden private. Dat voorkomt dat clients toegang krijgen tot mutable objecten waarnaar velden verwijzen en die objecten rechtstreeks wijzigen. Hoewel het technisch toegestaan is dat immutable klassen public final velden hebben met primitieve waarden of referenties naar immutable objecten, is het af te raden, omdat je daarmee uitsluit dat de interne representatie in een latere release verandert.
  5. Zorg voor exclusieve toegang tot alle mutable componenten. Als je klasse velden heeft die naar mutable objecten verwijzen, zorg er dan voor dat clients van de klasse geen referenties naar die objecten kunnen krijgen. Initialiseer zo’n veld nooit met een objectreferentie van een client en retourneer het veld nooit vanuit een accessor. Maak defensieve kopieën in constructors, accessors en readObject-methoden.

13. Geef de voorkeur aan compositie boven overerving

Overerving is een krachtige manier om code te hergebruiken, maar niet altijd het beste gereedschap voor de klus. Verkeerd gebruikt leidt het tot fragiele software. Overerving is veilig binnen een package, waar de implementaties van subklasse en superklasse onder controle van dezelfde programmeurs staan. Overerving is ook veilig bij het uitbreiden van klassen die specifiek zijn ontworpen en gedocumenteerd om te worden uitgebreid. Erven van gewone concrete klassen over packagegrenzen heen is echter gevaarlijk.

Anders dan het aanroepen van methoden schendt overerving de inkapseling. Je zou kunnen denken dat het veilig is om een klasse uit te breiden als je alleen nieuwe methoden toevoegt en bestaande methoden niet overschrijft. Hoewel dit soort uitbreiding veel veiliger is, is het niet zonder risico. Krijgt de superklasse in een latere release een nieuwe methode en heb je de pech dat je de subklasse een methode met dezelfde signatuur en een ander returntype hebt gegeven, dan compileert je subklasse niet meer. Heb je de subklasse een methode gegeven met dezelfde signatuur en hetzelfde returntype als de nieuwe superklassemethode, dan overschrijf je die nu, en loop je tegen de eerder beschreven problemen aan. Bovendien is het twijfelachtig of je methode het contract van de nieuwe superklassemethode nakomt, want dat contract was nog niet geschreven toen jij de subklassemethode schreef.

Gelukkig is er een manier om alle hierboven beschreven problemen te vermijden. In plaats van een bestaande klasse uit te breiden, geef je je nieuwe klasse een private veld dat naar een instantie van de bestaande klasse verwijst. Dit ontwerp heet compositie, omdat de bestaande klasse een component van de nieuwe wordt. Elke instantiemethode in de nieuwe klasse roept de overeenkomstige methode aan op de ingesloten instantie van de bestaande klasse en retourneert de resultaten. Dit heet forwarding, en de methoden in de nieuwe klasse heten forwarding methods. De resulterende klasse is ijzersterk, zonder afhankelijkheden van de implementatiedetails van de bestaande klasse. Zelfs het toevoegen van nieuwe methoden aan de bestaande klasse heeft geen invloed op de nieuwe klasse.

14. Geef de voorkeur aan interfaces boven abstracte klassen

Java heeft twee mechanismen om een type te definiëren dat meerdere implementaties toestaat: interfaces en abstracte klassen. Sinds de introductie van default methods voor interfaces in Java 8 kun je met beide mechanismen implementaties aanbieden voor bepaalde instantiemethoden.

Een belangrijk verschil is dat een klasse, om het type te implementeren dat door een abstracte klasse wordt gedefinieerd, een subklasse van die abstracte klasse moet zijn. Omdat Java alleen enkelvoudige overerving toestaat, beperkt dit het gebruik van abstracte klassen als typedefinities sterk. Elke klasse die alle vereiste methoden definieert en zich aan het algemene contract houdt, mag een interface implementeren, ongeacht waar de klasse zich in de klassenhiërarchie bevindt. Bestaande klassen kunnen eenvoudig achteraf worden aangepast om een nieuwe interface te implementeren.

15. Generics: gebruik geen raw types

Je ontdekt de fout pas tijdens runtime, lang nadat hij is opgetreden, en in code die ver verwijderd kan zijn van de code met de fout. Zodra je de ClassCastException ziet, moet je de hele codebase doorzoeken naar de methodeaanroep die de munt in de postzegelverzameling heeft gestopt. De compiler kan je niet helpen.

16. Generics: elimineer unchecked warnings

Als je met generics programmeert, krijg je veel compilerwaarschuwingen:

Hoe meer ervaring je met generics opdoet, hoe minder waarschuwingen je krijgt, maar verwacht niet dat nieuw geschreven code zonder waarschuwingen compileert. Veel unchecked warnings zijn makkelijk te elimineren. Stel bijvoorbeeld dat je per ongeluk deze declaratie schrijft:

Set<Lark> exaltation = new HashSet();

Verander dit in:

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

Sommige waarschuwingen zijn veel lastiger te elimineren. Krijg je waarschuwingen die wat denkwerk vergen, zet dan door! Elimineer elke unchecked warning die je kunt elimineren.

Als je alle waarschuwingen elimineert, weet je zeker dat je code typesafe is, en dat is heel goed. Het betekent dat je tijdens runtime geen ClassCastException krijgt, en het vergroot je vertrouwen dat je programma zich gedraagt zoals je bedoeld hebt. Kun je een waarschuwing niet elimineren, maar wel bewijzen dat de code die de waarschuwing veroorzaakt typesafe is, onderdruk de waarschuwing dan (en alleen dan) met een @SuppressWarnings("unchecked")-annotatie. Onderdruk je waarschuwingen zonder eerst te bewijzen dat de code typesafe is, dan geef je jezelf een vals gevoel van veiligheid. De code compileert dan misschien zonder waarschuwingen, maar kan tijdens runtime nog steeds een ClassCastException gooien.

Voeg elke keer dat je een @SuppressWarnings("unchecked")-annotatie gebruikt een commentaar toe dat uitlegt waarom dat veilig is.

17. Generics: wanneer ze zinvol zijn

Au – een klasse die dringend generics nodig heeft:

Voorbeeld van een klasse die generics nodig heeft

Zoiets moet uiterlijk bij de merge request worden tegengehouden!

18. Generics: hoe het niet moet

Sorry, woorden overbodig. Merge dit alsjeblieft NOOIT:

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> {
    // ...
}

Een simpele klassedeclaratie zou nooit 5 minuten mogen kosten om te begrijpen.

19. Geef de voorkeur aan lists boven arrays

Arrays verschillen op twee belangrijke punten van generieke typen. Ten eerste zijn arrays covariant. Dit eng klinkende woord betekent simpelweg dat als Sub een subtype is van Super, het arraytype Sub[] een subtype is van het arraytype Super[]. Generics zijn daarentegen invariant: voor elke twee verschillende typen Type1 en Type2 is List<Type1> geen subtype en ook geen supertype van List<Type2>.

20. Geef de voorkeur aan lambda’s boven anonieme klassen

// 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());
    }
});

Doe in plaats daarvan dit:

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

21. Houd de scope van lokale variabelen zo klein mogelijk

De krachtigste techniek om de scope van een lokale variabele te beperken, is haar te declareren op de plek waar ze voor het eerst wordt gebruikt...

22. Geef de voorkeur aan for-each-loops boven traditionele for-loops

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

23. Ken en gebruik de bibliotheken

Stel dat je willekeurige gehele getallen wilt genereren tussen nul en een bovengrens. Voor deze veelvoorkomende taak zouden veel programmeurs een kleine methode schrijven die er ongeveer zo uitziet:

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

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

Deze methode ziet er misschien goed uit, maar heeft drie gebreken. Het eerste is dat, als n een kleine macht van twee is, de reeks willekeurige getallen zich na een vrij korte periode herhaalt. Het tweede gebrek is dat, als n geen macht van twee is, sommige getallen gemiddeld vaker worden geretourneerd dan andere. Als n groot is, kan dit effect behoorlijk uitgesproken zijn.

Het derde gebrek van de random-methode is dat ze in zeldzame gevallen catastrofaal kan falen en een getal buiten het opgegeven bereik retourneert. Dat komt doordat de methode de waarde die rnd.nextInt() retourneert, probeert om te zetten naar een niet-negatieve int door Math.abs aan te roepen. Als nextInt() Integer.MIN_VALUE retourneert, dan retourneert Math.abs ook Integer.MIN_VALUE, en de restoperator (%) geeft een negatief getal terug, ervan uitgaande dat n geen macht van twee is. Dit laat je programma vrijwel zeker falen, en die fout is mogelijk moeilijk te reproduceren.

24. Vermijd float en double als exacte antwoorden vereist zijn

Gebruik BigDecimal voor geldbedragen. float en double veroorzaken afrondingsfouten.

Stel bijvoorbeeld dat je $ 1,03 op zak hebt en 42 cent uitgeeft. Hoeveel geld heb je nog over? Hier is een naïef programmafragment dat deze vraag probeert te beantwoorden:

System.out.println(1.03 - 0.42);

Helaas print het 0.6100000000000001. Dit is geen op zichzelf staand geval. Stel dat je een dollar op zak hebt en negen ringetjes koopt van tien cent per stuk. Hoeveel wisselgeld krijg je terug?

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

Volgens dit programmafragment krijg je $0.09999999999999998.

Je zou kunnen denken dat het probleem op te lossen is door de resultaten vóór het printen af te ronden, maar helaas werkt dat niet altijd. Stel bijvoorbeeld dat je een dollar op zak hebt en een plank ziet met een rij heerlijke snoepjes van 10 cent, 20 cent, 30 cent enzovoort, tot een dollar. Je koopt van elk snoepje één, te beginnen met dat van 10 cent, tot je het volgende snoepje op de plank niet meer kunt betalen.

25. Geef de voorkeur aan primitieve typen boven boxed primitives

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

26. Verwijs naar objecten via hun interfaces

Goed voorbeeld:

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

Slecht voorbeeld:

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

27. Geef de voorkeur aan interfaces boven reflection

Zo niet:

28. Houd je aan algemeen aanvaarde naamgevingsconventies

Het Java-platform heeft een goed ingeburgerde set naamgevingsconventies, waarvan er veel zijn vastgelegd in The Java Language Specification [JLS, 6.1].

29. Gebruik exceptions alleen voor uitzonderlijke situaties

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

Wat doet deze code? Dat is bij het lezen helemaal niet duidelijk, en dat is al reden genoeg om haar niet te gebruiken. Het blijkt een vreselijk slecht doordacht idioom te zijn om door de elementen van een array te lopen. De oneindige loop eindigt door een ArrayIndexOutOfBoundsException te gooien, op te vangen en te negeren wanneer hij probeert het eerste element buiten de grenzen van de array te benaderen. Het zou equivalent moeten zijn aan het standaardidioom om door een array te lopen, dat voor elke Java-programmeur meteen herkenbaar is:

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

Exceptions zijn, zoals de naam al zegt, alleen bedoeld voor uitzonderlijke situaties; gebruik ze nooit voor de gewone control flow.

30. Gebruik checked exceptions voor herstelbare situaties en runtime exceptions voor programmeerfouten

Gebruik checked exceptions voor situaties waarvan je redelijkerwijs mag verwachten dat de aanroeper ervan kan herstellen. Door de gebruiker te confronteren met een checked exception geeft de API-ontwerper een opdracht om van de situatie te herstellen. De gebruiker kan die opdracht negeren door de exception op te vangen en te negeren, maar dat is meestal een slecht idee.

Gebruik runtime exceptions om programmeerfouten aan te geven. Het overgrote deel van de runtime exceptions wijst op schendingen van precondities. Een schending van een preconditie betekent simpelweg dat de client van een API zich niet houdt aan het contract dat de API-specificatie vastlegt. Het contract voor arraytoegang bepaalt bijvoorbeeld dat de array-index tussen nul en de arraylengte min één moet liggen, inclusief. ArrayIndexOutOfBoundsException geeft aan dat deze preconditie is geschonden.

31. Vermijd onnodig gebruik van checked exceptions

Samengevat: spaarzaam gebruikt kunnen checked exceptions de betrouwbaarheid van programma’s vergroten; overmatig gebruikt maken ze API’s pijnlijk om te gebruiken.

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. Gebruik bij voorkeur standaard-exceptions

Wat ervaren programmeurs onderscheidt van minder ervaren programmeurs, is dat experts streven naar een hoge mate van hergebruik van code, en die meestal ook bereiken.

Exceptions vormen geen uitzondering op de regel dat hergebruik van code een goede zaak is. De Java-bibliotheken bieden een set exceptions die het grootste deel van wat de meeste API’s aan exceptions nodig hebben, dekt. Het hergebruiken van standaard-exceptions heeft meerdere voordelen. Het belangrijkste is dat je API makkelijker te leren en te gebruiken is, omdat hij aansluit bij gevestigde conventies die programmeurs al kennen. Een goede tweede is dat programma’s die je API gebruiken beter leesbaar zijn, omdat ze niet vol staan met onbekende exceptions. Als laatste (en minst belangrijke): minder exceptionklassen betekent minder geheugengebruik en minder tijd voor het laden van klassen.

Het meest hergebruikte exceptiontype is IllegalArgumentException. Dit is doorgaans de exception om te gooien wanneer de aanroeper een argument meegeeft met een ongeschikte waarde. Dit is bijvoorbeeld de exception om te gooien als de aanroeper een negatief getal meegeeft voor een parameter die aangeeft hoe vaak een actie herhaald moet worden. Een andere vaak hergebruikte exception is IllegalStateException. Dit is doorgaans de exception om te gooien als de aanroep niet is toegestaan vanwege de state van het ontvangende object. Dit is bijvoorbeeld de exception om te gooien als de aanroeper een object probeert te gebruiken voordat het correct is geïnitialiseerd.

Je kunt stellen dat elke foutieve methodeaanroep neerkomt op een ongeldig argument of een ongeldige state, maar voor bepaalde soorten ongeldige argumenten en states worden standaard andere exceptions gebruikt. Geeft een aanroeper null mee voor een parameter waarvoor null-waarden verboden zijn, dan schrijft de conventie voor dat er een NullPointerException wordt gegooid in plaats van een IllegalArgumentException. Evenzo moet, als een aanroeper een waarde buiten het bereik meegeeft voor een parameter die een index in een reeks voorstelt, een IndexOutOfBoundsException worden gegooid in plaats van een IllegalArgumentException.

Hergebruik Exception, RuntimeException, Throwable of Error niet rechtstreeks. Behandel deze klassen alsof ze abstract zijn. RuntimeException mag je wel gebruiken als je een checked exception wilt omzetten in een runtime exception en je echt weet wat je doet.

33. Negeer geen exceptions

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

Een leeg catch-blok ondermijnt het doel van exceptions, namelijk je dwingen om uitzonderlijke situaties af te handelen. Een exception negeren is te vergelijken met een brandalarm negeren – en het uitzetten, zodat niemand anders de kans krijgt om te kijken of er echt brand is.