C++

Розподіл класу на заголовковий файл та файл реалізації

Відокремлення оголошення від реалізації методів, організація класів у пари .h/.cpp, оператор дозволу області видимості ::, практичний патерн багатофайлового проєкту з класами, header guards та ODR для класів.

Розподіл класу на заголовковий файл та файл реалізації

Чому клас у одному файлі стає проблемою

До цього моменту ми писали класи цілком у одному файлі — оголошення та реалізація методів разом, всередині блоку class. Це зручно для навчання: весь код перед очима, не потрібно перемикатися між файлами, структура класу читається відразу. Але ця зручність має ціну, яка стає відчутною при зростанні розмірів класу.

Уявіть клас Student, що керує даними студента університету. Спочатку в ньому було кілька полів — прізвище, курс, середній бал — та кілька простих методів: конструктор, геттери, один сеттер. Файл займав тридцять рядків. Потім з'явилися методи для обчислення рейтингу, форматування виводу, валідації даних, порівняння двох студентів, сортування списків. Через місяць клас розрісся до двохсот рядків, через два — до п'ятисот. Тепер, щоб побачити, які методи взагалі існують у класі, доводиться прокручувати екрани коду з реалізацією, шукати фігурні дужки, відокремлювати інтерфейс від деталей.

Проблема не лише в читабельності. Інший розробник, який хоче використати ваш клас Student у своєму файлі, підключить заголовок #include "Student.h" — і отримає весь файл цілком: визначення всіх методів, приватні деталі, коментарі до реалізації. Але йому потрібне лише оголошення — список методів, параметри, типи повернення. Решта — це внутрішня кухня, яка не стосується того, хто користується класом.

Тепер змінимо один приватний метод у класі. IDE автоматично перекомпілює кожен файл, що підключає цей заголовок — адже формально вміст заголовка змінився. Якщо це великий проєкт із десятками файлів, які використовують Student, зміна однієї функції породжує хвилю перекомпіляції. Збірка затягується на хвилини. У великих командних проєктах це перетворюється на системну проблему.

Це не теоретичне міркування — це реальна індустріальна проблема. Час збірки впливає на продуктивність команди. Компанії витрачають інженерні місяці на оптимізацію залежностей між заголовками, щоб прискорити збірку на секунди.

Рішення цих проблем — розподіл класу на два файли: заголовковий файл (.h) з оголошенням класу та файл реалізації (.cpp) з визначеннями методів. Це стандартна практика C++, якої дотримується вся індустрія — від стандартної бібліотеки до корпоративних проєктів.


Від монолітного файлу до пари .h + .cpp

Перш ніж перейти до синтаксису, розглянемо зміни концептуально.

Було: клас Date живе цілком у main.cpp — всі методи визначені відразу всередині тіла класу:

main.cpp — монолітний підхід
#include <iostream>

class Date
{
private:
    int day;
    int month;
    int year;

public:
    Date(int d, int m, int y)
    {
        setDate(d, m, y);
    }

    void setDate(int d, int m, int y)
    {
        day = d;
        month = m;
        year = y;
    }

    void print() const
    {
        std::cout << day << "." << month << "." << year << "\n";
    }
};

int main()
{
    Date today(16, 9, 2026);
    today.print();
    return 0;
}

Все працює. Але якщо інший файл захоче використати Date, йому доведеться скопіювати оголошення класу або підключити весь main.cpp — що неможливо, адже main() може бути лише одна. Якщо ви змінюєте реалізацію setDate(), доводиться перекомпілювати весь файл.

Стане: клас розбивається на три файли:

  1. Date.h — заголовковий файл з оголошенням класу: структура, імена методів, параметри, типи повернення. Без реалізації.
  2. Date.cpp — файл реалізації з визначеннями методів. Підключає власний заголовок Date.h.
  3. main.cpp — основний файл програми, який підключає Date.h і використовує клас.

Структура проєкту стає такою:

Тепер програміст, який працює з класом, бачить у Date.h лише інтерфейс — що клас уміє робити. Як він це робить — приховано в Date.cpp. Зміна реалізації методу в Date.cpp вимагає перекомпіляції лише цього файлу. main.cpp і будь-які інші файли, що використовують Date, залишаються без змін.

