1. Чому «одна команда» працює
Коли ви вперше бачите команду на кшталт g++ main.cpp -o app, може виникнути відчуття, що компілятор — це такий собі «чарівний комбайн»: завантажили текст — отримали програму. Насправді всередині відбувається кілька кроків, але команда-драйвер (g++ або clang++) намагається спростити вам життя: ви даєте їй список вихідних файлів і просите «зібрати виконуваний файл», а вона сама компілює кожен .cpp, а потім лінкує результат.
Ця «одна команда» — чудовий режим для навчання й невеликих проєктів, бо вам не доводиться тримати в голові надто багато деталей. Поки що ви не думаєте про обʼєктні файли .o, окремі стадії, кешування збирання та інші «дорослі» речі. Достатньо памʼятати просту думку: усі потрібні .cpp мають бути перелічені в команді, і тоді на виході ви отримаєте виконуваний файл.
Щоб остаточно розвіяти це відчуття містики, уявімо збирання як маленьку кухню. Ви принесли продукти (вихідники .cpp), попросили кухаря (драйвер) приготувати страву (програму). Кухар сам вирішує, що нарізати, що посмажити, а що змішати. Але якщо ви забули принести мʼясо (один із .cpp із потрібною функцією), то на фінальному етапі вам скажуть: «вибачте, котлет не буде».
Нижче — схема того, що приблизно робить драйвер, коли ви запускаєте «одну команду»:
flowchart TD
A["Команда: g++/clang++ + прапорці + .cpp + -o app"] --> B["Компіляція кожного .cpp → тимчасові обʼєктні файли"]
B --> C["Лінкування обʼєктних файлів + стандартна бібліотека"]
C --> D["Готовий виконуваний файл (app)"]
2. Базова форма команди
Командний рядок — це як речення українською мовою: якщо не бачити структури, усе здається кашею. Тому наша мета — навчитися бачити шаблон, а не заучувати «магічне заклинання».
Типова форма має такий вигляд:
g++ -std=c++23 [інші_прапорці] <вихідники.cpp...> -o <імʼя_виходу>
Тут важливо розуміти зміст кожного блока. Компілятор, а точніше драйвер, спочатку зчитує прапорці, потім — список вхідних файлів, а далі, якщо ви цього попросили, створює результат із потрібним імʼям.
Зведемо це до «розбору речення»:
| Фрагмент | Приклад | Зміст |
|---|---|---|
| Драйвер | |
Команда, яка керує компіляцією та лінкуванням |
| Режим мови | |
За якими правилами C++ має читати ваш код |
| Вхідні файли | |
Які реалізації беруть участь у збиранні |
| Імʼя результату | |
Як назвати підсумковий виконуваний файл |
І одразу запамʼятайте дві практичні звички, які заощадять вам багато часу. Перша: завжди писати -std=c++23, навіть якщо «і так працює». Друга: завжди писати -o, щоб не плодити a.out і не гадати, який саме файл ви зараз запускаєте.
3. Один .cpp у виконуваний файл
Починати краще із ситуації, коли у вас лише один файл. Це як учитися їздити на велосипеді на порожньому паркувальному майданчику, а не одразу на жвавій дорозі.
Створімо main.cpp:
#include <iostream>
int main() {
std::cout << "Hello from one-file build!\n"; // Hello from one-file build!
return 0;
}
Тепер зберемо:
g++ -std=c++23 main.cpp -o app
Запускаємо (у Linux/macOS зазвичай так):
./app
На цьому етапі важливо відчути: одна команда робить усе. Але якщо в коді є помилка, ви побачите повідомлення компілятора, і воно буде привʼязане до рядка й колонки в main.cpp. Тобто шукати проблему вже значно простіше: компілятор сам показує координати.
4. Кілька .cpp однією командою
Щойно проєкт перестає бути «одним файлом на все», зʼявляється новий тип помилок: начебто все написано, але під час збирання щось «не знаходиться». І тут ключова навичка — розуміти, що .hpp підключає оголошення, а .cpp має потрапити в команду, інакше лінкер не побачить визначень.
Продовжимо наш навчальний консольний застосунок. Нехай це буде найпростіша «міні-утиліта» (умовно назвімо її tasker), яка вміє друкувати версію й виконувати маленьку дію — наприклад, збільшувати число на 1. Логіка буде в окремому файлі.
Створімо util.hpp:
#pragma once
int inc(int x);
Створімо util.cpp:
#include "util.hpp"
int inc(int x) {
return x + 1;
}
І main.cpp:
#include <iostream>
#include "util.hpp"
int main() {
std::cout << inc(41) << '\n'; // 42
return 0;
}
Тепер правильна команда збирання: обидва .cpp перелічено.
g++ -std=c++23 main.cpp util.cpp -o tasker
Чому #include "util.hpp" не замінює util.cpp
Дуже поширена помилка новачків — очікувати, що якщо main.cpp включає заголовок, то «ніби підʼєдналася й реалізація». Але #include — це буквально «встав текст заголовка сюди перед компіляцією». Заголовок не містить тіла функції (у нашому прикладі він містить лише оголошення), тому компілятор чесно компілює main.cpp, бачить, що inc існує, і на цьому етапі задоволений.
А далі починається лінкування: потрібно знайти, де саме міститься код inc. І якщо util.cpp ви не вказали в команді, цього коду немає серед зібраних частин, тож лінкер почне лаятися.
5. Читабельна команда: -o і порядок аргументів
Прапорець -o: чому імʼя результату — це не «косметика»
Іноді здається, що -o — це просто зручність. Але в навчальній практиці це ще й спосіб зменшити хаос.
Якщо ви не вказуєте -o, компілятор створює файл з імʼям за замовчуванням. На Linux/macOS це часто a.out. На Windows зазвичай буде a.exe (або щось схоже — залежно від середовища). Проблема в тому, що за кілька днів у вас може бути три різні проєкти, і в кожному — свій a.out. А ви, як герой класичного трилера, запускаєте «не того підозрюваного» й дивуєтеся: «чому вивід не той?».
Тому правило просте: щоразу задавайте імʼя результату, бажано таке, що відображає зміст:
g++ -std=c++23 main.cpp util.cpp -o tasker
Якщо хочеться мати «режими» (наприклад, зараз це просто демонстрація), можна називати так:
g++ -std=c++23 main.cpp util.cpp -o tasker_demo
Так, це все ще вручну й без систем збирання, але порядок в іменах — це вже половина порядку в голові.
Порядок аргументів: як зробити команду зручною для читання
Формально g++ і clang++ часто допускають доволі вільний порядок аргументів. Але новачкам це лише заважає: одного разу ви випадково напишете прапорець після файла, потім забудете, де -o, потім іще щось — і команда перетвориться на «заклинання, яке не можна чіпати».
Тому вводимо акуратний стиль запису. Він не єдино правильний, але читабельний:
g++ -std=c++23 <прапорці> <усі .cpp> -o <output>
Тобто спочатку йде драйвер, потім стандарт, далі — інші прапорці, якщо вони є, потім список вихідників, а наприкінці — імʼя результату.
Приклад у цьому стилі:
g++ -std=c++23 main.cpp util.cpp -o tasker
Якщо у вас багато файлів, переносити рядок можна через зворотний слеш (у bash/zsh), але це вже залежить від оболонки. Для навчальної практики достатньо просто тримати команду короткою й зрозумілою.
6. Де шукати помилки у виводі компілятора
Найпоширеніший сценарій на цьому етапі: ви запускаєте команду, а у відповідь отримуєте «простирадло тексту». Тут легко запанікувати, почати гортати вгору-вниз і подумати, що компілятор просто не любить вас особисто. Спойлер: компілятор не вміє ні любити, ні ненавидіти — він уміє лише страждати.
Головна техніка читання повідомлень така: шукаємо перше «справжнє» повідомлення про помилку, бо все інше часто є лише наслідками. Це особливо помітно в C++, де одна пропущена ; може породити десяток дивних повідомлень.
Як виглядає помилка компіляції
Помилки компіляції зазвичай містять назву файла й номер рядка. Наприклад, якщо ви забули крапку з комою:
#include <iostream>
int main() {
std::cout << "Oops!\n"
return 0;
}
Команда:
g++ -std=c++23 main.cpp -o app
Повідомлення буде приблизно в дусі «expected ‘;’ before ‘return’», і майже завжди там буде координата: main.cpp:<рядок>:<колонка>. Це означає, що проблема у тексті вихідного файла, і виправляти потрібно саме код.
Практична звичка тут така: дивіться на перший рядок, де вказано файл і позицію, потім переходьте в код і подивіться на 2–3 рядки вище. Дуже часто помилка насправді міститься трохи раніше, ніж місце, де компілятор «скрикнув».
Як виглядає помилка лінкування в режимі «одна команда»
А тепер найважливіший момент лекції. Коли ви збираєте однією командою кілька файлів, компіляція кожного файла може завершитися успішно, а потім лінкування «на фініші» впаде.
Типовий випадок: ви забули додати util.cpp у команду:
g++ -std=c++23 main.cpp -o tasker
Компілятор main.cpp скомпілює, бо оголошення inc він бачив у util.hpp, а от під час лінкування ви отримаєте повідомлення приблизно такого вигляду:
- десь буде фраза undefined reference to ...
- і десь поруч буде імʼя функції, наприклад inc(int)
Це означає: «виклик функції є, а визначення серед зібраних частин немає». На цьому етапі важливо не лікувати проблему «магією», а поставити собі просте запитання: чи перелічено в команді всі .cpp, де містяться визначення потрібних функцій?
І ось тут корисна «точка контролю»: якщо ви бачите помилку undefined reference, то це майже ніколи не про #include. Йдеться про те, що лінкер не отримав потрібний обʼєктний код (бо ви не передали якийсь .cpp).
У довгому виводі важливіше перше повідомлення
Коли помилок багато, око чіпляється за останній рядок, бо він найближче. Але компілятор зазвичай виводить повідомлення згори вниз в порядку виявлення, і часто саме перший error: — ключовий.
Якщо ви бачите багато рядків, можна діяти так: прокрутіть вивід команди до самого верху й знайдіть перше входження слова error:. У лінкувальних помилках слово error теж іноді трапляється, але там будуть характерні linker/ld/undefined reference. Суть вправи на цьому етапі проста: навчитися відрізняти «зламався вихідник» від «зламалося склеювання».
7. Практичний приклад: збираємо tasker із двох файлів
Щоб у вас перед очима залишилася «фотографія результату», зафіксуємо мінімальну структуру проєкту та одну команду збирання.
Нехай у нас такий набір файлів:
tasker/
main.cpp
util.hpp
util.cpp
Код, коротко й без зайвої магії, ми вже писали вище. Підсумкова команда збирання:
g++ -std=c++23 main.cpp util.cpp -o tasker
І запуск:
./tasker
Якщо все зроблено правильно, ви побачите 42 (у нашому прикладі, де ми друкували inc(41)).
Цей приклад важливий не тим, що він «робить щось корисне», а тим, що показує базову дисципліну: проєкт = кілька .cpp, і під час збирання «в одну команду» ви зобовʼязані перелічити їх усі.
8. Типові помилки
Помилка № 1: зібрати лише main.cpp і чекати, що решта частин «підтягнуться самі».
Зазвичай це трапляється після перших успіхів з однофайловими програмами. Здається, що #include "util.hpp" — це «підключити util». Але #include підключає лише текст заголовка, найчастіше — оголошення. Якщо визначення функцій лежать у util.cpp, то цей файл має бути явно вказаний у команді збирання, інакше під час лінкування ви побачите undefined reference.
Помилка № 2: не використовувати -o і запускати «не те, що ви щойно зібрали».
Коли ви не задаєте імʼя вихідного файла, зʼявляється a.out (або аналог). Потім ви змінюєте код, збираєте інший проєкт в іншому каталозі, а далі раптом запускаєте старий a.out і отримуєте старий вивід. У результаті здається, що «компілятор мене ігнорує», хоча насправді ви просто запускаєте не той файл.
Помилка № 3: намагатися лагодити проблему лінкування правками в #include.
Якщо ви бачите помилку лінкування на кшталт undefined reference, це майже ніколи не виправляється додаванням ще одного #include. Це виправляється тим, що ви додаєте в команду збирання потрібний .cpp (або пізніше — потрібний обʼєктний файл чи бібліотеку). На поточному етапі курсу достатньо памʼятати просту думку: лінкер «бачить» лише те, що ви реально передали на збирання як вхід.
Помилка № 4: панікувати через «простирадло» помилок і читати повідомлення знизу вгору.
Компілятор часто видає цілий ланцюжок наслідків. Якщо ви починаєте читати з кінця, то бачите симптоми й виправляєте не там. Корисна звичка: знайти перше ключове повідомлення (error: із зазначенням файла й рядка для компіляції, або undefined reference для лінкування), виправити його й лише потім дивитися, що залишилося.
Помилка № 5: збирати без -std=c++23, а потім дивуватися: «чому в мене інакше».
Сьогоднішня тема — форма команди. І в цій формі прапорець стандарту — не прикраса, а частина контракту збирання. Якщо ви його не вказуєте, компілятор може вибрати стандарт за замовчуванням, а це залежить від версії компілятора та політики збирання у вашій системі. Підсумок — нестабільна поведінка й «фантомні» помилки в інших людей.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