JavaRush /Курси /C++ SELF /Кожен .cpp компілюєт...

Кожен .cpp компілюється окремо

C++ SELF
Рівень 26 , Лекція 0
Відкрита

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 його використовують і яке оголошення має потрапити в одиницю трансляції. Тоді ви додаєте рівно те, що потрібно, — і починаєте керувати кодом, а не вмовляти його працювати.

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