Toyota ITC Benchmark – это набор синтетических тестов для C и C++, состоящий приблизительно из 650 примеров и предназначенный для тестирования анализаторов кода. Данная статья ответит на вопрос: "Насколько хорошо статический анализатор PVS-Studio покрывает Toyota ITC Benchmark?".
Введение
Мы уже тестировали PVS-Studio на Toyota ITC Benchmark около 5 лет назад. Тогда всё началось с того, что Билл Торпи написал в своем блоге заметку "Even Mo' Static". Билл протестировал наш анализатор и анализатор Cppcheck на Toyota ITC Benchmark, сравнил полученные результаты и пришел к выводу, что анализаторы примерно равны в своих возможностях.
Нам не понравилось такое утверждение, потому что мы считали (и считаем так же сейчас), что PVS-Studio значительно мощнее Cppcheck. Поэтому мой коллега — Андрей Карпов — провёл своё исследование и написал про это статью "Почему я не люблю синтетические тесты".
После этого исследования тема Toyota ITC Benchmark для нас была закрыта. Однако не так давно нам написал пользователь с вопросом: "А какое покрытие у PVS-Studio бенчмарка Toyota ITС?". Его интересовали цифры, а не философские рассуждения коллеги про то, что синтетика – это зло. Поэтому мы провели новое исследование, и я расскажу, какие цифры и как мы получили.
Как считать покрытие бенчмарков?
Прежде всего нужно выяснить, что мы должны посчитать. Для этого разберёмся в структуре бенчмарка Toyota ITC. Рассматривать будем версию бенчмарка с GitHub.
Бенчмарк включает 51 правило. Под правилом понимаем типовую ошибку, которая может быть допущена в C и/или C++ проекте. Например, в Toyota ITC есть правило "conflicting cond". Это правило означает, что в коде не должно быть противоречащих друг другу условий. Так, условие (a == 0) && (a == 1) состоит из двух противоречащих друг другу условий (a == 0) и (a == 1), а значит, содержит ошибку.
Для каждого такого правила в Toyota ITC Benchmark есть два файла с тестами. Первый называется "W_{название правила}.c/cpp"и содержит тесты, на которых анализатор должен выдать предупреждение. Второй называется "Wo_{название правила}.cpp" и содержит тесты, на которых анализатор не должен срабатывать. Тестом является функция, в которой допущена или, наоборот, не допущена типовая ошибка. В коде такой функции будет комментарий о том, где должно или не должно быть срабатывание анализатора.
Конечно, можно просто в лоб посчитать количество пройденных анализатором тестов, учитывая их тип. Т.е. среди тестов из W-файлов считать пройденными те, на которых анализатор выдал срабатывания. Среди тестов из Wo-файлов считать пройденными те, на которых анализатор не выдал срабатывания. Затем поделить полученное количество пройденных тестов на их общее количество и назвать полученный процент покрытия бенчмарка. Однако у такого подхода есть существенный недостаток: разные правила имеют разное количество тестов. Например, правило "dead_lock" содержит 10 тестов, а правило "overrun_st" – 108. Означает ли это, что нахождение возможных выходов за границу массива в 10 раз важнее определения потенциальных dead lock в программе? Думаю, нет.
Поэтому мы выбрали другой подход. Для каждого правила отдельно считаем количество пройденных тестов. Затем делим полученное число на общее количество тестов для правила. Если итоговый процент выше заранее установленного порогового значения, то считаем правило пройденным, иначе — нет. После этого подсчитываем количество пройденных правил, делим это число на общее количество правил (51) и полученный процент считаем итоговым покрытием бенчмарка.
Рассмотрим плюсы такого подхода. Во-первых, все правила считаются равнозначными. Поскольку пороговое значение устанавливается для всех правил единым, то для прохождения правила с бОльшим количеством тестов нужно бОльшее количество пройденных тестов этого правила. Не получится добиться хорошей статистики, поддержав пару правил с большим количеством тестов и вовсе не работая с правилами, для которых тестов мало.
Во-вторых, такой подход предоставляет гибкость в выборе порогового процента, необходимого для поддержания правила. Кто-то считает, что правило поддержано, только если все тесты пройдены, а для кого-то достаточно прохождения 75%. И для первых, и для вторых соответствующие проценты покрытия могут быть получены.
Минусы подхода вытекают из его плюсов. Во-первых, такой подход не годится, если мы всё-таки не считаем правила равнозначными. В таком случае придётся устанавливать вес для каждого правила и учитывать его при подсчёте итогового покрытия. Во-вторых, в зависимости от порогового значения, необходимого для поддержания правила, будут получаться разные проценты покрытия. А значит, говорить о покрытии X% без упоминания порогового значения в Y% уже не получится, что может быть не совсем удобно. Пришлось написать целый раздел в статье, чтобы объяснить, почему получилось несколько различных значений покрытия.
Что же получилось?
В качестве пороговых значений я выбрал 3 числа: 50%, 75% и 100%.
PVS-Studio поддерживает Toyota ITC Benchmark на 12% при пороговом значении в 100%, на 27% при пороге 75% и на 39% при пороге в 50%.
Многие тесты были не пройдены из-за специальных исключений в нашем анализаторе. Эти исключения имеют смысл при анализе реальных проектов и позволяют уменьшить количество ложных срабатываний. Теоретически можно сделать специальный режим анализатора, в котором такие исключения отключены. Тогда покрытие Toyota ITC Benchmark увеличится. Мы не видим смысла в таком режиме для большинства пользователей. Однако такой режим может оказаться полезен при анализе кода со специфичными требованиями, например в автомобильной индустрии. Если вам интересен подобный режим анализатора, а также тема бенчмарка Toyota ITC в целом и вы хотите это обсудить – напишите нам.
Ниже я приведу несколько примеров из тестов, которые помогут понять, почему получились именно такие цифры.
Dead code (на самом деле, unreachable code)
Есть в Toyota ITC Benchmark правило "dead_code". Это правило было первым, которое спровоцировало мой facepalm. Дело в том, что есть два понятия: dead code и unreachable code. Dead code означает фрагмент кода, который может исполняться, но его устранение не меняет наблюдаемого поведения программы. Простейший пример dead code:
int i;
i = 5;
i = 10;
Здесь присваивание i = 5; является dead code.
Unreachable code означает фрагмент кода, который никогда не будет исполняться. Пример:
bool cond = false;
int i;
if (cond)
{
i = 5;
}
Здесь присваивание i = 5; является unreachable code.
Так вот, все тесты на правило с названием "dead_code" в действительности оказались тестами на unreachable code!
В PVS-Studio нет конкретного правила, которое "отлавливало" бы все вариации unreachable code. Есть V779, которая предупреждает, что код, написанный после вызова noreturn функции, является недостижимым. Однако это только один из способов получить недостижимый код. Наличие в программе unreachable code – это результат некоторой ошибки, а не сама ошибка. Это симптом, а не причина. Мы считаем, что лучше указывать программисту на причину ошибки. Для этого у нас есть ряд диагностик, предупреждающих об ошибках, которые могут привести к появлению в программе unreachable codе. В случае Toyota ITC сработала V547. Разберём на примере:
void dead_code_002 ()
{
int flag = 0;
int a = 0;
int ret;
if (flag)
{
a ++; /*Tool should detect this line as error*/ /*ERROR:Dead Code*/
}
ret = a;
sink = ret;
}
Предупреждение PVS-Studio: V547 Expression 'flag' is always false.
Здесь переменная flag имеет значение false, поэтому конструкция a++; является недостижимой. Анализатор предупреждает о том, что условие в if всегда ложно. Несмотря на то, что PVS-Studio не выдал предупреждения на строку a++;, я засчитал этот тест как пройденный.
Интересно то, что подобный паттерн встречается в реальных проектах. Только в них присваивание и использование переменной, как правило, разделено сотнями строк кода, и найти подобную ошибку без использования анализатора оказывается крайне сложно.
А в следующем примере промолчала и диагностика V547.
void dead_code_001 ()
{
int a = 0;
int ret;
if (0)
{
a ++; /*Tool should detect this line as error*/ /*ERROR:Dead Code*/
}
ret = a;
sink = ret;
}
Дело в том, что в V547 специально сделано исключение для случаев вида if(0), while(1). Мы полагаем, что если программист написал подобный код, то он осознаёт то, что делает, и нет необходимости дополнительно предупреждать его о подозрительном условии. Поэтому на этом примере PVS-Studio не выдает предупреждение. Этот тест безусловно синтетический и, в отличие от предыдущего, не имеющий связи с реальностью, я не засчитал как пройденный.
Примечание. А зачем вообще программисты в реальных проектах пишут if (0)? Всё просто. Это известный паттерн комментирования кода, при котором код хоть и не выполняется, но продолжает компилироваться. Это позволяет в случае необходимости вернуть код "в строй" и при этом заранее быть уверенным, что он успешно скомпилируется. Ещё один более редкий приём: в режиме отладки вручную переместить точку выполнения на этот код, чтобы выполнить какое-то специфическое действие, помогающее отладке. Например, распечатать какие-то значения. В то же время другая, тоже странная на первый взгляд конструкция "while (1)" встречается в реальных проектах в виде следующего паттерна:
while (1)
{
doSomething();
if(condition) break;
doSomethingElse();
}
Это рабочий способ писать код, и нет смысла выдавать на нём предупреждение анализатора.
Null pointer
Это ещё одно правило, на котором PVS-Studio также не смог получить 100% пройденных тестов.
На "null pointer" анализатор не прошел несколько тестов из-за исключения для V522.
Примеры для этого правила уже подробно разобрал Андрей Карпов в своей статье.
Free null pointer
Ещё одним правилом, которое анализатор не смог покрыть на 100%, стало правило "free null pointer". Это правило запрещает передавать в функцию free нулевой указатель.
Заметим, что сам по себе вызов функции free на нулевом указателе не является ошибкой. В таком случае функция просто ничего не делает.
Тем не менее, мы согласны с создателями Toyota ITC Benchmark и тоже считаем, что в некоторых случаях передача нулевого указателя может быть ошибкой. Рассмотрим тестовый пример из бенчмарка:
void free_null_pointer_001 ()
{
char* buf= NULL;
free(buf);/* Tool should detect this line as error */
/*ERROR:Freeing a NULL pointer*/
buf = NULL;
}
Предупреждение PVS-Studio: V575 The null pointer is passed into 'free' function. Inspect the first argument.
В этом тесте анализатор делает ровно то, что от него и ожидает тестовый пример – предупреждает, что в функцию free передается нулевой указатель buf.
Всё не так радужно в другом тестовом примере:
int *free_null_pointer_002_gbl_ptr = NULL;
void free_null_pointer_002 ()
{
int a = 20;
if (a > 0)
{
free(free_null_pointer_002_gbl_ptr);
/* Tool should detect this line as error */
/*ERROR:Freeing a NULL pointer*/
free_null_pointer_002_gbl_ptr = NULL;
}
}
Здесь PVS-Studio молчит. Дело в том, что диагностика V575 выдаёт предупреждение, только если в функцию free передается точно нулевой указатель. В данном случае мы имеем дело с не константной глобальной переменной free_null_pointer_002_gbl_ptr. Анализатор хранит виртуальные значения только для константных глобальных переменных. Значения неконстантных глобальных переменных могут измениться в любом месте программы, и мы не отслеживаем их. Из-за этого PVS-Studio не считает указатель free_null_pointer_002_gbl_ptr точно нулевым и не выдаёт предупреждение.
Ладно, но может тогда можно научить анализатор разбираться в этом примере и понимать, что в данном случае указатель точно нулевой? В этом синтетическом примере – да, можно. Но лучше от этого PVS-Studio не станет. Такая доработка не поможет анализатору находить новые ошибки в реальном коде. В реальных проектах глобальные переменные используются сразу во многих местах, и разобраться в том, где какое значение будет иметь глобальная переменная, сложно (а часто для статического анализатора и невозможно).
Заключение
Были и другие спорные тесты. Однако эти примеры не так просто объяснить, поэтому я не стал их разбирать в своей заметке. Ещё раз повторю результаты своего исследования: PVS-Studio поддерживает Toyota ITC Benchmark на 12% при пороге в 100%, на 27% при пороге 75% и на 39% при пороге в 50%.
Как мы видели выше, у PVS-Studio есть возможности улучшения покрытия Toyota ITC Benchmark. Например, если просто отключить исключения на диагностиках, это уже даст неплохой результат в плане увеличения покрытия. Вопрос лишь в том, что для большинства наших пользователей такой режим навряд ли будет полезен. Добавлять же его только ради бенчмарка – решение весьма спорное. Но, если что-то подобное вас интересует, напишите нам.
Всем спасибо за внимание и безбажного вам кода!
Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Alexander Kurenev. What's with the PVS-Studio's coverage of Toyota ITC Benchmark?
a-tk
Вообще говоря
free(void*)
допускает передачу nullptr...https://en.cppreference.com/w/c/memory/free
AlexanderKurenev Автор
Согласен с Вами, что в функцию free можно передавать нулевой указатель. Цитирую из статьи: "Заметим, что сам по себе вызов функции free на нулевом указателе не является ошибкой."
Предупреждение PVS-Studio V575 прежде всего про то, что в функцию free странно передавать нулевой указатель. Возможно, указатель занулили до вызова free и получили утечку памяти. Может быть, опечатались в названии переменной, и хотели освободить память по другому адресу.
a-tk
Возможно, анализом вариантов присваивания (или хотя бы при инициализации) можно было бы понять, что там может быть NULL, и, если нет проверки на NULL, то отмечать это низкоприоритетным сообщением, но опять-таки... to reduce the amount of special-casing.