Відокремлення інтерфейсу від реалізації — це не лише технічний прийом. Це фундаментальний принцип дизайну програмного забезпечення, що називається інкапсуляцією на рівні модулів. Користувач класу бачить лише те, що йому потрібно для роботи.

Заголовковий файл: оголошення класу

Заголовковий файл містить структуру класу: список полів і методів з їхніми сигнатурами, але без тіл функцій. Це контракт: «клас Date має такі-то методи, приймають такі-то параметри, повертають такі-то типи».

Date.h
#ifndef DATE_H
#define DATE_H

class Date
{
private:
    int day;
    int month;
    int year;

public:
    Date(int d, int m, int y);
    void setDate(int d, int m, int y);
    void print() const;

    int getDay() const { return day; }
    int getMonth() const { return month; }
    int getYear() const { return year; }
};

#endif

Що відбувається у цьому файлі

Рядки 1–2, 22: Header guard — захист від подвійного підключення. Ми розглядали це детально в статті 46. Багатофайлові програми. Без цього підключення Date.h у кількох файлах призведе до помилки компіляції «redefinition of class Date».

Рядки 4–21: Визначення класу Dateповна структура класу, але без реалізації більшості методів. Компілятор бачить, які поля існують, які методи доступні, які параметри вони приймають. Цього достатньо, щоб перевірити коректність використання класу в інших файлах.

Рядки 12–14: Прототипи методів — оголошення без реалізації. Конструктор Date(int d, int m, int y); повідомляє компілятору: «існує конструктор із трьома параметрами типу int». Де саме цей конструктор визначений — компілятору під час обробки заголовка знати не потрібно. Лінкер знайде визначення пізніше.

Рядки 16–18: Тривіальні геттери визначені відразу всередині класу. Це дозволено і навіть рекомендовано для простих однорядкових методів. Такі методи автоматично стають inline — компілятор може вставити їхній код безпосередньо в місце виклику замість справжнього виклику функції, що прискорює виконання.

Правило: тривіальні методи (геттери, деструктори за замовчуванням, прості сеттери без логіки) можна залишати в заголовку. Нетривіальні — переносять у .cpp.

Файл реалізації: визначення методів поза класом

Файл реалізації містить визначення методів — їхнє повне тіло з реалізацією. Кожен метод оголошується як звичайна функція, але з префіксом ClassName:: — це говорить компілятору, що функція належить класу ClassName.

Date.cpp
#include "Date.h"
#include <iostream>

Date::Date(int d, int m, int y)
{
    setDate(d, m, y);
}

void Date::setDate(int d, int m, int y)
{
    day = d;
    month = m;
    year = y;
}

void Date::print() const
{
    std::cout << day << "." << month << "." << year << "\n";
}

Що відбувається у цьому файлі

Рядок 1: #include "Date.h" — файл реалізації завжди підключає власний заголовок. Це не просто зручність — це захисний механізм. Якщо сигнатура методу в .h і .cpp розійдуться (наприклад, у .h написано void setDate(int, int, int), а в .cppvoid setDate(int, int)), компілятор видасть помилку відразу під час компіляції Date.cpp. Без підключення заголовка ця помилка залишилася б непоміченою до етапу лінкування — де її складніше діагностувати.

Рядок 2: #include <iostream> підключається тут, а не в Date.h. Чому? Тому що iostream потрібен лише для реалізації методу print(). Заголовок Date.h не містить жодного виклику std::cout — йому iostream не потрібен. Правило: підключайте заголовки там, де вони безпосередньо використовуються. Це зменшує залежності та прискорює компіляцію.

Рядок 4: Date::Date(int d, int m, int y) — визначення конструктора. Префікс Date:: перед другим Date каже: «це не вільна функція Date, а конструктор класу Date». Без :: компілятор сприйняв би це як спробу визначити звичайну функцію з ім'ям Date, що повертає тип Date — неможливо і безглуздо.

Рядок 9: void Date::setDate(...) — визначення звичайного методу. Знову префікс Date::. Всередині цієї функції можна звертатися до приватних полів day, month, year — адже функція є членом класу, хоч і визначена поза його тілом.

Рядок 16: void Date::print() const — метод із суфіксом const. Суфікс const має бути і в оголошенні, і в визначенні. Якщо в .h метод оголошений як const, а в .cpp визначений без const — помилка компіляції.

