Best Practices из «Effective Java (3rd Edition)»

Выдержки из книги Joshua Bloch «Effective Java (3rd Edition) »

1. Возвращайте пустые коллекции или массивы, а не null

Если метод возвращает Set, не пишите return null; для пустого результата — пишите return Set.of();.

2. Рассмотрите статические фабричные методы вместо конструкторов

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

Они не всегда лучше, но в некоторых случаях дают массу преимуществ:

Недостатки:

3. Используйте Builder, когда у конструктора много параметров

У статических фабрик и конструкторов есть общее ограничение: они плохо масштабируются при большом количестве необязательных параметров. В некоторых проектах, над которыми я работал, это било очень больно.

4. Обеспечивайте свойство синглтона приватным конструктором или типом enum

Всё, чему не нужно несколько экземпляров, должно создаваться только один раз — будь то через @ApplicationScoped или с помощью другого паттерна синглтона.

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. Избегайте финализаторов и очистителей

Финализаторы непредсказуемы, часто опасны и, как правило, не нужны. Их использование может приводить к нестабильному поведению, низкой производительности и проблемам с переносимостью.

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

Безусловно, сторонники жёсткой линии правы, когда речь идёт о публичных классах: если класс доступен за пределами своего пакета, предоставьте методы доступа, чтобы сохранить возможность менять внутреннее представление класса. Если публичный класс открывает свои поля данных, надежду изменить его представление можно оставить, ведь клиентский код может быть разбросан повсюду.

Однако если класс доступен только в пределах пакета (package-private) или является приватным вложенным классом, нет ничего принципиально плохого в том, чтобы открыть его поля данных, — при условии, что они адекватно описывают абстракцию, которую предоставляет класс.

12. Минимизируйте изменяемость

Неизменяемый класс — это просто класс, экземпляры которого нельзя изменить. Вся информация, содержащаяся в экземпляре, зафиксирована на всё время жизни объекта, поэтому никаких изменений наблюдать невозможно. Библиотеки платформы Java содержат много неизменяемых классов, в том числе String, классы-обёртки примитивов, а также BigInteger и BigDecimal. На то есть много веских причин: неизменяемые классы проще проектировать, реализовывать и использовать, чем изменяемые. Они менее подвержены ошибкам и более безопасны. Чтобы сделать класс неизменяемым, следуйте пяти правилам:

  1. Не предоставляйте методы, которые изменяют состояние объекта (так называемые мутаторы).
  2. Гарантируйте, что класс нельзя расширить. Это не даёт небрежным или злонамеренным подклассам нарушить неизменяемость класса, ведя себя так, будто состояние объекта изменилось. Обычно наследование запрещают, например, объявив класс final.
  3. Сделайте все поля final. Так вы ясно выражаете своё намерение, а система следит за его соблюдением. Кроме того, это необходимо для корректного поведения, если ссылка на только что созданный экземпляр передаётся из одного потока в другой без синхронизации, как описано в модели памяти.
  4. Сделайте все поля private. Это не позволит клиентам получить доступ к изменяемым объектам, на которые ссылаются поля, и изменять эти объекты напрямую. Хотя технически неизменяемый класс может иметь public final-поля с примитивными значениями или ссылками на неизменяемые объекты, делать так не рекомендуется: это исключает изменение внутреннего представления в последующих версиях.
  5. Обеспечьте монопольный доступ ко всем изменяемым компонентам. Если в вашем классе есть поля, ссылающиеся на изменяемые объекты, позаботьтесь о том, чтобы клиенты класса не могли получить ссылки на эти объекты. Никогда не инициализируйте такое поле ссылкой на объект, полученный от клиента, и не возвращайте это поле из метода доступа. Делайте защитные копии в конструкторах, методах доступа и методах readObject.

13. Предпочитайте композицию наследованию

Наследование — мощный способ повторно использовать код, но не всегда лучший инструмент для этой задачи. При неправильном применении оно делает ПО хрупким. Наследование безопасно внутри пакета, где реализации подкласса и суперкласса находятся под контролем одних и тех же программистов. Безопасно оно и при расширении классов, специально спроектированных и задокументированных для расширения. А вот наследование от обычных конкретных классов через границы пакетов опасно.

В отличие от вызова методов, наследование нарушает инкапсуляцию. Можно подумать, что расширять класс безопасно, если вы лишь добавляете новые методы и не переопределяете существующие. Такое расширение действительно гораздо безопаснее, но и оно не лишено риска. Если в следующей версии у суперкласса появится новый метод, а вы, на свою беду, уже дали методу подкласса ту же сигнатуру, но другой возвращаемый тип, ваш подкласс перестанет компилироваться. Если же метод подкласса имеет ту же сигнатуру и тот же возвращаемый тип, что и новый метод суперкласса, то теперь вы его переопределяете — и сталкиваетесь с проблемами, описанными ранее. Более того, сомнительно, что ваш метод будет выполнять контракт нового метода суперкласса: когда вы писали метод подкласса, этого контракта ещё не существовало.

К счастью, есть способ избежать всех описанных выше проблем. Вместо того чтобы расширять существующий класс, добавьте в новый класс приватное поле, ссылающееся на экземпляр существующего класса. Такой подход называется композицией, потому что существующий класс становится компонентом нового. Каждый метод экземпляра в новом классе вызывает соответствующий метод содержащегося в нём экземпляра существующего класса и возвращает результат. Это называется переадресацией (forwarding), а методы нового класса — методами переадресации. Получившийся класс будет надёжным как скала и не будет зависеть от деталей реализации существующего класса. Даже добавление новых методов в существующий класс никак не скажется на новом классе.

14. Предпочитайте интерфейсы абстрактным классам

В Java есть два механизма для определения типа, допускающего несколько реализаций: интерфейсы и абстрактные классы. С тех пор как в Java 8 у интерфейсов появились методы по умолчанию, оба механизма позволяют предоставлять реализации некоторых методов экземпляра.

Главное различие в том, что для реализации типа, определённого абстрактным классом, класс должен быть подклассом этого абстрактного класса. Поскольку Java допускает только одиночное наследование, это ограничение серьёзно сужает применение абстрактных классов для определения типов. Реализовать интерфейс может любой класс, который определяет все необходимые методы и соблюдает общий контракт, независимо от того, где этот класс находится в иерархии классов. Существующие классы легко доработать так, чтобы они реализовывали новый интерфейс.

15. Дженерики: не используйте сырые типы

Вы обнаружите ошибку только во время выполнения, спустя долгое время после того, как она произошла, и в коде, который может находиться далеко от кода с ошибкой. Когда вы увидите ClassCastException, придётся прочёсывать кодовую базу в поисках вызова метода, который положил монету в коллекцию марок. Компилятор здесь не поможет.

16. Дженерики: устраняйте предупреждения 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 есть устоявшийся набор соглашений об именовании, многие из которых содержатся в спецификации языка 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 сводит на нет смысл исключений, а он в том, чтобы заставить вас обрабатывать исключительные ситуации. Игнорировать исключение — всё равно что игнорировать пожарную сигнализацию, да ещё и отключить её, чтобы больше никто не смог проверить, нет ли настоящего пожара.