SFINAE. Как много в этом слове

Одной из наиболее сложных для понимания функциональностей в C++, по праву считаются шаблоны. Нет, не их простейшее применение — здесь, как правило, у людей не возникает больших проблем. Проблемы возникают тогда, когда с использованием шаблонов начинают писать код, который порождает другой код, т.е. писать код на языке шаблонов — это называется метапрограммирование. Многие люди, увидев подобный код, впадают в состояние транса — тут ещё хуже чем с указателями, если указатели не все понимают, то с шаблонами дело обстоит ещё хуже. Оно и не мудрено — код с большим количеством шаблонов практически нечитаем. Не думайте, что есть какие-то супер гуру, которые его легко читают — нет. Пока ты пишешь такой код, его читать не очень сложно, а вот после того, как его написал и вернулся через месяц-другой, приходится его распутывать по новой. Именно поэтому, код с большим количеством шаблонов, раньше, как правило, находился только в библиотеках, которые писали профессионалы, и такие вещи, как SFINAE, далеко не обязательно нужно было понимать рядовому программисту.

Но времена меняются, язык обновляется, пополняется библиотека C++ и уже можно встретить enable_if даже в коде у новичков — ведь это так просто! Действительно, метапрограммирование стало проще, значительно проще. Но, тем не менее, используя новые метафункции из библиотеки, не все понимают как же они работают и почему. В настоящей статье, я бы хотел затронуть как раз эту тему и разобрать, что же такое SFINAE и почему оно так важно в современном C++-коде. Безусловно, понимание SFINAE вовсе не обязательно, чтобы писать достойные приложения на C++, но с пониманием оного, можно писать куда более элегантные решения и не «методом тыка», а с полным осознанием происходящего.

Перегрузка функций

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

function(1, 2);

И такой список деклараций:

void function(int, std::vector<int>);
void function(int, int);
void function(double, double);
void function(int, int, char, std::string, std::vector<int>);
void function(std::string);

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

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

void function(int, std::vector<int>);
void function(int, int);
void function(double, double);

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

void function(int, int);
void function(double, double);

После чего компилятор приступает к третьей стадии, при которой выбирается лучшая функция на основе не очень сложных, но довольно многословных правил. В нашем случае побеждает void function(int, int), т.к. эта функцию не требует никакого преобразования аргументов. А что было бы, если бы не оказалось одной лучшей? Если бы обе подходили одинаково хорошо? Тогда вызов был бы двусмысленным, о чём бы незамедлительно сообщил компилятор. Так, в общих чертах, и работает перегрузка методов в C++. Но это ещё не всё, что стоит сказать о ней, ведь в списке вышеупомянутых функций нет ни одной шаблонной. А для шаблонной функции есть особое правило, которое мы сейчас и рассмотрим.

Перегрузка и шаблонная функция

Итак, добавим к нашему списку следующую шаблонную функцию:

template<typename T>
void function(T, T);

Что изменится в ранее рассмотренном алгоритме? Несколько изменится первая стадия: так, если компилятор встречает шаблонную функцию, имя которой совпадает с именем вызова, тогда компилятор пытается вывести аргументы шаблона, на основании аргументов переданных в вызов. Заметьте, всё это происходит на первом шаге, где для нешаблонных функций анализа аргументов вообще не происходит. Мы не будет подробно останавливаться на выведении аргументов шаблонов, это довольно обширная тема сама по себе. Главное, что нам нужно знать,— это то, что если все аргументы удаётся вывести, тогда шаблонная функция, с выведенными аргументами, добавляется в список кандидатов функций. Таким образом, после первого шага у нас получается вот такой список:

void function(int, std::vector<int>);
void function(int, int);
void function(double, double);
void function<int>(int, int);