Сигнатура методу в .h і .cpp має збігатися повністю: ім'я, параметри, типи, const. Будь-яка розбіжність — помилка компіляції або лінкування.

Оператор дозволу області видимості ::

Оператор :: (scope resolution operator) — це інструмент, що дозволяє уточнити, у якій області видимості знаходиться ідентифікатор. Ми вже зустрічали його раніше: std::cout, std::string — це звернення до ідентифікаторів cout та string у просторі імен std.

Для методів класів :: працює аналогічно: ClassName::methodName означає «метод methodName класу ClassName».

Синтаксис визначення методу поза класом

Загальний шаблон:

ТипПовернення ClassName::methodName(параметри) const_якщо_потрібно
{
    // тіло методу
}

Конкретні приклади:

// Конструктор
Date::Date(int d, int m, int y)
{
    setDate(d, m, y);
}

// Звичайний метод
void Date::setDate(int d, int m, int y)
{
    day = d;
    month = m;
    year = y;
}

// Константний метод
int Date::getDay() const
{
    return day;
}

// Метод, що повертає складний тип
std::string Date::toString() const
{
    return std::to_string(day) + "." + std::to_string(month) + "." + std::to_string(year);
}

Важливі правила:

  1. Префікс ClassName:: обов'язковий — без нього компілятор сприйме це як звичайну вільну функцію.
  2. Суфікс const має бути у визначенні, якщо був в оголошенні.
  3. Параметри за замовчуванням вказуються лише в оголошенні.h), не в визначенні.cpp).
Типова помилка: вказати параметри за замовчуванням і в .h, і в .cpp. Це помилка компіляції. Правило: параметри за замовчуванням лише в заголовку — там, де їх бачить той, хто викликає метод.

Основний файл: використання класу

Тепер файл main.cpp стає максимально чистим — він підключає заголовок Date.h і працює з класом, не знаючи нічого про його реалізацію.

main.cpp
#include <iostream>
#include "Date.h"

int main()
{
    Date birthday(5, 12, 2005);
    Date today(16, 9, 2026);

    std::cout << "День народження: ";
    birthday.print();

    std::cout << "Сьогодні: ";
    today.print();

    std::cout << "Рік народження: " << birthday.getYear() << "\n";

    return 0;
}
./program
$ g++ Date.cpp main.cpp -o program && ./program
День народження: 5.12.2005
Сьогодні: 16.9.2026
Рік народження: 2005
Execution finished with exit code 0.

Що відбувається під час компіляції

Компіляція відбувається у два етапи:

Етап 1: компіляція кожного .cpp окремо

g++ -c Date.cpp -o Date.o    # Date.cpp → Date.o
g++ -c main.cpp -o main.o    # main.cpp → main.o

Компілятор обробляє Date.cpp:

  • Бачить #include "Date.h" → вставляє оголошення класу
  • Компілює визначення методів Date::Date(), Date::setDate(), Date::print()
  • Генерує об'єктний файл Date.o із машинним кодом цих методів

Компілятор обробляє main.cpp:

  • Бачить #include "Date.h" → вставляє оголошення класу
  • Бачить виклики Date birthday(5, 12, 2005); та birthday.print();
  • Перевіряє, чи існують ці методи в оголошенні — так, існують
  • Генерує об'єктний файл main.o із викликами цих методів (адреси методів ще невідомі)

Етап 2: лінкування

g++ Date.o main.o -o program

Лінкер об'єднує два об'єктних файли:

  • У main.o є виклик Date::print() без адреси
  • У Date.o є визначення Date::print() з адресою
  • Лінкер «зшиває» виклик із визначенням — тепер main.o знає, куди передати управління

Результат — виконуваний файл program.

Саме тому заголовковий файл достатній для компіляції main.cpp — компілятору потрібні лише оголошення, щоб перевірити коректність викликів. Визначення потрібні лінкеру, і вони знаходяться в Date.o.

Правило одного визначення (ODR) для класів

У статті 46. Багатофайлові програми ми детально розглядали правило ODR: кожна функція може мати лише одне визначення в усій програмі, але оголошень — скільки завгодно.

Для класів ODR працює інакше:

