Najważniejsze wnioski po lekturze "Effective Java"
Na ten artykuł składają się moje notatki z książki „Effective Java” (wydanie III) Joshuy Blocha, a także moje przemyślenia na ich temat. Wybrałem z niej kilka elementów, które chciałbym tutaj podkreślić. Książka ta zawiera bardzo cenne informacje dotyczące Javy i inżynierii oprogramowania, dlatego koniecznie powinna się znaleźć na Twojej liście lektur. Zaczynajmy.
Eliminacja przestarzałych referencji do obiektów
Jak wiadomo — Java posiada system automatycznego zarządzania pamięcią. System ten chroni programistów przed złożonością jawnego zarządzania pamięcią. Ale nie ma nic za darmo. Niektóre zachowania kodu mogą blokować GC, przez co mogą wystąpić wycieki i inne problemy z pamięcią.
Wyobraźmy sobie, że projektujemy stos, który używa tablicy w tle.
private int size = 0;
public Object pop() {
return elements[ — size];
}
Jeśli zaimplementuję metodę pop jak powyżej, GC nie może zwolnić nieużywanych elementów tablicy. I oto diabeł we własnej osobie “wyciek pamięci”.
public Object pop() {
Object result = elements[ — size];
elements[size] = null;
return result;
}
Jeśli klasa zarządza swoją pamięcią, programista powinien być czujny na jej wycieki. Za każdym razem, gdy element jest zwalniany, wszelkie referencje do obiektów zawarte w elemencie powinny zostać ustawione jako puste. Jednakże taka praktyka powinna być raczej wyjątkiem niż normą.
Dzieje się tak naturalnie, jeśli zdefiniujesz każdą zmienną w najwęższym możliwym zakresie.
Lepsze try-with-resources niż try-finally
Biblioteki mogą zawierać zasoby, które muszą zostać zamknięte ręcznie poprzez wywołanie metody close
. Operacje na plikach, połączenia z bazą danych, itp. Zdarza się, że zamknięcie zasobów jest pomijane przez klientów, co jednak może mieć przykre konsekwencje.
No dobra, zdecydowaliśmy się zamknąć zasoby. Ale dlaczego nie używamy starego dobrego przyjaciela try-finally
?
Podatność na błędy składniowe
Jeśli użyjemy wewnętrznych bloków try-finally
, czytelność kodu drastycznie spada.
Wyjątki
Zarówno w bloku “try
” jak i w bloku “finally
” kod potrafi rzucać wyjątki. W tym scenariuszu pierwszy wyjątek został nadpisany przez drugi (podczas uruchomienia metody close
). Tym sposobem pierwotna przyczyna problemu nie będzie widoczna w stosie wywołań powiązanym z wyjątkiem.
try-finally
static void copy(String src, String dst) throws IOException {
InputStream in = new FileInputStream(src);
try {
OutputStream out = new FileOutputStream(dst);
try {
……
} finally {
out.close();
}
} finally {
in.close();
}
}
try-with-resources
static void copy(String src, String dst) throws IOException {
try (InputStream in = new FileInputStream(src);
OutputStream out = new FileOutputStream(dst)) {
...
}
}
Mniejsza podatność na błędy, czysta składnia, czego jeszcze może chcieć programista?
Minimalizowanie dostępności klas i ich części składowych.
„Najważniejszym czynnikiem, który odróżnia dobrze zaprojektowany komponent od źle zaprojektowanego, jest stopień, w jakim komponent ukrywa swoje wewnętrzne dane i inne szczegóły implementacji przed innymi komponentami”
Enkapsulacja jest jednym z najważniejszych filarów programowania obiektowego
- Nie sprawia, że ma lepszą wydajność, ale umożliwia efektywną regulację (luźne sprzężenie modułów)
- Łatwiejsze ponowne wykorzystanie (ponieważ komponenty, które nie są silnie powiązane, mogą być ponownie wykorzystane w różnych kontekstach).
- Przyspiesza pisanie kodu.
Pola private
i publiczne metody dostępowe są powszechnie stosowanymi “najlepszymi praktykami”. Ta sama tendencja występuje na poziomie klasy i metody. Kolejność, w jakiej powinniśmy myśleć o modyfikatorach dostępu jest następująca: private, package-private, protected, public.
Minimalizowanie mutowalności
James Gosling (twórca Javy), zapytany w wywiadzie, kiedy należy używać obiektów niemutowalnych, odpowiedział:
„Używałbym obiektów niemutowalnych, kiedy tylko mogę”
Główne zalety stosowania niemutowalności;
- Prostota. Obiekt niemutowalny może być w dokładnie jednym stanie.
- Bezpieczeństwo w kontekście wielowątkowym, obiekty nie wymagają synchronizacji.
- Łatwość cache’owania.
Taką drogą podążają Java* i inne języki programowania*.
- *Java Record,
- *W języku programowania Rust zmienne są domyślnie niemutowalne.
Nie używaj typów surowych
Typy generyczne zostały dodane do języka programowania Javy w 2004 roku wraz z wersją 5. Jednak z powodu wymagań znanych jako “kompatybilność migracyjna” Java nadal obsługuje typy surowe. Na początek rzuć okiem na typy surowe i inne alternatywy.
List<String> strList = … → list of string
List strList = … → raw
Pierwszy przykład zapewnia silne sprawdzenie typu na etapie kompilacji. Nie wyskoczy w runtime’ie z niczym w stylu „Hej! Ta referencja nie jest ciągiem znaków, mam dla Ciebie mały ClassCastException”.
Ale druga z nich tworzy środowisko podatne na błędy. Możesz dodać dowolny rodzaj obiektu do tej listy bez błędu kompilacji (z ostrzeżeniem kompilatora). Dokładnie ta sytuacja opóźni wystąpienie błędu aż do momentu uruchomienia.
Z drugiej strony, jeśli chcę stworzyć metodę, która działa bez dbania o typ obiektu, czy mogę użyć typu surowego? Typ unbound wildcard
byłby właściwszy/bezpieczniejszy.
int numElementsInCommon(Set s1, Set s2) → raw type
int numElementsInCommon(Set<?> s1, Set<?> s2) → unbound wildcard
Krótko mówiąc, typy surowe istnieją tylko przez potrzebę kompatybilności i interoperacyjności z kodem starszego typu, który poprzedzał wprowadzenie typów generycznych. Nie używaj ich.
Zwracaj puste kolekcje lub tablice, nie nulle
Całkiem proste, ale skuteczne. Jeśli z tego nie korzystamy, każde wywołanie musi napisać okropną instrukcję if. W przeciwnym razie napotkanie NullPointerException
nie będzie fajne.
Java prezentuje kilka pustych, niemutowalnych: Collections.emptyList()
, Collections.emptySet()
, Collections.emptyMap()
. A ponieważ niemutowalne obiekty mogą być swobodnie współdzielone, korzystanie z tych kolekcji może być wygodniejsze.
public List<Item> getItems() {
return itemList.isEmpty() ? Collections.emptyList()
: new ArrayList<>(itemList);
}
Starannie zaprojektuj sygnaturę metody
Nazwa
Starannie dobieraj nazwy metod. Nazwy powinny być zawsze zgodne ze standardową konwencją nazewnictwa oraz powinny być zrozumiałe i spójne. Szczególnie złym nawykiem jest używanie różnych nazw dla tej samej operacji. Na przykład:
List<User> getAllUsers() {
// get all user from the database (with hibernate or any kind of ORM)
}
List<Store> loadAllStores() {
// get all store from the database (with hibernate or any kind of ORM)
}
Lista parametrów
Chociaż większość nowoczesnych IDE pomaga w zrozumieniu sygnatury metod, długa lista argumentów zawsze może przyczynić się do powstania błędu. Zbierz je wokół jakiejś koncepcji. Przykładowo:
addMarker(String title, String colorCode, String url, long latitude, long longitude)
addMarker(Marker marker)
Inną alternatywną techniką może być implementacja wzorca builder
.
Nie próbuj odkryć Ameryki
Zdarza się, że biblioteka może zawieść Twoje oczekiwania. Jednak w większości przypadków istnieje co najmniej jedna biblioteka, która daje funkcję, której potrzebujesz. Ta biblioteka może być w jakimś pod-pakietem Javy, ale może być też biblioteką zewnętrzną (aktywnie utrzymywaną, bezpieczną, renomowaną, itp.) W tym kontekście wymienienie kilku zalet korzystania z biblioteki może być dla nas korzystne:
- Korzystasz z wiedzy ekspertów, którzy ją napisali oraz z doświadczenia tych, którzy korzystali z niej przed Tobą.
- Oszczędność czasu. Więcej czasu na aplikację, a nie na infrastrukturę.
- Czyni Twój kod bardziej czytelnym, możliwym do utrzymania, ponownego użycia przez innych programistów.
- Nie wkładasz wysiłku, aby uzyskać nową funkcję, poprawę wydajności, itp.
Unikaj Float i Double, jeśli wymagana jest dokładna odpowiedź
Typy float i double są przeznaczone przede wszystkim do obliczeń naukowych i inżynierskich. Wykonują one binarną arytmetykę zmiennoprzecinkową*, która pozwala na szybkie przybliżenia w szerokim zakresie wielkości. Nie zapewniają one dokładnych wyników i nie powinny być stosowane tam, gdzie są one wymagane.
BigDecimal x = new BigDecimal(“1.89”);
BigDecimal y = new BigDecimal(“1.03”);
System.out.println(x.subtract(y));
System.out.println(1.89–1.03);
0.86
0.8599999999999999
Jeśli wymagany jest dokładny wynik (np. obliczenia związane z finansami), należy użyć BigDecimal lub innej implementacji pasującej do Twojego przypadku użycia.
*IEEE 754
Odwoływanie się do obiektów poprzez ich interfejsy
Użycie interfejsów jako typów sprawi, że Twój program będzie znacznie bardziej elastyczny. Jeśli zdecydujesz się na zmianę implementacji z jakiegokolwiek powodu (np. wydajności), wszystko, co musisz zrobić, to zmienić nazwę klasy i nie spowoduje to błędu kompilacji.
Map<String,String> map = new HashTable<>();
— — after a while, when I don’t need thread-safety
Map<String,String> map = new HashMap<>();
Jeśli nie ma odpowiedniego interfejsu, można użyć najmniej określonej klasy w hierarchii klas, która zapewnia wymagane funkcje.
Stosuj standardowe wyjątki
„Atrybutem, który odróżnia programistów-ekspertów od tych mniej doświadczonych jest to, że eksperci dążą do wysokiego stopnia ponownego wykorzystania kodu i zazwyczaj się im to udaje”
Biblioteki Javy dostarczają zestaw wyjątków, które zaspokajają potrzeby większości API w zakresie rzucania wyjątków. A oto kilka korzyści wynikających z ponownego wykorzystania wyjątków:
- Łatwiejsze do nauki i użytkowania (konwencje, z którymi programiści są już zaznajomieni)
- Potrzebują mniej pamięci.
- Mniej czasu na załadowanie klasy.
IllegalArgumentException
, IllegalStateException
, NullPointerException
, IndexOutOfBoundsException
, UnsupportedOperationException
, ConcurrentModificationException
. To tylko niektóre z nich.
Jeśli wyjątek spełnia Twoje potrzeby, użyj go. Z drugiej też strony, nie krępuj stworzyć podklasy na bazie standardowego wyjątku, jeśli chcesz dodać więcej szczegółów.
Oryginał tekstu w języku angielskim przeczytasz tutaj.