Как часто ваш статический анализатор не справляется с пониманием нюансов исходного кода? Наверняка это происходит чаще, чем хотелось бы. В этой статье мы расскажем о том, как мы с этим боролись, а именно о нашем новом механизме пользовательских аннотаций.
Зачем вообще нужна ручная аннотация кода?
Вопрос из заголовка, на самом деле, очень хорош. Действительно, зачем анализатору какие-то подсказки, если он и так видит весь исходный код? И вы абсолютно правы. Мы придерживаемся такого же мнения на этот счёт — если анализатор может что-то сделать сам, то не нужно беспокоить этим пользователя. Именно поэтому анализатор уже знает про наиболее часто используемые библиотеки и паттерны. Увы, далеко не всегда можно достоверно автоматически вывести какие-либо факты о коде.
Прим. автора: у нас в TODO, конечно же, есть задачи на решение проблемы остановки, анализ комментариев в коде о
костыляхмалых архитектурных решениях, а также на чтение мыслей разработчиков. По понятным причинам, прогресс по ним движется крайне медленно.
В реальности приходится идти на различные компромиссы с целью ускорения и оптимизации анализа. Ручное аннотирование — самый простой и надёжный способ "познакомить" анализатор с вашим кодом.
Например, чтобы по-честному решить один из наиболее частых запросов о корректной работе с nullable-классами (к ним же можно отнести различные классы-обёртки, std::optional и т.п.), анализатору необходимо вывести следующие факты:
класс хранит какой-то ресурс или ссылку на него;
для доступа к ресурсу нужна обязательная проверка состояния обёртки.
Проблема дополнительно усугубляется, если:
есть функции, которые могут менять состояние обёртки;
есть различные варианты инициализации;
для проверки используются нестандартные функции (что-то кроме operator bool, вариаций IsValid и прочего);
есть функции, которые можно вызывать и без проверки.
Много логики, эвристики и ещё больше времени для анализа, да ещё и с негарантированным результатом. А ведь можно просто напрямую сказать анализатору о своих желаниях.
Как раз для этого в PVS-Studio, начиная с версии 7.31 (для C и C++) и 7.32 (для C#), появилась возможность ручной аннотации функций и классов. Реализована она с помощью отдельных JSON-файлов.
Вы можете спросить, а зачем было делать систему аннотаций в отдельных файлах, да ещё и в JSON-формате, когда можно было сделать их прямо в коде? Ведь всегда удобнее держать код и его аннотации рядом друг с другом. Согласны, но на это, как и всегда, есть причины:
Многие клиенты используют в своей работе сторонние библиотеки и компоненты, код которых они не могут изменять.
Нередко бывает так, что команды внутри компании хотят использовать разные наборы аннотаций для одного и того же кода;
Минимальные аннотации в коде у нас уже есть.
В будущем, конечно же, планируется улучшение системы аннотаций в коде, но это уже совсем другая история. В процессе дизайна фичи мы раскопали много интересных материалов, поэтому нам есть что рассказать. Следите за обновлениями :)
Смотрим в деле
Уже сейчас новая система пользовательских аннотаций позволяет задать:
Для типов:
сходство со стандартными классами (например, если класс имеет интерфейс, схожий с одним из стандартных контейнеров);
семантику (cheap-to-copy, copy-on-write и т.д.);
прочие свойства (nullable-тип).
Для функций:
Свойства функции:
не возвращает управление;
объявлена как устаревшая;
чистая ли она;
должен ли использоваться её результат и т.п.
Свойства каждого из параметров функции:
должен отличаться от другого параметра;
nullable-объект должен быть валидным;
является источником или стоком taint-данных и т.п.
Ограничения для каждого из параметров:
можно запретить или разрешить передачу определённых целочисленных значений.
Свойства возвращаемых значений:
taint-данные;
nullable-объект будет валидным и т.п.
С учётом вышесказанного, предлагаю посмотреть, как будет выглядеть аннотация собственного шаблонного nullable-типа. Да, не самый простой случай, но этот пример хорошо показывает возможности системы аннотаций.
Допустим, у вас есть примерно такой код:
constexpr struct MyNullopt { /* .... */ } my_nullopt;
template <typename T>
class MyOptional
{
public:
MyOptional();
MyOptional(MyNullopt);
template <typename U>
MyOptional(U &&val);
public:
bool HasValue() const;
T& Value();
const T& Value() const;
private:
/* implementation */
};
Несколько уточнений по коду:
Конструктор по умолчанию и конструктор от типа MyNullopt инициализируют объект в состоянии "невалидный".
Шаблон конструктора, принимающий параметр типа U&&, инициализирует объект в состоянии "валидный".
Функция-член HasValue проверяет состояние объекта. Если объект в состоянии "валидный", то возвращается true, в обратном случае — false. Функция не меняет состояние объекта.
Функции-члены Value возвращают нижележащий объект и не меняют состояние объекта.
С учетом условий выше, мы можем составить следующую аннотацию:
{
"version": 2,
"annotations": [
{
"type": "class",
"name": "MyOptional",
"attributes": [ "nullable" ],
"members": [
{
"type": "ctor",
"attributes": [ "nullable_uninitialized" ]
},
{
"type": "ctor",
"attributes": [ "nullable_uninitialized" ],
"params": [
{
"type": "MyNullopt"
}
]
},
{
"type": "ctor",
"template_params": [ "typename U" ],
"attributes": [ "nullable_initialized" ],
"params": [
{
"type": "U &&val"
}
]
},
{
"type": "function",
"name": "HasValue",
"attributes": [ "nullable_checker", "pure", "nodiscard" ]
},
{
"type": "function",
"name": "Value",
"attributes": [ "nullable_getter", "nodiscard" ]
}
]
}
]
}
И теперь анализатор предупредит нас об опасном использовании нашего nullable-типа:
Упрощения для комфортной работы
Понимаем, что вынос аннотаций в отдельные файлы создаёт определённые трудности, поэтому мы также предлагаем несколько улучшений, которые позволят сгладить проблему.
Готовые примеры
Для облегчения знакомства с механизмом пользовательских аннотаций мы подготовили коллекцию примеров для наиболее часто встречающихся сценариев. Например, разметка функций форматного вывода (С++), пометка функций как опасных/устаревших (С++) и т.д. Ознакомиться с ними можно в соответствующем разделе документации.
JSON-схемы
Для каждого доступного языка мы сделали JSON-схемы с поддержкой версионирования. Благодаря этим схемам современные текстовые редакторы и IDE могут проводить валидацию, также подсказывать возможные значения и показывать подсказки прямо во время редактирования.
Для этого при составлении собственного файла аннотаций необходимо добавить в него поле $schema, в котором следует указать схему для необходимого языка. Например, для С++ анализатора поле будет выглядеть так:
{
"version": 2,
"$schema": "https://files.pvs-studio.com/media/custom_annotations/v2/cpp-annotations.schema.json",
"annotations": [
{ .... }
]
}
В таком случае, тот же Visual Studio Code сможет помогать вам при составлении аннотаций:
Актуальный список доступных языков для аннотаций и схемы для них опубликованы в соответствующем разделе документации.
Предупреждения анализатора
Далеко не все проблемы можно диагностировать на уровне валидации JSON-схемы. Поэтому мы создали диагностическое правило V019, которое подскажет, если что-то пойдёт не так. Например: отсутствие файлов аннотаций, ошибка парсинга, пропуск аннотаций из-за ошибок в них и т.д.
Удобство составления
Мы постарались сделать систему аннотаций так, чтобы вам приходилось писать как можно меньше. Примером таких упрощений может являться выбор перегрузок функций в С++.
Если вы хотите, чтобы аннотация применилась сразу на все функции с этим именем, то можно просто опустить поле params при описании функции.
// Code
void foo(); // dangerous
void foo(int); // dangerous
void foo(float); // dangerous
// Annotation
{
....
"type": "function",
"name": "foo",
"attributes": [ "dangerous" ]
....
}
Если же требуется аннотация именно на функцию без параметров, то нужно явно указать это:
// Code
void foo(); // dangerous
void foo(int); // ok
void foo(float); // ok
// Annotation
{
....
"type": "function",
"name": "foo",
"attributes": [ "dangerous" ],
"params": []
....
}
Гибкость
В продолжение прошлого пункта можно также отметить возможность использовать символы подстановки (wildcard). Благодаря им, например, можно не указывать какие-либо параметры функций, если они не имеют смысла для аннотации. Уже сейчас доступны:
"*" (звёздочка) — заменяет 0 или более параметров любого типа;
"?" (знак вопроса) — заменяет один параметр любого типа.
Рассмотрим, как это можно применить, например, для разметки функций форматированного вывода. Допустим, у нас имеется набор функций для вывода текста:
namespace Foo
{
void LogAtExit(const char *fmt, ...);
void LogAtExit(const char8_t *fmt, ...);
void LogAtExit(const wchar_t *fmt, ...);
void LogAtExit(const char16_t *fmt, ...);
void LogAtExit(const char32_t *fmt, ...);
}
В этом случае не нужно делать аннотацию на каждую функцию. Достаточно написать одну, а изменяющийся тип первого параметра заменить на символ подстановки:
{
"version": 1,
"annotations": [
{
"type": "function",
"name": "Foo::LogAtExit",
"attributes": [ "noreturn" ],
"params": [
{
"type": "?",
"attributes" : [ "format_arg", "not_null", "immutable" ]
},
{
"type": "...",
"attributes": [ "immutable" ]
}
]
}
]
}
Заключение
Мы уверены, что наш новый механизм пользовательских аннотаций значительно упростит жизнь разработчикам и повысит точность статического анализа кода. Приглашаем всех попробовать этот функционал в действии и убедиться в его эффективности. Для этого достаточно просто скачать анализатор по ссылке. Как говорится, лучше один раз попробовать, чем сто раз услышать :)
Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Mikhail Gelvikh. User annotations for PVS-Studio.