✅ Визначення класу — скільки завгодно

Визначення класу (структура у фігурних дужках) може бути продубльоване у кількох одиницях трансляції. Це не порушує ODR. Компілятор сприймає клас як тип, а типи звільнені від обмеження «одне визначення».

Наслідок: можна безпечно підключати Date.h у скільки завгодно файлів. Кожен .cpp отримає власну копію визначення класу — це нормально.

⚠️ Визначення методів — лише один раз

Визначення методів класу (тіло функції) слідують звичайному ODR для функцій — одне визначення на всю програму.

Наслідок: визначення методів мають бути у .cpp, а не в .h. Якщо визначити метод у .h, кожен файл, що підключить цей заголовок, отримає копію визначення — порушення ODR, помилка лінкера «multiple definition».

Виняток: inline-методи

Методи, визначені всередині тіла класу, автоматично стають inline. Inline-функції звільнені від ODR — їх визначення може бути продубльоване. Тому геттери та інші тривіальні методи можна залишати в заголовку:

Date.h
class Date
{
public:
    int getDay() const { return day; }  // inline — безпечно в заголовку
};

Методи, визначені поза тілом класу, не є inline автоматично (якщо не додано ключове слово inline явно). Їх визначення має бути в .cpp:

Date.cpp
void Date::print() const  // не inline — лише в .cpp
{
    std::cout << day << "." << month << "." << year << "\n";
}
Сучасні компілятори самі вирішують, чи робити функцію inline, навіть якщо ключове слово inline відсутнє. Але правило залишається: визначення в заголовку лише для тривіальних методів.

Структура проєкту з класами: еталонний патерн

Стандартна організація проєкту з класами виглядає так:

project/
├── Date.h          # Заголовковий файл класу Date
├── Date.cpp        # Реалізація методів класу Date
├── Time.h          # Заголовковий файл класу Time
├── Time.cpp        # Реалізація методів класу Time
└── main.cpp        # Основний файл програми

Кожен клас має пару файлів із однаковим ім'ям: ClassName.h + ClassName.cpp. Це не технічна вимога — компілятору байдуже, як названі файли. Це конвенція, що полегшує навігацію у проєкті: побачивши Student.h, ви одразу знаєте, що реалізація в Student.cpp.

Компіляція багатофайлового проєкту

Простий підхід — передати всі файли одразу:

g++ Date.cpp Time.cpp main.cpp -o program

Компілятор скомпілює кожен .cpp окремо і автоматично виконає лінкування.

Роздільна компіляція — для великих проєктів:

g++ -c Date.cpp -o Date.o
g++ -c Time.cpp -o Time.o
g++ -c main.cpp -o main.o
g++ Date.o Time.o main.o -o program

Переваги роздільної компіляції:

  • При зміні Time.cpp перекомпілюється лише цей файл, Date.o і main.o залишаються без змін
  • У великих проєктах це економить години часу збірки
У реальних проєктах використовують системи збірки (CMake, Makefile), які автоматично відстежують залежності та перекомпілюють лише змінені файли.

Типові помилки та їх діагностика

Помилка 1: «was not declared in this scope»

error: 'Date' was not declared in this scope

Причина: файл не підключає Date.h. Компілятор не знає, що таке Date.

Рішення: додайте #include "Date.h" на початок файлу.


Помилка 2: «undefined reference to Date::print()»

undefined reference to `Date::print()`

Причина: помилка лінкера, а не компілятора. Файл Date.cpp не доданий до збірки. Компілятор бачить оголошення методу в .h, але лінкер не знаходить його визначення.

Рішення: додайте Date.cpp до команди компіляції або до конфігурації IDE.


Помилка 3: «multiple definition of Date::setDate()»

multiple definition of `Date::setDate()`

Причина: метод визначений у заголовковому файлі, який підключено у кількох .cpp. Кожне підключення породжує нове визначення — порушення ODR.

Рішення: перенесіть визначення методу з .h у .cpp. У заголовку залиште лише оголошення.


Помилка 4: параметри за замовчуванням у .cpp

Date.h
void setDate(int d = 1, int m = 1, int y = 2000);
Date.cpp
void Date::setDate(int d = 1, int m = 1, int y = 2000)  // ❌ помилка!
{
    day = d;
    month = m;
    year = y;
}
error: default argument given for parameter 1

