В этой статье хотелось бы кратко рассмотреть особенности применения механизма обнаружения наличия функциональности у используемых типов данных на этапе компиляции.

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

Например, простейший код при компиляции выдаст такое количество строк текста, что смотреть и разбирать его не хочется (хотя, конкретно в этом примере не сложно разобраться):

class A {};

template <class T>
void print ( const T & value )
{
    std::cout << "value: " << value << std::endl;
}

int main ( int, char ** )
{
    print( A() );
    return 0;
}

Но если добавить в него обнаружение наличия необходимой функциональности, то всё становится намного понятнее.

template <class T>
std::enable_if_t< is_detected< LeftShiftOperator, std::ostream, T >(), void >
print ( const T & value ) {
    std::cout << "value: " << value << std::endl;
}

Еще одним случаем необходимости такой диагностики является пример, когда на этапе компиляции требуется выбрать, какой метод у объекта должен быть вызван:

template <class T>
void clear ( T & container )
{
    if constexpr ( is_detected< ClearMember, T >() )
        container.clear();
    else if ( is_detected< CleanMember, T >() )
        container.clean();
    else if ( is_detected< RemoveAllMember, T >() )
        container.removeAll();
}

В этих примерах можно было бы использовать концепты и рефлексию, но... В случае концептов их применение возможно только начиная с C++20 и их поддержка не везде реализована в полном объеме. А в случае с рефлексией маловероятно что её внедрение будет в C++23, если только в экспериментальных реализациях.

А что делать, если такая функциональность нужна прямо здесь и сейчас? Для этого пока что подойдет эксплуатация механизма SFINAE, который уже традиционно используется в делах рефлексии, а не только по своему прямому назначению. С помощью механизма SFINAE в стандартной библиотеке в type_traits уже определено большое количество средств обнаружения различных особенностей для типов. Более широкий набор инструментов определен в Boost.TypeTraits, в том числе средства обнаружения операторов с помощью boost::has_<operator_name> и boost::is_detected.

Наиболее часто рекомендуемым способом диагностировать наличие функциональности является применение конструкции вида:

void foo ();

template <class T, class... Args>
struct DoesFooFunctionExistsHelper
{
private:
    template <class _Test, class = decltype(foo(std::declval<Args>() ...) )>
    static constexpr ::std::true_type __test(int);

    template <class>
    static constexpr ::std::false_type __test(...);

public:
    using type = decltype(__test<T>(std::declval<int>()));
};

template < typename ... _Arguments >
inline constexpr bool doesFooFunctionExists () { return DoesFooFunctionExistsHelper< void, _Arguments ... >::type::value; }

static_assert( doesFooFunctionExists<>(), "The function foo() was declared but not detected!" );

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

Инструменты обнаружения

Замечательное предложение в стандарт N4502 (входит в состав Boost.TypeTraits), которое позволяет решить данную задачу весьма изящным способом. Не буду углубляться в техническую реализацию и принцип действия, они хорошо описаны в самом предложении N4502 и здесь. Углубимся в особенности его применения.

Предложение содержит реализацию детектора для поддержки следующих желаемых компонентов из набора средств обнаружения:

template <template<class...> class Op, class... Args>
using is_detected = ...; // std::true_type || std::false_type

template< template<class...> class Op, class... Args >
using detected_t = ...; // Op<Args...> || nonesuch

template< class Default, template<class...> class Op, class... Args >
using detected_or_t = ...; // Op<Args...> || Default

template <class Expected, template<class...> class Op, class... Args>
using is_detected_exact = std::is_same<detected_t<Op, Args...>, Expected>;

template <class To, template<class...> class Op, class... Args>
using is_detected_convertible = std::is_convertible<detected_t<Op, Args...>, To>;

is_detected

В зависимости от возможности определения типа Op<Args...> является std::true_type или std::false_type.

is_detected_exact

В зависимости от результата проверки идентичности типа detected_t и типа Expected является std::true_type или std::false_type.

is_detected_convertible

В зависимости от возможности преобразования типа detected_t к типу To является std::true_type или std::false_type.

detected_t

В зависимости от возможности определения типа Op<Args...> является Op<Args...> или специальным типом nonesuch.

detected_or_t

В зависимости от возможности определения типа Op<Args...> является Op<Args...> или указанным типом Default.

Рассмотрим подробнее, каким образом этим набором средств обнаружения можно пользоваться.

Обнаружение функции

Предположим, имеется следующие определения функций foo

void foo();
int foo(int);

и требуется обнаружить их наличие с помощью представленных средств.

Для этого необходимо определить вспомогательный тип:

template <class... Args>
using FooFunction = decltype( foo( std::declval<Args>() ... ) );