Но после 3-го шага останется всё равно только одна функция void function(int, int), которая и будет вызвана, т.к. нешаблонная функция, при прочих равных, «сильнее» шаблонной, согласно правилам C++. Но нам это не интересно, а интересно вернуться к первому шагу. Ранее я сказал, что если все аргументы удаётся вывести, тогда функция добавляется в список кандидатов, но не сказал ничего про ситуацию, когда их вывести не удаётся, что происходит тогда? Тогда происходит вполне логичная вещь: если, по какой либо причине, все аргументы шаблона не могут быть выведены из представленного вызова, тогда функция не добавляется в список кандидатов и полностью вычеркивается из дальнейшего рассмотрения Это и есть знаменитое правило SFINAE  — substitution failure is not an error, или, по-русски: невозможность замены не является ошибкой.

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

SFINAE это только про сигнатуру функции и выводимые параметры шаблона, больше ни про что. Запомните это.

Бывалые C++ программисты знают, что возвращаемый тип в функции не является частью её сигнатуры, но даже многие бывалые не знают того, что возвращаемый тип является частью сигнатуры шаблона функции. Это важная информация, которая позволяет использовать тот-же std::enabled_if на возвращаемом типе.

SFINAE

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

template <typename T>
void printContainer(T container)
{
    std::cout << "Values:{ ";
    for(auto value : container)
        std::cout << value << " ";
    std::cout << "}\n";
}

Но что будет, если вызвать эту функцию с типом, который не является стандартным контейнером? К примеру, с переменной типа int? Мы получим некоторую невразумительную ошибку. К примеру, одной из ошибок в MSVC будет error C3312: no callable 'begin' function found for type 'int'. И чем больше внутри использует переменная container, в том ключе, в котором переменная типа int использоваться не может, тем больше будет ошибок. Помимо того, что большое количество ошибок всегда расстраивает, из этой ошибки непонятно, что и где искать. А что если реализацию функции большая и сложная, а из её названия не понятно, в чём же вы ошиблись? Безусловно, я утрирую и пример надуман, но, тем не менее, мы можем сделать лучше. С помощью SFINAE мы можем полностью исключить эту функции из рассмотрения, для типов, которые не являются стандартными контейнерами(либо же не мимикрируют под них). Для этого достаточно изменить лишь шапку шаблона:

template <typename T, typename = typename T::iterator>

Теперь мы не будем получать массы ошибок вне зависимости от того, что содержится в реализации функции. Полученная нами ошибка будет кристально ясна: error C2672: 'printContainer': no matching overloaded function found.

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

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

  • Выводится тип параметра T, он будет int
  • Происходит попытка вывода второго параметра, который не имеет имени: int::iterator. Но компилятор не может найти типа iterator в int и поэтому попытка вывода проваливается
  • Функция исключается из рассмотрения
  • Т.к. больше никаких функций у нас нет, компиляция завершается ошибкой.

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

Метафункции

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

Использовать фиктивный параметр для SFINAE-целей можно следующий образом:

template <typename T>
void printContainer(T container, typename T::iterator* = nullptr)
{
    ...
}

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

Остаётся возвращаемое значение.  Казалось бы всё просто: давайте сделаем так же, как мы делали до этого, только перенесём в возвращаемый тип:

template <typename T>
typename T::iterator printContainer(T container)
{
    ...
}

Но такой метод порождает одну очень серьёзную проблему: мы меняем возвращаемое значение функции не потому, что нам нужно что-то вернуть, а просто потому, что нам нужно применить SFINAE. Это никуда не годится, функция printContainer должна возвращать void и только void, но как тогда применить SFINAE? А сделать это не сложно, но для начала мы рассмотрим вспомогательный класс:

template <bool condition, typename Type>
struct EnableIf;

template <typename Type>
struct EnableIf<true, Type>
{
    using type = Type;
};

Как вы можете видеть, реализация класса предельно проста: если первым аргументом шаблона будет false, тогда у результирующего класса EnableIf не будет члена type, а если будет true, — тогда будет. Классы такого вида принято называть метафункциями, и приведённая выше метафункция возвращает тип. Есть ещё один класс метафункций, которые возвращают значение, например:

template <typename T, typename U>
struct IsSame
{
    static constexpr bool value = false;
};

template <typename T>
struct IsSame<T, T>
{
    static constexpr bool value = true;
};

