1. Раздельная компиляция и почему нужно тело шаблона
Если рассуждать «по привычке», хочется разложить код так же, как для обычных функций: объявление — в .hpp, определение — в .cpp. Для не-шаблонного кода это правда стандартный и удобный подход. Но с шаблонами есть тонкость: компилятору нужно видеть определение (тело) шаблона в том месте, где шаблон используется, иначе он не сможет сгенерировать код под конкретный тип.
Причина упирается в модель сборки C++: каждый .cpp компилируется отдельно. Это значит, что при компиляции main.cpp компилятор не «заглядывает» в содержимое tasks.cpp, если вы явно не включили нужные определения через #include.
Наглядно процесс выглядит так:
flowchart LR
A[main.cpp] -->|compile| O1[main.o]
B[tasks.cpp] -->|compile| O2[tasks.o]
O1 -->|link| EXE[app.exe]
O2 -->|link| EXE
На этапе compile каждый .cpp превращается в объектный файл .o. Линковщик на этапе link не генерирует новый код — он только соединяет уже скомпилированные куски.
Теперь ключевой момент: шаблон — это не «готовая функция», а заготовка. Реальная функция появляется только после инстанциации.
Посмотрим на пример:
template <typename T>
T my_max(T a, T b) {
return (a < b) ? b : a;
}
Пока в коде не встретилось использование вроде my_max(1, 2) или my_max(std::string("a"), std::string("b")), эта сущность остаётся «чертежом». Как только компилятор встречает вызов, он фиксирует конкретный тип (например, T = int) и порождает версию my_max<int>.
И вот почему шаблоны «капризничают» с файлами: чтобы породить my_max<int>, компилятор обязан видеть тело my_max прямо во время компиляции того .cpp, где стоит вызов.
2. Антипаттерн: шаблон объявили в .hpp, а определили в .cpp
Разберём типичную ошибку на небольшом контексте: пишем консольное приложение TaskTracker (условный трекер задач). Хотим сделать универсальную функцию печати std::vector<T>.
Плохая раскладка по файлам
В заголовке оставляем только объявление:
// include/tt/print.hpp
#pragma once
#include <ostream>
#include <vector>
namespace tt {
template <typename T>
void print_vector(std::ostream& out, const std::vector<T>& v);
} // namespace tt
А определение прячем в .cpp:
// src/print.cpp
#include "tt/print.hpp"
namespace tt {
template <typename T>
void print_vector(std::ostream& out, const std::vector<T>& v) {
out << "[";
for (std::size_t i = 0; i < v.size(); ++i) {
out << v[i];
if (i + 1 != v.size()) out << ", ";
}
out << "]";
}
} // namespace tt
И используем в main.cpp:
// src/main.cpp
#include <iostream>
#include <vector>
#include "tt/print.hpp"
int main() {
std::vector<int> a{1, 2, 3};
tt::print_vector(std::cout, a);
std::cout << '\n'; // [1, 2, 3]
}
Что произойдёт на сборке
Часто происходит так:
1) main.cpp компилируется успешно, потому что объявление print_vector есть.
2) А на линковке вы получаете что-то вроде:
undefined reference to tt::print_vector<int>(...)
Почему так? Потому что в момент компиляции main.cpp компилятор не видит тело print_vector, а значит не может породить print_vector<int>.
А в print.cpp, где тело вроде бы есть, компилятор тоже не обязан генерировать print_vector<int>, потому что в этом файле никто не использовал шаблон с T = int. В итоге получается неприятный парадокс: код «как будто написан», а готовой инстанциации (машинного кода) нет нигде.
3. Правило размещения: определения шаблонов — там, где их увидит использование
Практическое правило, которое стоит запомнить:
Шаблоны (именно определения) почти всегда размещают в заголовках, чтобы любой .cpp, который использует шаблон, видел его тело и мог инстанцировать нужную версию.
То есть print_vector нужно определить прямо в .hpp:
// include/tt/print.hpp
#pragma once
#include <cstddef>
#include <ostream>
#include <vector>
namespace tt {
template <typename T>
void print_vector(std::ostream& out, const std::vector<T>& v) {
out << "[";
for (std::size_t i = 0; i < v.size(); ++i) {
out << v[i];
if (i + 1 != v.size()) out << ", ";
}
out << "]";
}
} // namespace tt
Теперь main.cpp подключает заголовок, видит тело и спокойно генерирует print_vector<int>.
4. Заголовки, повторные подключения и связь с ODR
Когда шаблонные определения живут в заголовках, закономерно возникает вопрос: «А если заголовок подключат много раз — всё не сломается?»
Здесь важно различать два сценария.
Первый — повторное включение внутри одного .cpp (через цепочки #include). Тогда текст заголовка может реально вставиться дважды в одну единицу трансляции, и вы получите ошибки компиляции (redefinition). От этого защищают include guards или #pragma once.
Классический include guard:
// include/tt/print.hpp
#ifndef TT_PRINT_HPP
#define TT_PRINT_HPP
// ... код ...
#endif // TT_PRINT_HPP
Или более короткий вариант:
#pragma once
// ... код ...
Второй сценарий — одно и то же определение попадает в разные .cpp. Вот это уже связано с ODR (One Definition Rule).
ODR в учебной формулировке можно держать так:
«В итоговой программе каждая сущность должна иметь одно согласованное определение; если вы определяете одно и то же по-разному или несколько раз так, что линковщик не может корректно объединить — будет проблема».
Почему обычную функцию опасно определять в заголовке без inline
Если сделать так:
// include/tt/util.hpp
#pragma once
int add(int a, int b) {
return a + b;
}
и включить util.hpp в два разных .cpp, то на линковке вы легко поймаете:
multiple definition of add(int,int)
Потому что это одна обычная функция с внешней линковкой, и вы фактически определили её в нескольких единицах трансляции.
Почему с шаблонами это чаще работает
Шаблон сам по себе — не «готовая функция». Реальным символом становится инстанциация, например print_vector<int>.
Если шаблон определён в заголовке и этот заголовок одинаково подключён везде, то разные .cpp видят один и тот же текст определения. Значит, сгенерированные инстанциации будут одинаковыми, и компоновщик обычно умеет корректно их объединять.
Опасная зона начинается там, где вы создаёте разные определения одного и того же шаблона в разных единицах трансляции (например, из-за #ifdef, #define перед #include и т.п.). Формально это нарушение ODR, а на практике может проявляться очень странно: «в Debug работает, в Release нет», «на одной платформе окей, на другой ломается».
5. Разносим реализацию: приём .inl / .tpp
Иногда шаблонного кода много, и держать всё в одном .hpp неудобно: заголовок превращается в «простыню». Тогда используют компромисс:
1) объявления — в .hpp;
2) определения — в отдельном файле (.inl, .tpp, .ipp);
3) но этот файл всё равно подключается из .hpp.
Пример:
// include/tt/print.hpp
#pragma once
#include <cstddef>
#include <ostream>
#include <vector>
namespace tt {
template <typename T>
void print_vector(std::ostream& out, const std::vector<T>& v);
} // namespace tt
#include "tt/print.inl"
А определения — в print.inl (это всё ещё «кусок заголовка», а не .cpp):
// include/tt/print.inl
#pragma once
namespace tt {
template <typename T>
void print_vector(std::ostream& out, const std::vector<T>& v) {
out << "[";
for (std::size_t i = 0; i < v.size(); ++i) {
out << v[i];
if (i + 1 != v.size()) out << ", ";
}
out << "]";
}
} // namespace tt
Смысл приёма простой: любой .cpp, который включил print.hpp, увидит и объявление, и определение, а значит сможет инстанцировать шаблон.
6. Мини-пример: печать задач в TaskTracker
Чтобы увидеть «смешанный стиль» (где обычное лежит в .cpp, а шаблон — в заголовке), соберём мини-пример.
Пусть у нас есть модель задачи:
// include/tt/task.hpp
#pragma once
#include <string>
namespace tt {
struct Task {
int id{};
std::string title;
bool done{};
};
} // namespace tt
Сделаем обычную функцию печати одной задачи. Её можно держать в .cpp, потому что она не шаблонная:
// include/tt/task_print.hpp
#pragma once
#include <ostream>
#include "tt/task.hpp"
namespace tt {
void print_task(std::ostream& out, const Task& t);
} // namespace tt
// src/task_print.cpp
#include "tt/task_print.hpp"
namespace tt {
void print_task(std::ostream& out, const Task& t) {
out << "#" << t.id << ": " << t.title;
out << (t.done ? " [done]" : " [todo]");
}
} // namespace tt
А теперь делаем шаблон «печати вектора через callback», и он уже должен быть видим в заголовке:
// include/tt/print.hpp
#pragma once
#include <cstddef>
#include <ostream>
#include <vector>
namespace tt {
template <typename T, typename Printer>
void print_vector_by(std::ostream& out, const std::vector<T>& v, Printer printer) {
out << "[\n";
for (std::size_t i = 0; i < v.size(); ++i) {
out << " ";
printer(out, v[i]); // printer должен уметь печатать T
out << "\n";
}
out << "]";
}
} // namespace tt
И используем в main.cpp:
// src/main.cpp
#include <iostream>
#include <vector>
#include "tt/print.hpp"
#include "tt/task_print.hpp"
int main() {
std::vector<tt::Task> tasks{
{1, "Write templates lecture", true},
{2, "Drink tea", false},
};
tt::print_vector_by(std::cout, tasks, tt::print_task);
std::cout << '\n';
// [
// #1: Write templates lecture [done]
// #2: Drink tea [todo]
// ]
}
Баланс здесь ровно такой, как и должен быть: обычные функции (print_task) спокойно живут в .cpp; шаблон (print_vector_by) остаётся в .hpp, потому что иначе main.cpp не сможет инстанцировать его под нужные типы.
7. Типичные ошибки
Ошибка №1: «Объявил шаблон в .hpp, определил в .cpp, а линковщик ругается undefined reference».
Это самая частая боль. Она появляется потому, что компилятор не генерирует инстанциации «где-то там по факту существования кода». Он генерирует их там, где видит использование, и только если видит определение. Если определение спрятано в .cpp, другой .cpp не сможет его использовать для генерации кода.
Ошибка №2: Путать ошибку компиляции и ошибку линковки и чинить не там.
Когда у вас нет тела шаблона в месте использования, нередко компиляция проходит, а падает только линковка. Новичок начинает переписывать типы, добавлять std:: и ругаться на CMake, хотя проблема проще: подключите определение шаблона туда, где он используется (обычно — перенесите тело в заголовок).
Ошибка №3: Определять обычные (не шаблонные) функции в заголовке без inline.
После темы про шаблоны легко сделать неправильный вывод: «Значит, всё можно держать в .hpp». Нет: обычные функции с внешней линковкой, определённые в заголовке, почти гарантированно дадут multiple definition, если заголовок включат в несколько .cpp. Шаблоны — особый случай, а inline — отдельный механизм, который помогает удовлетворять ODR.
Ошибка №4: Делать разные определения одного и того же шаблона в разных единицах трансляции.
Это более скрытая и опасная штука. Если один .cpp видит одну версию тела шаблона (например, из-за #define перед #include), а другой .cpp видит другую, формально вы нарушаете ODR. Иногда это проявляется сразу, иногда — только в неожиданных местах и только на одной платформе. Лучшее лекарство — держать единое определение в одном заголовке и не превращать подключение в квест.
Ошибка №5: Забывать include guards / #pragma once в заголовках с шаблонами и ловить redefinition на компиляции.
Если заголовок вставился в один .cpp дважды (через цепочку #include), то даже шаблонный код может конфликтовать внутри одной единицы трансляции. Защита заголовков — это не «косметика», а базовая гигиена: она предотвращает дублирование текста при препроцессинге.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