Причина: параметри за замовчуванням вказуються лише в оголошенні, не в визначенні.

Рішення:

Date.cpp
void Date::setDate(int d, int m, int y)  // ✅ без параметрів за замовчуванням
{
    day = d;
    month = m;
    year = y;
}

Практика: клас IntArray із розподілом на файли

Розглянемо клас, що керує динамічним масивом цілих чисел — приклад RAII-класу із попередньої статті 54. Деструктори та ідіома RAII. Тепер розділимо його на файли.


Inline-методи: коли визначати в заголовку

Методи, визначені всередині тіла класу, автоматично стають inline. Але що, якщо ви хочете визначити метод поза класом у заголовку, але зробити його inline? Для цього існує ключове слово inline.

Явне ключове слово inline

Date.h
#ifndef DATE_H
#define DATE_H

#include <iostream>

class Date
{
private:
    int day;
    int month;
    int year;

public:
    Date(int d, int m, int y);
    void print() const;
};

// Визначення методу поза класом, але в заголовку — треба inline
inline void Date::print() const
{
    std::cout << day << "." << month << "." << year << "\n";
}

#endif

Ключове слово inline перед визначенням каже компілятору: «це inline-функція, її визначення може бути продубльоване». Без inline це було б порушенням ODR.

Коли використовувати inline у заголовку

✅ Використовуйте inline

  • Тривіальні методи: геттери, сеттери, прості обчислення
  • Шаблонні методи (про них у наступних статтях)
  • Методи, що викликаються дуже часто у критичних ділянках коду

❌ Не використовуйте inline

  • Складні методи з багатьма рядками коду
  • Методи, що викликають інші функції
  • Методи з циклами або розгалуженнями
Сучасні компілятори самостійно визначають, які функції робити inline, навіть без ключового слова. inline сьогодні — це радше дозвіл на множинні визначення, ніж директива оптимізації.

Параметри за замовчуванням: лише в заголовку

Параметри за замовчуванням вказуються лише в оголошенні методу, а не в визначенні. Причина проста: той, хто викликає метод, бачить лише заголовковий файл. Саме там компілятор дізнається про параметри за замовчуванням.

Правильно

Date.h
class Date
{
public:
    void setDate(int d = 1, int m = 1, int y = 2000);
};
Date.cpp
void Date::setDate(int d, int m, int y)  // Без параметрів за замовчуванням!
{
    day = d;
    month = m;
    year = y;
}

Неправильно

Date.h
class Date
{
public:
    void setDate(int d, int m, int y);  // Без параметрів за замовчуванням
};
Date.cpp
void Date::setDate(int d = 1, int m = 1, int y = 2000)  // ❌ Помилка!
{
    day = d;
    month = m;
    year = y;
}

Компілятор видасть помилку: «default argument given for parameter». Параметри за замовчуванням мають бути там, де компілятор обробляє виклик методу, а не визначення.


Бібліотеки та попередньо скомпільовані файли

Відокремлення оголошення від реалізації — це не лише зручність організації коду. Це фундамент для створення бібліотек — готових модулів, які можна підключати до різних проєктів.

Стандартна бібліотека C++

Ви вже користувалися бібліотекою — стандартною бібліотекою C++ (STL). Коли ви пишете #include <iostream> або #include <vector>, ви підключаєте заголовкові файли, що містять оголошення класів std::cout, std::vector, std::string. Але де реалізація цих класів?

Реалізація знаходиться у попередньо скомпільованій бібліотеці — файлі .lib (Windows) або .a / .so (Linux/macOS), який автоматично підключається лінкером. Ви ніде не бачите вихідного коду методів std::cout.operator<<() або std::vector::push_back() — вони вже скомпільовані у машинний код і зберігаються окремо.

Як це працює

  1. Компіляція вашого коду: компілятор бачить оголошення з <iostream>, перевіряє коректність викликів, генерує main.o.
  2. Лінкування: лінкер знаходить визначення методів у системній бібліотеці (наприклад, libstdc++.a) і зшиває виклики з реалізацією.
  3. Результат: виконуваний файл, що містить ваш код і код стандартної бібліотеки.

Створення власної бібліотеки

