Время компиляции в C++: что ревьюить в заголовках и шаблонах
Проект собирается 40 минут вместо 8, и виноват не компилятор, а один <code>#include</code> в общем заголовке. Разбираем, что искать на ревью, чтобы билд не разрастался.

Полная пересборка проекта на 300 единиц трансляции выросла с 8 до 40 минут за полгода. Никто не менял флаги компилятора и не покупал более слабое железо. Просто в базовый заголовок, который подключают почти все, кто-то добавил #include <regex> ради одной функции, а в другой общий хедер приехал тяжёлый шаблонный класс. Компилятор честно инстанцирует эти шаблоны в каждой единице трансляции, где виден заголовок, — и делает это заново.
Время компиляции в C++ утекает медленно и почти всегда через ревью, которое смотрит на логику, но не на то, что именно попадает в граф зависимостей заголовков. Ниже — конкретные вещи, которые стоит проверять в diff'е, и почему каждая из них дорого стоит. Замеры и приёмы актуальны для GCC 13/14, Clang 17/18 и MSVC 19.3x (Visual Studio 2022). Речь о C++17/20; модули C++20 (import) — отдельная тема, здесь про классический препроцессорный include.
Почему один include стоит минут
Модель компиляции C++ — раздельная трансляция с текстовой подстановкой заголовков. Каждый .cpp — это единица трансляции (TU1). Препроцессор рекурсивно раскрывает все #include, и компилятор видит один большой файл. Если тяжёлый заголовок подключён в 200 TU, его содержимое парсится 200 раз.
Шаблоны усугубляют: std::vector<MyType>, использованный в 200 TU, инстанцируется в каждой из них, а линковщик потом выкидывает дубли. Парсинг и инстанцирование — это и есть основное время, а не оптимизация машинного кода.
Правило, которое экономит часы: заголовок должен подключать ровно то, что нужно его собственному интерфейсу. Всё, что нужно только реализации, живёт в .cpp.Что за это платят
Плата не только в минутах CI. Долгий инкрементальный билд ломает цикл «изменил — собрал — проверил»: разработчик уходит в мессенджер, теряет контекст. Правка в тяжёлом заголовке пересобирает пол-проекта вместо трёх файлов.
Красные флаги в diff'е
1. Новый тяжёлый include в заголовке
Смотрите на #include, добавленные в .hpp/.h, а не в .cpp. Дорогие стандартные заголовки, которые не стоит тащить в общий хедер без нужды:
<regex>— один из самых тяжёлых в стандартной библиотеке;<iostream>— тянет статические инициализаторы и много кода;<chrono>,<functional>,<memory>— тяжелее, чем кажутся;- любой заголовок сторонней библиотеки, объявляющий шаблоны (Boost — классический пример).
Замечание на ревью формулируйте так: «<regex> нужен только в parse_config() в .cpp — перенеси include туда, в заголовке он не используется». Конкретно, с указанием, где символ реально нужен.
2. include там, где хватило бы forward-декларации
Если в заголовке тип используется только как указатель, ссылка, аргумент или возвращаемое значение по значению в объявлении функции — полное определение не нужно. Хватит предварительного объявления.
// widget.hpp — было: тянет весь database.hpp в каждый включающий TU
#include "database.hpp"
class Widget {
Database* db_; // нужен только указатель
public:
void attach(Database& d); // нужна только ссылка
};
// widget.hpp — стало: определение Database в заголовке не раскрывается
class Database; // forward-декларация
class Widget {
Database* db_;
public:
void attach(Database& d);
};Полное #include "database.hpp" уезжает в widget.cpp, где вызываются методы db_. Правило неполного типа: для указателя/ссылки определение не требуется; для поля-значения, наследования, вызова метода или sizeof — требуется.
3. Тяжёлый шаблон, инстанцируемый на видимый всем тип
Ищите std::unordered_map, std::variant с длинным списком типов, глубоко вложенные шаблоны в публичных заголовках. Каждое их использование в TU — новая инстанциация. Если один и тот же набор параметров нужен в десятках TU, помогает явное инстанцирование в одном .cpp:
// heavy_types.hpp — объявляем, что инстанциация будет где-то ещё
extern template class std::vector<ComplexRecord>;
// heavy_types.cpp — единственное место, где шаблон реально разворачивается
template class std::vector<ComplexRecord>;Остальные TU увидят extern template и не будут инстанцировать vector<ComplexRecord> у себя.
4. Всё в inline и в заголовке
Крупные методы, определённые прямо в теле класса в заголовке, парсятся в каждом включающем TU и раздувают время. Заголовок-only библиотеки удобны, но за удобство расплачивается тот, кто их включает во все TU. На ревью спрашивайте: этот метод обязан быть в заголовке (это шаблон или должен быть inline по причине производительности) или его можно унести в .cpp?
5. Транзитивные зависимости, которые никто не видит
Самое коварное. Diff показывает добавленную строку в a.hpp, но a.hpp включён в base.hpp, а тот — везде. Ревьюер видит одну строку, а эффект — на весь проект. Здесь без инструментов не обойтись.
Инструменты вместо интуиции
Глазами весь граф include не удержать. Что подключить:
- Clang с флагом трассировки времени. Сборка Clang умеет писать профиль компиляции по TU в формате, который открывается визуализатором трассировок в браузере. Точный флаг и путь к артефакту уточните в разделе про трассировку в документации Clang вашей версии — имя флага между версиями стабильно, но лучше свериться, чем подставить наугад.
- include-what-you-use (IWYU). Отдельный инструмент на базе Clang: находит лишние include и предлагает недостающие forward-декларации. Даёт ложные срабатывания, поэтому его вывод — повод посмотреть, а не команда переписать.
- Флаг вывода зависимостей препроцессора. GCC и Clang умеют выдать список всех заголовков, которые реально раскрылись в TU. Точное имя опции смотрите в документации препроцессора вашего компилятора — оно есть и в GCC, и в Clang, но семантика вариантов различается.
Как мерить, а не гадать
Прежде чем что-то оптимизировать, зафиксируйте базу. Минимальный протокол:
- Замерьте полную пересборку из чистого состояния:
time make -j$(nproc)(или аналог для вашей системы сборки). Три прогона, берите медиану. - Отдельно замерьте инкрементальный сценарий: правка в одном тяжёлом заголовке — сколько TU пересобралось и сколько это заняло.
- Внесите изменение (перенос include, forward-декларация,
extern template). - Повторите замеры на том же железе, тех же флагах, том же числе потоков. Разница на другом ноутбуке ничего не значит.
Условия замера фиксируйте рядом с цифрой: модель CPU, число потоков сборки, версия компилятора, режим (Debug/Release). «Стало на 30% быстрее» без этих условий — не результат.
Что ревьюить: сравнение по цене
| Что заметили в diff'е | Стоимость правки | Эффект на время сборки |
|---|---|---|
| Тяжёлый include в общем заголовке | Перенести в .cpp — минуты | Высокий, если хедер включён везде |
| include вместо forward-декларации | Низкая, механическая | Средний, накапливается |
| Шаблон на общий тип в десятках TU | extern template — средняя | Высокий на больших проектах |
| Крупный inline-метод в заголовке | Вынести в .cpp — средняя | Средний |
| Pimpl для скрытия зависимостей | Высокая, меняет дизайн | Высокий, но не для каждого класса |
Pimpl (указатель на скрытую реализацию) прячет все зависимости класса за одним указателем, и заголовок перестаёт тянуть их в TU. Цена — уровень косвенности, аллокация и потеря inline. Это архитектурное решение, а не то, что стоит требовать на каждом ревью.
Где подход ломается
Не всё лечится переносом include, и часть правок может навредить.
- Forward-декларация ломается на типах из чужих неймспейсов и на type aliases. Объявить наперёд
std::stringнельзя корректно — этоtypedefшаблона, forward-декларация стандартных типов не гарантируется стандартом. Для своих классов — можно, для стандартной библиотеки — нет. - extern template не помогает, если тип используется в одном-двух TU. Смысл только при массовом переиспользовании одной инстанциации.
- Precompiled headers (PCH) ускоряют, но прячут проблему. С PCH тяжёлый заголовок парсится один раз, и красный флаг из diff'а перестаёт болеть в цифрах — до тех пор, пока кто-то не тронет содержимое PCH и не спровоцирует полную пересборку. PCH не отменяет гигиену заголовков.
- Unity-сборки маскируют дубли инстанцирования. Склеивание нескольких
.cppв один TU ускоряет полный билд, но ломает инкрементальность и прячет забытые include, которые «случайно» приехали из соседнего файла.
Что делать дальше
Начните не с правок, а с измерения: соберите проект с трассировкой времени в Clang и посмотрите, какие TU и какие заголовки дают пик. Оптимизировать вслепую — тратить ревью на файлы, которые собираются за секунды.
Поставьте IWYU в CI как предупреждение (не как блокер — ложных срабатываний много) и заведите правило: новый #include в общем заголовке требует обоснования в описании PR. Дальше смотрите на модули C++20 — они меняют саму модель раздельной трансляции, но поддержка в системах сборки пока неровная по компиляторам, так что переход стоит планировать отдельно и проверять на своём тулчейне.
Контент-конвейер: Из одной идеи — текст, изображение, озвучка и ролик. Один заказ вместо четырёх.
Спросить за 60 ₽Источники: Clang Compiler User's Manual — параметры компиляции и трассировки, include-what-you-use — репозиторий проекта
Частые вопросы
У меня GCC, а половина инструментов — про Clang. Что делать?
Перенёс include в .cpp, а проект перестал компилироваться. Почему?
Стоит ли требовать forward-декларации на каждом ревью?
Precompiled headers решат проблему целиком?
Как убедить команду, что это важно, если билд ещё терпимый?
Модули C++20 отменяют всю эту возню с заголовками?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.