Статический анализ кода ценен тем, что помогает выявлять ошибки на раннем этапе. Однако он не всесилен и есть ряд ограничений, которые не позволяют с его помощью находить любые разновидности ошибок. Давайте разберёмся в этом вопросе.
Статический анализатор кода принимает на вход исходный код программы и выдаёт на выходе предупреждения, указывающие на аномалии в коде. Разработчик знакомится с выданными предупреждениями и принимает решение, где необходимо что-то исправить в коде, а где на самом деле всё хорошо и правки не требуются.
Инструменты статического анализа применяют достаточно сложные технологии для обнаружения ошибок: анализ потока данных, символьное выполнение программы, межпроцедурный анализ и так далее. Более подробное их описание вы можете найти в статье "Технологии статического анализа кода PVS-Studio".
Однако, статические анализаторы кода имеют две слабые стороны:
Перечисленные недостатки методологии статического анализа на самом деле неизбежны. Сейчас мы разберём две причины, из-за которых они возникают.
Рассматривая только код, часто в принципе невозможно сказать, есть в нём ошибка или нет. Это не сможет сделать не только анализатор, но и человек. Чтобы понять, содержит код ошибку или нет, нужно знать, что хотел написать автор кода. Другими словами, нужно знать, какое поведение программы ожидается.
Рассмотрим простейший абстрактный пример:
double Calc(double a, double b)
{
return a * sin(b);
}
Есть здесь ошибка? Неизвестно. Возможно, автор ошибся и на самом деле в формуле должна использоваться функция для вычисления косинуса, а не синуса.
В других случаях наоборот – анализатор зря выдаёт предупреждение. Например, он может выдать предупреждение на такой код:
int Foo(int x, int y)
{
if (x == 0)
x++; y++;
return x * y;
}
Этот код выглядит очень подозрительно. Высока вероятность, что здесь забыты фигурные скобки, и анализатор справедливо обращает внимание на этот код. Так писать не нужно, но нельзя однозначно утверждать, что код работает не так, как требуется. Только автор кода может сказать, должен ли этот код выглядеть так:
int Foo(int x, int y)
{
if (x == 0)
{
x++;
y++;
}
return x * y;
}
Или так:
int Foo(int x, int y)
{
if (x == 0)
x++;
y++;
return x * y;
}
Как мы выяснили, одно из ограничений статического анализа кода вытекает из недостатка информации о том, как на самом деле должна функционировать программа. Способы компенсации этого недостатка:
Возможности статического анализатора упираются в "проблему остановки". Однако даже не рассматривая крайние случаи, статические анализаторы сталкиваются с ограничением вычислительных мощностей.
Рассмотрим тело простой функции:
void foo(int *p)
{
*p = 1;
}
Рассматривая только эту функцию, невозможно сказать, может или нет здесь произойти разыменование нулевого указателя.
Здесь начинаются поиски компромисса: выдать предупреждение, которое с большой вероятностью окажется ложным или промолчать и не сообщить о возможной проблеме.
Пойдя по первому пути, можно ругаться на этот код функции foo и стимулировать программистов всегда на всякий случай писать проверки:
void foo(int *p)
{
if (p != NULL)
{
*p = 1;
}
}
Однако это тупиковый путь, так как анализатор начинает выдавать большое количество малополезных предупреждений. Вместо нахождения реальных ошибок, он заставляет разработчика "бороться с предупреждениями".
Полезнее отследить, действительно ли в функцию foo где-то может быть передан нулевой указатель. Например, возможно, функция используется только в одном месте следующим образом:
void X(int *p)
{
if (p == NULL)
return;
foo(p);
}
Тогда точно никакой ошибки нет. Предупреждения выдавать не нужно. Но ведь это очень простой случай. На практике, функция может вызываться из разных мест, а указатель пробрасываться через множество вызовов.
Ещё сложнее отследить возможные значения глобальных переменных или элементов массивов. В этом случае статический анализатор в каком-то смысле начинает эмулировать выполнение кода программы, чтобы понять возможные значения переменных. И чем глубже и точнее он это делает, тем больше ему нужно памяти и времени. Статический анализатор не знает, какие могут быть входные данные, поэтому в идеале он должен перебрать все возможные варианты. Если перед нами большой программный проект, то сделать он это может только поглотив какое-то невероятное количество памяти и работая тысячелетия.
Естественно, никому не нужен анализатор, который будет проверять код дольше, чем потом люди будут пользоваться полученной программой :). Поэтому приходится идти на компромисс между точностью анализа и временем работы. На практике, к анализаторам предъявляется негласное требование, чтобы они проверяли проект за несколько часов. Например, ночью попутно со сборкой ночного билда.
Кстати, анализ входных данных порождает отдельную тему, связанную с выявлением потенциальных уязвимостей. Анализаторы применят для этого специальную технологию, называемую Taint-анализом (taint checking).
Компенсировать описанные вычислительные ограничения можно за счёт применения других методов, например, динамического анализа кода. Причём нет противопоставления "статический анализ vs динамический" или "статический анализ vs юнит-тесты". У каждой методологии есть свои сильные и слабые стороны. Высокое качество и надёжность кода достигается за счёт их совместного применения.
Статические анализаторы не всесильны, но являются хорошими помощниками. Они могут выявить многие ошибки, опечатки и опасные конструкции ещё до этапа обзора кода. Намного полезнее, чтобы участники обзора кода сосредоточились на обсуждении высокоуровневых недочётов и передаче знаний, чем выискивании, нет ли опечатки в скучном операторе сравнения. Тем более, как показывает наша практика, всё равно людям очень сложно заметить такие ошибки: Зло живёт в функциях сравнения. Пусть эту скучную работу сделает статический анализатор кода.