Метафункция IsSame, возвращает true, если оба переданных ей типа одинаковы и false в противном случае. Но если это функции, то как их вызывать? Вызываются эти функции очевидным образом:

IsSame<int, float>::value;
EnableIf<true, int>::type

Конечно, никто не обязывает вас использовать value и type, но так сложилось исторически. Следовательно, если вы хотите, чтобы ваш код понимали другие программисты, нужно использовать именно эти имена и именно в таком ключе — это стандарт де-факто. Метафункции есть как в библиотеке boost, так и в стандарте C++(и много где ещё). Так вот, используя метафункцию EnableIf мы можем «включать» и «выключать» функцию из перегрузки, например:

template <typename T>
EnableIf<true, void>::type 
printContainer(T container)
{
    ...
}

В этом виде наш код полностью эквивалентен простой записи функции printContainer с типом void, в качестве возвращаемого значения. Нам как-то нужно сделать так, чтобы для вектора наша функция работал, а для переменной типа int — нет.  Если задача состоит только в том, чтобы запретить функцию для int, то это делается элементарно даже с теми функциями, что у нас уже есть:

template <typename T>
typename EnableIf<!IsSame<T, int>::value, void>::type 
printContainer(T container)
{
    std::cout << "Values:{ ";
    for(auto value : container)
        std::cout << value << " ";
    std::cout << "}\n";
}

Теперь, наша функция не будет участвовать в списке кандидатов для аргумента типа int. Давайте разберём новую строчку подробнее: мы добавили typename, т.к. type стал зависеть от параметра шаблона T — таковы правила языка, затем, мы использовали метафункцию IsSame, чтобы определить является ли переданный аргумент int’ом или нет. Если не является, тогда получается, что EnableIf содержит член с именем type, в противном случае — нет, и включается SFINAE. Всё довольно просто.

Малой кровью, с помощью стандарта, мы можем сделать нашу проверку более универсальной и исключить все базовые типы, для этого воспользуемся метафункцией из стандарта std::is_integral:

template <typename T>
typename EnableIf<!std::is_integral<T>::value, void>::type 
printContainer(T container)
...

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

template <typename T>
typename EnableIf<!std::is_integral<T>::value, void>::type 
print(T container)
{
    std::cout << "Values:{ ";
    for(auto value : container)
        std::cout << value << " ";
    std::cout << "}\n";
}

template <typename T>
typename EnableIf<std::is_integral<T>::value, void>::type 
print(T value)
{
    std::cout << "Value: " << value << "\n";
}

Теперь мы можем печатать как контейнеры, так и простые типы не получая ошибок! Конечно, тот факт, что нам пришлось делать фактически 2 идентичные «шапки» из шаблонов не красят наш код, но за всё приходится платить. Кстати, в стандарте C++ уже есть функция std::enable_if(как и std::is_same) и нам совершенно не нужно писать свою, поэтому в дальнейшем мы будем использовать готовое решение. Я привёл своё решение лишь для того, чтобы вы увидели, как просто писать свои метафункции.

Всё в предыдущем коде хорошо, кроме того, что проверка используемая в EnableIf для контейнера никуда не годится, — если тип не является интегральным из этого не следует, что он является контейнером. Нам нужно решение лучше.

Улучшенный вариант

Прежде чем мы воспользовались EnableIf в предыдущем разделе, мы проверяли контейнер по наличию внутреннего члена с именем iterator, но это совершенно не верно для нашей функции print, что нам действительно важно, так это наличие функций `begin` и `end`, для типа T. Ведь именно наличие таких функций является необходимым условием использования объекта типа T в диапазонной версии цикла for. Именно поэтому, мы будем писать такую метафункцию, которая будет проверять, есть ли функция begin для нашего типа T(для end всё тоже самое).

Для начала нам понадобится функция, которая «всё стерпит»:

char begin(...);

Теперь напишем саму метафункцию:

template <typename T>
struct IsForReady
{
    static constexpr bool value 
        = sizeof(begin(std::declval<T>())) != sizeof(char);
};

