Заголовочные файлы в C++: как правильно разделять код на .h и .cpp (практическое руководство)
Правильная работа с заголовочными файлами в C++ экономит часы сборки и спасает от «multiple definition» и «undefined reference». Это руководство объясняет, что класть в .h и .cpp, как использовать include guards и #pragma once, когда нужны forward declaration, как разорвать циклические зависимости и какие ошибки встречаются чаще всего.
Что такое заголовочный файл в C++
Заголовочный файл (.h/.hpp) — место, где объявляются интерфейсы: функции, классы, константы, типы. Исходный файл (.cpp) — место, где находятся определения (реализации). Такой подход ускоряет сборку, улучшает модульность и позволяет переиспользовать код.
Что хранить в .h и в .cpp
- В .h: объявления функций, классов, enum, константные интерфейсы, инлайновые и шаблонные функции, документация к API.
- В .cpp: определения функций и методов, приватные детали реализации, статические объекты с внутренним связыванием.
Include guards и #pragma once
Чтобы заголовок не подключился дважды, используйте include guards или #pragma once.
// math.hpp
#ifndef MATH_HPP
#define MATH_HPP
namespace math {
double avg(int a, int b); // объявление
}
#endif // MATH_HPP
Альтернатива — #pragma once (поддерживается большинством компиляторов):
// util.hpp
#pragma once
int add(int a, int b);
Минимальный пример разделения на .h и .cpp
// math.hpp
#ifndef MATH_HPP
#define MATH_HPP
namespace math { double avg(int a, int b); }
#endif
// math.cpp
#include "math.hpp"
namespace math {
double avg(int a, int b) { return (a + b) / 2.0; }
}
// main.cpp
#include <iostream>
#include "math.hpp"
int main() {
std::cout << math::avg(3, 5) << "\n";
}
// Компиляция (GCC/Clang):
g++ -std=c++20 -Wall -Wextra -O2 main.cpp math.cpp -o app
Инлайн и constexpr в заголовках
Определения неинлайновых функций в .h вызывают множественные определения при линковке. Если маленькая функция нужна прямо в заголовке — объявляйте её inline или constexpr (когда это уместно).
// math_inline.hpp
#pragma once
inline int square(int x) { return x * x; }
constexpr int add1(int x) { return x + 1; }
Шаблоны и заголовки
Шаблонные функции и классы обычно полностью размещаются в .h, потому что компилятору нужны их определения при инстанцировании. Выносить реализацию шаблонов в .cpp нельзя (за редкими продвинутыми приёмами).
// clamp.hpp
#pragma once
template<typename T>
T clamp(T value, T lo, T hi) {
if (value < lo) return lo;
if (value > hi) return hi;
return value;
}
Forward declaration и разрыв циклических зависимостей
Если в заголовке нужен только указатель или ссылка на тип, достаточно предварительного объявления (forward declaration), а полный заголовок подключите в .cpp. Так вы уменьшаете зависимости и избегаете циклов.
// a.hpp
#ifndef A_HPP
#define A_HPP
class B; // forward declaration
class A {
public:
void set(B* b);
private:
B* b_{nullptr}; // указатель: полного типа пока не нужно
};
#endif
// a.cpp
#include "a.hpp"
#include "b.hpp" // здесь уже нужен полный тип B
void A::set(B* b) { b_ = b; }
// b.hpp
#ifndef B_HPP
#define B_HPP
class A; // forward declaration
class B {
public:
void ping(A* a);
};
#endif
// b.cpp
#include "b.hpp"
#include "a.hpp"
void B::ping(A* /*a*/) {}
Избегайте взаимных #include в заголовках без необходимости — это главный источник циклических зависимостей и роста времени сборки.
Организация include'ов и стиль
- В .cpp файлах первым подключайте «собственный» заголовок: это быстро выявляет пропущенные зависимости в нём самом.
#include "..."— для ваших файлов,#include <...>— для системных и библиотечных.- Подключайте только то, что реально используете (подход IWYU).
- Стабильный порядок include'ов улучшает повторяемость сборки.
// file.cpp
#include "file.hpp" // свой заголовок — первым
#include <iostream>
#include <vector>
Сокрытие реализации через PIMPL (по желанию)
Когда класс «тянет» тяжёлые зависимости в заголовок, используйте PIMPL: храните указатель на структуру реализации. Заголовок остаётся лёгким, изменённая реализация не пересобирает весь проект.
// widget.hpp
#ifndef WIDGET_HPP
#define WIDGET_HPP
#include <memory>
class Widget {
public:
Widget();
~Widget();
void draw();
private:
struct Impl;
std::unique_ptr<Impl> p_;
};
#endif
// widget.cpp
#include "widget.hpp"
#include <iostream>
struct Widget::Impl {
void drawImpl() { std::cout << "draw\n"; }
};
Widget::Widget() : p_(std::make_unique<Impl>()) {}
Widget::~Widget() = default;
void Widget::draw() { p_->drawImpl(); }
Типичные ошибки: ODR и multiple definition
- Определение переменной в заголовке: приведёт к множественным определениям при линковке.
// utils.hpp — так НЕЛЬЗЯ
int g_counter = 0; // multiple definition
// Правильно через extern:
// utils.hpp
#ifndef UTILS_HPP
#define UTILS_HPP
extern int g_counter;
#endif
// utils.cpp
#include "utils.hpp"
int g_counter = 0;
- Inline-переменные (C++17+) в заголовке — допустимый способ иметь одну «общую» сущность без multiple definition.
// config.hpp (C++17+)
#pragma once
inline int g_limit = 100; // OK: одно ODR-определение на программу
Чек‑лист по заголовочным файлам
- Каждый .h защищён include guard или
#pragma once. - В .h — только объявления, реализация в .cpp (кроме inline/constexpr/шаблонов).
- Минимизируйте зависимости: используйте forward declaration там, где можно.
- В .cpp подключайте свой .h первым.
- Не определяйте глобальные не-inline переменные в заголовках — используйте
externили inline переменные (C++17+). - Следите за циклическими зависимостями и размером заголовков: по возможности применяйте PIMPL.
Куда двигаться дальше
Научиться грамотно организовывать проект, управлять зависимостями и сборкой помогает практика. Хотите системно прокачать навыки современного C++? Посмотрите программу и задания курса — Прокачать C++ от нуля до гуру на практическом курсе.
-
Создано 17.12.2025 17:03:18
-
Михаил Русаков

Комментарии (0):
Для добавления комментариев надо войти в систему.
Если Вы ещё не зарегистрированы на сайте, то сначала зарегистрируйтесь.