1. Чому важливо розуміти, як працює збирання в IDE
На цьому етапі курсу ви вже пишете програми, що можуть складатися з кількох файлів. І саме тепер магія кнопки «Run» іноді починає… трохи підморгувати червоним. Тут важливо зрозуміти просту річ: кнопка запуску в IDE — це не одна дія, а цілий ланцюжок кроків. Коли щось ламається, корисно вміти сказати: «Гаразд, це зламалося на етапі, де поєднують файли» або «Це сталося ще раніше: файл навіть не вдалося нормально прочитати через #include».
У C++ збирання майже завжди поділяється на три великі стадії:
- preprocessing (препроцесування),
- compile (компіляція),
- link (компонування).
Ми говоритимемо про них без командного рядка, без жодних страшних g++ -std=c++23 ..., — лише як про внутрішній конвеєр, який IDE запускає за вас.
Загальна картина: від .cpp до запуску програми
Якщо уявити збирання як виробництво піци (так, я теж хотів би, щоб компонування було піцою), то препроцесор — це етап, на якому ви розпаковуєте інгредієнти й читаєте рецепт, компілятор — «готує окремі частини», а компонувальник складає все в одну коробку й закриває кришку. Аналогія не ідеальна, але вона допомагає не плутати ролі.
У вигляді схеми це має такий вигляд:
flowchart LR
A[".cpp + #include .hpp"] -->|препроцесування| B["одиниця трансляції (текст після #include/#define)"]
B -->|компіляція| C["обʼєктний файл (.o/.obj)"]
C -->|компонування| D["виконуваний файл (програма)"]
Нижче — невелика таблиця «вхід → вихід», щоб очам було за що зачепитися:
| Стадія | Що подаємо на вхід | Що отримуємо на вихід | Що відбувається за змістом |
|---|---|---|---|
| Препроцесування | Текст .cpp + підключені .hpp + макроси | Один великий текст (умовно) | #include вставляє файли, #define підставляє текст, #if/#ifdef вирізає або залишає фрагменти |
| Компіляція | Підсумковий текст одного .cpp | Обʼєктний файл (.o/.obj) | Перевірка синтаксису й типів + генерація машинного коду частинами |
| Компонування | Набір обʼєктних файлів | Виконуваний файл | Поєднання всіх частин, «звʼязування» викликів функцій із їхніми реалізаціями |
Ключовий практичний висновок поки що просто запамʼятайте: компілятор аналізує кожен .cpp окремо, а «зібрати все разом у програму» — це вже завдання компонувальника.
2. Preprocessing: коли #include — це офіційне «копіювати-вставити»
Препроцесор — це стадія, яку новачки часто недооцінюють, бо «ну це ж просто #include». А потім трапляється магія: ви змінюєте один рядок у заголовку — і раптом ламається взагалі все. Або навпаки: ви впевнені, що написали код, але компілятор ніби бачить інший текст. І в цьому немає містики: препроцесор буквально перетворює текст ще до того, як компілятор почне розуміти типи, функції й правила мови.
Що насправді робить #include
#include "file.hpp" означає: «візьми вміст file.hpp і встав його сюди». Тобто це майже Ctrl+C/Ctrl+V, тільки без відчуття провини й із підтримкою промислової розробки.
Уявімо, що ми далі розвиваємо наш навчальний консольний застосунок TaskBook — найпростіший список завдань. Інтерфейс, тобто оголошення, зберігаємо в .hpp, а реалізацію — у .cpp.
task.hpp (заголовок, оголошення):
#pragma once
#include <string>
void addTask(std::string title);
main.cpp:
#include <iostream>
#include "task.hpp"
int main() {
addTask("Read about linker");
std::cout << "OK\n"; // OK
}
Коли препроцесор обробить main.cpp, він фактично отримає «єдиний текст»: на місці #include "task.hpp" зʼявиться вміст заголовка, а також вміст тих заголовків, які підключив уже він, наприклад <string>. Компілятор далі аналізуватиме саме цей текст.
І тут важливо: препроцесор не розуміє C++ як мову. Він не знає, що таке «функція», «тип» чи «шаблон». Він працює лише з текстом і директивами #....
Макроси: підстановка тексту, а не «маленькі функції»
Макрос — це суто текстовий механізм. А це означає, що він не дотримується правил типів, областей видимості й узагалі поводиться так, ніби ви вручну замінили один фрагмент тексту іншим.
Приклад:
#include <iostream>
#define SQUARE(x) ((x) * (x))
int main() {
std::cout << SQUARE(1 + 2) << '\n'; // 9
}
Тут дужки рятують нас від класичної пастки пріоритетів. Якби ми написали #define SQUARE(x) x * x, то SQUARE(1 + 2) перетворилося б на 1 + 2 * 1 + 2, і результат був би… сюрпризом. Такий сюрприз особливо прикрий, бо компілятор формально не зобовʼязаний розуміти, що ви хотіли «квадрат числа»: він бачить просто коректний вираз.
Умовна компіляція: код, який «існує» не завжди
Ще один важливий інструмент препроцесора — умовна компіляція. Це ситуація, коли частину коду вмикають або вимикають до компіляції.
Наприклад, візьмімо просте налаштування для діагностики:
config.hpp:
#pragma once
#define ENABLE_DIAGNOSTICS 1
main.cpp:
#include <iostream>
#include "config.hpp"
int main() {
#if ENABLE_DIAGNOSTICS
std::cout << "Diagnostics ON\n"; // Diagnostics ON
#endif
std::cout << "Run\n"; // Run
}
Тут компілятор побачить або обидва std::cout, або лише "Run" — залежно від значення макроса. Тобто для компілятора «вирізаного» коду ніби взагалі не існує.
Невеликий теоретичний, але корисний факт: стандарт C++ описує «фази трансляції», і там окремо виділено кроки, на яких препроцесорні сутності відпрацьовують та зникають із тексту до подальшого аналізу. Формулювання й правки навколо цього регулярно обговорюють у робочих чернетках стандарту.
3. Одиниця трансляції: що це таке і чому це важливо
Слово «одиниця трансляції» звучить так, ніби ми зараз почнемо перекладати Шекспіра. Насправді це дуже практичне поняття: одиниця трансляції — це те, що утворюється з одного .cpp після роботи препроцесора. Тобто це .cpp плюс усе, що до нього включилося через #include — прямо й опосередковано, — плюс розгорнуті макроси, плюс оброблені #if.
Чому це важливо? Тому що компіляція відбувається за одиницями трансляції. Тобто якщо у вас є main.cpp, task.cpp, storage.cpp, то ви матимете три окремі одиниці трансляції, і компілятор обробить їх окремо.
Це одразу пояснює дві «дивини», які ви, напевно, вже бачили.
- Перша дивина: «Чому я написав функцію в task.cpp, але в main.cpp компілятор каже, що такої функції немає?» Тому що компілятор компілює main.cpp окремо й має побачити оголошення, зазвичай через .hpp, безпосередньо у main.cpp після #include.
- Друга дивина: «Чому, якщо я визначу змінну в заголовку, усе раптом ламається в багатьох файлах?» Тому що заголовок включиться в кілька одиниць трансляції, і те саме визначення розмножиться. Саме тут ми й виходимо на тему ODR, яка вже зʼявлялася в попередніх лекціях: один проєкт, одне визначення сутності — у потрібному сенсі цього правила.
4. Compile: компіляція як «складання деталей окремо»
Компіляція — це стадія, на якій із тексту, уже після препроцесора, утворюється обʼєктний файл. І найважливіша думка сьогодні така: кожен .cpp компілюється окремо. Не «весь проєкт цілком», не «усі файли одночасно», а суворо по одному.
Що таке обʼєктний файл і чому ви його рідко бачите
Обʼєктний файл (.o або .obj) — це результат компіляції одного .cpp. IDE зазвичай ховає ці файли в службових каталогах, щоб вони не заважали, але логічно можна думати так:
- там уже є машинний код для функцій із цього .cpp,
- там є інформація про те, які символи (функції/глобальні змінні) цей файл визначає,
- і які символи він використовує, але не визначає, наприклад викликає функції з інших .cpp.
Якщо повернутися до нашого TaskBook, то task.cpp після компіляції стане «коробкою з деталями»: усередині буде реалізація addTask, але сама коробка ще не є готовою програмою.
Чому компіляція може пройти, навіть якщо програму ще не можна зібрати
Ось поширена ситуація, яка здається парадоксальною, але насправді цілком логічна.
task.hpp:
#pragma once
#include <string>
void addTask(std::string title);
main.cpp:
#include <iostream>
#include "task.hpp"
int main() {
addTask("Buy milk");
std::cout << "done\n"; // done
}
Цей main.cpp спокійно компілюється, бо компілятору достатньо знати оголошення addTask. Він бачить: «гаразд, є функція, вона приймає std::string і повертає void». Компілятор може згенерувати код виклику.
Але де лежить тіло addTask? Це вже не проблема компіляції main.cpp. Вона виникне пізніше, на етапі компонування, якщо реалізації немає або вона не бере участі у збиранні.
Чому зміна .hpp часто «перекомпілює пів проєкту»
Якщо ви змінюєте .cpp, IDE зазвичай перекомпілює тільки цей .cpp. Але якщо ви змінюєте заголовок .hpp, який підключають багато .cpp, то IDE змушена перекомпілювати всі повʼязані одиниці трансляції, адже після препроцесора в них змінюється підсумковий текст.
Це одна з причин, чому ми раніше говорили «include what you use» і «не тягніть у .hpp зайвого»: заголовки сильно впливають на час збирання.
5. Link: поєднання обʼєктних файлів в одну програму
Компонування — це фінальна стадія: ми беремо кілька обʼєктних файлів і отримуємо один виконуваний файл — програму, яку вже можна запускати. Якщо компіляцію можна порівняти з виготовленням деталей конструктора, то компонування — це інструкція «як зʼєднати все в одну модель, щоб вона не розвалилася після першого „Run“».
Що означає «звʼязати символи»
Коли main.cpp викликає addTask(...), в обʼєктному файлі main.o буде приблизно такий запис: «мені потрібна функція addTask(std::string)». А в обʼєктному файлі task.o буде запис: «а ось вона, функція addTask(std::string), беріть».
Компонувальник виступає своєрідним «весільним ведучим» для цих двох: знаходить, де визначено потрібний символ, і повʼязує виклик із реалізацією.
- Якщо реалізацію не знайдено, компонувальник не може зібрати програму.
- Якщо реалізацію знайдено у двох різних обʼєктних файлах, тобто ви визначили одне й те саме двічі, компонувальник теж зупиниться, бо незрозуміло, яку з двох реалізацій вважати правильною.
Мініприклад «оголосили, але не визначили»
Зробімо маленький приклад у стилі «як це виглядає в проєкті».
math.hpp:
#pragma once
int add(int a, int b);
main.cpp:
#include <iostream>
#include "math.hpp"
int main() {
std::cout << add(2, 3) << '\n'; // хочемо 5
}
Якщо в проєкті немає math.cpp з реалізацією add, то main.cpp може скомпілюватися, але вся програма цілком не збереться. Саме тому, що згенерувати виклик функції можна, знаючи оголошення, а реально виконати цей виклик — лише якщо десь є її тіло.
Важливо: ми зараз не занурюємося в детальну класифікацію повідомлень про помилки — це буде окрема лекція. Наразі нам потрібна лише модель: компонування — це крок, на якому проєкт перетворюється на єдиний виконуваний файл.
6. Як мислити про стадії збирання
Як визначити стадію за симптомами
Після сьогоднішньої лекції у вас має зʼявитися звичка: коли щось пішло не так, подумки запитуйте: «На якому етапі це сталося?» Це дуже допомагає не метатися по коду хаотично.
- Якщо проблема повʼязана з #include (файл не підключився, макрос дивно підставився, фрагмент коду «зник»), то це майже завжди зона препроцесора.
- Якщо проблема повʼязана з тим, що «код не сприймається як коректний C++» (синтаксис, типи, «не можу перетворити», «немає такого методу»), то це зона компіляції.
- Якщо проблема повʼязана з тим, що «все було нормально, але наприкінці щось не зійшлося» (не знайшлася реалізація, знайшлося два визначення), то це зона компонування.
І так, вам не обовʼязково памʼятати всі ці терміни. Головне — тримати в голові, що кнопка «Run» в IDE запускає не одну магію, а три. (Якби магія була одна, C++ був би аж підозріло добрим.)
Практичний приклад: TaskBook проходить увесь конвеєр
Зараз акуратно зберемо мініверсію TaskBook із трьох файлів, щоб побачити повний шлях без зайвої складності. Зауважте: приклади маленькі, але структура вже «доросла»: інтерфейс у .hpp, реалізація — у .cpp, використання — у main.cpp.
task.hpp:
#pragma once
#include <string>
void addTask(const std::string& title);
task.cpp:
#include "task.hpp"
#include <iostream>
void addTask(const std::string& title) {
std::cout << "Added: " << title << '\n'; // Added: ...
}
main.cpp:
#include "task.hpp"
int main() {
addTask("Learn preprocessing");
addTask("Learn compilation");
}
Тепер подумки проганяємо збирання.
- Спершу — препроцесування для main.cpp: воно вставить вміст task.hpp, а там, зі свого боку, вставиться <string>. Після цього компілятор побачить оголошення addTask.
- Потім — компіляція: main.cpp перетворюється на обʼєктний файл, де є main() і є «виклики addTask, реалізація десь має бути». Окремо компілюється task.cpp: там зʼявляється реальна функція addTask.
- Потім — компонування: компонувальник поєднує main() і addTask в одну програму. І лише після цього її можна запускати.
7. Типові помилки
Помилка № 1: думати, що компілятор «бачить увесь проєкт одразу».
Це одна з найчастіших ментальних пасток. Здається логічним: «ну в мене ж проєкт, там усі файли поруч». Але фактично компіляція йде по одному .cpp. Тому, якщо ви не підключили потрібний .hpp, то «task.cpp, який лежить поруч» не врятує: компілятор під час компіляції main.cpp нічого про нього знати не зобовʼязаний.
Помилка № 2: очікувати від препроцесора розуміння типів і областей видимості.
Препроцесор працює з текстом. Він не знає, що таке std::string, не знає, що таке «змінна», і не вміє «акуратно підставити макрос». Він просто замінює один фрагмент тексту іншим. Тому макроси без дужок, хитрі #define, дивні #if часто дають ефекти, які виглядають як баги компілятора, хоча насправді це звичайна текстова підстановка.
Помилка № 3: тримати визначення в заголовках «бо так простіше».
Новачкам дуже хочеться все писати в .hpp, щоб «точно було видно». Але заголовок підключається до багатьох .cpp, і визначення, особливо глобальних змінних і не-inline функцій, починають розмножуватися. Це може призвести до того, що фінальне збирання не завершиться: компонувальник побачить кілька однакових визначень і відмовиться збирати програму.
Помилка № 4: плутати «файл існує» і «файл бере участь у збиранні».
Навіть якщо task.cpp лежить у папці проєкту, це ще не гарантує, що IDE додала його до збирання. У деяких середовищах файл треба явно включити в target/проєкт. Тоді компіляція окремих файлів може проходити, але на фінальному кроці «склеювання» раптом виявиться, що потрібної реалізації ніби немає.
Помилка № 5: лікувати проблеми стадії link правками в коді, який і так компілюється.
Коли програма «не збирається цілком», рука тягнеться переписувати main.cpp, додавати #include, навмання змінювати сигнатури. Часто це швидко перетворюється на хаос. Набагато спокійніше спершу запитати себе: «Моя проблема точно на етапі компонування?» Тоді ви шукатимете невідповідність між оголошеннями й визначеннями або відсутність потрібного .cpp у збиранні. Такий підхід економить години й помітно знижує рівень бажання «піти в Python» (хоча Python не винен).
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