Как вы можете видеть, мы написали метафункцию, возвращающую значение. Значение её будет варьироваться в зависимости от переданного типа T. Так, если при поиске подходящей функции begin, которая может принять то, что возвращает std::declval<T>(), мы вернулись с пустыми руками, тогда компилятор, отчаявшись, вызовет нашу begin(…), которая возвращает char. Поэтому, если функция найдена, то IsForReady вернёт true, и false в противном случае. Теперь мы можем использовать новоиспечённую метафункцию вместо std::is_integral в предыдущем примере, чтобы более точно определить, что за функцию нам нужно вызывать.

Понимаю, что у людей, впервые с этим столкнувшимся, может голова пойти кругом: «как всё это работает???». Поэтому, давайте постараемся разобраться по шагам.Начнём с главного: в С++ есть операторы, которым можно передать выражение, которые его не вычисляют, а лишь выжимают из этого выражения им необходимую информацию. Примером таких операторов являются sizeof и decltype; здесь я остановлюсь только на sizeof, хотя принципы совпадают, различается лишь результат — но он для нас, в рамках данной статьи, не важен. Если хотите почитать про decltype, я упоминал его в ��татьях: раз и два. Итак, sizeof возвращает количество байтов, которое занимает тип, переданный ему в качестве аргумента. Но что если ему передан не тип, а выражение? В этом случае, sizeof должен применить все свои силы, для определения того, что за тип будет результатом выражения. Важно понимать, что ему важен только тип, поэтому, если в выражении встречается функция, тогда ему важна лишь её сигнатура ничего более. Даже если функция лишь задекларирована, но не определена — не важно. Именно такой случай мы имеем в нашем примере: begin(…) задекларирована, но не определена — мы не собираемся её вызвать, она нам нужна для наших «фокусов».

Ещё одним интересным субъектом нашего выражения является std::declval. Это такая удобная функция, возвращающая объект типа, идентичный T, но с добавлением rvalue-ссылки. Вот как она задекларирована:

template <class T>
std::add_rvalue_reference<T>::type declval() noexcept;

В чём её смысл? Почему бы просто не написать sizeof(begin(T{}))? Потому что такая запись потребовала бы от T наличия конструктора по умолчанию, а мы не хотим и не должны требовать подобного. Поэтому, мы используем трюк с rvalue-ссылкой, которая выполняет то, что от неё требуется: вынуждает компилятор найти требуемую функцию(если она есть) и не налагать лишних ограничений на тип, который мы можем использовать.

Таким образом, наше «страшное» выражение на деле оказывается довольно простым: мы просим компилятор найти функцию, которая могла быть принять объект типа T в качестве своего аргумента. И он обязательно это сделает, т.к. в случае, если такой функции нигде нет, есть наша «всеядная» функция, которая возвращает char. Конечно, этот метод не сработал бы, если бы где-то могла бы найтись реальная функция begin, которая возвращала бы тип, размер которого был бы идентичен char, но это крайне маловероятно. Однако, это даёт пищу к размышлениям: условия, по которым можно определять те или иные вещи, должны определятся исходя из задачи и должны быть тщательно проанализированы. Если функция begin вряд ли будет возвращать char, то функции из других областей вполне могут это делать.

Хотелось бы почеркнуть, что всё, что сделано выше, с известными модификациями, можно было сделать и до C++11: ничего такого, что нельзя было сделать раньше(пусть и немного в другой нотации) я до настоящего момента не привёл. Но есть ли какие-то изменения в C++11 или 14, которые могли бы упростить нашу жизнь(и наш пример)? Есть.

Expression SFINAE

SFINAE существует довольно давно, но с появлением C++11 оно стало качественно лучше, и для этого не потребовалось переписывать стандарт или явно вводить какие-то новые ключевые слова. Всё, что для этого потребовалось, это разрешить вычисление выражений при выводе шаблона и считать невозможность вычисления как SFINAE, а не как ошибку. Это может показаться несущественным, но это не так. Если раньше, SFINAE работало только в простейших случаях(как мы рассматривали ранее), то теперь мы можем написать любое выражение и компилятор должен полностью «вычислить» его, применяя где надо  разрешение перегрузки и прочие вещи, которые присущи нормальному выражению!

