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, а проблема проявилася в іншому місці через крихкість заголовка.

Правило тут просте: якщо тип або функція згадується в заголовку, то заголовок має сам підключити потрібний стандартний заголовок. Саме так 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> має бути в заголовку. Мінімізувати треба не «будь-якою ціною», а прибираючи саме зайве: те, що потрібне лише реалізації й не є частиною інтерфейсу.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