1. Зачем нужны санитайзеры и как они работают
Если вы пишете программу и она «вроде запускается», мозг автоматически хочет поставить галочку «готово». И я вас понимаю: мозгу приятно закрывать вкладки. Проблема в том, что некоторые ошибки в C++ не обязаны проявляться сразу. Они могут проявиться только при другой оптимизации, на другом компьютере, после небольшого рефакторинга или вообще только когда вы уже показали демку заказчику (а это обычно происходит в самый неподходящий момент).
Санитайзеры — это попытка сделать так, чтобы программа падала раньше и понятнее, вместо того чтобы тихо портить память или считать бессмысленные числа. Их удобно воспринимать как «режим повышенной честности»: компилятор добавляет в вашу программу дополнительные проверки, а при нарушении правил печатает подробный отчёт.
Важно держать в голове: санитайзер — это не «ещё одна библиотека», которую мы подключаем #include <sanitizer>. Это именно режим сборки, когда компилятор инструментирует (добавляет проверки) в машинный код.
Инструментирование: почему это другой бинарник
Когда мы говорим «собрать с санитайзером», имеется в виду, что компилятор вставляет дополнительные инструкции: проверки границ, проверки корректности операций, «охранные зоны» вокруг массивов и другие хитрости. В результате получается исполняемый файл, который работает медленнее, но зато очень внимательно следит за тем, что происходит.
Удобно представить это как две разные реальности одного и того же исходника:
flowchart LR
A[main.cpp] --> B[Обычная сборка]
A --> C[Сборка с санитайзером]
B --> D[app]
C --> E[app_sanitized]
D --> F[Запуск: быстро, но без 'детектора лжи']
E --> G[Запуск: медленнее, но ловит классы ошибок]
То есть вы не «включаете ASan на минутку внутри программы». Вы создаёте другую сборку (часто даже отдельную папку build-asan/), запускаете её и смотрите, что она скажет.
2. ASan и UBSan: роли и зона ответственности
Сегодня нас интересуют два санитайзера, которые чаще всего дают максимальную пользу новичку (и не только новичку).
ASan: AddressSanitizer
Он специализируется на ошибках доступа к памяти. На нашем текущем уровне это в первую очередь «вышли за границы массива/буфера/вектора» и «прочитали/записали не туда».
UBSan: UndefinedBehaviorSanitizer
Он диагностирует некоторые виды Undefined Behavior, связанные не столько с «памятью как контейнером», сколько с «операциями языка»: деление на ноль, переполнение signed-типа, некорректные сдвиги и подобные вещи.
Чтобы не превращать это в список «пятьдесят оттенков UB», сделаем компактную таблицу — как карту местности:
| Инструмент | Что ловит лучше всего | Самые типичные «попадания» на нашем уровне |
|---|---|---|
|
Ошибки доступа к памяти | выход за границы массива a[i], ошибки индексации vector при operator[], запись за конец буфера |
|
Некоторые виды UB в выражениях | деление на 0, signed overflow, некорректный битовый сдвиг |
Важная психологическая мысль: ASan и UBSan не конкурируют, они дополняют друг друга. Часто вы включаете один из них «по подозрению», а иногда включаете оба, чтобы не гадать.
3. ASan: какие ошибки доступа к памяти ловит
Когда говорят «ошибки памяти», новички часто думают про new/delete и страшные указатели. Но самый частый путь к проблемам памяти у начинающих — вообще не new/delete, а банальный индекс не туда.
И вот здесь ASan прямо красавчик: он превращает «случайную порчу данных» в чёткий сигнал «вы вышли за границы, вот строка, вот стек».
Выход за границы C-массива: stack-buffer-overflow
Начнём с классики: массив фиксированного размера.
#include <iostream>
int main() {
int a[3] = {10, 20, 30};
#if 0
std::cout << a[3] << '\n'; // UB: индекс 3 вне диапазона 0..2
#endif
std::cout << a[0] << '\n'; // 10
}
Почему это важно именно в контексте ASan: без санитайзера такой код может «просто вывести мусор», может «случайно вывести 0», а может «упасть не здесь». С ASan вы, скорее всего, получите понятное сообщение вида stack-buffer-overflow и место, где это произошло.
Выход за границы std::vector при operator[]
std::vector — контейнер удобный, но operator[] у него не проверяет границы. Это сознательный дизайн: быстрее, но опаснее.
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{1, 2, 3};
std::size_t i = v.size(); // i == 3
#if 0
std::cout << v[i] << '\n'; // UB: v[3] — это "за концом"
#endif
std::cout << v[2] << '\n'; // 3
}
Эта ошибка настолько типовая, что её стоит запомнить как рефлекс: size() — это количество элементов, а последний индекс — size() - 1 (если контейнер не пуст).
Запись за границу: обычно опаснее чтения
Чтение «за границей» уже плохо. Но запись «за границей» обычно хуже: вы портите память. И программа может продолжать работать ещё долго, пока не рухнет в совершенно другом месте.
#include <vector>
int main() {
std::vector<int> v{10, 20, 30};
#if 0
v[3] = 99; // UB: пишем за конец
#endif
return 0;
}
Здесь ASan особенно полезен, потому что он часто ловит такие вещи «на месте преступления», а не через час, когда у вас сломалась сортировка.
Что ASan не гарантирует поймать
ASan ловит целые классы проблем, но он не волшебник и не психотерапевт для вашей бизнес-логики. Если вы перепутали формулу и считаете среднее как sum / (n + 1) — это не к ASan. Если вы неправильно обработали ввод — это тоже не к ASan.
Именно поэтому санитайзер — это не «замена аккуратности», а усилитель: он помогает ловить те ошибки, которые иначе выглядят как хаос.
4. UBSan: какие виды UB диагностирует
UBSan полезен там, где код компилируется, выглядит «почти нормально», но нарушает жёсткие правила языка. И коварство таких мест в том, что компилятор имеет право оптимизировать их так, что логика начнёт вести себя странно.
Кстати, даже стандартная библиотека и сам стандарт постоянно оговаривают «вот здесь поведение не определено, так делать нельзя», особенно вокруг арифметики и итераторов.
Деление на ноль в целых числах
Даже если вы «в математике видели такое выражение», в целочисленной арифметике C++ деление на 0 — UB.
#include <iostream>
int main() {
int a = 10;
int b = 0;
#if 0
int c = a / b; // UB: деление на 0
std::cout << c << '\n';
#endif
std::cout << a << '\n'; // 10
}
UBSan обычно сообщает что-то вроде runtime error: division by zero. И это очень удобно: вы перестаёте гадать «почему оно упало».
Signed overflow: переполнение int — это UB
Многие приходят в C++ с ожиданием: «ну переполнилось и стало отрицательным, как в обычном двухкомплементарном мире». Но по правилам языка переполнение signed целого — UB.
#include <iostream>
#include <limits>
int main() {
int x = std::numeric_limits<int>::max();
#if 0
int y = x + 1; // UB: signed overflow
std::cout << y << '\n';
#endif
std::cout << x << '\n'; // 2147483647 (обычно)
}
Практический вывод (без фанатизма): если вы считаете суммы, произведения, накапливаете результат в цикле, думайте о диапазоне типов. Иногда достаточно заменить int на long long. Иногда нужно проверять границы. А UBSan полезен как сигнал «вот тут вы реально вышли за обещания языка».
Некорректные сдвиги: отрицательные и слишком большие
Битовые операции выглядят как арифметика, но у них есть строгие требования: нельзя сдвигать на отрицательное число, нельзя сдвигать «слишком сильно».
#include <iostream>
int main() {
int x = 1;
int shift = -1;
#if 0
int y = x << shift; // UB: отрицательный сдвиг
std::cout << y << '\n';
#endif
std::cout << x << '\n'; // 1
}
На практике такие ошибки возникают, когда shift вычисляется из входных данных или из размера типа/массива, и вы не проверили границы.
5. Как включать ASan/UBSan в сборке
Включение санитайзеров почти всегда делается флагами компилятора. Мы не уходим в тонкости разных платформ и редких режимов — нам нужен базовый рабочий рецепт.
Типовая форма команды
Если вы компилируете из консоли (или IDE делает это за вас), общая идея такая:
# ASan
g++ -std=c++23 -O0 -g -fsanitize=address main.cpp
clang++ -std=c++23 -O0 -g -fsanitize=address main.cpp
# UBSan
g++ -std=c++23 -O0 -g -fsanitize=undefined main.cpp
clang++ -std=c++23 -O0 -g -fsanitize=undefined main.cpp
Почему тут почти всегда стоят -O0 и -g.
С -g санитайзер может показать вам нормальные «файл:строка», а не загадочные адреса. С -O0 поведение ближе к «как написано», а отчёты обычно понятнее. Оптимизации не запрещены, но для обучения и диагностики на уровне новичка проще начинать с -O0.
В IDE и CMake: общая идея
Если проект на CMake, вы обычно добавляете флаги к целевому приложению. Идея примерно такая (это не «единственно правильный» вариант, а понятный шаблон):
# Пример-идея: в CMakeLists.txt
target_compile_options(app PRIVATE -g -O0 -fsanitize=address)
target_link_options(app PRIVATE -fsanitize=address)
Смысл здесь в том, что санитайзер — это не только компиляция, но и линковка: подключаются нужные runtime-компоненты.
6. Практический пример: StepStats и поиск ошибок
С этого места мы будем развивать маленькое консольное приложение. Пусть оно хранит шаги за дни и считает статистику. Это нарочно простая задача: мы хотим, чтобы внимание было на диагностике, а не на «как спроектировать бизнес».
Базовая структура данных
#include <vector>
struct StepStats {
std::vector<int> steps;
};
Никакой магии: steps — это список значений.
Функция добавления значения
#include <vector>
void AddSteps(std::vector<int>& steps, int value) {
steps.push_back(value);
}
Это «скучный» код — и это хорошо. Чем больше у вас скучных функций, тем меньше неожиданных приключений.
Среднее: место, где легко получить UB
Среднее кажется невинным, но тут есть две типовые проблемы: «деление на ноль» и «переполнение при суммировании». Начнём с самой базовой версии и посмотрим, где риск.
#include <vector>
double Average(const std::vector<int>& steps) {
int sum = 0;
for (int x : steps) sum += x;
return static_cast<double>(sum) / steps.size();
}
Если steps пустой, steps.size() равен 0, и деление превращается в UB. UBSan как раз любит такие места: он не рассуждает «а хотел ли программист так», он просто сообщает «деление на 0».
Чтобы не оставлять код «с миной», делаем безопаснее:
#include <vector>
double AverageOrZero(const std::vector<int>& steps) {
if (steps.empty()) return 0.0;
long long sum = 0;
for (int x : steps) sum += x;
return static_cast<double>(sum) / steps.size();
}
Здесь мы одновременно убрали деление на 0 и снизили риск signed overflow, используя long long для суммы.
Типовая ошибка индексации: кандидат для ASan
Представим, что мы хотим получить «последнее значение». Новичок иногда пишет так:
#include <vector>
int LastBad(const std::vector<int>& v) {
#if 0
return v[v.size()]; // UB: size() — не индекс последнего элемента
#else
return 0;
#endif
}
На реальном коде это может «работать» годами, пока размер вектора маленький и вам «везёт». Но с ASan шансы, что ошибка будет поймана, сильно растут.
Правильная безопасная версия может выглядеть так:
#include <vector>
int LastOrZero(const std::vector<int>& v) {
if (v.empty()) return 0;
return v.back();
}
Да, back() — это «честный способ» взять последний элемент.
Мини-main, чтобы связать всё вместе
#include <iostream>
#include <vector>
int main() {
std::vector<int> steps;
steps.push_back(5000);
steps.push_back(8300);
std::cout << AverageOrZero(steps) << '\n'; // 6650
}
Если вы поэкспериментируете и сделаете steps пустым, старая версия Average() дала бы UB (деление на 0). А UBSan помог бы это не «угадывать», а увидеть.
7. Практическая стратегия выбора санитайзера
Иногда кажется, что правильный ответ — «включать всё всегда». На практике это не всегда удобно: отчётов может быть много, программа может заметно замедлиться, а вы — утонуть в диагностике.
Более жизненная стратегия такая: если вы подозреваете, что проблема в «индексе» или «пишем не туда», начинайте с ASan. Если проблема похожа на «внезапно упало на делении, сдвиге или подозрительной арифметике», начинайте с UBSan. Если вы не уверены, а код небольшой, включайте оба по очереди (или вместе, если ваша среда это поддерживает) и смотрите, что получится.
Главная привычка дня: не пытаться «отлаживать UB как обычный баг». Сначала исключаем UB (санитайзер + проверки), потом уже обсуждаем, почему формула неправильная или почему UX грустный.
8. Типичные ошибки при работе с ASan/UBSan
Ошибка №1: пытаться «подключить санитайзер кодом».
Новички иногда ищут #include или думают, что ASan — это какая-то функция типа EnableAsan(). В реальности санитайзер включается флагами сборки, потому что он меняет машинный код: добавляет проверки, меняет раскладку памяти вокруг объектов, подключает runtime.
Ошибка №2: собирать с санитайзером, но без отладочной информации.
Санитайзер и без -g иногда может показать адреса и частично стек, но для обучения и быстрой локализации проблемы почти всегда хочется видеть main.cpp:123. Поэтому в диагностическом режиме держите -g, даже если вы вообще-то не любите дебаггеры.
Ошибка №3: ожидать, что санитайзер найдёт логические ошибки.
Если программа считает среднее не так, путает валюты или неверно сортирует — санитайзеры не обязаны это замечать. Они ловят классы ошибок, связанные с памятью и UB, но не проверяют «смысл задачи». Для смысла задачи нужны тесты, внимательность и иногда хороший кофе.
Ошибка №4: чинить симптом, а не причину.
Иногда санитайзер ругается на строку return v[i];, и рука тянется «поправить здесь». Но часто причина выше: i стал неправильным ещё в другой функции. Полезно помнить: место, где ошибка обнаружена, не всегда является местом, где она родилась.
Ошибка №5: думать, что если санитайзер молчит, то UB точно нет.
Санитайзеры очень полезны, но они не дают математической гарантии «всё чисто». Они повышают вероятность поймать ошибки и делают диагностику гораздо более удобной, но дисциплина проверок (границы, empty(), диапазоны типов) всё равно остаётся вашей обязанностью.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