Sfinae c что это
Перейти к содержимому

Sfinae c что это

SFINAE — это просто

image

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

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

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

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

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

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

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

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

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

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

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

SFINAE

This rule applies during overload resolution of function templates: When substituting the explicitly specified or deduced type for the template parameter fails, the specialization is discarded from the overload set instead of causing a compile error.

This feature is used in template metaprogramming.

Contents

[edit] Explanation

Function template parameters are substituted (replaced by template arguments) twice:

  • explicitly specified template arguments are substituted before template argument deduction
  • deduced arguments and the arguments obtained from the defaults are substituted after template argument deduction

Substitution occurs in

  • all types used in the function type (which includes return type and the types of all parameters)
  • all types used in the template parameter declarations
  • all expressions used in the function type
  • all expressions used in a template parameter declaration
  • all expressions used in the explicit specifier

A substitution failure is any situation when the type or expression above would be ill-formed (with a required diagnostic), if written using the substituted arguments.

Only the failures in the types and expressions in the immediate context of the function type or its template parameter types or its explicit specifier (since C++20) are SFINAE errors. If the evaluation of a substituted type/expression causes a side-effect such as instantiation of some template specialization, generation of an implicitly-defined member function, etc, errors in those side-effects are treated as hard errors. A lambda expression is not considered part of the immediate context. (since C++20)

This section is incomplete
Reason: mini-example where this matters

Substitution proceeds in lexical order and stops when a failure is encountered.

If there are multiple declarations with different lexical orders (e.g. a function template declared with trailing return type, to be substituted after a parameter, and redeclared with ordinary return type that would be substituted before the parameter), and that would cause template instantiations to occur in a different order or not at all, then the program is ill-formed; no diagnostic required.

[edit] Type SFINAE

The following type errors are SFINAE errors:

  • attempting to instantiate a pack expansion containing multiple packs of different lengths
  • attempting to create an array of void, array of reference, array of function, array of negative size, array of non-integral size, or array of size zero:
  • attempting to use a type on the left of a scope resolution operator :: and it is not a class or enumeration:
  • attempting to use a member of a type, where
  • the type does not contain the specified member
  • the specified member is not a type where a type is required
  • the specified member is not a template where a template is required
  • the specified member is not a non-type where a non-type is required
  • attempting to create a pointer to reference
  • attempting to create a reference to void
  • attempting to create pointer to member of T, where T is not a class type:
  • attempting to give an invalid type to a non-type template parameter:
  • attempting to perform an invalid conversion in
  • in a template argument expression
  • in an expression used in function declaration:
  • attempting to create a function type with a parameter of type void
  • attempting to create a function type which returns an array type or a function type

[edit] Expression SFINAE

Only constant expressions that are used in types (such as array bounds) were required to be treated as SFINAE (and not hard errors) before C++11.

The following expression errors are SFINAE errors

  • Ill-formed expression used in a template parameter type
  • Ill-formed expression used in the function type:

[edit] SFINAE in partial specializations

Deduction and substitution also occur while determining whether a specialization of a class or variable (since C++14) template is generated by some partial specialization or the primary template. Compilers do not treat a substitution failure as a hard-error during such determination, but ignore the corresponding partial specialization declaration instead, as if in the overload resolution involving function templates.

Notes: currently partial specialization SFINAE is not formally supported by the standard (see also CWG issue 2054), however, LFTS requires it works since version 2 (see also detection idiom).

[edit] Library support

The standard library component std::enable_if allows for creating a substitution failure in order to enable or disable particular overloads based on a condition evaluated at compile time.

In addition, many type traits must be implemented with SFINAE if appropriate compiler extensions are unavailable.

The standard library component std::void_t is another utility metafunction that simplifies partial specialization SFINAE applications.

[edit] Alternatives

Where applicable, tag dispatch , if constexpr (since C++17) , and concepts (since C++20) are usually preferred over use of SFINAE.

static_assert is usually preferred over SFINAE if only a conditional compile time error is wanted.

[edit] Examples

A common idiom is to use expression SFINAE on the return type, where the expression uses the comma operator, whose left subexpression is the one that is being examined (cast to void to ensure the user-defined operator comma on the returned type is not selected), and the right subexpression has the type that the function is supposed to return.