Данное определение означает, что FooFunction<Args...> является типом, возвращаемым функцией с параметрами foo(Args...) . Для эмуляции вызова функции foo с параметрами используется вспомогательный метод std::declval, с описанием которого можно ознакомится здесь.

Используя это определение, можно обнаружить наличие функции foo следующим способом:

static_assert( is_detected< FooFunction >(), "The foo() was defined but not detected!" );
static_assert( is_detected< FooFunction, int >(), "The foo(int) was defined but not detected!" );
static_assert( !is_detected< FooFunction, int, double >(), "The foo(int,double) was not defined but detected!" );
static_assert( !is_detected< FooFunction, std::string >(), "The foo(string) was not defined but detected!" );

А так как FooFunction<Args...> является типом, возвращаемым функцией с параметрами foo(Args...), то в этом случае можно проверить и возвращаемый тип на идентичность и на конвертируемость:

static_assert( is_detected_exact< int, FooFunction, int >(), "The int foo(int) was defined but not detected!" );
static_assert( !is_detected_exact< double, FooFunction, int >(), "The double foo(int) was not defined but detected!" );
static_assert( is_detected_convertible< double, FooFunction, int >(), "The convertible int foo(int) was defined but not detected!" );      

Всё красиво и можно радоваться, но... Попробуем обнаружить функцию foo(double):

static_assert( is_detected< FooFunction, double >(), "The convenient foo(int) was defined but not detected!" ); // Oops!

И мы её обнаружили! Как же так?

Всё дело в том, что при определении FooFunction<Args...> использовалась имитация вызова функции foo с подходящими параметрами, а не с идентичными. Это значит, что если существует возможность вызова функции foo с учетом правил преобразования типов, то функция будет обнаружена.

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

template <class... Args>
using StrictFooFunction = decltype( std::integral_constant< detected_t<FooFunction, Args...>(*)(Args...), (&foo) >::value( std::declval<Args>() ... ) );

Использование std::integral_constant позволяет гарантировать точное соответствие сигнатуры функции foo указанным типам для её параметров. Мы не указываем возвращаемый тип функции foo явно, поэтому задаем его с помощью типа detected_t<FooFunction, Args...>, который, как мы помним, и является типом, возвращаемым функцией с параметрами foo(Args...) или nonesuch.

В этом случае обнаружение функции будет происходить строго в соответствии с сигнатурой:

static_assert( is_detected< StrictFooFunction, int >(), "The foo(int) was defined but not detected!" );
static_assert( is_detected< StrictFooFunction, double >(), "The foo(double) was not defined but detected!" );

Есть несколько ограничений при обнаружении функций.

Если определение функции foo будет произведено после определения типа FooFunction<Args...>, то она не будет обнаружена.

template <class... Args>
using FooFunction = decltype( foo( std::declval<Args>() ... ) );

int foo(int);

template <class... Args>
using OtherFooFunction = decltype( foo( std::declval<Args>() ... ) );

static_assert( !is_detected< FooFunction, int >(), "The foo(int) was not defined before FooFunction but detected!" );
static_assert( is_detected< OtherFooFunction, int >(), "The foo(int) was defined before OtherFooFunction but not detected!" );

Если до определения типа StrictFooFunction<Args...> не будет произведено ни одной декларации функции foo, то возникнет ошибка компиляции.

Обнаружение метода структуры или класса

Пусть имеется следующая декларация

struct A
{
    void foo();
    int foo(int) const;
    static void foo(int, int);
};

и требуется обнаружить наличие метода foo.

Как и в случае для функции можно обнаружить метод с подходящими параметрами или с их строгим соответствием. При этом учитываются также квалификаторы доступа к функциям const и volatile.

Для обнаружения метода с подходящими параметрами необходимо определить тип:

template <class T, class... Args>
using FooMember = decltype(std::declval<T>().foo(std::declval<Args>() ...));

Данное определение означает, что FooMember<Args...> является типом, возвращаемым функцией с параметрами T::foo(Args...) . Используя это определение, можно обнаружить наличие методаfoo следующим способом:

static_assert( is_detected< FooMember, A >(), "The member A::foo() was declared but not detected!" );
static_assert( !is_detected< FooMember, A const >(), "The member A::foo() const was not declared but detected!" );
static_assert( is_detected< FooMember, A, int >(), "The member A::foo(int) const was declared but not detected!" );
static_assert( is_detected< FooMember, A const, double >(), "The convenient member A::foo(int) const was declared but not detected!" );
static_assert( is_detected< FooMember, A, int, int >(), "The static member A::foo(int,int) was declared but not detected!" );

Для обнаружения метода в строгом соответствии с сигнатурой есть нюансы.

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

template <class T, class... Args>
using StrictFooStaticMember = decltype( std::integral_constant<detected_t<FooMember, T, Args ...>(*)(Args ...), &std::decay_t<T>::foo >::value(std::declval<Args>() ...) );

