Шаблонная функция с неверно переданным типом во времена SFINAE наказывала не отказом в компиляции, а стеной текста. Экран заполняют строки подстановок, отвергнутый candidate, note на десяток строк, и какое именно ожидание автора функции нарушено, разработчик должен понять сам, перечитывая чужую хитрость в сигнатуре. Инструмент, который должен был ограничивать множество допустимых типов, стал источником самых дорогих минут в отладке. Концепты C++20 появились, чтобы вернуть ограничениям человеческий вид: названное условие вместо трюка, короткая диагностика вместо расследования. Ниже разбор того, что именно изменилось, чем платил старый подход, где концепты уже безальтернативны и как переводить большую библиотеку, не сломав сборку на полпути.

Как SFINAE решал задачу ограничений и чем заплатил за универсальность

Аббревиатура SFINAE в развёрнутом виде означает простое правило: ошибка подстановки аргументов шаблона не считается ошибкой компиляции. Если при подстановке типа в сигнатуру перегрузки что-то расходится, компилятор не останавливается, а молча выбрасывает кандидата из списка и ищет дальше. На этом правиле два десятилетия строился весь контроль типов в шаблонах: проверку прятали внутрь сигнатуры так, чтобы при неподходящем типе она проваливалась сама.

Классический инструмент трюка, std::enable_if, пришёл из Boost начала двухтысячных и вошёл в стандарт вместе с C++11:

template <typename T,
          typename std::enable_if<std::is_integral<T>::value, int>::type = 0>
T twice(T value) {
    return value * 2;
}

Смысл прочитать можно, но вслух это звучит как шифровка: фиктивный параметр шаблона по умолчанию зависит от истинности предиката и целочисленного нуля. Для проверки наличия члена у типа появилась идиома детекции через void_t, разошедшаяся по докладам в 2014 году и вошедшая в стандарт с C++17:

template <typename T, typename = void>
struct has_reserve : std::false_type {};

template <typename T>
struct has_reserve<T, std::void_t<decltype(std::declval<T&>().reserve(0))>>
    : std::true_type {};

Трюк работает, но у него три счёта к оплате. Первый: намерение автора спрятано в механике, читателю нужно знать идиому, чтобы понять, что функция принимает целые числа. Второй: масштабирование, каждое новое условие умножает перегрузки и вырождает сигнатуры в макеты. Третий, самый дорогой: диагностика. Когда ни один кандидат не подошёл, компилятор честно перечисляет всех отвергнутых с длинными следами подстановок, а сообщения о том, какое именно требование нарушено, не даёт, потому что названных требований в коде попросту нет.

Был и четвёртый счёт, неочевидный снаружи: порядок выбора перегрузок. Когда условий несколько, их расставляли по приоритетам вручную, через теги, дополнительные фиктивные параметры и цепочки вложенных условий. Каждая такая лестница работала, пока её не трогали, и ломалась при первом расширении набора типов.

Масштаб проблемы легко оценить по любому большому шаблонному проекту тех лет. В зрелых библиотеках счёт enable_if-перегрузок шёл на сотни, и почти каждая нестандартная сигнатура требовала комментария с расшифровкой, что именно здесь ограничивается. Комментарий рядом с трюком, это признание того, что код не читается сам, а комментарии устаревают быстрее кода.

Что концепты дают разработчику в requires-выражении и requires-предложении

Концепт в C++20, это именованный предикат над типами, объявленный прямо в языке:

template <typename T>
concept Integral = std::is_integral_v<T>;

Имя употребляется тремя способами: как ограничение списка параметров шаблона, как сокращённый тип параметра обычной функции и как условие в requires-предложении:

template <typename T> requires Integral<T>
T twice(T value) { return value * 2; }

Integral auto triple(auto value) { return value * 3; }

Для составных проверок существует requires-выражение, которое проверяет целые наборы выражений сразу:

template <typename T>
concept Reservable = requires(T t, std::size_t n) {
    t.reserve(n);
    { t.size() } -> std::convertible_to<std::size_t>;
};

