1. Роздільна компіляція та одиниця трансляції
Коли ви навчаєтеся, один файл — це нормально: менше перемикань, легше копіювати, простіше здавати задачі. Але щойно програма починає жити довше, ніж один вечір, зʼявляється типова ситуація: main.cpp розростається, як тека «Завантаження» в людини, яка щиро вірить, що колись усе розбере. В одному місці — введення, в іншому — обчислення, поруч — виведення, і все це переплетено так, що страшно чіпати навіть зайвий пробіл.
Багатофайловий проєкт дає змогу розкласти код за змістом: окремо — логіка, окремо — введення й виведення, окремо — моделі даних. І тут важливо одне: ми ділимо код не для компілятора, а для людей. Компілятор, якщо чесно, готовий проковтнути й величезний файл, але жити в ньому потім доведеться саме вам.
Починаючи з сьогоднішньої теми, ми поступово надаватимемо нашому навчальному проєкту дорослішого вигляду. Уявімо, що в нас є маленький консольний застосунок «Список справ» (todo): він зберігає завдання у std::vector<std::string>, виводить список і дає змогу додати нове завдання. Поки що все це було в одному main.cpp. Сьогодні ми спробуємо винести частину функцій в окремий .cpp — і саме тут упіймаємо головну ідею лекції.
Компілятор обробляє кожен .cpp окремо
Важлива думка дня звучить майже аж образливо просто: компілятор компілює кожен .cpp окремо. Він не відкриває «весь проєкт» як один великий текст — принаймні за замовчуванням. Він бере один .cpp, обробляє його, перетворює на проміжний результат (спрощено — «обʼєктний файл»), потім бере наступний .cpp, і так далі.
Чому так зроблено? Тому що це зручно й швидко для великих проєктів. Якщо ви змінили одну функцію в одному .cpp, було б дивно щоразу перекомпілювати весь проєкт. Окрема компіляція дає змогу перебудовувати лише змінені частини.
Але в цієї моделі є прямий наслідок, який підстерігає новачків уже з першої спроби «рознести код по файлах»: код з одного .cpp не стає автоматично видимим в іншому .cpp. Тобто «функція існує в сусідньому файлі» не рятує, доки компілятор не побачить хоча б її оголошення там, де ви її викликаєте.
Уявіть, що у вас дві контрольні роботи в різних аудиторіях. В аудиторії № 1 ви написали чудову формулу на аркуші. В аудиторії № 2 викладач перевіряє інший аркуш і гадки не має, що ви написали в першій аудиторії. Приблизно так само поводяться різні .cpp: кожен живе своїм життям.
Що таке одиниця трансляції
Тепер — ключовий термін: одиниця трансляції (translation unit).
Коли компілятор «компілює файл main.cpp», він компілює не лише текст із main.cpp, а й усе, що туди потрапило через #include. Можна уявити, що #include — це дуже проста, але корисна операція: «вставити сюди текст іншого файлу». Тому одиниця трансляції — це:
поточний .cpp + текст усіх заголовків, які до нього включили (прямо або опосередковано)
Звідси й логіка: якщо ви хочете, щоб у main.cpp було видно оголошення функції або типу, воно має потрапити всередину одиниці трансляції main.cpp. Найчастіше для цього підключають заголовок (.hpp), але сьогодні нам важливо зафіксувати сам принцип.
Стандарт C++ формально описує етапи «перекладу» вихідного коду. На одному з них, після роботи препроцесора, і формується те, що компілятор розглядає як одиницю трансляції. У робочих матеріалах стандарту це повʼязують із фазами трансляції та підкреслюють, що поняття translation unit стосується саме їх.
Корисна мінісхема (дуже спрощена, але цілком робоча):
flowchart TD
A[main.cpp + #include ...] --> B[Препроцесор: вставив текст заголовків]
B --> C[Одиниця трансляції]
C --> D[Компіляція: перевірка синтаксису й типів, генерація обʼєктного коду]
D --> E[... потім збирання всіх частин у програму]
Сьогодні для нас найважливіше саме місце «Одиниця трансляції»: це межа видимості на етапі компіляції одного файлу.
2. Практика: чому .cpp не «бачать» один одного
Мінімальний приклад: «функція є, але компілятор її не знає»
Розгляньмо найпростіший приклад, який показує проблему.
Варіант A: «здається, все має працювати»… але ні
math.cpp:
int add(int a, int b) {
return a + b;
}
main.cpp:
#include <iostream>
int main() {
std::cout << add(2, 3) << '\n'; // хочемо 5
}
Логіка новачка зрозуміла: «функція ж є, ось вона, у сусідньому файлі». Але компілятор компілює main.cpp окремо. В одиниці трансляції main.cpp імені add немає. Тому ви побачите приблизно таку помилку (формулювання залежить від компілятора або IDE): «error: 'add' was not declared in this scope».
Тобто: «я не знаю, що таке add».
Варіант B: додали оголошення — компілятор уже задоволений
Тепер додамо оголошення функції до main.cpp — лише один рядок, без тіла:
main.cpp:
#include <iostream>
int add(int a, int b); // оголошення: “така функція існує”
int main() {
std::cout << add(2, 3) << '\n'; // 5
}
math.cpp залишається тим самим:
int add(int a, int b) {
return a + b;
}
Тепер, коли компілятор обробляє main.cpp, він уже знає сигнатуру add(int, int) і може перевірити коректність виклику. Тіло функції на цьому етапі бачити не обовʼязково — оголошення достатньо.
Тут і зʼявляється звичка, яку ми розвиватимемо далі: виклик — в одному .cpp, реалізація — в іншому .cpp, а оголошення має бути доступним там, де ми викликаємо функцію.
Варіант C: чому не можна «просто вставити сусідній .cpp через include»
Іноді студент думає: «ну раз #include вставляє текст, давайте зробимо так: #include "math.cpp"». Це справді дасть змогу компілятору «побачити» код… але так ви почнете руйнувати модель роздільної компіляції. Чому це погана ідея, обговоримо нижче, а поки що зафіксуємо: .cpp — це файл, який передбачається компілювати окремо, а не «підклеювати» до інших.
Мініприклад: виносимо виведення завдань в окремий файл
Зробімо це на чомусь трохи живішому, ніж add. Нехай у нас є міні-застосунок «Todo»: ми хочемо виводити задачі й додавати нові. Раніше все було в main.cpp, але тепер спробуємо винести виведення списку в окремий файл todo_print.cpp.
Крок 1: у main.cpp ми викликаємо функцію друку
main.cpp:
#include <iostream>
#include <string>
#include <vector>
void print_tasks(const std::vector<std::string>& tasks); // оголошення
int main() {
std::vector<std::string> tasks = {"Buy milk", "Learn C++"};
print_tasks(tasks);
std::cout << "Done!\n"; // Done!
}
Зверніть увагу на послідовність: спочатку оголошення, потім використання. Це дає змогу main.cpp компілюватися незалежно.
Крок 2: реалізацію кладемо в todo_print.cpp
todo_print.cpp:
#include <iostream>
#include <string>
#include <vector>
void print_tasks(const std::vector<std::string>& tasks) {
for (std::size_t i = 0; i < tasks.size(); ++i) {
std::cout << (i + 1) << ") " << tasks[i] << '\n';
}
}
Цей код простий, але вже показує важливу річ: кожен .cpp має підключати саме те, що йому потрібне. todo_print.cpp використовує std::vector, std::string, std::cout — отже, він має підключити <vector>, <string>, <iostream>. Не можна сподіватися, що «в main.cpp це вже підключено».
Якщо ви забудете #include <vector>, компілятор, обробляючи todo_print.cpp, не зобовʼязаний «памʼятати», що десь у проєкті був <vector>. Він компілює файл окремо — і це знову повертає нас до моделі одиниць трансляції.
Що варто винести з цього блоку
Поки що ми використовуємо «тимчасовий» стиль: оголошення функції прямо в main.cpp. Для першого розуміння це нормально. У наступній лекції ми зробимо це правильно й акуратно: винесемо оголошення в заголовок .hpp, щоб не дублювати його в різних місцях.
Але зараз важливо зрозуміти головну механіку: main.cpp і todo_print.cpp компілюються як різні світи, і єдиний спосіб обмінятися знаннями між ними — зробити так, щоб потрібні оголошення потрапили в обидва світи.
3. Чому не можна #include "something.cpp" і як виправляти помилку «імʼя не знайдено»
Чому #include "something.cpp" — погана ідея
Дуже часто новачок знаходить «швидке рішення»: якщо main.cpp не бачить функцію з math.cpp, давайте просто вставимо math.cpp всередину main.cpp за допомогою #include. Формально це може спрацювати: текст math.cpp опиниться в одиниці трансляції main.cpp, компілятор побачить визначення функції, і все ніби запрацює.
Проблема в тому, що ви випадково перетворюєте проєкт на «один величезний файл, зібраний зі шматків». А це ламає одразу кілька важливих речей.
По-перше, ви втрачаєте роздільну компіляцію: зміни в підключуваному .cpp тепер змушуватимуть перезбирати все, що його включає. По-друге, зʼявляється ризик дублювання: якщо два різні .cpp включать той самий файл, у вас почнуть виникати конфлікти вже на етапі збирання підсумкової програми. Сьогодні ми не заглиблюємося в деталі, але повірте: проблем вистачить.
Правильна архітектурна ідея така: .cpp компілюються окремо, а «спільні оголошення» живуть у підключуваних заголовках. Ми поки що не розбираємо будову заголовків докладно — це тема наступної лекції, — але правило «.cpp не підключаємо» вже корисно запамʼятати так само, як «не пхайте виделку в розетку».
Як мислити, коли компілятор лається: «імʼя не знайдено»
Коли ви лише починаєте ділити код на файли, помилки на кшталт «не оголошено» трапляються частіше. Це нормально: ви змінюєте модель проєкту, і до неї ще треба звикнути.
Якщо компілятор каже щось на кшталт:
- was not declared in this scope
- use of undeclared identifier
- «не вдалося знайти ідентифікатор …»
то майже завжди це означає одну з двох ситуацій.
Перша ситуація — ви справді забули оголосити імʼя до використання в межах одного файлу. Це класика: ви знаєте її ще з теми про функції. Написали main, викликали foo(), а оголошення foo нижче — компілятор його не бачить.
Друга ситуація — більш «сьогоднішня»: імʼя існує, але не потрапило в одиницю трансляції цього .cpp. Наприклад, ви оголосили функцію в іншому .cpp і думаєте, що цього достатньо. Але компілятор, обробляючи поточний .cpp, не читає сусідній .cpp. Отже, у вас немає оголошення в поточному файлі або в заголовку, який ви підключили.
Корисна мікротаблиця для самоперевірки (без фанатизму, просто як орієнтир):
| Де знаходиться оголошення/визначення? | Чи бачить це компілятор під час компіляції main.cpp? | Чому |
|---|---|---|
| У самому main.cpp вище за текстом | Так | Це одна й та сама одиниця трансляції |
| У заголовку .hpp, підключеному в main.cpp | Так | #include вставив текст в одиницю трансляції |
| В іншому .cpp | Ні | Інший .cpp — інша одиниця трансляції |
І ще один маленький практичний прийом: коли ви бачите помилку, завжди ставте собі запитання: «У якому файлі компілятор зараз працює?» Помилка майже завжди вказує файл і рядок. Це не дрібниця. Це підказка: «у цій одиниці трансляції потрібного імені немає».
4. Типові помилки
Помилка № 1: очікувати, що «якщо функція є в проєкті, її побачать усюди».
Це інтуїція з моделі «один файл»: там справді все видно всюди, якщо ви правильно розташували код. У багатофайловій моделі це перестає бути правдою. Кожен .cpp компілюється окремо, і сусідній .cpp не є «продовженням» поточного. Виправляється це простою звичкою: там, де ви використовуєте імʼя, має бути його оголошення, зазвичай через #include заголовка.
Помилка № 2: писати оголошення функції «на око» в кількох місцях і випадково зробити їх різними.
Новачки часто копіюють сигнатуру вручну: в одному файлі int add(int, int);, а в іншому раптом стало long add(int, int); або додався const. Компілятор може почати видавати дивні помилки, а ви дивитиметеся на код і думатимете: «ну майже ж однаково». Правильна звичка така: оголошення має бути одне — у заголовку, — а решта файлів мають його підключати. Сьогодні ми це лише намітили, а в наступній лекції оформимо як норму.
Помилка № 3: підключати .cpp через #include, бо «так швидше».
Це та сама шкідлива звичка, яка здається робочою, доки проєкт маленький. Потім вона починає жити власним життям: уповільнює збирання, провокує конфлікти, робить структуру проєкту нечитабельною й перетворює налагодження на археологію. Дисципліна проста: підключаємо заголовки, .cpp компілюємо окремо.
Помилка № 4: забувати, що кожен .cpp повинен мати свої #include для використовуваних типів.
Часто це виглядає так: ви перенесли функцію в utils.cpp, а вона використовує std::string, і раптом компілятор каже, що std::string не існує. Це здається дивним: «але ж у main.cpp був <string>!». Але utils.cpp — окрема одиниця трансляції. Якщо тип використовується в цьому файлі, отже, потрібний стандартний заголовок має бути підключений саме тут.
Помилка № 5: панікувати й «лікувати» помилки додаванням #include <bits/stdc++.h> або десятка зайвих заголовків.
Іноді хочеться просто додати купу інклудів, аби все замовкло. Це як заклеїти скотчем трубу, що протікає: на мить проблема ніби зникла, але насправді її не розвʼязано. Значно корисніше зрозуміти, яке імʼя не знайдено, у якому .cpp його використовують і яке оголошення має потрапити в одиницю трансляції. Тоді ви додаєте рівно те, що потрібно, — і починаєте керувати кодом, а не вмовляти його працювати.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