Ревью кода на C++: чек-лист из практики
На что смотреть в C++-ревью, кроме форматирования: владение ресурсами, время жизни ссылок, исключения и переполнения. Конкретные формулировки замечаний вместо «поправьте качество кода».

В C++ ревью — не про табуляцию и порядок инклудов. Компилятор молча пропускает код, который упадёт на проде: висячая ссылка на локальную переменную, дважды освобождённый указатель, целочисленное переполнение при вычислении размера буфера. Ни линтер, ни компилятор с флагами по умолчанию этого не поймают. Замечание «обратите внимание на качество кода» ревьюер может не писать вовсе — оно бесполезно. Ниже — что реально ловить и как это формулировать, чтобы автор понял и исправил. Ориентируюсь на C++17 и C++20, отдельно помечаю, где версия важна.
Порядок разделов — по убыванию цены ошибки. Гонка данных и use-after-free стоят инцидента и ночного дежурства. Лишняя копия вектора стоит миллисекунд. Ревьюер, который начинает с форматирования и заканчивает временем жизни объектов, тратит внимание не туда.
Владение ресурсами: кто освобождает и когда
Первое, что смотрю в diff: появились ли new, delete, сырые указатели-владельцы, ручные fopen/fclose, lock/unlock без обёртки. Каждый такой случай — кандидат на утечку или двойное освобождение при раннем return или исключении.
- Сырой
newбез немедленной передачи в умный указатель. Формулировка замечания: «ЗдесьConnection* c = new Connection(), а между созданием иdeleteесть триreturnи вызов, который может кинуть. Оберните вstd::unique_ptr<Connection>— освобождение станет автоматическим на любом пути выхода». deleteв деструкторе при наличии копирующего конструктора по умолчанию. Классический двойной free: скопировали объект, оба экземпляра держат один указатель, оба его удаляют. Замечание: «Класс владеет указателем черезdeleteв деструкторе, но копирование не запрещено и не переопределено. При копии — двойное освобождение. Либо= deleteна копирование, либо правило пяти, либоunique_ptr».- Мьютекс без
lock_guard/scoped_lock. Ручнойmtx.lock()оставит мьютекс захваченным, если между ним иunlock()вылетит исключение. Замечание: «Заменитеlock/unlockнаstd::scoped_lock lk(mtx)— при исключении мьютекс освободится в раскрутке стека».
Если в diff есть сырой указатель-владелец, вопрос не «нормально ли это», а «на каком пути выхода он течёт». Такой путь почти всегда находится.
RAII1 как критерий, а не как лозунг
Проверка простая: для каждого ресурса найдите объект, чей деструктор его освобождает. Если такого объекта нет — ресурс освобождается вручную, значит есть путь, где освобождение не случится. Это работает и для памяти, и для файлов, и для сокетов, и для блокировок.
Время жизни: ссылки и указатели на то, что уже умерло
Самый дорогой класс багов в C++ и самый незаметный на ревью, потому что код компилируется и часто даже проходит тесты — падает под нагрузкой или на другом компиляторе.
- Возврат ссылки или указателя на локальную переменную.
const std::string& getName() { std::string s = ...; return s; }— ссылка на уничтоженный объект. Компилятор с-Wreturn-local-addrловит прямой случай, но не через промежуточную переменную. - Захват по ссылке в лямбде, которая переживёт захваченное. Лямбда с
[&], сохранённая вstd::functionи вызванная позже (колбэк, поток, таймер), обращается к уже уничтоженным локальным переменным. Замечание: «Лямбда сохраняется в член класса и вызывается из другого потока. Захват[&]ссылается на локальные переменные вызывающей функции — к моменту вызова их нет. Захватывайте по значению или черезshared_ptr». - Висячая ссылка из
std::string_view(C++17) иstd::span(C++20). Оба не владеют данными.std::string_view sv = getTemporary();, гдеgetTemporary()возвращает по значению, — view на уничтоженный временный объект. - Инвалидация итераторов и ссылок при модификации контейнера. Сохранили ссылку на элемент
std::vector, потом сделалиpush_back— реаллокация, ссылка висячая. Дляvectorинвалидируется всё при реаллокации; дляstd::deque,list,mapправила другие. Замечание должно называть контейнер и операцию.
// C++20. Пример висячего span — ревью должно это отметить.
std::span<const int> makeSpan() {
std::vector<int> local = {1, 2, 3};
return std::span<const int>(local); // span на уничтоженный вектор
}
// Корректно: время жизни вектора длиннее времени жизни span
void process(const std::vector<int>& data) {
std::span<const int> view(data); // ок, data живёт у вызывающего
// работаем с view
}Копии, которых не должно быть
Не для микрооптимизации, а потому что скрытая копия большого контейнера в горячем пути — реальная деградация, и на ревью её видно, а в профайлере потом искать дольше.
- Приём тяжёлого объекта по значению вместо
const&.void f(std::vector<int> v)копирует вектор на каждом вызове. Если функция не забирает владение и не модифицирует локально —const std::vector<int>&. - Цикл
for (auto x : container)по контейнеру нетривиальных объектов. Копирует каждый элемент. Замечание: «autoв range-for копирует каждыйstd::string. Нужноconst auto&, если элементы не меняются». - Возврат
std::moveиз локальной переменной вreturn. Мешает NRVO (оптимизации возврата) и превращает потенциальное копирование-элизию в гарантированное перемещение — обычно хуже.return std::move(local)в конце функции — почти всегда лишний move.
Где копия оправдана
Если функция сохраняет аргумент себе (кладёт в контейнер-член), приём по значению плюс std::move внутри — корректный и понятный идиом, не замечание. Отличайте забор владения от чтения.
Целочисленные ловушки
Незнаковое переполнение и знаковое переполнение (UB) — источник уязвимостей и «необъяснимых» падений. На ревью ищите арифметику над размерами и индексами.
- Вычитание беззнаковых.
size_tрезультатa - bприb > a— огромное положительное число.for (size_t i = v.size() - 1; ...)при пустом векторе уходит в бесконечный цикл или out-of-bounds. - Сравнение знакового с беззнаковым.
int i = -1; if (i < v.size())—iприводится к беззнаковому, условие ложно. Компилятор с-Wsign-compareпредупреждает; в C++20 естьstd::cmp_lessи родственные для безопасного сравнения. - Умножение для размера буфера без проверки переполнения.
malloc(count * sizeof(T))при большомcountпереполняется и выделяет меньше, чем ожидалось.
Исключения и гарантии
Вопрос на ревью не «использовать ли исключения», а «в каком состоянии останется объект, если операция кинет посередине».
- Модификация нескольких членов без строгой гарантии. Если функция меняет два поля и между ними может вылететь исключение, объект остаётся в промежуточном состоянии. Идиома copy-and-swap даёт строгую гарантию.
- Исключение из деструктора. Если деструктор кидает во время раскрутки стека —
std::terminate. Деструкторы неявноnoexceptс C++11; бросающий деструктор — почти всегда ошибка. - Отсутствие
noexceptна move-конструкторе.std::vectorпри реаллокации не станет перемещать элементы, если их move-конструктор не помеченnoexcept— он их скопирует ради строгой гарантии. Замечание: «Пометьте move-конструкторnoexcept, иначеvectorбудет копировать вместо перемещения при росте».
Приоритеты: с чего начинать ревью
| Категория | Цена ошибки | Ловит ли компилятор |
|---|---|---|
| Гонки данных, use-after-free | Инцидент, UB, уязвимость | Нет (нужен sanitizer) |
| Двойное освобождение, утечки | Падение или рост памяти | Частично |
| Целочисленное переполнение размеров | UB, переполнение буфера | Частично (с флагами) |
| Гарантии исключений | Порча состояния | Нет |
| Лишние копии | Деградация производительности | Нет |
| Форматирование, именование | Читаемость | Линтер/clang-format |
Форматирование — последняя строка не случайно. Его должен делать clang-format в CI, а не человек в комментариях. Если ревьюер тратит внимание на пробелы, у него не остаётся его на висячие ссылки.
Чего чек-лист не заменяет
Ревью глазами не находит гонки данных и use-after-free надёжно — их находят инструменты. AddressSanitizer, ThreadSanitizer и UndefinedBehaviorSanitizer в CI ловят то, что человек пропускает. Ревью отвечает на вопрос «правильна ли идея и владение ресурсами», санитайзеры — на «нет ли конкретного UB на этом прогоне». Одно без другого дырявое.
Что настроить после
Чек-лист работает, когда его подпирает автоматика. Минимум для C++-проекта:
- clang-format в pre-commit — убирает форматирование из ревью полностью.
- clang-tidy с наборами
bugprone-*,cppcoreguidelines-*— ловит часть перечисленного статически. - Санитайзеры в CI — отдельный прогон тестов с
-fsanitize=address,undefinedи отдельный с-fsanitize=thread(их нельзя включать одновременно). - Флаги предупреждений как ошибки —
-Wall -Wextra -Wsign-compareи-Werrorхотя бы на CI.
После этого ревью человека сосредотачивается на том, что машина не видит: замысел, владение, время жизни, гарантии. Именно ради этого чек-лист и нужен.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: C++ Core Guidelines, clang-tidy checks reference
Частые вопросы
А если у нас старый стандарт, C++11 или C++14?
clang-tidy же сам всё это находит, зачем ручной чек-лист?
Как ловить гонки данных, если ревью их не видит?
Сколько времени закладывать на C++-ревью?
Автор обижается на замечания про владение и время жизни, считает это придирками. Что делать?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.