1. Роздільна компіляція
Якщо ви лише починаєте програмувати, роздільна компіляція може здаватися дивним ритуалом: «Чому не можна просто завжди виконувати g++ main.cpp -o app і жити спокійно?» На маленьких прикладах — можна. Але щойно в проєкті зʼявляється 5–20 файлів, стає помітно, що збирання дедалі більше нагадує приготування борщу: навіть якщо ви додали одну дрібку солі, вам чомусь пропонують заново виростити буряк.
Роздільна компіляція розвʼязує саме цю проблему. Ідея дуже проста: окремо компілювати кожен .cpp у проміжний артефакт, тобто в обʼєктний файл, а потім «зшивати» все лінкувальником. Тоді, якщо зміниться один .cpp, вам не доведеться перескладати весь проєкт: ви перескладаєте лише один обʼєктник і заново лінкуєте результат.
Це заощаджує час і допомагає краще зрозуміти, де саме виникла помилка: на етапі компіляції конкретного файла чи на етапі лінкування всіх частин в одну програму.
2. Обʼєктний файл і прапорець -c
Що таке обʼєктний файл .o
Обʼєктний файл (.o) — це результат компіляції одного .cpp файла. Усередині міститься машинний код або щось дуже близьке до нього, а також таблиці символів: які функції та змінні цей файл визначає і які він очікує отримати ззовні.
Уявіть, що .cpp — це окрема деталь конструктора LEGO. Компілятор перетворює її на «готову деталь» — обʼєктник, але в ній можуть залишатися місця, куди мають вставитися інші деталі. Лінкувальник — той, хто бере всі деталі й збирає з них один великий корабель, перевіряючи, що все збіглося і нічого не загубилося під диваном.
Важливо памʼятати: обʼєктник ще не є програмою. Його не можна «запустити». Це лише проміжний результат.
Прапорець -c: «компілюй, але не лінкуй»
Прапорець -c буквально каже компілятору: «Зроби все, що стосується компіляції, але зупинися перед етапом лінкування». Тобто компілятор візьме ваш .cpp, перетворить його на обʼєктний файл .o — і на цьому завершить роботу.
Типова форма команди має такий вигляд:
g++ -std=c++23 -c file.cpp
Якщо не вказати -o, компілятор зазвичай створить обʼєктник з очікуваною назвою file.o. Але коли ви збираєте проєкт із кількох файлів, краще відразу привчити себе явно називати вихідні артефакти:
g++ -std=c++23 -c file.cpp -o file.o
3. Міні‑проєкт TextStats
Щоб не обговорювати роздільну компіляцію абстрактно, зберемо невеликий застосунок із кількох файлів. Він читатиме рядок і виводитиме кількість літерних символів і пробілів. Логіка проста, але її зручно винести в окремий модуль.
Структура файлів
flowchart LR A[main.cpp] -->|#include 'text_stats.hpp'| B[text_stats.hpp] C[text_stats.cpp] -->|#include 'text_stats.hpp'| B A -->|викликає функції| C
text_stats.hpp: оголошення функцій
Коли ми починаємо ділити проєкт на файли, дуже важливо чітко розрізняти дві ролі: заголовок .hpp повідомляє, «що існує» (оголошення), а .cpp містить «як саме це працює» (визначення). Це схоже на меню в кафе: меню обіцяє, що «піца існує», але саму піцу ви отримуєте не з меню, а з кухні.
Створімо заголовок text_stats.hpp:
#pragma once
#include <string>
int count_letters(const std::string& s);
int count_spaces(const std::string& s);
Зверніть увагу: тут немає реалізації, лише сигнатури — і це нормально.
text_stats.cpp: визначення функцій
Тепер створімо файл text_stats.cpp, який реалізує ці функції. Тут важливо, щоб сигнатури точно збігалися з тим, що було оголошено в .hpp.
#include "text_stats.hpp"
#include <cctype>
int count_letters(const std::string& s) {
int cnt = 0;
for (char c : s) {
if (std::isalpha(static_cast<unsigned char>(c))) ++cnt;
}
return cnt;
}
І ось друга функція (ми спеціально робимо приклади невеликими):
#include "text_stats.hpp"
int count_spaces(const std::string& s) {
int cnt = 0;
for (char c : s) if (c == ' ') ++cnt;
return cnt;
}
Так, тут двічі використано #include "text_stats.hpp". У реальному проєкті ви, найімовірніше, тримали б обидві функції в одному text_stats.cpp, але для навчання корисно побачити, що кожен .cpp сам по собі є окремою компільованою сутністю.
main.cpp: використовуємо функції
У main.cpp ми читаємо рядок і виводимо статистику:
#include <iostream>
#include <string>
#include "text_stats.hpp"
int main() {
std::string s;
std::getline(std::cin, s);
std::cout << "letters=" << count_letters(s) << '\n';
std::cout << "spaces=" << count_spaces(s) << '\n';
}
Приклад введення й виведення, щоб відчути результат:
input: Hello world
output: letters=10
spaces=1
4. Збирання вручну: .cpp → .o → executable
Тепер найцікавіше: як зібрати все це вручну.
Крок 1: компілюємо кожен .cpp в обʼєктник
Спочатку компілюємо кожен вихідний файл окремо:
g++ -std=c++23 -c text_stats.cpp -o text_stats.o
g++ -std=c++23 -c main.cpp -o main.o
На цьому етапі нічого не запускається. У вас просто зʼявляються файли text_stats.o і main.o.
Крок 2: лінкуємо обʼєктники в програму
Тепер потрібно «зшити» обʼєктники в один виконуваний файл. Це робиться звичайним викликом g++, але вже без -c, а вхідними даними будуть .o:
g++ main.o text_stats.o -o textstats
Після цього зʼявиться textstats (або textstats.exe у середовищі на кшталт Windows). І ось його вже можна запускати:
./textstats
5. Інкрементальне перескладання
Тепер уявіть типову ситуацію: ви вирішили поліпшити count_letters і додати обробку певних символів. Ви змінюєте лише text_stats.cpp.
За роздільної компіляції ви дієте так:
-
перескладаєте лише один обʼєктник:
g++ -std=c++23 -c text_stats.cpp -o text_stats.o -
потім заново лінкуєте:
g++ main.o text_stats.o -o textstats
main.cpp при цьому не змінюється, main.o залишається тим самим. У цьому й полягає практичний виграш: проєкт може бути великим, а ви змінюєте маленький фрагмент — і перескладаєте лише його.
6. Помилка undefined reference: що вона означає
Помилки компіляції зазвичай більш-менш зрозумілі: вам показують рядок, колонку і те, що очікували. Помилки лінкування спочатку виглядають як «прокляття давньою мовою». Але в них є цілком чітка логіка.
Фраза undefined reference to ... означає таке: десь є використання функції або змінної, але серед того, що ви передали лінкувальнику, немає відповідного визначення.
Найчастіша причина на нашому рівні — ви забули додати потрібний обʼєктник (або відповідний .cpp) на етапі лінкування.
Давайте спеціально зберемо все неправильно. Ви виконали:
g++ -std=c++23 -c main.cpp -o main.o
g++ main.o -o textstats
Тобто text_stats.o ви не додали до лінкування. Компіляція main.cpp пройде, бо заголовок text_stats.hpp дав компілятору оголошення. Але коли лінкувальник почне «зшивати» програму, він скаже: «Окей, у main.o є виклик count_letters, але де реалізація? Я її не бачу».
І саме тут виникає надважливе правило:
#include допомагає компіляції (робить видимими оголошення), але не допомагає лінкуванню (не підключає реалізації).
Психологічно це типова пастка для новачка: «Але ж я підключив заголовок!» Так, підключили. Але заголовок — це не .cpp і не .o.
7. Як розбирати undefined reference за списком файлів
Коли ви бачите undefined reference, дуже хочеться почати діяти навмання: переставляти файли, дописувати #include де завгодно, перейменовувати все підряд. Це захопливо, але зазвичай не допомагає.
Натомість варто тримати в голові простий і спокійний алгоритм.
Спочатку визначаємо, що це помилка лінкування
Лінкувальні помилки зазвичай виглядають так: у повідомленні трапляються слова на кшталт undefined reference, ld, linker, collect2. Компілятор при цьому вже міг успішно створити .o.
Якщо це лінкування, то проблема майже завжди в одному з трьох:
- не додали потрібний .o (або .cpp) до лінкування;
- оголосили одне, а визначили інше (сигнатури не збіглися);
- визначили «занадто багато разів» (це вже multiple definition, сьогодні згадаємо це лише побіжно).
Якщо бракує обʼєктника, поставте собі одне запитання
Запитання звучить так: у якому .cpp мав міститися код цієї функції?
Наприклад, якщо помилка стосується count_spaces, то очевидний кандидат — text_stats.cpp. Отже, під час лінкування має бути text_stats.o.
Тобто команда лінкування має перелічувати обʼєктники так, щоб серед них був той, у якому міститься визначення.
Якщо обʼєктник є, але символ усе одно не знайдено
Це хитріший випадок: ви начебто лінкуєте text_stats.o, але помилка не зникає. Тоді дуже ймовірно, що компілятор і лінкувальник бачать дві різні функції:
- у main.cpp ви викликаєте int count_letters(const std::string&),
- а в text_stats.cpp випадково написали int count_letters(std::string) (за значенням) або забули const.
Для людини це «майже те саме». Для лінкувальника — два різні символи. Він не зобовʼязаний вгадувати ваші наміри. І це добре, бо інакше довелося б вгадувати взагалі все підряд.
Звʼязок з ODR і inline
Сьогодні ми не заглиблюємося в теорію, але корисно знати: багато лінкувальних проблем повʼязані з правилом «одне визначення» (ODR). Навіть у робочих нотатках до стандарту підкреслюють, що inline — це насамперед інструмент, який дає змогу кільком оголошенням задовольняти ODR, а не «порада оптимізатору».
Якщо сказати простіше: якщо ви почнете без розуміння переносити визначення функцій у заголовки, то легко отримаєте multiple definition. Тому на поточному рівні краще дотримуватися простого правила: оголошення — у .hpp, визначення — у .cpp.
Шпаргалка команд
Іноді корисно мати коротку підказку, щоб не тримати все в голові.
| Мета | Команда | Результат |
|---|---|---|
| Скомпілювати один файл, але не лінкувати | |
обʼєктник |
| Злінкувати програму з обʼєктників | |
виконуваний |
| Перескласти лише змінений модуль | |
оновлений |
| Швидко зрозуміти undefined reference | перевірити, що під час лінкування є потрібний |
зникнення помилки |
8. Типові помилки
Помилка № 1: очікувати виконуваний файл після команди з -c.
-c вимикає лінкування. Компілятор чесно створює обʼєктник і зупиняється. Якщо після g++ -c main.cpp ви намагаєтеся запустити результат, то фактично намагаєтеся запустити «напівфабрикат». Це як намагатися зʼїсти тісто лише тому, що «я ж уже змішав борошно і воду».
Помилка № 2: думати, що #include «підключає реалізацію».
#include підставляє текст заголовка у ваш .cpp на етапі препроцесора. Він допомагає компілятору побачити оголошення, але не додає до лінкування жодного коду. Тому undefined reference усувається не додаванням #include, а додаванням потрібного .o (або .cpp) до команди лінкування.
Помилка № 3: лінкувати не всі обʼєктники проєкту.
Це найчастіша причина undefined reference на рівні командного рядка. Ви зібрали main.o, зібрали text_stats.o, але в команді лінкування написали лише g++ main.o -o app. Лінкувальник не вгадує намірів: якщо файла немає в списку, то для нього його ніби не існує.
Помилка № 4: невідповідність сигнатур оголошення й визначення.
Дуже підступно виглядає ситуація, коли в заголовку int f(const std::string&);, а в .cpp ви написали int f(std::string);. Компіляція кожного файла окремо може пройти, а лінкування — завершитися помилкою. Лінкувальник шукає точний збіг символу. Для нього це різні функції, навіть якщо вам здається, що «різниці майже немає».
Помилка № 5: «швидко виправити» проблему, перенісши визначення функції в заголовок, і отримати нові проблеми.
Іноді після undefined reference новачок думає: «Ага! Значить, треба зробити так, щоб реалізація була видима скрізь» — і вставляє тіло функції в .hpp. На маленькому проєкті це може «випадково спрацювати», але в реальному проєкті легко призводить до multiple definition (бо один і той самий код потрапляє в кілька одиниць трансляції). На поточному етапі безпечніше дотримуватися базової дисципліни: реалізація — у .cpp, заголовок — для оголошень.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