Привет, Хабр!

Сегодня я хочу поговорить про SFINAE, загадочную аббревиатуру из C++. Расшифровывается SFINAE не менее загадочно: Substitution Failure Is Not An Error, по-русски: «неудавшаяся подстановка — не ошибка». Сейчас рассмотрим, почему это правило появилось, как оно работает и как мы можем использовать его себе во благо.

Что такое SFINAE и зачем он нужен

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

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

SFINAE придумали исторически, чтобы посторонний шаблон не ломал вашу программу. Допустим, вы подключили какой-то заголовок, а там объявлена шаблонная функция с именем f, которая вообще не в тему, но теоретически могла бы соответствовать вызову f(ваш_тип). Без SFINAE компилятор попытался бы инстанцировать её и, вероятно, упал с ошибкой. С SFINAE же компилятор сначала попробует, не вышло, ну и фиг с ним, берём другой перегруженный вариант. То есть «неудачная подстановка — не ошибка, а просто неподходящий кандидат».

Чтобы было понятнее, рассмотрим пример на псевдокоде. Допустим, у нас есть три перегрузки функции foo:

void foo(int x);                                    // #1 обычная функция
template<typename T> void foo(T x);                 // #2 шаблон без ограничений
template<typename T> void foo(T x, typename T::id); // #3 шаблон с требованием

