Как часто ваш статический анализатор не справляется с пониманием нюансов исходного кода? Наверняка это происходит чаще, чем хотелось бы. В этой статье мы расскажем о том, как мы с этим боролись, а именно о нашем новом механизме пользовательских аннотаций.
Зачем вообще нужна ручная аннотация кода?
Вопрос из заголовка, на самом деле, очень хорош. Действительно, зачем анализатору какие-то подсказки, если он и так видит весь исходный код? И вы абсолютно правы. Мы придерживаемся такого же мнения на этот счёт — если анализатор может что-то сделать сам, то не нужно беспокоить этим пользователя. Именно поэтому анализатор уже знает про наиболее часто используемые библиотеки и паттерны. Увы, далеко не всегда можно достоверно автоматически вывести какие-либо факты о коде.
Прим. автора: у нас в 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.
ссылка на оригинал статьи https://habr.com/ru/articles/848250/
Добавить комментарий