Топ 10 ошибок в Java разработке

| May 6, 2023

Баг в программе (по мнению Kandinsky 2.1)

Введение

“Скучно было вечером, делать было нечего.” <- драйвер всех открытий чудных

Как-то раз, Я попросил нашумевший AI - ChatGPT сформировать для меня список из 10 самых распространённых ошибок, которые совершают Java разработчики. Естественно, нейронка послушно это сделала и, сегодня, предлагаю обсудить, что она нам предложила в качестве ответа на мой запрос.

Итак, 10 самых распространенных ошибок в Java программах по мнению ChatGPT:

#1 NullPointerException

NullPointerException - эта ошибка возникает, когда попытка обратиться к null ссылке. Она может возникнуть, если не был проинициализирован объект, а также в других ситуациях, когда ссылка не указывает на объект.

Довольно частая история, метод, возвращающий null. Например:

public String getString() {
    if (....) {
        return null;
    }
    ...
    return "some value";
}

и конструкции вида:

getString().equals("other");

, то есть, использование возвращаемого значения без его проверки. Это гарантировано приведёт к NullPointerException.

В Java, начиная с 8-ой версии, есть отличный механизм, помогающий справиться с этой проблемой - Optional. Этот тип был создан исключительно для того, чтобы описывать значение или его отсутствие. Как исправить пример выше? Очень просто:

public Optional<String> getString() {
    if (....) {
        return Optional.empty();
    }
    ...
    return Optional.of("some value");
}

// и использование возвращённого значения становится невозможным без его предварительной проверки (или хотя бы распаковки)

getString().get().orElse("").equals("other")

Стоит ли сопровождать абсолютно каждый метод Optional типом? Нет, ни в коем разе. В своей практике, Я придерживаюсь следующего правила:

  • публичные методы, являющиеся частью API, клиенты которого нам могут быть не известны - сопровождаются Optional в качестве возвращаемого типа
  • приватные методы возвращают обычные типы, без Optional. Приватные методы используются внутри класса, в котором они объявлены, и автору класса скорее всего хорошо известна особенность метода, возвращающего null.

#2 Не правильное использование коллекций

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

Простейший пример: Нам необходимо отобрать списки значений по какому-то ни было признаку. Конечно же, можно создать необходимое количество ArrayList‘ов по типам, и сложить значения в них. Но разработчик с более широким кругозором вспомнит, что в Java есть ещё тип, хранящий соответствие ключ-значение и называется он Map<K, V>. И, пожалуй, он больше подходит, для того, чтобы решить эту проблему.

Усложняем пример: А если нам необходимо значения в эту коллекцию писать из разных потоков? Тогда продвинутый разработчик вспомнит о существовании целого пакета java.util.concurrent, в котором, в том числе, есть потокобезопасные коллекции.

#3 Неправильное использование исключений

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

Общая рекомендация здесь будет следующая:

  • бизнесовые ошибки стоит делать проверяемыми, чтобы код Вашей бизнес-логики мог корректно реагировать на подобные ошибки и не прерывать работу пользователей
  • технические ошибки стоит делать непроверяемыми

И ещё один момент - при работе с исключениями есть подход, называемый fail-fast (дословно “падай быстро”). На мой взгляд, он прекрасно работает в сочетании с техническими ошибками.

#4 Отсутствие управления памятью

Java имеет автоматическую систему управления памятью, но это не означает, что за памятью не нужно следить. В мире микросервисов широко применяются системы мониторинга и почти всегда можно найти отдельные метрики JVM (утилизация ресурсов, в том числе - памяти). Если за ними не следить, можно легко стать читателем моей статьи про OOM 😀.

Для автоматической работы с памятью в Java применяются Garbage Collector’ы. И это первый способ для нас, как для разработчиков повлиять на работу JVM памятью. Мы можем сменить сборщик мусора или внести изменения в его настройки и настройки JVM. Больше о памяти JVM Вы сможете узнать из Введения в Java Process Memory Model.

Вторым способом повлиять на работу с памятью является тип java.lang.ref.Reference. Ссылку на документацию оставляю для самостоятельного изучения.

#5 Использование ненужных переменных

Проблему с ненужными переменными можно разделить на два типа:

  • неиспользуемая переменная
  • лишняя переменная

С неиспользуемыми переменными всё предельно просто - сама IDE их подсвечивает, как бы намекая, что найден кандидат на удаление из исходного кода. То же самое делают статические анализаторы кода типа SonarQube. Никакого зла для Java программы неиспользуемые переменные не несут. Разве что, увеличивают когнитивную нагрузку на читателя исходного кода. Да и наверняка умные компиляторы неиспользуемые переменные вырезают из программ. Так что, это не большая проблема.

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

#6 Неправильное применение equals() и hashCode()

Эти методы используются для сравнения объектов и хэширования, соответственно. Неправильное их использование может привести к неправильным результатам.

Общее правило тут довольно простое - если объекты класса используются в коллекциях, то методы equals() и hashCode() необходимо реализовать совместно. Ну а уж сравнивать ли ссылки на объекты или их параметры - зависит от задачи, которую Вы решаете. Юнит-тесты помогут проконтролировать корректность логики работы equals() и hashCode() методов.

#7 Неправильное использование final

Ключевое слово final используется для обозначения неизменяемой переменной или поля, а так же невозможности перекрытия метода или невозможности наследования класса. С ключевым словом final можно легко допустить архитектурные ошибки, в слепую запрещая наследование или перекрытие методов. Так же, довольно частая история - применение ключевого слова final с абсолютно всеми локальными переменными. Это тоже, не является проблемой функционала, но проблема с точки зрения дизайна метода.

Тут каких-бы то ни было специальных советов быть не может, кроме - перечиать документацию по использованию final.

#8 Использование неправильных модификаторов доступа

Java имеет модификаторы доступа, такие как public, protected, private и другие. Неправильное их использование может привести к несоответствующей защите данных и повреждению архитектуры программы.

Общее правило следующее - если метод можно сделать приватным, то лучше это сделать. В случае использования остальных модификаторов стоит прежде всего задать себе вопрос - “кто использует этот метод / поле / класс?” и выбрать правильный модификатор, в зависимости от ответа на этот вопрос.

Ещё одна частая проблема, которая режет глаз в коде, это добавление методов-сеттеров с модификатором доступа по умолчанию для использования в юнит тестах. Иногда этот подход используется совместно с аннотацией-оправданием com.google.common.annotations.VisibleForTesting. Ничего криминального в этом нет - логика работы программы от этого не страдает, но - это явная проблема в архитектуре - нарушается принцип инкапсуляции. Это повод пересмотреть подход либо к проектированию классов, либо к созданию тестов.

#9 Использование неправильных циклов

Java имеет различные циклы, такие как for, while, do-while. Использование неправильных циклов может привести к неправильным результатам и бесконечным циклам. И если перепутанные циклы (использование do-while вместо while) легко найти, то, проблемы с не верным вычислением индексов или условий выхода из цикла, действительно, могут представлять собой серьёзные ошибки.

Чтобы не путаться с while / do-while циклами, достаточно запомнить:

while ([условие]) {
    // код будет выполнен ПОСЛЕ проверки условия и только в случае успешного его выполнения    
} 

do {
    // код будет выполнен ДО проверки условия, то есть, даже если условие будет не выполнено
} while ([условие])

А для борьбы с логическими ошибками в циклах помогут юнит-тесты.

#10 Не использование try-with-resources

try-with-resources - это конструкция языка, которая позволяет автоматически закрывать ресурсы по выходу из try-блока. Выглядит это следующим образом:

try (FileReader fr = new FileReader(path); BufferedReader br = new BufferedReader(fr)) {
    return br.readLine();
}

Саму конструкцию ввели в язык ещё с Java 7 для того, чтобы решить частую проблему не закрытых io-стримов. И, видимо, проблема была действительно частой, раз сам язык менялся. Однако, далеко не все приноровились использовать try-with-resources в своих программах и, таким образом, продолжили получать не закрытые стримы и ресурсы.

Чтобы не связываться с этой проблемой, достаточно запомнить - любой тип, реализующий интерфейс java.lang.AutoCloseable, может быть использован в try-with-resources блоке.

Больше о конструкции - 🇬🇧 The try-with-resources Statement

Заключительная часть

Вот такой вот список получился у ChatGPT. Полный ли это список? Действительно ли приведены самые часто совершаемые ошибки? Ответ на оба вопроса - скорее нет. На мой профессиональный взгляд, в списке, действительно присутствуют ошибки, которые совершаются довольно часто. Однако, с большинством позиций в топе, Я, скорее не согласен. Как-нибудь расскажу о своём топе 10 ошибок, которые Я встречаю в Java программах.

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

Список материалов

  • ChatGPT от OpenAI. Именно эта нейронная сеть использовалась при для генерации топа ошибок при подготовке материала для статьи