Ви можете створити власну бібліотеку — набір класів і функцій, які використовуються у кількох проєктах.

Крок 1: скомпілюйте .cpp-файли у об'єктні файли:

g++ -c Date.cpp -o Date.o
g++ -c Time.cpp -o Time.o

Крок 2: об'єднайте об'єктні файли у статичну бібліотеку:

ar rcs libdatetime.a Date.o Time.o

Крок 3: використовуйте бібліотеку в іншому проєкті:

g++ main.cpp -L. -ldatetime -o program

Прапор -L. каже лінкеру шукати бібліотеки у поточній директорії, -ldatetime — підключити libdatetime.a.

Створення бібліотек — тема окремого курсу. На даному етапі достатньо розуміти, що розподіл класів на .h і .cpp — це перший крок до побудови модульного коду.

Переваги розподілу: підсумок

📖 Читабельність

Заголовковий файл — це «зміст» класу. Програміст бачить список методів без деталей реалізації. Це як API-документація, вбудована в код.

⚡ Швидкість компіляції

Зміна реалізації методу в .cpp вимагає перекомпіляції лише цього файлу. Файли, що використовують клас, залишаються без змін.

🔒 Приховування деталей

Реалізація захована в .cpp. Користувач класу бачить лише інтерфейс. Це основа інкапсуляції на рівні модулів.

♻️ Повторне використання

Клас, розділений на .h і .cpp, легко переносити між проєктами. Підключив заголовок, додав .cpp до збірки — готово.

Рекомендації щодо організації класів

На завершення — практичні поради від індустрії, які допоможуть уникнути типових пасток.

Правило 1: один клас — одна пара файлів

Клас StudentStudent.h + Student.cpp. Клас CourseCourse.h + Course.cpp.

Не поміщайте кілька несповнених класів в один файл utils.h + utils.cpp. Це порушує принцип одиничної відповідальності.

Правило 2: .cpp завжди підключає власний .h

Файл Date.cpp завжди починається з #include "Date.h". Це захисний механізм: якщо сигнатури розійдуться, помилка виявиться одразу.

Правило 3: тривіальні методи — в заголовку, нетривіальні — у .cpp

Тривіальні (у заголовку):

  • Геттери: int getDay() const { return day; }
  • Порожні деструктори: ~Date() {}
  • Прості сеттери без логіки

Нетривіальні (у .cpp):

  • Методи з циклами, умовами, обчисленнями
  • Методи, що викликають інші функції
  • Конструктори з ініціалізацією

Правило 4: мінімізуйте залежності в заголовках

Не підключайте заголовки, які не потрібні безпосередньо в .h. Якщо <iostream> використовується лише в .cpp, підключайте його там.

Погано:

Date.h
#include <iostream>  // Не потрібен у заголовку!
#include <string>    // Не потрібен у заголовку!

class Date { ... };

Добре:

Date.h
class Date { ... };  // Нічого зайвого
Date.cpp
#include "Date.h"
#include <iostream>  // Підключаємо тут
Кожен зайвий #include у заголовку сповільнює компіляцію всіх файлів, що підключають цей заголовок. У великих проєктах це критично.

Висновки

Розподіл класу на заголовковий файл та файл реалізації — це не просто технічний прийом. Це фундаментальна практика організації коду в C++, що забезпечує:

  • Інкапсуляцію на рівні модулів: інтерфейс відокремлений від реалізації
  • Масштабованість: клас легко використовувати у багатьох файлах і проєктах
  • Швидкість збірки: зміни реалізації не вимагають перекомпіляції всього проєкту
  • Читабельність: заголовок — це документація класу

Основні правила, які слід запам'ятати:

  1. Заголовок .h: оголошення класу, прототипи методів, тривіальні inline-методи
  2. Реалізація .cpp: визначення методів через ClassName::methodName
  3. Header guards: обов'язковий захист від подвійного підключення
  4. Параметри за замовчуванням: лише в заголовку, не в .cpp
  5. .cpp підключає власний .h: завжди, без винятків

На наступному уроці ми розглянемо, як класи взаємодіють із ключовим словом const — константні об'єкти та константні методи. Це ще один рівень контролю над поведінкою класу, який забезпечує безпеку та передбачуваність коду.

Copyright © 2026