JavaRush /Курсы /C++ SELF /“Include what you use”: минимизация зависимостей

“Include what you use”: минимизация зависимостей

C++ SELF
27 уровень , 1 лекция
Открыта

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.

Полезно держать в голове простую табличку (не абсолютную, но очень практичную по ощущениям):

Заголовок Обычно “тяжесть” Когда подключать
<string>
,
<vector>
,
<utility>
чаще умеренная если тип упоминается в интерфейсе
<iosfwd>
лёгкая если в интерфейсе есть std::ostream&/std::istream&
<iostream>
часто тяжёлая почти всегда только в .cpp, где реально печатаете в std::cout
<algorithm>
умеренная обычно в .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> должен быть в заголовке. Минимизировать надо не «любой ценой», а убирая именно лишнее: то, что нужно только реализации и не является частью интерфейса.

1
Задача
C++ SELF, 27 уровень, 1 лекция
Недоступна
Карточка контакта
Карточка контакта
1
Задача
C++ SELF, 27 уровень, 1 лекция
Недоступна
Визитка сотрудника
Визитка сотрудника
1
Задача
C++ SELF, 27 уровень, 1 лекция
Недоступна
Лёгкий принтер
Лёгкий принтер
1
Задача
C++ SELF, 27 уровень, 1 лекция
Недоступна
Чистка списка
Чистка списка
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