1. Принцип Include What You Use
Когда проект становится больше, компиляция начинает занимать заметное время. Сначала это выглядит невинно: «ну подумаешь, 3 секунды». Потом вы моргнули — и уже пьёте чай, пока собирается «простое изменение в одном файле». Принцип Include What You Use (подключай то, что используешь) помогает держать зависимости под контролем и не превращать сборку в сериал на 12 сезонов.
Идея очень простая и почти бытовая: каждый заголовок должен явно подключать всё, что нужно для его объявлений, и при этом не тащить лишнее. Это делает проект устойчивее к рефакторингу и часто заметно ускоряет сборку, потому что уменьшается объём кода, который компилятор вынужден «пережёвывать» при каждом изменении.
Представим наш учебный проект — небольшой консольный трекер задач MiniTracker. Мы уже не пишем всё в main.cpp, а разложили код по файлам. В такой ситуации любой «лишний» #include в заголовке начинает размножаться как мемы в понедельник утром: один заголовок подключают 10 .cpp, и вот лишний файл попал в компиляцию 10 раз.
Самодостаточный заголовок
Есть отличный практический критерий качества заголовка: самодостаточность. Это означает, что если вы возьмёте какой-то X.hpp и подключите его в пустой .cpp, то он должен компилироваться (при условии, что сам проект настроен нормально). Это похоже на правило «каждый ингредиент подписан»: открываешь банку — и понимаешь, что внутри, а не угадываешь по запаху, что «это вроде бы сахар, но почему он шипит?».
Самодостаточность напрямую связана с Include What You Use. Если ваш заголовок использует std::string, он обязан подключить <string>. Если он объявляет что-то со std::vector, он обязан подключить <vector>. Не «может», не «было бы неплохо», а обязан, потому что иначе заголовок начинает зависеть от порядка подключений в других файлах, а это хрупкость в чистом виде.
Давайте сделаем простой заголовок модели задачи в нашем MiniTracker.
// include/app/task.hpp
#pragma once
#include <string> // Task хранит std::string, значит <string> нужен прямо здесь
namespace app {
struct Task {
int id{};
std::string title;
bool done{};
};
} // namespace app
Здесь всё честно: Task хранит std::string, поэтому <string> подключён. Неважно, кто и в каком порядке будет подключать task.hpp — он не будет «падать» из-за того, что где-то забыли <string>.
Транзитивные зависимости
Самый коварный враг Include What You Use — транзитивная зависимость. Это когда ваш код компилируется не потому, что вы всё правильно подключили, а потому что «повезло»: нужный тип приехал через чужой #include. Сегодня повезло, завтра кто-то удалил лишний include в другом месте — и всё сломалось. Это как жить с мыслью: «ну у соседа же есть отвертка, если что…» — а потом сосед уехал.
Вот плохой вариант заголовка: он использует std::string, но <string> не подключает.
// include/app/task_bad.hpp
#pragma once
namespace app {
struct TaskBad {
int id{};
std::string title; // Ошибка: std::string может быть не виден
};
} // namespace app
Почему это может «вдруг работать»? Потому что где-то до этого заголовка кто-то подключил <string>. Например:
// src/main.cpp
#include <string> // случайно!
#include "app/task_bad.hpp" // теперь компилируется (но это иллюзия)
int main() {
app::TaskBad t{1, "Buy milk"};
(void)t;
}
А теперь представьте, что в main.cpp кто-то решил, что <string> здесь лишний (и он действительно лишний, если вы его нигде не используете напрямую), и удалил:
// src/main.cpp
#include "app/task_bad.hpp" // внезапно перестало компилироваться
int main() {
app::TaskBad t{1, "Buy milk"};
(void)t;
}
И вот вы ловите ошибку компиляции «std::string не объявлен». Самое обидное — автор правки в main.cpp вообще не виноват: он сделал хорошее дело (убрал лишний include), а проблема проявилась в другом месте из-за хрупкости заголовка.
С точки зрения дисциплины, правильная мысль звучит так: если тип/функция упоминается в заголовке — заголовок должен сам принести нужный стандартный header. Это и есть Include What You Use в самом полезном виде.
Что класть в .hpp, а что оставлять в .cpp
Очень частая ошибка новичков — подключать «всё подряд» в заголовке, просто чтобы «точно работало». Так появляется стиль <iostream> в каждом .hpp, а потом компиляция начинает напоминать загрузку старой игры на PlayStation 1: вроде уже пора, но ещё не пора.
Главный ориентир здесь такой: .hpp содержит интерфейс, а .cpp содержит реализацию. Если какой-то include нужен только для того, чтобы внутри .cpp что-то напечатать или посчитать, он не обязан быть в .hpp.
Рассмотрим пример: мы хотим печатать задачу в поток вывода. Сделаем для этого отдельный модуль.
// include/app/task_printer.hpp
#pragma once
#include <iosfwd> // достаточно объявлений std::ostream (легче, чем <iostream>)
#include "app/task.hpp" // печатаем Task, значит нужен Task
namespace app {
void print_task(std::ostream& out, const Task& task);
} // namespace app
Обратите внимание на важную деталь: здесь нет <iostream>. И это хорошо. В заголовке нам нужен только std::ostream как тип параметра. Для этого существует специальный стандартный заголовок <iosfwd> — он «лёгкий» и не тянет кучу деталей.
А вот реализация живёт в .cpp, и там мы уже можем подключить всё тяжёлое, что нужно:
// src/task_printer.cpp
#include "app/task_printer.hpp"
#include <iostream> // здесь уже можно: реализация использует форматированный вывод
namespace app {
void print_task(std::ostream& out, const Task& task) {
out << "[" << (task.done ? 'x' : ' ') << "] "
<< task.id << ": " << task.title << '\n';
}
} // namespace app
Смысл этого разделения не в «красоте» (хотя аккуратный код тоже приятно). Смысл в том, что заголовок task_printer.hpp будет подключаться много где, а task_printer.cpp компилируется как отдельная единица трансляции. Чем меньше заголовок тянет лишнего — тем меньше работы компилятору на каждый .cpp, который этот заголовок включает.
Теперь пример типичной «перегрузки заголовка». Допустим, кто-то написал так:
// include/app/task_printer_bad.hpp
#pragma once
#include <iostream> // тяжело, и не нужно для объявления
#include "app/task.hpp"
namespace app {
void print_task(std::ostream& out, const Task& task);
}
Функция объявлена ровно так же. Но теперь любой файл, который хочет просто знать про print_task, вынужден пережёвывать <iostream>. А <iostream> — это не маленькая бумажка, это целая энциклопедия с закладками.
Подключайте свой .hpp первым
Есть привычка, которая очень быстро ловит проблемы транзитивных зависимостей: в каждом .cpp первым include должен быть свой заголовок. Это не религиозная война и не эстетика — это способ «сломать иллюзию, что всё нормально», если заголовок на самом деле не самодостаточен.
Почему это работает? Если ваш .cpp сначала подключит чужие стандартные заголовки, а потом свой .hpp, вы можете случайно «привезти» нужные типы транзитивно и не заметить дыру в заголовке. А если ваш .hpp подключён первым — он обязан выстоять сам.
Вот хороший стиль:
// src/task_service.cpp
#include "app/task_service.hpp" // первым!
#include <algorithm> // нужно только для реализации
namespace app {
int count_done(const std::vector<Task>& tasks) {
return static_cast<int>(std::count_if(tasks.begin(), tasks.end(),
[](const Task& t) { return t.done; }));
}
} // namespace app
А вот стиль, который часто скрывает проблемы:
// src/task_service.cpp
#include <vector> // случайно привезли что-то полезное
#include "app/task_service.hpp" // а заголовок может быть не самодостаточен
// ...
Разница кажется мелкой, но на реальном проекте это превращается в суперсилу: вы ловите ошибку сразу в «правильном месте», а не спустя неделю после того, как кто-то поменял порядок #include в другом файле.
Чтобы закрепить, сделаем заголовок сервиса задач (пусть он пока содержит одну функцию). Заметьте: раз в интерфейсе есть std::vector, мы подключаем <vector> прямо в .hpp.
// include/app/task_service.hpp
#pragma once
#include <vector> // интерфейс использует std::vector
#include "app/task.hpp" // интерфейс использует Task
namespace app {
int count_done(const std::vector<Task>& tasks);
} // namespace app
Здесь заголовок самодостаточен и не надеется на удачу.
Стоимость #include и лёгкие заголовки
Важно правильно понимать, что делает #include. Препроцессор буквально вставляет текст одного файла в другой. Если заголовок тянет ещё 20 заголовков, а те тянут ещё 50, то на вход компилятору прилетает огромный текст. Даже если include guards / #pragma once спасают от повторного включения одного и того же файла, компилятор всё равно должен открыть файлы, обработать директивы, а иногда и разобрать немалую часть кода.
Удобно представить сборку как «снежный ком». Вы меняете один заголовок — и пересобирается всё, что его включает (прямо или транзитивно). Чем больше лишних зависимостей в заголовках, тем больше этот ком.
Нарисуем схему зависимости (упрощённо) для нашего MiniTracker:
flowchart TD
A[task.hpp] --> B[task_service.hpp]
A --> C[task_printer.hpp]
C --> D[main.cpp]
B --> D
Если task_printer.hpp по ошибке включает <iostream>, то <iostream> оказывается в каждом .cpp, который печатает задачи. Если таких файлов 15 — вы 15 раз платите компиляцией за заголовок, который вам по сути нужен только в одном .cpp.
Полезно держать в голове простую табличку (не абсолютную, но очень практичную по ощущениям):
| Заголовок | Обычно “тяжесть” | Когда подключать |
|---|---|---|
, , |
чаще умеренная | если тип упоминается в интерфейсе |
|
лёгкая | если в интерфейсе есть std::ostream&/std::istream& |
|
часто тяжёлая | почти всегда только в .cpp, где реально печатаете в std::cout |
|
умеренная | обычно в .cpp, если алгоритмы не нужны в интерфейсе |
Да, «тяжёлость» — не строгий термин из стандарта. Это скорее инженерное ощущение: некоторые заголовки тянут много деталей и шаблонов, и компиляция от этого реально дорожает.
Есть ещё один важный момент: Include What You Use — это не «подключай как можно меньше». Это «подключай ровно то, что нужно». Иногда это означает, что в .hpp будет на одну-две строки include больше, чем вам хотелось бы. И это нормально, потому что цена «лишней строки» меньше, чем цена хрупкости проекта.
Например, если у нас есть функция, которая возвращает строку, то <string> должен быть подключён прямо в заголовке, даже если «и так уже где-то подключён через task.hpp». Мы не играем в угадайку.
// include/app/task_format.hpp
#pragma once
#include <string> // интерфейс использует std::string
#include "app/task.hpp" // интерфейс использует Task
namespace app {
std::string format_task(const Task& task);
} // namespace app
Если потом Task внезапно перестанет хранить std::string (например, вы замените поле на что-то другое), task.hpp может больше не включать <string>. И вот тут транзитивная зависимость бы вас укусила — но мы её заранее выключили правильным include.
2. Типичные ошибки
Ошибка №1: заголовок “работает только в правильном порядке include’ов”.
Это классика транзитивных зависимостей: вы используете std::string, std::vector или std::ostream, но не подключаете нужный стандартный заголовок, потому что «в другом месте уже подключено». Такая экономия похожа на попытку сэкономить на тормозах у велосипеда: кажется, что всё нормально, пока не надо тормозить.
Ошибка №2: подключать тяжёлые заголовки в .hpp “на всякий случай”.
Очень часто новичок кладёт <iostream> в каждый заголовок, потому что «вдруг пригодится». Потом вдруг действительно пригодилось — но уже вам не, а компилятору, который теперь постоянно страдает. Более здоровая привычка: в .hpp — только то, что нужно объявлениям, а всё для реализации — в .cpp.
Ошибка №3: не отделять интерфейс от реализации и тащить include’ы за реализацию в заголовок.
Если в заголовке лежат определения функций (не просто объявления) и они используют кучу вещей, заголовок неизбежно раздувается. В нашем текущем уровне курса мы держимся простой дисциплины: объявления в .hpp, определения в .cpp. Это автоматически помогает держать include’ы на диете без голодовки.
Ошибка №4: не включать свой .hpp первым в .cpp и долго не замечать проблему.
Когда порядок include’ов «случайно правильный», ошибки не проявляются. Потом проект растёт, кто-то делает рефакторинг, и у вас внезапно ломается сборка «в непонятном месте». Подключение своего заголовка первым — это как пристёгиваться ремнём: большую часть времени кажется лишним, но в момент проблемы сильно экономит нервы.
Ошибка №5: путать “минимизация зависимостей” с “удалить все include’ы и надеяться на магию”.
Include What You Use не требует героизма. Если ваш интерфейс использует std::vector, то <vector> должен быть в заголовке. Если интерфейс использует std::string, то <string> должен быть в заголовке. Минимизировать надо не «любой ценой», а убирая именно лишнее: то, что нужно только реализации и не является частью интерфейса.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