SFINAE — это просто

в 5:51, , рубрики: c++, c++11, sfinae, template, метапрограммирование, метки: , , , ,

TLDR: как определять, есть ли в типе метод с данным именем и сигнатурой, а также узнавать другие свойства типов, не сойдя при этом с ума.

image

Здравствуйте, коллеги.
Хочу рассказать о SFINAE, интересном и очень полезном (к сожалению*) механизме языка C++, который, однако, может представляться неподготовленному человеку весьма мозгоразрывающим. В действительности принцип его использования достаточно прост и ясен, будучи сформулирован в виде нескольких чётких положений. Эта заметка рассчитана на читателей, обладающих базовыми знаниями о шаблонах в C++ и знакомых, хотя бы шапочно, с C++11.
* Почему к сожалению? Хотя использование SFINAE — интересный и красивый приём, переросший в широко используемую идиому языка, гораздо лучше было бы иметь средства, явно описывающие работу с типами.

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

Аббревиатура SFINAE расшифровывается как substitution failure is not an error и означает следующее: при определении перегрузок функции ошибочные инстанциации шаблонов не вызывают ошибку компиляции, а отбрасываются из списка кандидатов на наиболее подходящую перегрузку. Выражаясь по-человечески, это означает вот что:

  • Когда речь заходит о SFINAE, это обязательно связано с перегрузкой функций.
  • Это работает при автоматическом выводе типов шаблона (type deduction) по аргументам функции.
  • Некоторые перегрузки могут отбрасываться в том случае, когда их невозможно инстанциировать из-за возникающей синтаксической ошибки; компиляция при этом продолжается как ни в чём не бывало, без ошибок.
  • Отбросить могут только шаблон.

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

Рассмотрим простой пример:

int difference(int val1, int val2)
{
    return val1 - val2;
}

template<typename T>
typename T::difference_type difference(const T& val1, const T& val2)
{
    return val1 - val2;
}

Функция difference отлично работает для целых аргументов. А вот с пользовательскими типами данных начинаются тонкости. Результат вычитания не всегда имеет тот же самый тип, что и операнды. Так, разность двух дат — интервал времени, который сам по себе датой не является. Если пользовательский тип MyDate имеет внутри себя определение typedef MyInterval difference_type; и оператор вычитания MyInterval operator - (const MyDate& rhs) const;, к нему применима шаблонная перегрузка. Вызов difference(date1, date2) сможет «увидеть» и шаблонную перегрузку, и версию, принимающую int, при этом шаблонная перегрузка будет сочтена более подходящей.
Тип MyString, в котором нет difference_type, при подстановке вызовет ошибку: функция возвращала бы несуществующий тип. Аналогично, конструкция val1 - val2 требует наличия бинарного оператора «минус» и тоже может породить синтаксическую ошибку. Вызов difference с аргументами типа MyString сможет «увидеть» только int-версию функции. Эта единственная версия окажется достаточно подходящей только если в MyString определён оператор преобразования в число. Получается, что шаблонная функция difference проверяет тип аргумента на одновременное выполнение сразу трёх условий: наличие difference_type, наличие оператора вычитания и возможность приведения результата вычитания к типу difference_type (преобразование подразумевается оператором return). Типам, нарушающим хотя бы одно условие, эта перегрузка не видна.

Попробуем же придумать, как сделать метафункцию, которая говорит нам, есть ли в каком-то типе метод void foo(int). Заботливая STL, особенно начиная с версии C++11, уже определила для нас много полезных метафункций, размещённых в основном в заголовках type_traits и limits, однако именно такой, какую мы дерзновенно замыслили сделать, там почему-то нет. Метафункция обычно выглядит как шаблонная структура без данных, внутри которой определён результат операции: заданный через typedef тип с именем type или статическая константа value. Такого соглашения придерживается STL, и причин оригинальничать у нас нет, поэтому будем придерживаться установленного образца.

Можно сразу написать «скелет» нашей будущей метафункции:

template<typename T> struct has_foo{};

Она определяет наличие метода, из чего сразу ясно, что результат должен иметь булевский тип:

