Шаблонная магия C++: SFINAE, concepts и CRTP изнутри
Почему SFINAE молчит вместо ошибки, как компилятор проверяет concept и во что превращается CRTP на этапе кодогенерации — разбор механики трёх приёмов, а не их синтаксиса.

Возьмите функцию, которая должна принимать только типы с методом .size(). До C++20 вы писали её через std::enable_if и молились, чтобы перегрузка «правильно не подошла». В C++20 вы пишете requires requires(T t){ t.size(); } и получаете внятную диагностику. Разница не косметическая: под капотом это два разных механизма подстановки, и понимание границы между ними избавляет от целого класса ошибок вида «candidate template ignored: substitution failure». Ниже — как именно компилятор обрабатывает SFINAE, concepts и CRTP, что происходит на этапе инстанцирования и где каждый приём ломается. Код проверялся на GCC 13 и Clang 17 в режиме -std=c++20.
SFINAE: подстановка, которая не считается ошибкой
SFINAE — Substitution Failure Is Not An Error1. Когда компилятор подбирает перегрузку шаблонной функции, он подставляет выведенные аргументы в сигнатуру каждого кандидата. Если подстановка в непосредственный контекст сигнатуры даёт некорректный тип — кандидат молча выбрасывается из списка, а не порождает ошибку компиляции. Ключевое слово здесь — «непосредственный контекст»: ошибка внутри тела функции или в глубине инстанцируемого типа уже не SFINAE, а честная ошибка (hard error).
Классический пример — включение перегрузки по наличию вложенного типа:
#include <type_traits>
#include <iostream>
// Кандидат виден только если у T есть вложенный тип value_type
template <typename T>
typename T::value_type first_element(const T& container) {
return *container.begin();
}
// Запасной кандидат для всего остального
template <typename T>
T first_element(const T& scalar) {
return scalar;
}
int main() {
std::cout << first_element(42) << "\n"; // int: value_type нет, первый кандидат отброшен
}
Когда мы передаём int, выражение typename int::value_type в возвращаемом типе первого кандидата некорректно. Компилятор не ругается — он вычёркивает кандидата и берёт второй. Если убрать второй, получите ошибку «no matching function»: список кандидатов пуст.
Где SFINAE перестаёт работать
Ошибка должна возникнуть в сигнатуре. Перенесите проверку в тело — и получите hard error при инстанцировании:
template <typename T>
auto bad(const T& x) {
typename T::value_type v{}; // тело, не сигнатура
return v;
}
// bad(42) -> ошибка компиляции, а не отбрасывание кандидата
Второй капкан — глубина. Если некорректность спрятана за уровнем шаблонных зависимостей (например, внутри инстанцируемого std::vector<void>), это уже не непосредственный контекст, и SFINAE не спасёт.
Concepts: та же идея, но как отдельная сущность
C++20 сделал проверку требований именованной и композируемой. Concept — это именованный предикат над типами, который компилятор вычисляет во время подбора перегрузки и при инстанцировании. Механически это по-прежнему проверка корректности выражений, но с двумя принципиальными отличиями от SFINAE.
Первое — диагностика. При SFINAE вы видите «substitution failure» без объяснения. При concept компилятор говорит, какое именно требование не выполнено:
#include <concepts>
#include <iostream>
template <typename T>
concept HasSize = requires(const T& obj) {
{ obj.size() } -> std::convertible_to<std::size_t>;
};
template <HasSize T>
void print_size(const T& c) {
std::cout << c.size() << "\n";
}
int main() {
print_size(std::string{"abc"}); // ок: 3
// print_size(42); // ошибка: constraints not satisfied, 'int' does not satisfy 'HasSize'
}
Второе — упорядочивание перегрузок. Компилятор умеет сравнивать concept-ограничения по «силе»: перегрузка с более строгим (subsumed) ограничением считается более специализированной. Это то, чего SFINAE не давал никогда — там все кандидаты равноправны, и неоднозначность приходилось разруливать вручную через приоритеты тегов.
SFINAE отвечает на вопрос «подходит ли кандидат». Concepts дополнительно отвечают «какой из подходящих кандидатов ближе» — и объясняют, почему остальные отсеяны.
Как компилятор вычисляет requires-выражение
Тело requires(...) не выполняется — оно проверяется на корректность формирования выражений (well-formedness). Строка { obj.size() } -> std::convertible_to<std::size_t>; означает: выражение obj.size() должно компилироваться, а его тип должен удовлетворять convertible_to. Никаких вызовов в рантайме, вся работа на этапе семантического анализа.
CRTP: статический полиморфизм через наследование от себя
Curiously Recurring Template Pattern — базовый класс параметризуется типом наследника. Механизм разворачивается на этапе инстанцирования шаблона: базовый класс получает точный тип производного и может кастовать this к нему, вызывая методы производного без виртуальной таблицы.
#include <iostream>
template <typename Derived>
struct Shape {
void draw() const {
// Статический каст к производному типу, известному на этапе компиляции
static_cast<const Derived*>(this)->draw_impl();
}
};
struct Circle : Shape<Circle> {
void draw_impl() const { std::cout << "circle\n"; }
};
struct Square : Shape<Square> {
void draw_impl() const { std::cout << "square\n"; }
};
template <typename T>
void render(const Shape<T>& s) { s.draw(); }
int main() {
render(Circle{}); // circle
render(Square{}); // square
}
Вызов s.draw() внутри render разрешается в draw_impl конкретного типа во время компиляции. Виртуального вызова нет — компилятор инлайнит цепочку, если тела видимы. Это и есть выигрыш: диспетчеризация по типу без указателя на vtable и без косвенного перехода.
Цена CRTP
Плата за отсутствие рантайм-диспетчеризации:
- Нет общего базового типа.
Shape<Circle>иShape<Square>— разные, не связанные наследованием типы. Сложить их в одинstd::vectorнельзя. Если нужен гетерогенный контейнер — CRTP не ваш инструмент, здесь возвращаются виртуальные функции илиstd::variant. - Раздувание кода. Каждая инстанциация базового шаблона порождает отдельный набор функций. Много производных типов — рост размера бинарника.
- Ошибки при неверном параметре. Если написать
struct Circle : Shape<Square>,static_castк неродственному типу даст неопределённое поведение при обращении к полям — компилятор это не всегда ловит.
Что выбрать под задачу
| Приём | Что решает | Когда брать | Чем платите |
|---|---|---|---|
| SFINAE | Условное включение перегрузки | Код обязан компилироваться на C++11/14/17 | Нечитаемые ошибки, ручное упорядочивание |
| Concepts | То же плюс упорядочивание и диагностика | Доступен C++20, нужна читаемая ошибка | Требует компилятор с поддержкой C++20 |
| CRTP | Полиморфизм без vtable | Тип известен на компиляции, важна скорость | Нет общего базового типа, рост бинарника |
SFINAE и concepts решают одну задачу — управление тем, какие перегрузки видны. Если проект уже на C++20, новый код пишите на concepts: та же семантика, но ошибки читаемые, а перегрузки упорядочиваются автоматически. SFINAE держите для legacy-стандартов. CRTP — из другой оперы: это способ диспетчеризации, а не проверки требований, и он конкурирует не с concepts, а с виртуальными функциями.
Частая связка: concept как ограничитель CRTP
Три приёма нередко работают вместе. Concept проверяет, что производный класс действительно реализует нужный метод, до того как CRTP попытается его вызвать:
template <typename Derived>
concept Drawable = requires(const Derived& d) {
d.draw_impl();
};
template <typename Derived>
requires Drawable<Derived>
struct CheckedShape {
void draw() const { static_cast<const Derived*>(this)->draw_impl(); }
};
Тонкость: на момент проверки requires Drawable<Derived> тип Derived ещё неполон (его определение не завершено, пока идёт разбор базового класса). Поэтому такую проверку часто выносят из объявления класса в отдельный метод или в статическую проверку внутри тела, где Derived уже полный. Проверяйте на своей паре компилятор/стандарт — поведение с неполными типами тонкое.
Что делать дальше
Соберите примеры выше на своём компиляторе с флагом -std=c++20 и намеренно передайте неподходящий тип в каждую из трёх конструкций — сравните текст ошибок. Разница в диагностике SFINAE и concepts станет очевидной. Дальше посмотрите на std::ranges в стандартной библиотеке: это концентрат concepts на реальном коде. Инвалидация и совместимость с более ранними стандартами — отдельная тема, здесь мы разбирали только механику трёх приёмов на C++20.
1 SFINAE — правило, по которому ошибка подстановки шаблонных аргументов в сигнатуру кандидата не прерывает компиляцию, а лишь убирает этот кандидат из перегрузок. Subsumption — механизм C++20, определяющий, какое из двух concept-ограничений строже, чтобы выбрать более специализированную перегрузку.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: cppreference — SFINAE, cppreference — Constraints and concepts
Частые вопросы
У меня C++17, concepts недоступны. Чем заменить?
Почему CRTP работает быстрее виртуальных функций?
Concept-ошибка всё равно многословная. Это нормально?
Можно ли хранить разные CRTP-типы в одном контейнере?
Не сработала проверка concept внутри объявления CRTP-базы — почему?
SFINAE отбрасывает кандидата, но всё равно ошибка компиляции. В чём дело?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.