В этом случае обнаружение будет выглядеть так:

static_assert( is_detected< StrictFooStaticMember, A, int, int >(), "The static member void A::foo(int,int) was declared but not detected!" );
static_assert( !is_detected< StrictFooStaticMember, A, int&, int& >(), "The static member void A::foo(int&,int&) was not declared but detected!" );

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

namespace detail
{
    template <class T, class M>
    struct member_signature;
    template <class T, class R, class... Args>
    struct member_signature< T, R( Args ...) > { using type = R(std::decay_t<T>::*)(Args ...); };
    template <class T, class R, class... Args>
    struct member_signature< T const, R(Args ...) > { using type = R(std::decay_t<T>::*)(Args ...) const; };
    template <class T, class R, class... Args>
    struct member_signature< T const &, R(Args ...) > { using type = R(std::decay_t<T>::*)(Args ...) const &; };
    template <class T, class R, class... Args>
    struct member_signature< T const &&, R(Args ...) > { using type = R(std::decay_t<T>::*)(Args ...) const &&; };
    template <class T, class R, class... Args>
    struct member_signature< T volatile, R(Args ...) > { using type = R(std::decay_t<T>::*)(Args ...) volatile; };
    template <class T, class R, class... Args>
    struct member_signature< T volatile &, R(Args ...) > { using type = R(std::decay_t<T>::*)(Args ...) volatile &; };
    template <class T, class R, class... Args>
    struct member_signature< T volatile &&, R(Args ...) > { using type = R(std::decay_t<T>::*)(Args ...) volatile &&; };
    template <class T, class R, class... Args>
    struct member_signature< T const volatile, R(Args ...) > { using type = R(std::decay_t<T>::*)(Args ...) const volatile; };
    template <class T, class R, class... Args>
    struct member_signature< T const volatile &, R(Args ...) > { using type = R(std::decay_t<T>::*)(Args ...) const volatile &; };
    template <class T, class R, class... Args>
    struct member_signature< T const volatile &&, R(Args ...) > { using type = R(std::decay_t<T>::*)(Args ...) const volatile &&; };
}

template <class T, class Sign >
using member_signature_t = typename detail::member_signature< T, Sign >::type;

В этом случае определение вспомогательного типа для обнаружения не статического метода будет выглядеть так:

template <class T, class... Args>
using StrictFooMember = decltype( (std::declval<T>() .* std::integral_constant< member_signature_t<T, detected_t<FooMember, T, Args ...>(Args ...)>, &std::decay_t<T>::foo>::value)(std::declval<Args>() ...) );

Конструкция выглядит устрашающей). Но по сути своей эмулирует попытку вызова метода T::foo(Args ...) по её адресу с проверкой сигнатуры с помощью применения std::integral_constant.

Обнаружение наличия метода с помощью StrictFooMember будет выглядеть уже привычным способом:

static_assert( is_detected< StrictFooMember, A >(), "The member A::foo() was declared but not detected!" );
static_assert( !is_detected< StrictFooMember, A, int >(), "The member A::foo(int) was not declared but detected!" );
static_assert( is_detected< StrictFooMember, A const, int >(), "The member A::foo(int) const was declared but not detected!" );
static_assert( !is_detected< StrictFooMember, A const, double >(), "The member A::foo(double) const was not declared but detected!" );

Описанные ранее ограничения при обнаружении функций не распространяются на обнаружение членов класса. Единственным ограничением обнаружения членов класса является публичный доступ к ним.

Проверка возвращаемого типа на идентичность и на конвертируемость для всех представленных определений может быть произведена с помощью функций is_detected_exact и is_detected_convertible.

Выводы

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

Данный механизм может быть использован как альтернатива элементам концептов и рефлексии там, где последние еще не реализованы.

PS

Надеюсь, что кому-то помог разобраться в нюансах использования представленного инструмента обнаружения функциональности.

Детальный пример использования подобного подхода представлен в моем проекте инструментов ScL (Detection). Также этот подход применяется при рефлексии операторов для класса-обертки, представленной в статье "Добавляем дополнительные особенности реализации на C++ с помощью «умных» оберток".

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


  1. kovserg
    24.03.2022 17:27

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

    Наличие метода вообще-то не говорит о том что он реализован, например там может быть заглушка которая кидает исключение not implemented или возвращает всегда ошибку и еще помечен как depricated. В чем полезность такого механизма, кроме самой возможности его реализации?


    1. NickViz
      24.03.2022 17:34

      скорее всего - static_assert() в библиотеках для более удобочитаемых сообщений об ошибках. ну или общие библиотеки которые могут вызывать возможно по разному поименованные схожие методы. ну к примеру, есть классы с clear() и Clear() в двух разных библиотеках, а нам надо шаблон который будет работать с любой из них.