С++ и надежный, безопасный код

Довольно часто можно встретить высказывания о том, что C++ и надежный, безопасный и легко поддерживаемых код несовместимы. Особенно часто такими высказываниями грешат адепты управляемых языков и довольно долгое время это было в общем-то небезосновательно. Сам я остро почувствовал данную проблему с C++ после того как основательно погрузился в Go, где благодаря большому количеству утилит для автоматического анализа кода, удается добиться приличного уровня кодовой базы проекта с относительно не высокими временными затратами. К счастью, в мире C++ в последние годы ситуация сильно изменилась и получить сопоставимый по качеству уровень стало возможно с приблизительно сопоставимыми усилиями.

Единый стиль форматирования

Казалось бы, единый стиль форматирования проекта – это мелочь, но ведь не просто так почти каждая уважающая себя компания имеет увесистый документ “BestCompanyInTheWorld Coding Standard”, который как минимум на половину состоит из того, как код должен быть отформатирован. При этом, код в BestCompanyInTheWorld почти наверняка отформатирован вразнобой, так как применение стандарта контролируется в лучшем случае в процессе ревью, но кто же помнит все эти хитросплетения пробелов и скобок? Работая с Go я впервые оценил прелесть того, что весь код включая сторонние библиотеки действительно написан в едином стиле, так как существует единое правило форматирования для всего языка. Эта казалось бы мелочь очень сильно упрощает работу над чужой кодовой базой и в случае с Go легко достигается автоматически при помощи доступных по умолчанию форматтеров.

В случае с современным C++ всё тоже довольно не плохо: ClangFormat делает то же самое что и форматтер из Go, работает на всех платформах и легко прикручивается к любой IDE или редактору. Если пользоваться Vim/Emacs, то можно пойти дальше и организовать форматирование как предварительный шаг при сохранении файла, что я у себя и сделал. Для активации поддержки ClangFormat необходимо создать подходящее описание стиля форматирования и сохранить его как .clang-format файл в корневой директории проекта. Мой текущий стиль в качестве примера можно посмотреть тут.

После того как идеальный стиль был разработан возникает еще один интересный момент – кто-то в команде может игнорировать не только стандарт кодирования, но и полениться установить ClangFormat. Бороться с этим так же можно и как я считаю нужно. Достаточно добавить в сборочный конвейер проверку на то, что все файлы из коммита форматированы в соответствии с ожиданием, в противном случае просто “ломать” сборку. Проверить отформатирован ли файл в соответствии с описанием из .clang-format можно следующим образом:

#!/bin/sh

function is_formatted() {
hash_cmd="md5 -q"
TMPFILE=`mktemp tmp.XXXXXXXXXXXX`
clang-format $1 > ${TMPFILE}
md5_before=`${hash_cmd} $1`
md5_after=`${hash_cmd} ${TMPFILE}`
if [[ "${md5_before}" != "${md5_after}" ]]; then
echo "Not formatted!"
fi
rm ${TMPFILE}
}
is_formatted $1

За исключением примера со скриптиком, написанным на Sh (но ведь ничто не мешает написать его на Shell script, верно?), решение с ClangFormat является кроссплатформенным и работает на Windows, Linux и macOS.

Статический анализ кода

C++ – это сложный язык с большим количеством подводных камней и с этим ничего не поделать. За больше чем 30 лет его развития грабли были очень щедро разбросаны везде где только можно, и их надо как-то ловить. Расчитывать на то, что программисты будут: а) сознательные и осилят несколько талмудов по программированию на C++ и б) будучи сознательными не упустят чего-то важное было бы крайне неосмотрительно. Поэтому, статический анализ кода строго необходим, даже куда сильнее нежели единый стиль форматирования. На данный момент существует много как платных так и бесплатных приложений для статического анализа разной степени кривизны. Перепробовав довольно многие из них я остановился на ClangTidy, который по качеству анализа превосходит все остальные испробованные мной решения. Особенно в этом анализаторе подкупает нулевое количество ложно положительных срабатываний, чем ни один даже коммерческий продукт из опробованных мной похвастаться не мог.

