Bune practici din „Effective Java (3rd Edition)”
Extras din „Effective Java (3rd Edition) ” de Joshua Bloch
1. Returnează colecții sau array-uri goale, nu null
Dacă metoda returnează un Set, nu folosi return null; pentru un rezultat gol – folosește în schimb return Set.of();.
2. Ia în considerare metode factory statice în locul constructorilor
public static Boolean valueOf(boolean b) {
return b ? Boolean.TRUE : Boolean.FALSE;
}
Nu sunt întotdeauna mai bune, dar în unele cazuri de utilizare oferă numeroase avantaje:
- Un avantaj al metodelor factory statice este că, spre deosebire de constructori, au nume.
- Nu sunt obligate să creeze un obiect nou la fiecare apel.
- Pot returna un obiect de orice subtip al tipului lor de retur.
- Clasa obiectului returnat poate varia de la un apel la altul, în funcție de parametrii de intrare.
- Clasa obiectului returnat nu trebuie să existe în momentul în care este scrisă clasa care conține metoda.
Dezavantaje:
- Principala limitare a oferirii exclusive de metode factory statice este că din clasele fără constructori publici sau protejați nu se pot deriva subclase.
- Un al doilea neajuns al metodelor factory statice este că programatorii le găsesc greu.
3. Ia în considerare un builder când ai mulți parametri de constructor
Factory-urile statice și constructorii au o limitare comună: nu se adaptează bine la un număr mare de parametri opționali. Asta a lovit foarte tare câteva proiecte la care am lucrat.
4. Impune proprietatea de singleton cu un constructor privat sau cu un tip enum
Tot ce nu are nevoie de mai multe instanțe ar trebui instanțiat o singură dată – fie cu @ApplicationScoped, fie cu un alt pattern singleton.
5. Impune neinstanțierea cu un constructor privat
Uneori vei vrea să scrii o clasă care este doar o grupare de metode statice și câmpuri statice. Astfel de clase și-au făcut o reputație proastă, pentru că unii le folosesc abuziv ca să evite să gândească în termeni de obiecte, dar au și utilizări legitime.
private MyStaticClass() {
// private constructor to prevent instantiation
}
6. Evită crearea de obiecte inutile
String s = new String("bikini"); // DON'T DO THIS!
Sau:
// 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;
}
Soluția: Acest program obține rezultatul corect, dar este mult mai lent decât ar trebui, din cauza unei greșeli de tipar de un singur caracter. Variabila sum este declarată ca Long în loc de long, ceea ce înseamnă că programul construiește aproximativ 2^31 de instanțe Long inutile (aproximativ câte una pentru fiecare adunare a lui long i la Long sum).
7. Evită finalizatorii și cleanerii
Finalizatorii sunt imprevizibili, adesea periculoși și, în general, inutili. Folosirea lor poate provoca un comportament neregulat, performanță slabă și probleme de portabilitate.
8. Preferă try-with-resources în locul try-finally
// try-with-resources - the best way to close resources!
try (BufferedReader br = new BufferedReader(new FileReader(path))) {
return br.readLine();
}
9. Ia în considerare implementarea Comparable
Implementând Comparable, îi permiți clasei tale să funcționeze împreună cu numeroșii algoritmi generici și implementări de colecții care depind de această interfață.
10. Minimizează accesibilitatea claselor și a membrilor
Cel mai important factor care deosebește o componentă bine proiectată de una prost proiectată este măsura în care componenta își ascunde datele interne și celelalte detalii de implementare față de alte componente. O componentă bine proiectată își ascunde toate detaliile de implementare, separând clar API-ul de implementare. Componentele comunică apoi doar prin API-urile lor și nu știu nimic despre funcționarea internă a celorlalte. Acest concept, cunoscut ca ascunderea informației sau încapsulare, este un principiu fundamental al designului software.
11. În clasele publice, folosește metode de acces, nu câmpuri publice
Uneori poți fi tentat să scrii clase degenerate, care nu servesc altui scop decât grupării unor câmpuri de instanță:
// Degenerate classes like this should not be public!
class Point {
public double x;
public double y;
}
Pentru că câmpurile de date ale unor astfel de clase sunt accesate direct, aceste clase nu oferă beneficiile încapsulării. Nu poți schimba reprezentarea fără să schimbi API-ul, nu poți impune invarianți și nu poți executa acțiuni suplimentare când un câmp este accesat. Programatorii orientați pe obiecte de linie dură consideră că astfel de clase sunt o anatemă și ar trebui înlocuite întotdeauna cu clase cu câmpuri private și metode de acces publice (getteri) și, pentru clasele mutabile, cu mutatori (setteri):
// 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; }
}
Desigur, adepții liniei dure au dreptate când vine vorba de clase publice: dacă o clasă este accesibilă în afara pachetului său, oferă metode de acces, ca să păstrezi flexibilitatea de a schimba reprezentarea internă a clasei. Dacă o clasă publică își expune câmpurile de date, orice speranță de a-i schimba reprezentarea este pierdută, pentru că codul client poate fi răspândit în toate direcțiile.
Totuși, dacă o clasă este package-private sau este o clasă imbricată privată, nu e nimic fundamental greșit în a-i expune câmpurile de date – presupunând că acestea descriu adecvat abstracțiunea oferită de clasă.
12. Minimizează mutabilitatea
O clasă imutabilă este pur și simplu o clasă ale cărei instanțe nu pot fi modificate. Toate informațiile conținute în fiecare instanță sunt fixe pe toată durata de viață a obiectului, așa că nu se poate observa niciodată vreo schimbare. Bibliotecile platformei Java conțin multe clase imutabile, printre care String, clasele primitivelor boxed, precum și BigInteger și BigDecimal. Există multe motive bune pentru asta: clasele imutabile sunt mai ușor de proiectat, implementat și folosit decât cele mutabile. Sunt mai puțin predispuse la erori și mai sigure. Pentru a face o clasă imutabilă, urmează aceste cinci reguli:
- Nu oferi metode care modifică starea obiectului (cunoscute ca mutatori).
-
Asigură-te că clasa nu poate fi extinsă. Astfel împiedici subclasele neglijente sau rău intenționate să compromită comportamentul imutabil al clasei, purtându-se ca și cum starea obiectului
s-ar fi schimbat. Derivarea este de obicei împiedicată, de exemplu, declarând clasa
final. - Declară toate câmpurile final. Astfel îți exprimi clar intenția, într-un mod impus de sistem. De asemenea, este necesar pentru a asigura un comportament corect dacă o referință la o instanță nou creată este transmisă de la un thread la altul fără sincronizare, așa cum este descris în modelul de memorie.
- Declară toate câmpurile private. Astfel împiedici clienții să obțină acces la obiectele mutabile referite de câmpuri și să modifice direct aceste obiecte. Deși tehnic este permis ca o clasă imutabilă să aibă câmpuri public final care conțin valori primitive sau referințe la obiecte imutabile, nu este recomandat, pentru că împiedică schimbarea reprezentării interne într-o versiune ulterioară.
-
Asigură acces exclusiv la orice componente mutabile. Dacă clasa ta are câmpuri care referă obiecte mutabile, asigură-te că clienții clasei nu pot obține referințe la
aceste obiecte. Nu inițializa niciodată un astfel de câmp cu o referință de obiect primită de la client și nu returna câmpul dintr-o metodă de acces. Fă copii defensive în constructori, metode de acces și
metodele
readObject.
13. Preferă compoziția în locul moștenirii
Moștenirea este o modalitate puternică de a reutiliza cod, dar nu este întotdeauna cel mai bun instrument pentru această sarcină. Folosită nepotrivit, duce la software fragil. Moștenirea poate fi folosită în siguranță în interiorul unui pachet, unde implementările subclasei și ale superclasei sunt sub controlul acelorași programatori. Este de asemenea sigură atunci când extinzi clase proiectate și documentate special pentru extindere. Moștenirea din clase concrete obișnuite, peste granițele pachetelor, este însă periculoasă.
Spre deosebire de apelul de metodă, moștenirea încalcă încapsularea. Ai putea crede că e sigur să extinzi o clasă dacă doar adaugi metode noi și te abții de la a suprascrie metodele existente. Deși acest tip de extindere este mult mai sigur, nu este lipsit de riscuri. Dacă superclasa primește o metodă nouă într-o versiune ulterioară și ai ghinionul să fi dat subclasei o metodă cu aceeași semnătură și alt tip de retur, subclasa ta nu se va mai compila. Dacă ai dat subclasei o metodă cu aceeași semnătură și același tip de retur ca noua metodă din superclasă, atunci acum o suprascrii, deci ești expus problemelor descrise mai devreme. Mai mult, e îndoielnic că metoda ta va respecta contractul noii metode din superclasă, pentru că acel contract nu fusese încă scris când ai scris metoda din subclasă.
Din fericire, există o cale de a evita toate problemele descrise mai sus. În loc să extinzi o clasă existentă, dă-i noii clase un câmp privat care referă o instanță a clasei existente. Acest design se numește compoziție, pentru că clasa existentă devine o componentă a celei noi. Fiecare metodă de instanță din noua clasă apelează metoda corespunzătoare a instanței conținute a clasei existente și returnează rezultatele. Acest lucru se numește forwarding (redirecționare), iar metodele din noua clasă se numesc metode de redirecționare. Clasa rezultată va fi solidă ca o stâncă, fără dependențe de detaliile de implementare ale clasei existente. Chiar și adăugarea de metode noi în clasa existentă nu va avea niciun impact asupra noii clase.
14. Preferă interfețele în locul claselor abstracte
Java are două mecanisme pentru a defini un tip care permite mai multe implementări: interfețele și clasele abstracte. De la introducerea metodelor default pentru interfețe în Java 8, ambele mecanisme îți permit să oferi implementări pentru unele metode de instanță.
O diferență majoră este că, pentru a implementa tipul definit de o clasă abstractă, o clasă trebuie să fie subclasă a clasei abstracte. Pentru că Java permite doar moștenire simplă, această restricție a claselor abstracte le limitează serios utilizarea ca definiții de tip. Orice clasă care definește toate metodele necesare și respectă contractul general are voie să implementeze o interfață, indiferent unde se află clasa în ierarhia de clase. Clasele existente pot fi adaptate ușor pentru a implementa o interfață nouă.
- Interfețele sunt ideale pentru definirea de mixinuri.
- Interfețele permit construirea de cadre de tipuri neierarhice.
- Interfețele permit îmbunătățiri de funcționalitate sigure și puternice prin idiomul clasei wrapper. Dacă folosești clase abstracte pentru a defini tipuri, programatorului care vrea să adauge funcționalitate nu-i lași altă alternativă decât moștenirea.
15. Generice: nu folosi tipuri raw
Nu descoperi eroarea decât la runtime, mult după ce s-a produs, și într-un cod care poate fi departe de codul care conține eroarea. Odată ce vezi ClassCastException, trebuie să cauți prin tot codul apelul de metodă care a pus moneda în colecția de timbre. Compilatorul nu te poate ajuta.
16. Generice: elimină avertismentele unchecked
Când programezi cu generice, vei vedea multe avertismente de compilare:
- avertismente unchecked cast
- avertismente unchecked method invocation
- avertismente unchecked parameterized vararg type
- avertismente unchecked conversion.
Cu cât acumulezi mai multă experiență cu genericele, cu atât vei primi mai puține avertismente, dar nu te aștepta ca un cod nou scris să se compileze curat. Multe avertismente unchecked sunt ușor de eliminat. De exemplu, să presupunem că scrii din greșeală această declarație:
Set<Lark> exaltation = new HashSet();
Schimb-o în:
Set<Lark> exaltation = new HashSet<>();
Unele avertismente vor fi mult mai greu de eliminat. Când primești avertismente care cer puțină gândire, perseverează! Elimină fiecare avertisment unchecked pe care îl poți elimina.
Dacă elimini toate avertismentele, ai garanția că codul tău este typesafe, ceea ce e un lucru foarte bun. Înseamnă că nu vei primi un ClassCastException la runtime și îți crește încrederea
că programul se va comporta cum ți-ai propus. Dacă nu poți elimina un avertisment, dar poți demonstra că codul care l-a provocat este typesafe, atunci (și numai atunci) suprimă
avertismentul cu o adnotare @SuppressWarnings("unchecked"). Dacă suprimi avertismente fără să demonstrezi mai întâi că codul este typesafe, îți creezi un fals sentiment
de siguranță.
Codul se poate compila fără niciun avertisment, dar poate totuși arunca un ClassCastException la runtime.
De fiecare dată când folosești o adnotare @SuppressWarnings("unchecked"), adaugă un comentariu care explică de ce este sigur să faci asta.
17. Generice: când au sens
Au – o clasă care are mare nevoie de generice:
Așa ceva ar trebui prins cel târziu la merge request!
18. Generice: cum să nu faci
Scuze, nu e nevoie de cuvinte. Te rog, nu face NICIODATĂ merge la așa ceva:
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> {
// ...
}
O simplă declarație de clasă n-ar trebui să ceară niciodată 5 minute ca s-o înțelegi.
19. Preferă listele în locul array-urilor
Array-urile diferă de tipurile generice în două privințe importante. În primul rând, array-urile sunt covariante. Acest cuvânt înfricoșător înseamnă pur și simplu că, dacă Sub este un subtip al lui Super, atunci
tipul de array Sub[] este un subtip al tipului de array Super[]. Genericele, în schimb, sunt invariante: pentru oricare două tipuri distincte Type1 și Type2,
List<Type1> nu este nici subtip, nici supertip al lui List<Type2>.
20. Preferă lambda-urile în locul claselor anonime
// 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());
}
});
În schimb, fă așa:
// Lambda expression as function object (replaces anonymous class)
Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));
21. Minimizează domeniul de vizibilitate al variabilelor locale
Cea mai puternică tehnică pentru a minimiza domeniul de vizibilitate al unei variabile locale este s-o declari acolo unde este folosită prima dată...
22. Preferă buclele for-each în locul buclelor for tradiționale
// The preferred idiom for iterating over collections and arrays
for (Element e : elements) {
... // Do something with e
}
23. Cunoaște și folosește bibliotecile
Să presupunem că vrei să generezi numere întregi aleatoare între zero și o limită superioară. Confruntați cu această sarcină obișnuită, mulți programatori ar scrie o metodă mică, arătând cam așa:
// Common but deeply flawed!
static Random rnd = new Random();
static int random(int n) {
return Math.abs(rnd.nextInt()) % n;
}
Metoda poate arăta bine, dar are trei defecte. Primul este că, dacă n este o putere mică a lui doi, secvența de numere aleatoare se va repeta după o perioadă destul de scurtă. Al doilea defect este că, dacă n nu este o putere a lui doi, unele numere vor fi returnate, în medie, mai des decât altele. Dacă n este mare, efectul poate fi destul de pronunțat.
Al treilea defect al metodei random este că, în cazuri rare, poate eșua catastrofal, returnând un număr din afara intervalului specificat. Asta se întâmplă pentru că
metoda încearcă să transforme valoarea returnată de rnd.nextInt() într-un int nenegativ apelând Math.abs. Dacă nextInt() returnează
Integer.MIN_VALUE, atunci și Math.abs va returna Integer.MIN_VALUE, iar operatorul rest (%) va returna un număr negativ, presupunând că
n nu este o putere a lui doi. Aproape sigur, asta va face programul să eșueze, iar eșecul poate fi greu de reprodus.
24. Evită float și double dacă ai nevoie de rezultate exacte
Folosește BigDecimal pentru sume de bani. float și double produc erori de rotunjire.
De exemplu, să presupunem că ai 1,03 $ în buzunar și cheltui 42 ¢. Câți bani îți rămân? Iată un fragment de program naiv care încearcă să răspundă la această întrebare:
System.out.println(1.03 - 0.42);
Din păcate, afișează 0.6100000000000001. Nu este un caz izolat. Să presupunem că ai un dolar în buzunar și cumperi nouă șaibe a câte zece cenți fiecare. Cât rest
primești?
System.out.println(1.00 - 9 * 0.10);
Potrivit acestui fragment de program, primești $0.09999999999999998.
Ai putea crede că problema se rezolvă simplu, rotunjind rezultatele înainte de afișare, dar, din păcate, asta nu funcționează întotdeauna. De exemplu, să presupunem că ai un dolar în buzunar și vezi un raft cu un șir de bomboane delicioase care costă 10 ¢, 20 ¢, 30 ¢ și tot așa, până la un dolar. Cumperi câte una din fiecare, începând cu cea de 10 ¢, până când nu-ți mai permiți să cumperi următoarea bomboană de pe raft.
25. Preferă tipurile primitive în locul primitivelor boxed
// Broken comparator - can you spot the flaw?
Comparator<Integer> naturalOrder = (i, j) -> (i < j) ? -1 : (i == j ? 0 : 1);
26. Referă obiectele prin interfețele lor
Exemplu bun:
Set<Son> sonSet = new LinkedHashSet<>();
Exemplu prost:
LinkedHashSet<Son> sonSet = new LinkedHashSet<>();
27. Preferă interfețele în locul reflection-ului
Altfel:
- Pierzi toate beneficiile verificării tipurilor la compilare, inclusiv verificarea excepțiilor. Dacă un program încearcă să apeleze prin reflection o metodă inexistentă sau inaccesibilă, va eșua la runtime, dacă nu ai luat măsuri speciale de precauție.
- Codul necesar pentru accesul prin reflection este stângaci și prolix. E plictisitor de scris și greu de citit.
- Performanța are de suferit. Apelul de metodă prin reflection este mult mai lent decât apelul normal.
28. Respectă convențiile de denumire general acceptate
Platforma Java are un set bine stabilit de convenții de denumire, multe dintre ele cuprinse în The Java Language Specification [JLS, 6.1].
29. Folosește excepțiile doar pentru situații excepționale
// Horrible abuse of exceptions. Don't ever do this!
try {
int i = 0;
while (true) {
range[i++].climb();
}
} catch (ArrayIndexOutOfBoundsException e) {
// catch it
}
Ce face acest cod? Nu e deloc evident la prima vedere, și acesta e un motiv suficient să nu-l folosești. Se dovedește a fi un idiom îngrozitor de prost gândit pentru parcurgerea elementelor unui
array. Bucla infinită se termină prin aruncarea, prinderea și ignorarea unei ArrayIndexOutOfBoundsException atunci când
încearcă să acceseze primul element din afara limitelor array-ului. Ar trebui să fie echivalentă cu idiomul standard de parcurgere a unui array, pe care îl recunoaște imediat
orice programator Java:
for (Mountain m : range) {
m.climb();
}
Excepțiile sunt, după cum le spune și numele, de folosit doar în situații excepționale; nu ar trebui folosite niciodată pentru fluxul de control obișnuit.
30. Folosește excepții checked pentru situații recuperabile și excepții runtime pentru erori de programare
Folosește excepții checked pentru situații din care se poate aștepta în mod rezonabil ca apelantul să-și revină. Confruntând utilizatorul cu o excepție checked, designerul API-ului îi impune să trateze situația. Utilizatorul poate ignora această obligație prinzând excepția și ignorând-o, dar de obicei e o idee proastă.
Folosește excepții runtime pentru a semnala erori de programare. Marea majoritate a excepțiilor runtime indică încălcări ale precondițiilor. O încălcare a unei precondiții înseamnă pur și simplu că
clientul unui API nu a respectat contractul stabilit de specificația API-ului. De exemplu, contractul pentru accesul la array specifică faptul că indexul trebuie să fie între zero și lungimea
array-ului minus unu, inclusiv. ArrayIndexOutOfBoundsException indică faptul că această precondiție a fost încălcată.
31. Evită folosirea inutilă a excepțiilor checked
Pe scurt, folosite cu măsură, excepțiile checked pot crește fiabilitatea programelor; folosite în exces, fac API-urile greu de folosit.
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. Preferă excepțiile standard
O trăsătură care îi deosebește pe programatorii experți de cei mai puțin experimentați este că experții urmăresc și de obicei obțin un grad înalt de reutilizare a codului.
Excepțiile nu fac excepție de la regula că reutilizarea codului e un lucru bun. Bibliotecile Java oferă un set de excepții care acoperă majoritatea nevoilor de aruncare de excepții ale celor mai multe API-uri. Reutilizarea excepțiilor standard are mai multe beneficii. Principalul este că API-ul tău devine mai ușor de învățat și de folosit, pentru că respectă convenții consacrate, pe care programatorii le cunosc deja. Pe locul doi, la mică distanță, programele care folosesc API-ul tău sunt mai ușor de citit, pentru că nu sunt încărcate cu excepții necunoscute. În sfârșit (și cel mai puțin important), mai puține clase de excepții înseamnă un consum mai mic de memorie și mai puțin timp petrecut cu încărcarea claselor.
Tipul de excepție reutilizat cel mai des este IllegalArgumentException. În general, aceasta este excepția de aruncat când apelantul transmite un argument cu o valoare nepotrivită. De
exemplu, aceasta ar fi excepția de aruncat dacă apelantul ar transmite un număr negativ într-un parametru care reprezintă de câte ori trebuie repetată o acțiune. O altă excepție reutilizată des
este IllegalStateException. În general, aceasta este excepția de aruncat dacă apelul este ilegal din cauza stării obiectului care îl primește. De exemplu, aceasta ar fi
excepția de aruncat dacă apelantul ar încerca să folosească un obiect înainte ca acesta să fi fost inițializat corespunzător.
Se poate argumenta că orice apel de metodă eronat se reduce la un argument sau o stare ilegală, dar alte excepții sunt folosite în mod standard pentru anumite tipuri de argumente și stări ilegale. Dacă un apelant
transmite null într-un parametru pentru care valorile null sunt interzise, convenția cere să fie aruncată NullPointerException, nu IllegalArgumentException.
Similar, dacă un apelant transmite o valoare din afara intervalului într-un parametru care reprezintă un index într-o secvență, ar trebui aruncată IndexOutOfBoundsException, nu IllegalArgumentException.
Nu refolosi direct Exception, RuntimeException, Throwable sau Error. Tratează aceste clase ca și cum ar fi abstracte. Totuși, RuntimeException poate fi folosită dacă vrei
să transformi o excepție checked într-o excepție runtime și știi cu adevărat ce faci.
33. Nu ignora excepțiile
// Empty catch block ignores exception - Highly suspect!
try {
...
} catch (SomeException e) {
}
Un bloc catch gol anulează scopul excepțiilor, care este să te forțeze să tratezi situațiile excepționale. A ignora o excepție este ca și cum ai ignora o alarmă de incendiu – și ai opri-o, ca nimeni altcineva să nu mai aibă ocazia să vadă dacă e un incendiu adevărat.