Найкращі практики з «Effective Java (3rd Edition)»
Уривки з книжки «Effective Java (3rd Edition) » Joshua Bloch
1. Повертайте порожні колекції або масиви, а не null
Якщо метод повертає Set, не пишіть return null; для порожнього результату — натомість пишіть return Set.of();.
2. Розгляньте статичні фабричні методи замість конструкторів
public static Boolean valueOf(boolean b) {
return b ? Boolean.TRUE : Boolean.FALSE;
}
Не завжди кращі, але в деяких випадках мають чимало переваг:
- Одна з переваг статичних фабричних методів у тому, що, на відміну від конструкторів, вони мають імена.
- Вони не зобов’язані створювати новий об’єкт під час кожного виклику.
- Вони можуть повертати об’єкт будь-якого підтипу свого типу повернення.
- Клас повернутого об’єкта може змінюватися від виклику до виклику залежно від вхідних параметрів.
- Класу повернутого об’єкта не обов’язково існувати на момент написання класу, що містить метод.
Недоліки:
- Головне обмеження класів лише зі статичними фабричними методами: від класів без публічних або захищених конструкторів не можна успадковуватися.
- Другий недолік статичних фабричних методів — програмістам їх важко знайти.
3. Використовуйте builder, коли конструктор має багато параметрів
Статичні фабрики й конструктори мають спільне обмеження: вони погано масштабуються, коли необов’язкових параметрів багато. У деяких проєктах, над якими я працював, це обмеження дуже дошкуляло.
4. Забезпечуйте властивість singleton приватним конструктором або типом enum
Усе, що не потребує кількох екземплярів, слід створювати лише один раз — чи то через @ApplicationScoped, чи через інший патерн singleton.
5. Забороняйте створення екземплярів приватним конструктором
Іноді потрібно написати клас, який є просто набором статичних методів і статичних полів. Такі класи зажили поганої слави, бо дехто зловживає ними, щоб не мислити категоріями об’єктів, але вони мають цілком слушні застосування.
private MyStaticClass() {
// private constructor to prevent instantiation
}
6. Уникайте створення зайвих об’єктів
String s = new String("bikini"); // DON'T DO THIS!
Або:
// 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;
}
Розв’язок: Ця програма дає правильну відповідь, але працює набагато повільніше, ніж мала б, через одну-єдину друкарську помилку. Змінну sum оголошено як Long замість long, тож програма створює приблизно 2^31 зайвих екземплярів Long (приблизно по одному щоразу, коли long i додається до Long sum).
7. Уникайте фіналізаторів і cleaner-ів
Фіналізатори непередбачувані, часто небезпечні й загалом непотрібні. Їхнє використання може спричиняти хаотичну поведінку, низьку продуктивність і проблеми з переносимістю.
8. Віддавайте перевагу 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. Розгляньте реалізацію Comparable
Реалізуючи Comparable, ви даєте своєму класу змогу взаємодіяти з усіма численними узагальненими алгоритмами та реалізаціями колекцій, що залежать від цього інтерфейсу.
10. Мінімізуйте доступність класів і членів
Найважливіший чинник, що відрізняє добре спроєктований компонент від погано спроєктованого, — те, наскільки компонент приховує свої внутрішні дані та інші деталі реалізації від інших компонентів. Добре спроєктований компонент приховує всі деталі реалізації, чітко відокремлюючи свій API від реалізації. Тоді компоненти взаємодіють лише через свої API й нічого не знають про внутрішню будову одне одного. Ця концепція, відома як приховування інформації або інкапсуляція, — один з основоположних принципів проєктування програмного забезпечення.
11. У публічних класах використовуйте методи доступу, а не публічні поля
Іноді може виникнути спокуса написати вироджені класи, єдине призначення яких — згрупувати поля екземпляра:
// Degenerate classes like this should not be public!
class Point {
public double x;
public double y;
}
Оскільки до полів даних таких класів звертаються напряму, вони не дають переваг інкапсуляції. Не можна змінити представлення, не змінивши API, не можна забезпечити інваріанти й не можна виконати додаткову дію під час звернення до поля. Непохитні прихильники ООП вважають такі класи анафемою й переконані, що їх завжди слід замінювати класами з приватними полями та публічними методами доступу (гетерами), а для змінюваних класів — мутаторами (сетерами):
// 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; }
}
Безумовно, непохитні мають рацію, коли йдеться про публічні класи: якщо клас доступний поза своїм пакетом, надайте методи доступу, щоб зберегти гнучкість і мати змогу змінити внутрішнє представлення класу. Якщо публічний клас відкриває свої поля даних, надію змінити його представлення втрачено, бо клієнтський код може бути розкиданий де завгодно.
Проте якщо клас приватний у межах пакета або є приватним вкладеним класом, немає нічого поганого в тому, щоб відкрити його поля даних, — за умови, що вони належно описують абстракцію, яку надає клас.
12. Мінімізуйте змінюваність
Незмінний клас — це просто клас, екземпляри якого не можна змінювати. Уся інформація, що міститься в кожному екземплярі, фіксована протягом усього життя об’єкта, тож жодних змін ніколи не видно. Бібліотеки платформи Java містять багато незмінних класів, зокрема String, класи-обгортки примітивів, а також BigInteger і BigDecimal. Для цього є багато вагомих причин: незмінні класи легше проєктувати, реалізовувати й використовувати, ніж змінювані. Вони менш схильні до помилок і безпечніші. Щоб зробити клас незмінним, дотримуйтеся цих п’яти правил:
- Не надавайте методів, які змінюють стан об’єкта (так званих мутаторів).
-
Подбайте, щоб клас не можна було розширити. Це не дає недбалим або зловмисним підкласам порушити незмінну поведінку класу, поводячись так, ніби стан
об’єкта змінився. Успадкуванню зазвичай запобігають, наприклад, оголошуючи клас як
final. - Зробіть усі поля final. Це чітко виражає ваш намір у спосіб, який забезпечує сама система. Крім того, це потрібно для коректної поведінки, якщо посилання на щойно створений екземпляр передається з одного потоку в інший без синхронізації, як описано в моделі пам’яті.
- Зробіть усі поля приватними. Це не дає клієнтам отримати доступ до змінюваних об’єктів, на які посилаються поля, і змінювати ці об’єкти напряму. Хоча технічно незмінні класи можуть мати публічні final-поля з примітивними значеннями або посиланнями на незмінні об’єкти, це не рекомендується, бо унеможливлює зміну внутрішнього представлення в наступному релізі.
-
Забезпечте ексклюзивний доступ до будь-яких змінюваних компонентів. Якщо ваш клас має поля, що посилаються на змінювані об’єкти, подбайте, щоб клієнти класу не могли отримати посилання на
ці об’єкти. Ніколи не ініціалізуйте таке поле посиланням на об’єкт, наданий клієнтом, і не повертайте це поле з методу доступу. Робіть захисні копії в конструкторах, методах доступу та
методах
readObject.
13. Віддавайте перевагу композиції перед успадкуванням
Успадкування — потужний спосіб повторно використовувати код, але не завжди найкращий інструмент для цього. Якщо застосовувати його недоречно, воно робить програмне забезпечення крихким. Успадкування безпечне в межах одного пакета, де реалізації підкласу й суперкласу контролюють ті самі програмісти. Безпечне воно й тоді, коли розширюються класи, спеціально спроєктовані й задокументовані для розширення. Натомість успадкування від звичайних конкретних класів через межі пакетів небезпечне.
На відміну від виклику методу, успадкування порушує інкапсуляцію. Можна подумати, що розширювати клас безпечно, якщо лише додавати нові методи й не перевизначати наявні методи. Хоча таке розширення набагато безпечніше, воно не позбавлене ризику. Якщо в наступному релізі суперклас отримає новий метод, а вам не пощастило дати підкласу метод із такою самою сигнатурою, але іншим типом повернення, ваш підклас більше не скомпілюється. Якщо ж ви дали підкласу метод із такою самою сигнатурою й типом повернення, як у нового методу суперкласу, то тепер ви його перевизначаєте, а отже, стикаєтеся з описаними раніше проблемами. Крім того, сумнівно, що ваш метод виконуватиме контракт нового методу суперкласу, адже цей контракт ще не було написано, коли ви писали метод підкласу.
На щастя, є спосіб уникнути всіх описаних вище проблем. Замість розширювати наявний клас, дайте новому класу приватне поле, що посилається на екземпляр наявного класу. Такий дизайн називається композицією, бо наявний клас стає компонентом нового. Кожен метод екземпляра в новому класі викликає відповідний метод на вкладеному екземплярі наявного класу й повертає результат. Це називається переадресацією (forwarding), а методи нового класу — методами переадресації. Отриманий клас буде надзвичайно надійним і не залежатиме від деталей реалізації наявного класу. Навіть додавання нових методів до наявного класу ніяк не вплине на новий клас.
14. Віддавайте перевагу інтерфейсам перед абстрактними класами
Java має два механізми для визначення типу, що допускає кілька реалізацій: інтерфейси та абстрактні класи. Відколи в Java 8 з’явилися методи за замовчуванням в інтерфейсах, обидва механізми дають змогу надавати реалізації для деяких методів екземпляра.
Головна відмінність у тому, що для реалізації типу, визначеного абстрактним класом, клас має бути підкласом цього абстрактного класу. Оскільки Java допускає лише одиничне успадкування, це обмеження абстрактних класів суттєво звужує їх використання як визначень типів. Будь-який клас, що визначає всі потрібні методи й дотримується загального контракту, може реалізувати інтерфейс, незалежно від того, де цей клас розташований в ієрархії класів. Наявні класи легко доопрацювати, щоб вони реалізовували новий інтерфейс.
- Інтерфейси ідеально підходять для визначення міксинів.
- Інтерфейси дають змогу будувати неієрархічні системи типів.
- Інтерфейси уможливлюють безпечне й потужне розширення функціональності за допомогою ідіоми класу-обгортки. Якщо для визначення типів використовувати абстрактні класи, програмістові, який хоче додати функціональність, не залишається іншого вибору, крім успадкування.
15. Дженерики: не використовуйте сирі типи (raw types)
Ви не виявите помилку до часу виконання — задовго після того, як вона сталася, і в коді, що може бути далеко від коду з помилкою. Побачивши ClassCastException, доведеться прочісувати кодову базу в пошуках виклику методу, який поклав монету в колекцію марок. Компілятор тут не допоможе.
16. Дженерики: усувайте unchecked-попередження
Коли ви програмуєте з дженериками, то бачите багато попереджень компілятора:
- unchecked-попередження про приведення типів
- unchecked-попередження про виклик методу
- unchecked-попередження про параметризований vararg-тип
- unchecked-попередження про перетворення.
Що більше досвіду з дженериками ви набуваєте, то менше попереджень отримуєте, але не чекайте, що щойно написаний код скомпілюється без жодного. Багато unchecked-попереджень легко усунути. Припустімо, наприклад, ви випадково написали таке оголошення:
Set<Lark> exaltation = new HashSet();
Змініть його на:
Set<Lark> exaltation = new HashSet<>();
Деякі попередження усунути набагато складніше. Коли трапляються попередження, що потребують роздумів, не здавайтеся! Усувайте кожне unchecked-попередження, яке тільки можете.
Якщо ви усунете всі попередження, то матимете гарантію, що ваш код типобезпечний, а це дуже добре. Це означає, що під час виконання ви не отримаєте ClassCastException, і посилює вашу впевненість,
що програма поводитиметься так, як ви задумали. Якщо попередження усунути не вдається, але ви можете довести, що код, який його спричинив, типобезпечний, тоді (і лише тоді) приглушіть
попередження анотацією @SuppressWarnings("unchecked"). Якщо ви приглушуєте попередження, не довівши спершу, що код типобезпечний, то створюєте собі хибне відчуття
безпеки.
Код може скомпілюватися без жодного попередження, але під час виконання все одно кинути ClassCastException.
Щоразу, коли використовуєте анотацію @SuppressWarnings("unchecked"), додавайте коментар, який пояснює, чому це безпечно.
17. Дженерики: коли вони доречні
Ой — клас, якому конче бракує дженериків:
Таке треба виловлювати щонайпізніше на етапі merge request!
18. Дженерики: як не треба робити
Вибачте, слова зайві. Будь ласка, НІКОЛИ не мерджте таке:
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> {
// ...
}
Щоб зрозуміти саме лише оголошення класу, ніколи не повинно знадобитися 5 хвилин.
19. Віддавайте перевагу спискам перед масивами
Масиви відрізняються від узагальнених типів двома важливими аспектами. По-перше, масиви коваріантні. Це страшнувате слово означає лише те, що коли Sub є підтипом Super, то
тип масиву Sub[] є підтипом типу масиву Super[]. Дженерики, навпаки, інваріантні: для будь-яких двох різних типів Type1 і Type2
List<Type1> не є ні підтипом, ні супертипом List<Type2>.
20. Віддавайте перевагу лямбдам перед анонімними класами
// 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());
}
});
Натомість робіть так:
// Lambda expression as function object (replaces anonymous class)
Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));
21. Мінімізуйте область видимості локальних змінних
Найпотужніший спосіб мінімізувати область видимості локальної змінної — оголошувати її там, де вона вперше використовується...
22. Віддавайте перевагу циклам for-each перед традиційними циклами for
// The preferred idiom for iterating over collections and arrays
for (Element e : elements) {
... // Do something with e
}
23. Знайте й використовуйте бібліотеки
Припустімо, вам потрібно генерувати випадкові цілі числа від нуля до певної верхньої межі. Зіткнувшись із цим поширеним завданням, багато програмістів написали б невеликий метод приблизно такого вигляду:
// Common but deeply flawed!
static Random rnd = new Random();
static int random(int n) {
return Math.abs(rnd.nextInt()) % n;
}
Цей метод може здаватися непоганим, але він має три вади. Перша: якщо n — невеликий степінь двійки, послідовність випадкових чисел повторюватиметься після доволі короткого періоду. Друга вада: якщо n не є степенем двійки, деякі числа в середньому повертатимуться частіше за інші. Якщо n велике, цей ефект може бути дуже помітним.
Третя вада методу random полягає в тому, що в рідкісних випадках він може катастрофічно збоїти, повертаючи число поза вказаним діапазоном. Так стається тому, що
метод намагається відобразити значення, яке повертає rnd.nextInt(), на невід’ємний int, викликаючи Math.abs. Якщо nextInt() повертає
Integer.MIN_VALUE, то Math.abs теж поверне Integer.MIN_VALUE, а оператор остачі (%) поверне від’ємне число, якщо
n не є степенем двійки. Це майже напевно призведе до збою програми, і цей збій може бути важко відтворити.
24. Уникайте float і double, якщо потрібні точні відповіді
Для грошових сум використовуйте BigDecimal. float і double дають похибки округлення.
Припустімо, у вас у кишені 1,03 долара, і ви витрачаєте 42 центи. Скільки грошей у вас залишилося? Ось наївний фрагмент програми, що намагається відповісти на це запитання:
System.out.println(1.03 - 0.42);
На жаль, він виводить 0.6100000000000001. І це не поодинокий випадок. Припустімо, у кишені у вас долар, і ви купуєте дев’ять шайб по десять центів кожна. Скільки
решти ви отримаєте?
System.out.println(1.00 - 9 * 0.10);
Згідно з цим фрагментом програми, ви отримаєте $0.09999999999999998.
Можна подумати, що проблему розв’яже просте округлення результатів перед виведенням, але, на жаль, це спрацьовує не завжди. Припустімо, наприклад, у кишені у вас долар, і ви бачите полицю з рядом смачних цукерок по 10, 20, 30 центів і так далі — аж до долара. Ви купуєте по одній цукерці кожного виду, починаючи з тієї, що коштує 10 центів, доки не зможете дозволити собі наступну цукерку на полиці.
25. Віддавайте перевагу примітивним типам перед упакованими примітивами
// Broken comparator - can you spot the flaw?
Comparator<Integer> naturalOrder = (i, j) -> (i < j) ? -1 : (i == j ? 0 : 1);
26. Звертайтеся до об’єктів через їхні інтерфейси
Добрий приклад:
Set<Son> sonSet = new LinkedHashSet<>();
Поганий приклад:
LinkedHashSet<Son> sonSet = new LinkedHashSet<>();
27. Віддавайте перевагу інтерфейсам перед рефлексією
Інакше:
- Ви втрачаєте всі переваги перевірки типів під час компіляції, зокрема перевірку винятків. Якщо програма через рефлексію намагається викликати неіснуючий або недоступний метод, вона впаде під час виконання, якщо ви не вжили спеціальних запобіжних заходів.
- Код для рефлексивного доступу незграбний і багатослівний. Його нудно писати й важко читати.
- Страждає продуктивність. Рефлексивний виклик методу значно повільніший за звичайний.
28. Дотримуйтеся загальноприйнятих угод про іменування
Платформа Java має усталений набір угод про іменування, багато з яких містяться в The Java Language Specification [JLS, 6.1].
29. Використовуйте винятки лише для виняткових ситуацій
// Horrible abuse of exceptions. Don't ever do this!
try {
int i = 0;
while (true) {
range[i++].climb();
}
} catch (ArrayIndexOutOfBoundsException e) {
// catch it
}
Що робить цей код? З першого погляду це зовсім не очевидно, і вже цього достатньо, щоб його не використовувати. Виявляється, це жахливо непродумана ідіома для перебору елементів
масиву. Нескінченний цикл завершується викиданням, перехопленням та ігноруванням ArrayIndexOutOfBoundsException, коли
він намагається звернутися до першого елемента поза межами масиву. Передбачається, що це рівнозначно стандартній ідіомі перебору масиву, яку миттєво впізнає
будь-який Java-програміст:
for (Mountain m : range) {
m.climb();
}
Винятки, як випливає з їхньої назви, слід використовувати лише для виняткових ситуацій; їх ніколи не слід використовувати для звичайного потоку керування.
30. Використовуйте перевірювані винятки для ситуацій, з яких можна відновитися, а винятки часу виконання — для помилок програмування
Використовуйте перевірювані винятки для ситуацій, з яких від викликача можна обґрунтовано очікувати відновлення. Ставлячи користувача перед перевірюваним винятком, розробник API зобов’язує його відновитися з цієї ситуації. Користувач може знехтувати цим обов’язком, перехопивши виняток і проігнорувавши його, але зазвичай це погана ідея.
Використовуйте винятки часу виконання, щоб сигналізувати про помилки програмування. Переважна більшість винятків часу виконання вказує на порушення передумов. Порушення передумови — це просто недотримання
клієнтом API контракту, встановленого специфікацією API. Наприклад, контракт доступу до масиву вимагає, щоб індекс масиву був від нуля до довжини масиву
мінус один включно. ArrayIndexOutOfBoundsException сигналізує, що цю передумову порушено.
31. Уникайте зайвого використання перевірюваних винятків
Отже, якщо використовувати перевірювані винятки помірковано, вони можуть підвищити надійність програм; якщо ж зловживати ними, користуватися API стає болісно.
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. Надавайте перевагу стандартним виняткам
Риса, що відрізняє досвідчених програмістів від менш досвідчених, — те, що досвідчені прагнуть високого рівня повторного використання коду й зазвичай його досягають.
Винятки не є винятком із правила, що повторне використання коду — це добре. Бібліотеки Java надають набір винятків, що покриває більшість потреб більшості API у викиданні винятків. Повторне використання стандартних винятків має кілька переваг. Головна з них — ваш API стає легше вивчати й використовувати, бо він відповідає усталеним угодам, уже знайомим програмістам. Близька друга — програми, що використовують ваш API, легше читати, бо їх не захаращують незнайомі винятки. І остання (і найменш важлива): менше класів винятків — це менший обсяг пам’яті й менше часу на завантаження класів.
Найчастіше повторно використовуваний тип винятку — IllegalArgumentException. Зазвичай саме цей виняток слід кидати, коли викликач передає аргумент із недоречним значенням. Наприклад,
його слід кинути, якщо викликач передав від’ємне число в параметр, що задає кількість повторень певної дії. Інший часто повторно використовуваний
виняток — IllegalStateException. Зазвичай його кидають, якщо виклик неприпустимий через стан об’єкта-отримувача. Наприклад, його слід
кинути, якщо викликач спробував використати об’єкт до того, як його належним чином ініціалізовано.
Можна стверджувати, що кожен помилковий виклик методу зводиться до неприпустимого аргументу або стану, але для певних видів неприпустимих аргументів і станів стандартно використовують інші винятки. Якщо викликач
передає null у параметр, для якого значення null заборонені, за угодою слід кидати NullPointerException, а не IllegalArgumentException.
Аналогічно, якщо викликач передає значення поза діапазоном у параметр, що є індексом у послідовності, слід кидати IndexOutOfBoundsException, а не IllegalArgumentException.
Не використовуйте повторно Exception, RuntimeException, Throwable чи Error напряму. Ставтеся до цих класів так, ніби вони абстрактні. Хоча RuntimeException можна використати, якщо ви хочете
перетворити перевірюваний виняток на виняток часу виконання й справді знаєте, що робите.
33. Не ігноруйте винятки
// Empty catch block ignores exception - Highly suspect!
try {
...
} catch (SomeException e) {
}
Порожній блок catch зводить нанівець саму ідею винятків, яка полягає в тому, щоб змусити вас обробляти виняткові ситуації. Ігнорувати виняток — те саме, що ігнорувати пожежну сигналізацію, — та ще й вимкнути її, щоб ніхто інший не міг перевірити, чи справді горить.