И мы вызываем foo(42). Какие кандидаты видит компилятор?

  1. Обычная foo(int) (#1) точно подходит.

  2. Шаблон foo(T) (#2) тоже может быть вызван с T=int — подходит.

  3. Шаблон foo(T, typename T::id) (#3) выглядит подходящим, но при попытке вывести T из аргументов произойдёт казус: у типа int нет вложенного типа id. Выражение typename T::id не имеет смысла для T=int и это ошибка.

Вот на этом этапе включается SFINAE: шаблонная перегрузка #3 отбрасывается, но компиляция не прерывается. У нас остаются кандидаты #1 и #2, и между ними уже выбирается лучшая перегрузка по привычным правилам (в данном случае #1 победит, так как не шаблон, или по другим критериям соответствия).

Если суммировать:

  • SFINAE затрагивает только шаблонные функции (или методы, операторные шаблоны, не суть, главное, что речь о функциях).

  • Он срабатывает только на этапе подстановки типов шаблона во время подбора перегрузки.

  • Если шаблонный кандидат не смог инстанцироваться из-за ошибок в своей объявленной сигнатуре (важно: в теле функции ошибки не прощаются!), то кандидат просто исключается из рассмотрения.

  • Нешаблонные функции, естественно, не попадают под действие SFINAE. Если у вас единственная функция и она не соответствует вызову, будет обычная ошибка типа no matching function.

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

Используем SFINAE для ограничения шаблонов

Один из самых простых и популярных примеров SFINAE — это std::enable_if. Этот шаблон позволяет включать или отключать перегрузку функции в зависимости от булева условие на типы. Если условие не выполнено, шаблонная перегрузка как бы пропадает, и SFINAE убирает её из кандидатов.

Допустим, мы пишем функцию print для разных типов: хотим, чтобы для контейнеров она выводила содержимое, а для остальных типов просто печатала значение. У нас может быть два шаблона print с разными ограничениями. В C++17+ с if constexpr и концептами это делается проще, но давайте по-старому, через SFINAE, чтобы прочувствовать идею. Используем std::enable_if_t (это удобный синоним для typename std::enable_if<условие, Тип>::type):

#include <type_traits>
#include <iostream>
#include <vector>
#include <list>

// Шаблон для контейнеров: распознаем их по наличию вложенного типа iterator
template<typename T>
std::enable_if_t<!std::is_void<typename T::iterator>::value> 
print(const T& container) {
    std::cout << "Container with elements: ";
    for (auto& elem : container) {
        std::cout << elem << " ";
    }
    std::cout << std::endl;
}

// Шаблон для остальных типов (не имеющих T::iterator)
template<typename T>
std::enable_if_t<std::is_void<typename T::iterator>::value> 
print(const T& value) {
    std::cout << "Single value: " << value << std::endl;
}

int main() {
    std::vector<int> vec = {1, 2, 3};
    int x = 42;
    print(vec);  // вызовет первую перегрузку, распечатает элементы
    print(x);    // вызовет вторую перегрузку, выведет "Single value: 42"
}

Этот код, признаться, слегка перегружен шаблонной темой, но суть в том, что мы определили две версии функции print для разных категорий типов. Как это работает?

В первой перегрузке стоит условие std::enable_if_t<!std::is_void<typename T::iterator>::value>. Уконтейнеров обычно определён вложенный тип iterator (а у не-контейнеров его нет). Выражение typename T::iterator будет синтаксически невалидным, если у типа T нет такого вложенного имени. Мы это завернули в std::is_void, просто чтобы получить true/false: если типа iterator нет, typename T::iterator вызовет ошибку подстановки, и компилятор не увидит std::is_void<...>::value вообще. В результате для типа без iterator первая шаблонная функция не инстанцируется (SFINAE её выкинет). Аналогично, для типа с iterator отвалится вторая перегрузка, потому что там мы хитро ожидаем, что T::iterator нет (применяя std::is_void к существующему типу, получим false, а значит std::enable_if_t<false> просто не имеет type, подстановка неудачна, шаблон мимо).

В итоге:

  • Если T имеет вложенный тип iterator, остаётся только первая перегрузка print, вторая отфильтровалась.

  • Если у T нет iterator, компилятор отсеет первую, и будет использовать вторую.

Вы, наверное, заметили: что мы явно не вызываем нигде функций enable_if, мы лишь указываем, что возвращаемый тип функции print определяется через enable_if_t<...>. Когда условие внутри enable_if_t истинно, enable_if_t превращается в заданный тип (по умолчанию void), то есть сигнатура становится корректной. А когда условие ложно, enable_if_t не имеет типового определения (::type отсутствует), происходит Substitution Failure. То есть функция просто отбрасывается из перегрузок.

В примере выше, конечно, есть нюансик: я допущу, что у всех контейнеров определён T::iterator, а у всех не-контейнеров его нет. В реальности это не стопроцентно, но для демонстрации годится. Можно было бы проверять иначе (например, через <iterator_traits> или концепты), но тогда пример стал бы сильно громоздким. Главное — принцип: SFINAE позволяет написать универсальный код, который либо есть, либо как бы комментируется компилятором, в зависимости от свойств типов.

Кстати, заметим, что enable_if_t здесь указан прямо в объявлении функции (вместо возвращаемого типа). Некоторые предпочитают помещать условие как дополнительный шаблонный параметр по умолчанию, так иногда чище выглядит:

template<typename T, typename = std::enable_if_t<условие>>
void foo(const T& x) { ... }

Эффект тот же, если условие ложное, то попытка вывести этот дополнительный шаблонный параметр провалится, и функция foo просто не будет рассматриваться.

Проверяем свойства типов

Разогревшись на enable_if, копнём глубже. Один из самых впечатляющих моментов с SFINAE — проверка на наличие определённого метода или типа внутри класса.

Например, мы хотим написать метафункцию has_foo<T>, которая на этапе компиляции даст знать, есть ли у типа T метод foo(int). Зачем такое может понадобиться? Допустим, мы пишем шаблон, который для типов с методом foo будет вызывать его, а для остальных – делать что-то другое. Нам нужно компилируемо (без выполнения) узнать, обладает ли тип нужным методом.

Через SFINAE подобное возможно. Существует классическая идиома "Detection Idiom". С появлением C++17 она упростилась благодаря std::void_t, но реализуем что-то подобное вручную, чтобы понять механику. Мы воспользуемся частичной специализацией шаблонов и SFINAE:

#include <type_traits>
#include <utility>

template<typename, typename = std::void_t<>> 
struct has_foo : std::false_type {};

// Специализация, которая будет выбрана, если внутри std::void_t всё ок
template<typename T>
struct has_foo<T, std::void_t<decltype(std::declval<T>().foo(std::declval<int>()))>> 
    : std::true_type {};

Разберём по частям. У нас шаблонная структура has_foo с двумя параметрами: первый проверяемый тип T, второй некий typename = std::void_t<> со значением по умолчанию. По умолчанию там просто std::void_t<> без параметров, что при расширении даёт тип void. Эта основная шаблонная дефиниция наследует от std::false_type, т.е. по дефолту считаем, что foo(int) нет.

Далее идет частичная специализация has_foo<T, std::void_t< ... >> : std::true_type. Она сработает, только если выражение внутри std::void_t<...> успешно сформирует тип. Внутри мы пишем decltype(std::declval<T>().foo(std::declval<int>())). Это хитрый способ попробовать вызвать метод foo(int) у типа T в контексте компиляции:

  • std::declval<T>() эмулирует значение типа T (без создания объекта).

  • Таким образом, std::declval<T>().foo(std::declval<int>()) — это выражение вызова foo(int) для типа T.

  • decltype( ... ) пытается вывести тип этого выражения. Если метод существует и вызов корректен, decltype даст какой-то тип (нам, кстати, даже не важно какой). Если метода нет или сигнатура не подходит — произойдёт ошибка.

И вот тут SFINAE, эта ошибка будет не фатальной, а просто приведёт к тому, что специализация шаблона has_foo не подойдет. Тогда будет использована основная версия (наследующая false_type). Если же decltype успешно вычислился (метод есть) — специализация применяется, наследуясь от true_type.

Проверим в коде:

struct X { void foo(int) {} };
struct Y { };

static_assert(has_foo<X>::value, "X should have foo(int)");
static_assert(!has_foo<Y>::value, "Y should not have foo(int)");

int main() {
    std::cout << std::boolalpha;
    std::cout << "X has foo? " << has_foo<X>::value << std::endl;
    std::cout << "Y has foo? " << has_foo<Y>::value << std::endl;
}

Если вы скомпилируете это, static_assert-ы пройдут, а вывод в main будет: X has foo? true и Y has foo? false. Мы научили компилятор узнавать про наличие метода и принимать разные пути для разных типов.

Стоит отметить: шаблон std::void_t просто превращает любое набор типов в тип void. Его фишка, если внутри скобок выражение некорректное, то подстановка для этой специализации провалится, но сама специализация не выдаст ошибку компиляции (потому что SFINAE). Без void_t пришлось бы городить более сложные конструкции.

Сейчас же это делается относительно компактно, как вы видите.

Ограничения и современные альтернативы

Конечно, у SFINAE есть свои тонкси. Главное ограничение: правило действует только на ошибки в объявлении шаблона. Если ошибка возникает внутри тела функции, когда компилятор уже выбрал перегрузку и начал её инстанцировать, SFINAE вас не спасёт, это просто ошибка компиляции. Поэтому все уловки обычно проделывают либо с возвращаемым типом (через decltype или enable_if), либо с шаблонными параметрами. Ещё SFINAE применим и к классам через частичную специализацию, как мы делали с has_foo. Но не бывает SFINAE для обычных (не шаблонных) функций или для полного специализации (там просто чёткие соответствия).

Код, активно использующий SFINAE, может становиться трудно читаемым. К счастью, язык развивается. В C++17 ввели if constexpr, который позволяет внутри шаблонной функции просто условно откинуть кусок кода в зависимости от типа — зачастую это удобнее, чем плодить перегрузки. В C++20 появились концепты, по сути, способ явно указать ограничения на шаблонные параметры, и компилятор сам разберётся, кто подходит, а кто нет. Тем не менее, SFINAE никуда не делся: концепты всё равно используют похожие идеи, да и во многих местах старый добрый enable_if до сих пор рулит (например, при написании шаблонных конструкторов или совсем хитрых случаев).


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

Проверьте уровень знаний для обучения C++ на уровне Pro
Проверьте уровень знаний для обучения C++ на уровне Pro

SFINAE — лишь один из элементов современного арсенала C++, и ценность его по-настоящему раскрывается, когда вы уверенно двигаетесь по всей экосистеме языка. Если хочется системно прокачать навыки — от шаблонов и многопоточности до практик C++20/23 и архитектуры — практический курс "C++ Developer. Pro" поможет выстроить цельную картину и перейти к уровню профессиональной разработки.

Если хотите понять формат обучения — записывайтесь на бесплатные демо-уроки от преподавателей курса:

  • 11 декабря. Корутины в C++. Асинхронность без классических потоков. Записаться

  • 23 декабря. Cache friendly код - оптимизируем работу с памятью. Записаться

Комментарии (0)