template<typename T> struct has_foo{
{
    static constexpr bool value = true;  // Сейчас придумаем, что здесь написать, а пока всем отвечаем "да".
};

А вот теперь надо придумать, как сделать перегрузку, определяющую нужные нам свойства типа, и как получить из неё булевскую константу. Прелесть в том, что нам не нужно давать тела перегрузкам: поскольку вся работа происходит в режиме компиляции за счёт манипуляций с типами, хватит одних объявлений.
Очевидно, мы хотим, чтобы наша метафункция была применима для любого типа. Ведь про любой тип можно сказать, есть ли в нём искомый метод. Значит, has_foo не должна вызывать ошибки компиляции, какой бы параметр мы ни подставили. А ошибка-то произойдёт, если вдруг окажется, что Получается, что нам нужно две перегрузки одной проверочной функции. Одна из них, «детектор» должна быть синтаксически правильной только для типов, содержащих нужный метод. Другая, «подложка», должна быть всеядной, то есть быть достаточно подходящей для любых подставленных типов. В то же время «детектор» должен иметь неоспоримое преимущество в «подходящести» перед «подложкой». Наименее приоритетным и в то же время максимально всеядным в определении перегрузок является эллипсис (троеточие, обозначающее переменное количество аргументов):

template<typename T> struct has_foo{
{
    void detect(...); // С "подложкой" всё просто.
    static constexpr bool value = true;  // Ладно-ладно, уже скоро придумаем!
};

Теперь надо объявить «детектор». Это должен быть шаблон: того, что он уже внутри шаблоной структуры, недостаточно! Нужен шаблон внутри шаблона [несколько секунд наслаждаемся одобрительными взглядами со стороны героев фильма Inception]. К «подложке» это не относится, поскольку её мы не будем выкидывать никогда. А вот для «детектора» воспользуемся волшебным словом decltype, которое определяет тип выражения, причём само выражение не вычисляется и в код не переводится. Подставим в качестве выражения вызов того самого метода, с аргументами нужного типа. Тогда ответом decltype будет возвращаемый тип метода. А если метода с таким именем нет, или он принимает другие типы аргументов, то мы получим ту самую контролируемую ошибку, которую и хотели. Пусть «детектор» возвращает то же, что и foo:

template<typename T> struct has_foo{
{
    void detect(...);
    template<typename U> decltype(U().foo(42)) detect(const U&);
    static constexpr bool value = true;  // Теперь точно скоро!
};

Если передадим в detect ссылку на const T&, получится, что U — тот же самый тип, что и T. Для проверки соответствия типа возвращаемого значения мы потом усовершенствуем детектор или придумаем что-то другое по ходу дела.
Однако постойте! Мы вызываем метод на свежесконструированном анонимном объекте, причём сконструирован-то он по умолчанию. А что будет, если мы передадим в has_foo тип, у которого нет конструктора по умолчанию? Конечно же, ошибка компиляции. Правильнее было бы объявить какую-нибудь функцию, возвращающую значение нужного типа. Вызываться она всё равно не будет, а нужный эффект будет достигнут. STL позаботилась и об этом: в заголовке utility есть функция declval:

template<typename T> struct has_foo{
{
    void detect(...);
    template<typename U> decltype(std::declval<U>().foo(42)) detect(const U&);
    static constexpr bool value = true;  // Почти готово!
};

Осталось только научиться отличать «подложку» от «детектора». Тут нам поможет всё тот же decltype. У «подложки» тип возвращаемого значения всегда void, а у «детектора» — тип, возвращаемый методом, то есть в случае, когда метод соответствует нашим требованиям… тот же самый void. Так не пойдёт. Сменим-ка мы для «подложки» тип на int. Тогда проверка получается простой: если вызов detect на объекте T имеет тип void, то сработал «детектор» и метод полностью соответствует нашим требованиям. Если тип другой, то либо сработала «подложка», либо метод существует, принимает те самые аргументы, но возвращает что-то не то. Проверяем, насколько заботлива STL, и тут же находим метафункцию проверки типов на равенство is_same:

template<typename T> struct has_foo{
{
private:  // Спрячем от пользователя детали реализации.
    static int detect(...);  // Статическую функцию и вызывать проще.
    template<typename U> static decltype(std::declval<U>().foo(42)) detect(const U&);
public:
    static constexpr bool value = std::is_same<void, decltype(detect(std::declval<T>()))>::value;  // Вот видите, готово.
};

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

SFINAE используется настолько широко, что даже в заботливую STL включили специальную метафункцию enable_if. Её параметры — булевская константа и тип (по умолчанию void). Если передано true, то в метафункции присутствует тип type: тот, что передан вторым параметром. Если же передано false, то никакого type там нет, что и создаёт ту самую контролируемую ошибку. В свете соображений, перечисленных выше в аккуратном списочке, надо помнить, что enable_if сможет «вычеркнуть» перегрузку функции только если она — шаблон, а также озаботиться тем, чтобы список «невычеркнутых» перегрузок никогда не оставался совсем пустым. Можно применять enable_if и в специализациях шаблонного класса, но в таком случае это уже не SFINAE, а нечто вроде static_assert.

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

Автор: OldFisher

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js