1. Зачем вообще сравнивать SFINAE и concepts
Когда вы начинаете писать «универсальные» функции, мозг радостно рисует картинку: «сделаю один шаблон, и он будет работать для всех типов». На практике C++ вежливо напоминает: не все типы умеют одно и то же. У int нет .size(), у std::vector<int> нет operator+ (в смысле «сложить два вектора и получить новый»), а у std::string есть много чего, что у чисел никогда не появится. Поэтому язык должен уметь отвечать на вопрос: «какую функцию вызывать для данного типа?» — и желательно так, чтобы человек не плакал, читая ошибку компилятора.
Исторически C++ решал часть этой задачи приёмом, который получил имя SFINAE. С C++20 появился более прямой, читабельный и «дружелюбный» инструмент — concepts и requires. В этой лекции мы разберём идею SFINAE на уровне смысла (без погружения в музей метапрограммирования), сравним с concepts и соберём небольшой кусочек кода для нашего учебного мини‑проекта edu_utils.
SFINAE по‑человечески
SFINAE расшифровывается как Substitution Failure Is Not An Error. То есть «ошибка подстановки (типа) — не ошибка». Звучит как философия дзен: компилятор не ругается, а делает вид, что ничего не произошло. Идея такая: когда компилятор подставляет типы в шаблон и понимает, что какая-то часть объявления функции стала некорректной, он не обязан падать сразу — он может просто выкинуть эту перегрузку из кандидатов и попробовать другие варианты.
Очень характерная формулировка этого поведения в стандартизационных текстах звучит так: «функция не участвует в разрешении перегрузки, если выражение некорректно» — то есть: "shall not participate in overload resolution unless the expression … is well-formed".
Важно: SFINAE обычно «работает» только там, где компилятор именно выбирает из перегрузок. Если у вас одна единственная функция, и она не подходит, то вы получите ошибку no matching function (что, честно говоря, всё равно часто лучше, чем «простыня» изнутри тела шаблона).
SFINAE — это не if и не runtime‑проверка. Это чистая история про компиляцию: «подходит ли тип под форму функции».
2. Мини‑пример SFINAE: требование спрятано в decltype(...)
Чтобы прочувствовать, почему SFINAE часто считают «нечитаемым», достаточно посмотреть на классический учебный трюк: «функция существует только если выражение корректно».
Допустим, в нашем мини‑проекте edu_utils есть функция «возвести в квадрат». Мы хотим, чтобы она работала только для типов, где выражение x * x имеет смысл.
#include <string>
template <class T>
auto square_sfinae(T x) -> decltype(x * x) {
return x * x;
}
int main() {
(void)square_sfinae(5);
// (void)square_sfinae(std::string{"hi"}); // не компилируется
}
С точки зрения компилятора происходит примерно следующее: для T = int всё хорошо, потому что x * x корректно. Для T = std::string выражение x * x некорректно, поэтому тип возвращаемого значения decltype(x * x) вычислить нельзя — и эта перегрузка «отваливается».
Проблема для начинающего здесь простая: контракт функции спрятан. Глядя на имя square_sfinae, вы не видите, что функция требует operator*. Это требование запрятано в хвост объявления, в decltype(...), как будто оно стесняется быть публичным.
Вторая проблема — сообщения компилятора. Даже в простых случаях они могут быть не самыми дружелюбными. А если выражение внутри decltype(...) сложнее, то вы можете получить «роман» на тему «как компилятор страдал».
4. Те же требования через concept: контракт виден в сигнатуре
Concepts делают ту же идею прямой: «тип должен удовлетворять условию». Вы не прячете требование в техническом трюке, вы пишете его явно — и компилятор тоже начинает общаться с вами в терминах требований, а не в терминах «сломался decltype где-то в подвале».
Сделаем concept Multipliable (название простое и честное: «можно перемножить»):
template <class T>
concept Multipliable = requires(T x) {
x * x;
};
template <Multipliable T>
T square_concept(T x) {
return x * x;
}
int main() {
(void)square_concept(5);
// (void)square_concept(std::string{"hi"}); // Multipliable не выполнен
}
Теперь контракт читается глазами: square_concept работает только для Multipliable. Ошибка компиляции будет формулироваться в духе «ограничение не выполнено» — обычно это намного проще переварить, чем «где-то не получилось вывести тип».
Есть ещё один тонкий, но важный смысл: concepts ограничивают именно аргументы шаблона, а не «значения параметров». Это обычно формулируют так: "Concepts constrain template arguments, not parameters".
То есть это именно про типы на этапе компиляции, а не про то, что лежит в переменной во время выполнения.
5. Перегрузки и фильтрация кандидатов: почему с concepts понятнее
Когда вы пишете две перегрузки «для разных семейств типов», вы по сути проектируете маленький API: «если пользователь передал число — делай так, если контейнер — делай иначе». В старом стиле это часто делали через SFINAE‑приёмы, чтобы «неподходящая» перегрузка исчезала.
С concepts это выглядит почти как русский язык (ну, как русский язык программиста).
Например, добавим в edu_utils функцию, которая печатает, что за число нам дали:
#include <concepts>
#include <iostream>
void print_kind(std::integral auto x) {
std::cout << "integral: " << x << '\n'; // integral: 10
}
void print_kind(std::floating_point auto x) {
std::cout << "floating: " << x << '\n'; // floating: 3.14
}
int main() {
print_kind(10);
print_kind(3.14);
}
Здесь компилятор выбирает перегрузку так: среди кандидатов остаются только те, чьи ограничения выполнены. По смыслу это очень близко к «фильтру кандидатов» — то, чего SFINAE добивался обходными путями.
А теперь важный момент, который многие ловят лбом: пересечение ограничений. Если вы сделаете две перегрузки, которые подходят одновременно, компилятор скажет «я не понимаю, что вы хотели».
#include <concepts>
template <class T>
concept AnyNumber = std::integral<T> || std::floating_point<T>;
void f(AnyNumber auto) {
// версия A
}
void f(std::integral auto) {
// версия B
}
int main() {
// f(10); // потенциальная неоднозначность: обе подходят
}
Это не «проблема concepts», это полезный сигнал дизайну: вы написали правила так, что вызов попадает в две категории сразу. То же самое можно было устроить и на SFINAE, но там вы бы потратили больше времени, чтобы вообще понять, что происходит.
6. Почему concepts обычно предпочтительнее SFINAE
Когда спрашивают «что лучше», хочется ответить как взрослый инженер: «зависит». Но в учебном и прикладном modern C++ есть довольно стабильное правило: если у вас есть concepts (C++20/23) — используйте concepts, а не SFINAE‑фокусы.
| Критерий | SFINAE | concepts / requires |
|---|---|---|
| Читаемость контракта | Часто спрятан в , хитрых параметрах, «технических» костылях |
Контракт виден в сигнатуре: или |
| Ошибки компиляции | Часто длинные и «про внутренности механизма» | Обычно короче и «про невыполненное требование» |
| Разделение на «кирпичики требований» | Возможно, но обычно выглядит тяжело | Нативно: — это и есть именованный кирпичик |
| Перегрузки | Работает, но дизайн сложнее читать | Естественно: constraints фильтруют кандидатов |
| Поддержка новичками команды | Часто превращается в «код, который боятся трогать» | Чаще воспринимается как нормальный контракт API |
Если коротко: SFINAE — это как чинить велосипед изолентой. Да, иногда можно доехать. Но если у вас есть нормальные болты, шестигранник и инструкция, лучше всё-таки собрать велосипед нормально.
7. Практический пример: pretty_print для чисел, строк и диапазонов
Сейчас сделаем аккуратный мини‑кусочек, который показывает «современный стиль» перегрузок с constraints и одновременно демонстрирует, как думать о пересечениях. Представим, что в проекте edu_utils мы хотим функцию pretty_print, которая печатает:
- целые числа как "int: ...",
- вещественные как "double: ...",
- строки как "string: ...",
- любой диапазон (range) как список элементов.
И да, std::string — это диапазон символов, так что без отдельной перегрузки строка могла бы печататься посимвольно. Мы сделаем это поведение управляемым: для строк будет отдельная «специальная» перегрузка, чтобы всё выглядело по‑человечески.
Перегрузки для чисел и строк
Начнём с самого очевидного. Здесь нет никакой магии: просто два concepts и одна не‑шаблонная перегрузка для строк.
#include <concepts>
#include <iostream>
#include <string_view>
namespace edu {
void pretty_print(std::integral auto x) {
std::cout << "int: " << x << '\n'; // int: 42
}
void pretty_print(std::floating_point auto x) {
std::cout << "double: " << x << '\n'; // double: 3.5
}
void pretty_print(std::string_view s) {
std::cout << "string: " << s << '\n'; // string: hello
}
} // namespace edu
Здесь полезно заметить маленькую «инженерную хитрость»: std::string_view — лёгкий параметр, и std::string к нему хорошо приводится. Так мы не обязаны писать перегрузки отдельно для std::string, const char* и так далее.
Перегрузка для std::ranges::range
Теперь добавим обработку диапазонов. Мы пока не лезем в «настоящие ranges‑алгоритмы», нам достаточно того, что range можно пройти через begin/end и сделать range‑for.
#include <iostream>
#include <ranges>
namespace edu {
void pretty_print(std::ranges::range auto const& r) {
std::cout << "range: ";
for (const auto& x : r) {
std::cout << x << ' ';
}
std::cout << '\n'; // range: 1 2 3
}
} // namespace edu
Здесь может возникнуть вопрос: «а почему это не конфликтует со строками?» Хорошая новость в том, что у нас есть перегрузка pretty_print(std::string_view), и она не шаблонная. Обычно такая перегрузка выигрывает по правилам выбора (она «конкретнее»), поэтому строка напечатается как строка, а не как набор символов.
Мини‑демо в main()
Соберём маленькую проверку. Код специально короткий, чтобы вы могли скопировать его в песочницу и не утонуть в деталях.
#include <vector>
#include <string>
int main() {
edu::pretty_print(42);
edu::pretty_print(3.5);
std::string s = "hello";
edu::pretty_print(s);
std::vector<int> v{1, 2, 3};
edu::pretty_print(v);
}
Если вы сейчас думаете: «Ого, это реально читается как набор правил, а не как ритуал призыва компилятора», — значит, вы почувствовали главное преимущество concepts.
Почему всё ещё полезно понимать SFINAE
Может показаться, что SFINAE теперь можно забыть и жить счастливо. В прикладном C++23 для нового кода часто так и делают. Но знать идею SFINAE полезно по трём причинам.
- Во‑первых, вы будете встречать SFINAE в старых кодовых базах, в статьях, в ответах на Stack Overflow и даже в частях стандартной библиотеки (особенно исторически).
- Во‑вторых, сама идея «не участвовать в overload resolution, если выражение некорректно» — это фундаментальная модель мышления. Concepts не отменяют эту модель, они дают ей красивый синтаксис и нормальные сообщения об ошибках.
- В‑третьих, иногда вы ограничены компилятором/стандартом проекта, и concepts могут быть недоступны. Тогда SFINAE остаётся рабочим инструментом. Но если вы в C++23 и можете выбирать — почти всегда лучше выбрать concepts.
8. Типичные ошибки
Ошибка №1: считать, что SFINAE — это «проверка во время выполнения».
Иногда новички думают, что SFINAE как-то «если не подходит — попробует другую ветку во время работы программы». Нет: всё решается на этапе компиляции. Если код скомпилировался, то во время выполнения уже никто не «переключает перегрузки».
Ошибка №2: прятать требования в теле функции и надеяться, что ошибки будут понятными.
Шаблон без ограничений принимает почти любой тип, пока не упрётся в неподдерживаемую операцию. В итоге ошибка возникает при инстанцировании и выглядит страшно. В modern C++ лучше вынести требования в concept/requires, чтобы контракт был в объявлении.
Ошибка №3: писать constraints «на вырост» и случайно запрещать корректные типы.
Очень частая история: вы требуете слишком много (например, «и .size(), и push_back, и operator[]»), хотя в теле функции используете только .size(). Такой «перестраховочный контракт» делает API неудобным и неожиданно ломает повторное использование.
Ошибка №4: делать пересекающиеся ограничения и получать неоднозначность перегрузок.
Когда две перегрузки подходят одновременно, компилятор честно говорит: «выберите сами». Это обычно означает, что категории в вашем API пересекаются. Лечится либо уточнением constraints, либо добавлением отдельной «более конкретной» перегрузки для особого случая (как мы сделали со строками через std::string_view).
Ошибка №5: пытаться использовать SFINAE‑стиль как основной подход в C++23 «потому что в старом коде так написано».
SFINAE — мощный, но технически тяжёлый инструмент. Concepts появились не ради моды, а чтобы те же идеи выражались проще, читались лучше и давали более дружелюбные ошибки. Если проект на C++20/23 — обычно нет причин специально выбирать SFINAE‑трюки вместо concept/requires.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