Как сделать SFINAE изящным и надежным

Сегодня у нас гостевой пост Адама Балаша (Ádám Balázs). Адам является инженером-программистом в Verizon Smart Communities Hungary и занимается разработкой видеоаналитики для встраиваемых систем. Одна из его страстей — оптимизации времени компиляции, поэтому он сразу согласился написать гостевой пост на эту тему. Вы можете найти Адама в онлайне на LinkedIn.

В серии статей о том, как сделать SFINAE изящным, мы увидели, как сделать наш SFINAE-шаблон довольно лаконичным и выразительным.

Просто взгляните на его оригинальную форму:

И сравните ее с этой более выразительной формой:

Мы можем разумно полагать, что уже можно расслабиться и начать использовать его в производстве. Мы могли бы, он работает в большинстве случаев, но — как мы говорим об интерфейсах — наш код должен быть безопасным и надежным. Так ли это? Давайте попробуем взломать его!

Недостаток № 1: SFINAE можно обойти

Обычно SFINAE используется для отключения части кода в зависимости от условия. Это может быть очень полезно, если нам нужно реализовать, например, пользовательскую функцию abs по какой-либо причине (пользовательский арифметический класс, оптимизация для конкретного оборудования, в учебных целях и т. д.):

Эта программа выводит следующее, что выглядит вполне нормально:

Но мы можем вызвать нашу функцию abs с беззнаковыми аргументами T , и эффект будет катастрофическим:

Действительно, теперь программа выводит:

a: 4294967295 myAbs( a ): 1

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

Код работает должным образом: вызов myAbs с беззнаковым типом вызывает ошибку времени компиляции:

candidate template ignored: requirement ‘std::is_signed_v< unsigned int>’ was not satisfied [with T = unsigned int]

Взлом SFINAE состояния

Тогда что не так с этой функцией? Чтобы ответить на этот вопрос, мы должны проверить, как myAbs реализует SFINAE.

myAbs — это шаблон функции с двумя типами параметров шаблона для ввода. Первый является фактическим типом аргумента функции, второй является анонимным типом назначенным по умолчанию IsSigned < T > (иначе Std::enable_if_t < std::is_signed_v < T > > или иначе Std::enable_if < std::is_signed_v < T>, void>::type , который является void или неудавшейся подстановкой).

Как мы можем вызвать myAbs ? Есть 3 способа:

Первый и второй вызовы незамысловаты, но третий вызывает интерес: что это за аргумент шаблона void ?

Второй параметр шаблона является анонимным, имеет тип по умолчанию, но он все еще является параметром шаблона, поэтому его можно явно указать. Является ли это проблемой? В этом случае это действительно огромная проблема. Мы можем использовать третью форму, чтобы обойти нашу SFINAE-проверку:

Этот код прекрасно компилируется, но приводит к катастрофическим результатам, для избежания которых, мы использовали SFINAE:

Мы решим эту проблему — но сначала: есть ли другие недостатки? Что ж…

Недостаток № 2: У нас не может быть конкретных реализаций

Другое распространенное использование SFINAE — предоставление конкретных реализаций для определенных условий времени компиляции. Что, если мы не хотим полностью запретить вызов myAbs со значениями без знака и предоставляем тривиальную реализацию для этих случаев? Мы можем использовать if constexpr в C ++ 17 (мы рассмотрим это позже), или же мы можем:

Ой-ой, стандарт C++ (C++ 17; §17.1.16) гласит следующее:

Упс, это именно то, что мы сделали…

Почему бы не использовать обычный if?

Мы могли бы просто использовать if во время выполнения вместо этого:

Компилятор оптимизировал бы условие, потому что if (std::is_signed_v < T>) становится if (true) или if (false) после создания шаблона. Да, с нашей текущей реализацией myAbs это будет работать. Но в целом это накладывает огромное ограничение: операторы if и else должны быть действительными для каждого T . Что если мы немного изменим нашу реализацию:

Наш код сразу даст сбой:

Это ограничение — то, что устраняет SFINAE: мы можем написать код, который действителен только для подмножества T (в myAbs действителен только для беззнаковых типов или действителен только для знаковых типов).

Решение: еще одна форма для SFINAE

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