Здесь читается намерение, а не механика: тип должен уметь резервировать память и сообщать размер. Стандартная библиотека содержит готовый набор таких предикатов в заголовке <concepts>, от std::integral и std::same_as до std::signed_integral, а подсистема ranges построена на концептах целиком, ограничения там являются частью публичного интерфейса алгоритмов.

Отдельное удобство дают ограничения на сокращённых параметрах: обычная функция с auto-параметром получает требования без единой строки template, а тело остаётся обычным кодом. Порог входа в обобщённое программирование стал заметно ниже, концепт в параметре читается как тип.

Путь концептов в стандарт был долгим: отдельная техническая спецификация, шумные споры 2016 года, из-за которых механизмы не вошли в C++17, затем переработка и включение в C++20, опубликованный в декабре 2020 года. Результат стоит дороги, ограничение перестало быть трюком и стало конструкцией языка со своей семантикой.

Диагностика компилятора до и после, разница на живом примере

Попробуем вызвать функцию twice с типом double. Вариант на std::enable_if ответит отказом без объяснений: компилятор сообщит, что подходящей перегрузки нет, и приложит список отвергнутых кандидатов, внутри каждого спрятана цепочка подстановок с многоэтажным типом-заглушкой. Понять, что автор ждал именно целых чисел, можно, но это работа: след подстановок нужно дочитать до конца и догадаться, что точка отказа была преднамеренной.

Вариант на концептах называет причину прямо. Сообщение строится по ясной схеме: вызов такой-то, ограничение не выполнено, тип не удовлетворяет концепту Integral. Строка читается человеком за секунды, а не минуту. На больших библиотеках, где сигнатуры тянут за собой пять-шесть параметров шаблона с вложенными контейнерами, разница между коротким сообщением и страницей следов умножается на каждый вызов.

Типичная сцена из практики: в шаблонный хелпер передают контейнер собственного класса, который похож на вектор, но не умеет резервировать память. Старый вариант уводит сборку в глубины подстановок и роняет её где-то в недрах алгоритма, в сто двадцать первой строке трассировки. Концептный вариант отвечает с порога: тип не удовлетворяет Reservable, потому что вызов reserve с подходящей сигнатурой отсутствует. Поиск причины занимает секунды, а не полдня.

Есть и тихая польза, не связанная с длиной сообщений. При подходе с enable_if опечатка в имени типа могла уводить программу в немые перегрузки, и ошибка находилась позже, где-нибудь на этапе линковки или первого запуска. С концептами несоответствие фиксируется в точке вызова, и целый класс поздних ошибок передвинулся на этап первой компиляции.

Упорядочивание перегрузок через субсумцию концептов

Вторая тихая революция касается приоритета перегрузок. Пусть есть два варианта обработки: общий для всех целых и уточнённый для знаковых:

template <typename T> requires std::integral<T>
void process(T v) { /* общий путь для целых */ }

template <typename T> requires std::signed_integral<T>
void process(T v) { /* ускоренный путь для знаковых */ }

Вызов с int попадает в уточнённый вариант без единой строчки ручной логики. Правило субсумции работает так: если один концепт включает другой по построению, компилятор сам считает более конкретную перегрузку лучшим кандидатом. std::signed_integral собран из std::integral, значит, он сильнее ограничивает и выигрывает конкурс без всяких лестниц.

В мире SFINAE то же самое приходилось строить руками: приоритезировать перегрузки через дополнительные параметры, теги и цепочки условий, по сути, заново программировать порядок выбора. Тот же выбор для двух вариантов обработки потребовал бы двух разных имён или каскада тегов, и о намерении пришлось бы сообщать комментариями. Каждая лестница была источником тонких ошибок при расширении набора типов. С концептами лестницу заменяет система правил с отношением включения, и она работает одинаково во всех трёх больших компиляторах.

Зрелость компиляторов и доля проектов на старых стандартах