Что это даёт на практике? Больше нет необходимости писать метафункции: теперь можно использовать выражения прямо в декларации шаблона. И за счёт этого, написание многих вещей упрощается. Значит ли это, что метафункции исчезают из обихода? Нет, как и любая другая функция, метафункция важна как именованная часть действия, которая позволяет упростить код. Просто теперь мы можем выбирать: хотим ли мы писать метафункции или у нас есть план получше — раньше такого выбора у нас не было. Давайте теперь рассмотрим, как мы можем переписать наш пример с print,с применением expression SFINAE:

template <typename T>
decltype(begin(std::declval<T>()), end(std::declval<T>()), void())
print(T container)
{
    std::cout << "Values:{ ";
    for(auto value : container)
        std::cout << value << " ";
    std::cout << "}\n";
}

template <typename T>
decltype(std::cout << std::declval<T>(), void())
print(T value)
{
    std::cout << "Value: " << value << "\n";
}

И всё! Никаких дополнительных метафункций, никакой игры с размером типа — ничего. Более того, теперь мы проверяем не просто тот факт, что тип является интегральным: мы вполне конкретно проверяем, может ли std::cout вывести объект переданного типа самостоятельно. Этот подход даёт нам не только меньше кода, он ещё и куда более выразителен.

Для тех, кто всё-таки не совсем понял, что происходит в коде выше я поясню. Разберём самое сложное выражение: decltype(begin(std::declval<T>()), end(std::declval<T>()), void()). Как вы знаете, результатом применения оператора decltype является тип, полученный из переданного выражения, по тем же правилам, что и для ранее рассмотренного sizeof. Выражение внутри decltype у нас следующее: begin(std::declval<T>()), end(std::declval<T>()), void(). Как вы можете видеть, в нём используется вездесущий оператор запятая. Почему вездесущий? Потому что это один из наиболее интересных, для метапрограммиста, операторов в C++! Суть его проста: встроенный(не перегруженный) оператор запятая выполняет оба выражения и возвращает результат того, что идёт справа. Таким образом, наша запись выше будет выполнена в следующем порядке: begin(std::declval<T>()), потом end(std::declval<T>()) и, наконец, void(). Но, как вы помните, мы находимся внутри decltype, а значит всё выражение должно быть корректным, но нам важен только результат. Поэтому, если хоть одно из выражений разделённых запятой окажется не корректным(не сможет компилятор найти соответствующего вызова), тогда decltype не может определить результирующий тип и вся функция исключается из перегрузки — обычное SFINAE.

Но, если компилятор смог найти и begin и end, тогда нам нужно вернуть результат выражения, которым является третий член оного: void(), а это ничто иное как prvalue типа void, что и возвращает в результате наш decltype! Т.е вся эта «магия» нужна для того, чтобы проверить может ли тип T быть использован в определённом ключе, а последним аргументом идёт то, какой тип возвращаемого значения должен быть у результирующей функции. Всё это может выглядеть довольно неприглядно и сложно, но когда привыкаешь, всё становится очевидным.

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

Итог

SFINAE безусловно является очень важной частью языка C++, но, как вы наверняка заметили, всё это больше походит на трюки и «магию», чем на нормальное программирование. Это довольно легко объясняется: метапрограммирование на C++ было открыто, а не было задумано тем, что получилось. С годами, использование шаблонов всё усложнялось, и люди открывали новые техники, пока не дошли до того, что мы имеем сейчас. И я смею вас уверить, что до сих пор открываются новые интересные техники по использованию шаблонов, которые упрощают написание метакода.

Несомненно, комитет по стандартизации всё это видит и предпринимает попытки превратить метапрограммирование на C++, из «магического» во что-то более обыденное и мирское. Отчасти, таковым можно считать и SFINAE для выражений: да, синтаксис пока не из лучших, но уже несколько лучше, чем было раньше. Другим нововведением, которое мы рассматривали ранее, является constexpr. Есть и другие, которые скоро появятся и о которых мы поговорим в будущих статьях. Пока же, нужно пользоваться тем, что есть.

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