Что если мы используем наш SFINAE-код для объявления типа параметра шаблона вместо предоставления типа по умолчанию? Давайте попробуем:

Нам нужно, чтобы IsSigned был типом, отличным от void в допустимых случаях, потому что мы хотим предоставить значение по умолчанию для этого типа. Для типа void нет значения, поэтому мы должны использовать что-то другое: bool, int, enum, nullptr_t и т. д. Обычно я использую bool — в этом случае выражения выглядят осмысленно:

Оно работает! Для myAbs (5u) компилятор выдает ошибку, как и раньше:

Второй вызов — myAbs < int> (5u) — все еще действителен, мы сообщаем компилятору тип T явно, поэтому он преобразует 5u в int .

Наконец, мы больше не можем обводить myAbs вокруг пальца: myAbs < unsigned int, true> (5u) вызывает ошибку. Неважно, если мы предоставляем в вызове значение по умолчанию или нет, часть выражения SFINAE оценивается в любом случае, потому что компилятору нужен тип аргумента анонимного значения шаблона.

Мы можем перейти к следующей проблеме — но погодите минуту! Я думаю, что мы больше не переопределяем аргумент по умолчанию для того же параметра шаблона Какова была исходная ситуация?

Но теперь с текущим кодом:

Он выглядит очень похоже на предыдущий код, поэтому мы можем подумать, что это тоже не сработает, но на самом деле этот код не имеет той же проблемы. Что такое IsUnsigned < T> ? Bool или неудавшаяся подстановка. А что такое IsSigned < T> ? То же самое, но если одно из них Bool, другое — неудавшаяся подстановка.

Это означает, что мы не переопределяем аргументы по умолчанию, так как есть только одна функция с аргументом шаблона bool, другая — неудавшаяся подстановка, поэтому она не существует.

Синтаксический сахар

Если вы предпочитаете enable_if с void , а не bool -версию, то у нас для вас есть хорошие новости — вы можете использовать следующий синтаксис:

Как это работает? Это шаблонная функция с бестиповой частью с переменным количеством аргументов, которая принимает в качестве аргументов шаблона ноль или более значений типа IsSigned < T> (void или неудавшаяся подстановка).

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

Старые версии C ++

Все вышеперечисленное работает с C++11, единственное отличие — многословность определений ограничений между стандартными версиями:

Но шаблон остается прежним:

В старом добром C++98 нет псевдонимов шаблонов, кроме того, шаблоны функций не могут иметь типы или значения по умолчанию. Мы можем вставить наш SFINAE-код в тип результата или только в список параметров функции. Рекомендуется второй вариант, потому что конструкторы не имеют типов результатов. Лучшее, что мы можем сделать, это что-то вроде этого:

Просто для сравнения — современная версия C++:

Версия C++98 уродлива, вводит бессмысленный параметр, но она работает — вы можете использовать ее, если это крайне необходимо. И да: my_enable_if и my_is_signed должны быть реализованы ( std :: enable_if и std :: is_signed были новыми в C++11).

Современное состояние

C++17 ввел if constexpr — способ для отбрасывания кода на основе условий во время компиляции. Оба оператора if и else должны быть синтаксически корректны, но условие будет оцениваться во время компиляции.

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

К счастью, существует лазейка: в шаблонных объектах отброшенные операторы не создаются, если условие не зависит от значения. Отлично!

Таким образом, единственная проблема с нашим кодом состоит в том, что он дает сбой во время определения шаблона. Если бы мы могли отложить оценку static_assert до времени создания шаблона, проблема была бы решена: он был бы создан тогда и только тогда, когда все наши условия false. Но как мы можем отложить static_assert до создания шаблона? Сделайте его условие зависимым от типа!

О будущем

Мы действительно уже близки, но нужно еще немного подождать, пока C++20 принесет окончательное решение: концепции (concept)! Это полностью изменит способ использования шаблонов (и SFINAE).

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

И как мы можем использовать концепции? Есть три способа:

Обратите внимание, что третья форма все еще объявляет функцию шаблона! Вот полная реализация myAbs в C++20:

Закомментированный вызов дает следующую ошибку:

Я призываю всех смело использовать эти методы в производственном коде, время компиляции дешевле, чем время выполнения. Happy SFINAEing!

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *