Проверка проекта CDK с помощью статического анализатора IntelliJ IDEA

в 9:45, , рубрики: intellij idea, java, баги, Блог компании JetBrains, проверка кода, Программирование, статический анализ, Тестирование IT-систем

Я решил потестировать статический анализатор Java-кода IntelliJ IDEA и с его помощью проверил проект The Chemistry Development Kit. Здесь я приведу некоторые ошибки, которые я нашёл. Думаю, что часть из них характерна для Java-программ в целом, поэтому могут быть интересны.

The Chemistry Development Kit — это Java-библиотека с открытыми исходниками для решения задач хемоинформатики и биоинформатики. Когда я занимался биоинформатикой, мы активно её использовали. Проект разрабатывается уже более 20 лет, у него десятки авторов, и качество кода там очень неровное. Тем не менее, в проекте имеются юнит-тесты, а в pom.xml прописана интеграция с анализатором покрытия JaCoCo. Вдобавок там настроены плагины целых трёх статических анализаторов: FindBugs, PMD, Checkstyle. Тем интереснее проверить, какие же предупреждения остаются.

Статический анализатор Java-кода, встроенный в IntelliJ IDEA, не уступает специализированным инструментам статического анализа, а в чём-то и превосходит их. Кроме того, практически все возможности статического анализа доступны в Community Edition — бесплатной версии IDE с открытым исходным кодом. В частности, бесплатная версия выдаёт все предупреждения, описанные в этой статье.

По умолчанию статический анализ проводится постоянно в режиме редактирования кода, поэтому если вы пишете код в IntelliJ IDEA, то множество ошибок вы исправите буквально за секунды после того как их допустили, даже перед запуском тестов. Можно проверить и весь проект или его часть в пакетном режиме с помощью меню Analyze | Inspect Code либо запустить отдельную инспекцию с помощью Analyze | Run Inspection by Name. В этом случае становятся доступны некоторые инспекции, которые из-за трудоёмкости не работают в режиме редактирования. Впрочем, таких инспекций немного.

Множество инспекций IntelliJ IDEA сообщают не о баге, а скорее о неаккуратности в коде или предлагают более идиоматичную, красивую или быструю альтернативу. Это полезно, когда вы постоянно работаете в IDE. Однако в моём случае лучше начать с тех сообщений, которые предупреждают о реальных багах. В основном, интересна категория Java | Probable Bugs, хотя есть и другие категории, в которых стоит порыться, например, Numeric Issues.

Я расскажу только о некоторых интересных предупреждениях.

1. Унарный плюс

Унарных плюсов набралось аж 66 в проекте. Писать +1 вместо просто 1 иногда хочется для красоты. Однако в некоторых случаях унарный плюс вылезает, если вместо += написали =+:

int totalCharge1 = 0;
while (atoms1.hasNext()) {
  totalCharge1 = +((IAtom) atoms1.next()).getFormalCharge();
}
Iterator<IAtom> atoms2 = products.atoms().iterator();
int totalCharge2 = 0;
while (atoms2.hasNext()) {
  totalCharge2 = +((IAtom) atoms2.next()).getFormalCharge();
}

Очевидная опечатка, в результате которой игнорируются все итерации цикла кроме последней. Может показаться странным, что написано не «пробел равно плюс равно», а «пробел равно пробел плюс». Однако странность исчезает, если покопаться в истории. Изначально «равно» и «плюс» действительно были рядом, но в 2008 году прошлись автоматическим форматтером, и код изменился. Тут, кстати, мораль для статических анализаторов: выдавать предупреждения на основании странного форматирования разумно, но если код подвергается автоматическому форматированию, предупреждения исчезнут, а баги останутся.

2. Целочисленное деление с приведением к дробному

Довольно обидная ошибка, но статические анализаторы её хорошо находят. Вот пример:

angle = 1 / 180 * Math.PI;

К сожалению, угол получился вовсе не один градус, а ноль. Похожая ошибка:

Integer c1 = features1.get(key);
Integer c2 = features2.get(key);

c1 = c1 == null ? 0 : c1;
c2 = c2 == null ? 0 : c2;
sum += 1.0 - Math.abs(c1 - c2) / (c1 + c2); // integer division in floating-point context

Похоже, оба числа c1 и c2 неотрицательные, а, значит, модуль разности никогда не превысит сумму. Поэтому результат будет 0, если оба числа ненулевые, либо 1, если одно из них 0.

3. Вызов Class.getClass()

Иногда люди вызывают метод getClass() у объекта типа Class. В результате получается опять же объект типа Class с константным значением Class.class. Обычно это ошибка: getClass() вызывать не надо. Например, вот:

public <T extends ICDKObject> T ofClass(Class<T> intf, Object... objects) {
    try {
        if (!intf.isInterface()) 
            throw new IllegalArgumentException("expected interface, got " + 
                                                intf.getClass());
    ...

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

4. Вызов toString() на массиве

Это классика жанра: toString() для массивов не переопределён, и его результат довольно бесполезен. Обычно такое можно встретить в диагностических сообщениях.

int[]        dim             = {0, 0, 0};
...
return "Dim:" + dim + " SizeX:" + grid.length + " SizeY:" + grid[0].length + " SizeZ:"...

Глазами проблему заметить сложно, потому что здесь dim.toString() неявный, но конкатенация строк делегирует к нему. Сразу же предлагается исправление — обернуть в Arrays.toString(dim).

5. Коллекция читается, но не заполняется

Такое тоже нередко встречается в кодовой базе, не проходящей постоянную проверку статическим анализатором. Вот простой пример:

final Set<IBond> bondsToHydrogens = new HashSet<IBond>();
// ... 220 строк логики, но bondsToHydrogens нигде не заполняется!
for (IBond bondToHydrogen : bondsToHydrogens) // в цикл не зайдём
  sgroup.removeBond(bondToHydrogen);

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

6. Наоборот: заполняем, но не читаем

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

int[] tmp = new int[trueBits.length - 1];
System.arraycopy(trueBits, 0, tmp, 0, i);
System.arraycopy(trueBits, i + 1, tmp, i, trueBits.length - i - 1);

Инспекция знает, что третий аргумент метода arraycopy используется только для записи массива, а после этого массив никак не используется. Судя по логике кода, пропущена строка trueBits = tmp;.

7. Сравнение Integer по ==

Это коварный баг, потому что малые значения объектов Integer кэшируются, и всё может неплохо работать, пока в один прекрасный день число не превысит 127. Такая проблема может совсем не бросаться в глаза:

for (int a = 0; a < cliqueSize; a++) {
  for (int b = 0; b < vecSize; b += 3) {
    if (cliqueList.get(a) == compGraphNodes.get(b + 2)) {
      cliqueMapping.add(compGraphNodes.get(b));
      cliqueMapping.add(compGraphNodes.get(b + 1));
    }
  }
}

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

8. Дубликат в Map

В этой инспекции картинка стоит тысячи слов. Видите ошибку?
Проверка проекта CDK с помощью статического анализатора IntelliJ IDEA - 1

А так лучше?

Проверка проекта CDK с помощью статического анализатора IntelliJ IDEA - 2

9. Не используется результат метода

Результат некоторых методов глупо не использовать, о чём IDEA с готовностью сообщает:

currentChars.trim();

Вероятно, имелось в виду currentChars = currentChars.trim();. Так как строки в Java неизменяемые, если результат не переприсвоить, ничего не произойдёт. Встречается ещё, например, str.substring(2).

Кстати, это довольно сложная инспекция. Помимо заранее подготовленного списка методов мы иногда пытаемся автоматически определять методы, результат которых использовать стоит. Тут требуется межпроцедурный анализ, причём как по исходному тексту, так и по байткоду библиотек. И всё это делается на лету в процессе редактирования кода!

10. Недостижимые ветки switch

// if character is out of scope don't
if (c > 128)
    return 0;
switch (c) {
    case 'u002d': // hyphen
    case 'u2012': // figure dash
    case 'u2013': // en-dash
    case 'u2014': // em-dash
    case 'u2212': // minus
        return '-'; // 002d
    default:
        return c;
}

Так как мы исключили символы с кодом больше 128, ветки u2012-u2212 недостижимы. Кажется, исключать не стоило.

11. Недостижимое условие

Совершенно чудесная проблема в цепочке условий:

if (oxNum == 0) {
    if (hybrid.equals("sp3")) { ... } else 
    if (hybrid.equals("sp2")) return 47;
} else if (oxNum == 1 && hybrid.equals("sp3"))
    return 47;
else if ((oxNum == 2 && hybrid.equals("sp3")) 
        || (oxNum == 1 && hybrid.equals("sp2"))
        || (oxNum == 0 && hybrid.equals("sp"))) // вот это вот недостижимо
    return 48;
else if ((oxNum == 3 && hybrid.equals("sp3")) 
        || (oxNum >= 2 && hybrid.equals("sp2"))
        || (oxNum >= 1 && hybrid.equals("sp"))) return 49;

В сложной условной логике такое встречается нередко: мы проверяем условие, которое не может быть истинным, потому что его фрагмент уже проверили выше. Здесь мы имеем отдельную ветку oxNum == 0, а иначе проверяем oxNum == 0 && hybrid.equals("sp"), чего, конечно, быть не может.

12. Пишем в массив нулевой длины

Иногда IntelliJ IDEA заметит, если вы пишете в массив за пределами его размера:

Point3d points[] = new Point3d[0]; // завели массив на 0 элементов
if (nwanted == 1) {
    points = new Point3d[1];
    points[0] = new Point3d(aPoint);
    points[0].add(new Vector3d(length, 0.0, 0.0));
} else if (nwanted == 2) {
    // а тут пытаемся в него писать — исключение неминуемо
    points[0] = new Point3d(aPoint);
    points[0].add(new Vector3d(length, 0.0, 0.0));
    points[1] = new Point3d(aPoint);
    points[1].add(new Vector3d(-length, 0.0, 0.0));
}

13. Проверка длины после обращения по индексу

Ещё одна частая проблема с порядком действий и опять во время обработки ошибок:

public void setParameters(Object[] params) throws CDKException {
    if (params.length > 1) {
        throw new CDKException("...");
    }
    if (!(params[0] instanceof Integer)) { // раз прочитали нулевой элемент
        throw new CDKException("The parameter must be of type Integer");
    }
    if (params.length == 0) return; // то длина точно не нуль
    maxIterations = (Integer) params[0];
}

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

14. Проверка на null после обращения

И опять нарушен порядок действий, на этот раз с null'ом:

while (!line.startsWith("frame:") && input.ready() && line != null) {
    line = input.readLine();
    logger.debug(lineNumber++ + ": ", line);
}

IDEA пишет, что line != null всегда истинно. Случается, что проверка действительно избыточна, но здесь код выглядит так, будто null действительно может быть.

15. Дизъюнкция вместо конъюнкции

Люди часто путают логические операторы И и ИЛИ. Проект CDK — не исключение:

if (rStereo != 4 || pStereo != 4 || rStereo != 3 || pStereo != 3) { ... }

Чему бы ни были равны rStereo и pStereo, понятно, что они не могут быть равны четвёрке и тройке одновременно, поэтому такое условие всегда истинно.

16. И снова дизъюнкция вместо конъюнкции

Похожая ошибка, но ловится другим сообщением:

if (getFirstMapping() != null || !getFirstMapping().isEmpty()) { ... }

В правую часть мы можем попасть только если getFirstMapping() вернул null, но в этом случае нам гарантирован NullPointerException, о чём IDEA и предупреждает. Кстати, тут мы полагаемся на стабильность результатов метода getFirstMapping(). Иногда мы используем эвристики, но здесь стабильность прямо проанализирована. Так как класс финальный, метод не может быть переопределён. IDEA проверяет его тело return firstSolution.isEmpty() ? null : firstSolution и определяет, что стабильность сводится к стабильности метода Map#isEmpty, который заранее проаннотирован как стабильный.

17. Иерархия интерфейсов и instanceof

Когда проверяете объект на принадлежность какому-либо интерфейсу, не забывайте, что интерфейсы могут наследовать друг друга:

if (object instanceof IAtomContainer) {
    root = convertor.cdkAtomContainerToCMLMolecule((IAtomContainer) object);
} else if (object instanceof ICrystal) {
    root = convertor.cdkCrystalToCMLMolecule((ICrystal) object);
} ...

Интерфейс ICrystal расширяет интерфейс IAtomContainer, поэтому вторая ветка заведомо недостижима: если сюда придёт кристалл, он попадёт в первую ветку.

18. Обход пустого списка

Вероятно, автор этого кода не очень знаком с языком Java:

List<Integer> posNumList = new ArrayList<Integer>(size);

for (int i = 0; i < posNumList.size(); i++) {
    posNumList.add(i, 0);
}

Параметр size в ArrayList указывает изначальный размер внутреннего массива. Это используется для оптимизации, чтобы уменьшить число аллокаций, если вы знаете заранее, сколько туда сложить элементов. При этом фактически элементы в списке не появляются, и метод size() возвращает 0. Поэтому следующий цикл с попыткой инициализировать элементы списка нулями совершенно бесполезен.

19. Не забываем инициализировать поля

Анализатор особым образом проверяет конструкторы, учитывая инициализаторы полей. Благодаря этому нашлась такая ошибка:

public class IMatrix {
  public double[][] realmatrix;
  public double[][] imagmatrix;
  public int        rows;
  public int        columns;

  public IMatrix(Matrix m) {
    rows = m.rows;
    columns = m.columns;
    int i, j;
    for (i = 0; i < rows; i++)
      for (j = 0; j < columns; j++) {
        realmatrix[i][j] = m.matrix[i][j]; // NullPointerException гарантирован
        imagmatrix[i][j] = 0d;
      }
  }
}

Несмотря на то что поля публичные, здесь точно никто не мог вклиниться и инициализировать их перед конструктором. Поэтому IDEA смело выдаёт предупреждение, что обращение к элементу массива вызовет NullPointerException.

20. По два раза не повторять

Повторные условия тоже случаются нередко. Вот пример:

if (commonAtomCount > vfMCSSize && commonAtomCount > vfMCSSize) {
  return true;
}

Такие баги коварны, потому что никогда не знаешь, второе условие просто лишнее или автор хотел проверить что-то другое. Если это не исправить сразу, потом может быть тяжело разобраться. Это ещё одна причина, почему статический анализ надо использовать постоянно.

Я сообщил о некоторых из этих багов в баг-трекер проекта. Любопытно, что когда авторы проекта исправили часть, они сами воспользовались анализатором IntelliJ IDEA, нашли другие проблемы, о которых я не писал, и тоже стали их исправлять. Я считаю это хорошим знаком: авторы осознали важность статического анализа.

Автор: Тагир Валеев

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js