Принцип использования получается в точности такой же как и с проверкой форматирования кода – сборка на конвейере должна быть отмечена как “сломанная”, если хотя бы одно предупреждение было сгенерировано ClangTidy. Тут то и проявляет себя отсутствие ложно положительных срабатываний с самой лучшей стороны – если сломалось, значит точно по делу. Если по каким-то причинам исправлять найденную ошибку не представляется возможным, то можно воспользоваться NOLINT комментарием, который подавит генерацию ошибки для одной конкретной строки.

Порывшись в документации к ClangTidy я пришел к выводу, что мне нравятся все проверки кроме:

android – так как не пишу под Android;
google-readability – не помню уже что мне там не понравилось, но это уже личное, кому-то может и нравится;
llvm – правила специфичные для проекта LLVM, далеко не везде уместны.

Одни из наиболее рекомендуемых, но отключенных по умолчанию проверок относятся к группе readability-function-size.*Threshold. Дело в том, что благодаря им можно настроить максимально допустимое количество аргументов у функции, максимально допустимую длину функции и максимально допустимое количество ветвлений в функции. С двумя последними в случае с C++ стоить быть довольно аккуратным, так как подсчет делается после того, как макросы будут развернуты, что добавляет некую непредсказуемость.

Как и в случае с ClangFormat, ClangTidy работает на всех платформах, были бы прописаны пути для поиска заголовков. Пример .clang-tidy файла, который должен лежать в корневой директории проекта.

Динамический анализ кода

То, что не было выявлено на этапе статического анализа, нужно искать в динамике. Я не вижу какого-то действительно удобного решения для Windows, тут, наверное, Гуру MSVS могут что-то знать. Но если проект кроссплатформенный либо Unix-ориентированный, то динамический анализ довольно легко проводится посредствам встроенных в Clang и GCC санитарайзерам.

Одна из основных причин моей любви именно к подходу с использованием санитайзеров в гарантии отсутствия ложно положительных срабатываний. Поэтому любое, даже совершенно на первый взгляд безумное срабатывание требует детального изучения – ошибка точно есть. Из неудобного – приложение падает на первой найденной ошибке, никакой дальнейший анализ не производится и как следствие спрогнозировать как много времени потребуется на исправление (еще не) обнаруженных ошибок в объемной существующей кодовой базе не представляется возможным. Не менее досадным моментом является невозможность подключения более чем одного санитайзера в сборку. Ну и самое серьезное из замеченного мной на данный момент – нет поддержки fork, что в ряде случаев приводит к невозможности проверки кода без его серьезных модификаций.

Рекомендуемые санитайзеры на примере доступных в Clang (для GCC тот же набор):
AddressSanitizer – невероятно полезная штука для отслеживания проблем с памятью. Великолепно ловит выход за границы массива, двойное освобождение памяти и многое другое, из за чего принято называть C++ опасным языком.
LeakSanitizer – не менее полезная утилита для поиска утечек памяти. На данный момент находится в экспериментальном состоянии и является расширением AddressSanitizer (включается через аргумент ASAN_OPTIONS=detect_leaks=1). Так же не работает из коробки на macOS, так как не входит в сборку Clang от Apple.
UndefinedBehaviorSanitizer – иногда незаслуженно игнорируется на фоне остальных, хотя на мой взгляд эта проверка не менее важная в контексте наличия большого количества UB в стандарте C++.

Если говорить про то, как же использовать санитарайзеры для автоматического отслеживания регрессий, то можно начать с простого: собирать и запускать все Unit-тесты с использованием санитайзеров. Подобный подход сильно повышает качество кодовой базы при наличии хорошего тестового покрытия и предотвращает регрессии. Можно пойти дальше и использовать подход основанный на использовании проекта с включенными санитарайзерами в интеграционных тестах, так как это даст возможность наблюдения за сборкой в реальных условиях. Ну а если нет ничего из вышеперечисленного и всё тестирование производится вручную (тут, наверное, стоит задуматься о качестве проекта более серьезно), то последним доступным вариантом становится ручное тестирование сборки с включенными санитайзерами.

По моим текущим ощущениям, приведенных выше шагов достаточно для автоматического поддержания качества кодовой базы C++ проекта на очень приличном уровне. После введения подобного подхода у меня сильно уменьшилось количество проблем с качеством и процедура ревью ощутимо упростилась.

Leave a Reply