Введение
Статья 549 представила concepts как современный, явный механизм ограничения шаблонов — SFINAE (Substitution Failure Is Not An Error) — более ранняя, гораздо менее интуитивная техника, решавшая похожие задачи до появления concepts, и в этой статье разберём принцип SFINAE для понимания существующего, унаследованного кода, который мог использовать эту технику до повсеместного перехода на concepts.
Концепция
SFINAE — особое правило вывода типов шаблона: если при попытке подстановки конкретных типов в сигнатуру шаблонной функции (или специализации шаблона класса) возникает ошибка типов в самой сигнатуре (а не в теле функции), компилятор не считает это фатальной ошибкой компиляции, а просто исключает эту конкретную перегрузку/специализацию шаблона из рассмотрения, переходя к попытке подобрать другую, подходящую альтернативу среди других доступных перегрузок — это позволяло (до появления concepts) создавать функции, выбирающие разную реализацию в зависимости от того, поддерживает ли конкретный тип определённую операцию или имеет определённый член, хотя синтаксис для выражения этой идеи через SFINAE традиционно считается значительно менее интуитивным и более многословным по сравнению с современным синтаксисом concepts.
Пример кода: SFINAE и его
#include <iostream>
#include <type_traits>
// SFINAE — выбор реализации в зависимости от того, является ли T целочисленным типом
template<typename T>
typename std::enable_if<std::is_integral<T>::value, void>::type
processValue(T value)
{
std::cout << "Целочисленное значение: " << value << std::endl;
}
template<typename T>
typename std::enable_if<!std::is_integral<T>::value, void>::type
processValue(T value)
{
std::cout << "Не-целочисленное значение: " << value << std::endl;
}
int main()
{
processValue(42); // выбирается ПЕРВАЯ версия — int удовлетворяет is_integral
processValue(3.14); // выбирается ВТОРАЯ версия — double НЕ удовлетворяет is_integral
return 0;
}
// Тот же результат, значительно компактнее и понятнее через concepts (C++20, статья 549)
#include <concepts>
#include <iostream>
template<std::integral T> // ОДНА перегрузка с явным, понятным ограничением через concept
void processValueModern(T value)
{
std::cout << "Целочисленное значение: " << value << std::endl;
}
template<typename T> requires (!std::integral<T>) // ВТОРАЯ перегрузка с противоположным ограничением
void processValueModern(T value)
{
std::cout << "Не-целочисленное значение: " << value << std::endl;
}
Пояснения к коду
std::enable_if<std::is_integral<T>::value, void>::type показывает классический, типичный для SFINAE синтаксис — крайне многословный и неинтуитивный для разработчиков, впервые встречающих эту конструкцию: enable_if «включает» определённый тип (void в данном случае, используемый как тип возвращаемого значения) только если указанное условие истинно, а если условие ложно, сама попытка получить ::type из enable_if приводит к ошибке подстановки, которая (согласно принципу SFINAE) не считается фатальной, а просто исключает эту конкретную перегрузку из рассмотрения, позволяя компилятору попробовать вторую, альтернативную перегрузку с противоположным условием. Сравнение с processValueModern, использующим std::integral<T> концепт и requires (!std::integral<T>), наглядно демонстрирует, насколько значительно concepts (статья 549) улучшили читаемость и понятность той же самой, концептуально идентичной идеи — современный синтаксис прямо, явно выражает условие («T должен быть целочисленным» / «T не должен быть целочисленным»), не требуя понимания неочевидного, «побочного» механизма SFINAE для достижения того же результата.
Подводные камни
- Попытка понять или написать SFINAE-код без знания общего принципа «ошибка в подстановке типа — это не фатальная ошибка компиляции», лежащего в основе всей техники — без этого фундаментального понимания SFINAE-код (как пример с
enable_if) выглядит крайне неинтуитивно и труднообъяснимо, и для разработчиков, сталкивающихся с существующим, унаследованным кодом, использующим SFINAE, важно явно понимать этот базовый принцип, даже если для нового кода предпочтительнее использовать значительно более понятные concepts. - Написание нового кода с использованием SFINAE вместо concepts в проекте, уже использующем (или способном перейти на) C++20 — поскольку concepts решают концептуально ту же задачу значительно более понятным, явным и менее подверженным труднообъяснимым ошибкам синтаксисом, для нового кода в проектах с доступным C++20 практически нет веских причин предпочесть SFINAE вместо concepts, и сознательное продолжение использования устаревшей техники SFINAE для нового кода обычно говорит либо о недостаточной осведомлённости о современных альтернативах, либо о реальном, обоснованном ограничении (необходимость поддержки более старого стандарта).
- Трудность диагностики ошибок при неправильном использовании SFINAE-условий, особенно если ни одна из нескольких SFINAE-ограниченных перегрузок не подходит для конкретного, реально использованного типа — в отличие от concepts, дающих явное, понятное сообщение о том, какое конкретное требование не было удовлетворено (статья 549), SFINAE в подобной ситуации часто приводит к менее информативной ошибке вида «не найдена подходящая перегрузка функции», не объясняющей напрямую, какое именно условие SFINAE не было удовлетворено и почему, что существенно усложняет диагностику для разработчика, столкнувшегося с этой ошибкой впервые.
- Путаница между SFINAE для выбора между несколькими перегрузками функций и частичной специализацией шаблонов классов (статья 543), решающей концептуально похожую, но синтаксически иную задачу выбора альтернативной реализации в зависимости от свойств типа — хотя оба механизма решают родственные задачи (выбор подходящей реализации в зависимости от характеристик типа параметра шаблона), синтаксис и точные правила применения существенно различаются между ними, и смешение или путаница между этими двумя разными техниками — частый источник недопонимания при изучении этой, признанно сложной области шаблонного программирования C++ до появления concepts.