Инфраструктура созревала несколько лет. GCC 10.1, вышедший 7 мая 2020 года, первым из больших релизов GNU-компилятора получил концепты в рабочем виде. Clang добавил базовую поддержку в версии 10, а отдельные уточнения семантики доезжали годами: фрагмент P0857 добрался до Clang 16, условно тривиальные функции из P0848 до Clang 19. Microsoft подключила поддержку в Visual Studio 2019 начиная с обновления 16.3 в режиме новейшего стандарта. Сегодняшние ветки тройки компиляторов обрабатывают концепты стабильно, но разброс версий в реальных проектах остаётся.

Ещё один практический фильтр, возраст зависимостей: если проект тянет библиотеки, собираемые под старыми флагами, стандарт в дереве оказывается неоднородным, и концепты приходится включать точечно, секциями, а не одним глобальным флагом.

Практическая оговорка про реальность: большое количество кодовых баз встроенной сферы и легаси-сектора всё ещё собирается под C++11, C++14 или C++17, где концептов нет в принципе. Требование стандарта нужно проверять не для одной машины, а для всей цепочки: матрица непрерывной интеграции, целевые платформы, внешние зависимости с их собственными флагами. Переезд на C++20 ради одних концептов, как правило, не оправдан в одиночку, но в связке с ranges, расширенной constexpr-механикой и обновлением цепочки инструментов выгода накапливается.

План постепенного перехода библиотеки с enable_if на концепты

Миграция окупается, когда делается по шагам и от края к центру. Порядок, который не ломает сборку на промежуточных стадиях:

  1. Поднять стандарт сборки до C++20 во всех конфигурациях или составить точный список цепочек инструментов, которые его ещё не держат;
  2. Завести общие концепты проекта рядом со стандартным заголовком <concepts>, не плодя копии в каждой библиотеке;
  3. Перевести сначала публичные интерфейсы, там выигрыш в читаемости и диагностике максимальный;
  4. Мигрировать внутренние enable_if-проверки после интерфейсов, когда тесты уже страхуют поведение;
  5. Держать на переходный период оба варианта под управлением версии стандарта, чтобы код собирался и на старых платформах;
  6. Удалить ставший мёртвым SFINAE-код после переезда, не оставляя два механизма в одной библиотеке навсегда.

Сами правки механические. Было:

template <typename T,
          typename std::enable_if<std::is_integral<T>::value>::type* = nullptr>
T sum(T a, T b) { return a + b; }

Стало:

template <typename T> requires std::integral<T>
T sum(T a, T b) { return a + b; }

Когда SFINAE остаётся уместным и как жить со смешанной кодовой базой

Хоронить SFINAE рано по трём причинам. Первая: детекция сложных свойств типа, наличие вложенного типа или члена с точной сигнатурой, иногда компактнее пишется старой идиомой через void_t, особенно если проверка нужна в одном месте и не заслуживает собственного концепта. Вторая: заявленная совместимость со старым стандартом, если библиотека декларирует сборку под C++14, у авторов нет выбора. Третья: вес привычек команды, если SFINAE-код покрыт тестами и не меняется годами, его переезд не срочный, а очередь.

Смешанная база проблемой не является: концепты и SFINAE совместимы на уровне перегрузок, старый и новый механизмы уживаются в одной библиотеке. Разумная стратегия звучит так: новое пишется на концептах, а старое переводится по мере прикосновения. Открыли файл за правкой, заодно перевели его требования на новый механизм, не открыли, оставили в покое.

Последний практический аргумент за спокойный темп, читаемость выигрывает у пуристов. Цель миграции не в том, чтобы в кодовой базе не осталось ни одного enable_if, а в том, чтобы новые ограничения писались так, будто язык всегда умел это делать. Остатки старой техники в дальних углах библиотеки вреда не приносят.

Суть перемены не в том, что язык научился ограничивать типы, он умел это двадцать лет. Суть в том, что ограничение получило имя, место в сигнатуре и право на человеческое сообщение об ошибке. Трюк, который понимал только автор, превратился в контракт, который понимает компилятор, и это ровно то, за что в инженерии платят.