c++templatessfinaedependent-namerequires-expression

Why do a SFINAE function template and a regular function template have different binding rules?


I had a checker function that used the requires keyword to detect if a target function was defined or not. I wanted to make it work with C++17, so I switched the checker from using requires to using expression SFINAE.

That's when I was surprised to discover that if I called both checkers without defining the target function, then defined the target function and checked again, the requires checker would reuse the old result while the SFINAE checker would update to properly show the target function is defined.

// Compiles on Clang, GCC, and MSVC.

#include <type_traits>

struct MyStruct {};

constexpr bool Checker_SFINAE(...)
{
    return false;
}

template <typename T>
constexpr auto Checker_SFINAE(T) -> decltype(Foo(T()), bool())
{
    return true;
}

template <typename T>
constexpr auto Checker_requires(T)
{
    return std::bool_constant<requires { Foo(T()); }>();
}

static_assert(!Checker_SFINAE(MyStruct()));
static_assert(!Checker_requires(MyStruct()));

void Foo(MyStruct) {}

static_assert(Checker_SFINAE(MyStruct()));
// Will fail is the previous Checker_requires is removed
static_assert(!Checker_requires(MyStruct()));

int main(){}

What's causing this difference? Both checker functions use a dependent type for their return type, so I would have figured they would behave identically.

More practically, what makes a function template be interpreted differently between instances even when all its template parameters stay the same?


Solution

  • I did some more digging, and think I have an answer. Essentially, the template version of Checker_SFINAE() is not being instantiated until after Foo() is declared.

    Let's start with the basics. When an overloaded function is called, whichever function overload that works best will be called at that time. If we later add another overload and call the function again, that new overload will be included in the candidates.

    struct MyStruct {};
    
    constexpr bool HasOverload(...)
    {
        return false;
    }
    
    static_assert(!HasOverload(MyStruct{}));
    
    constexpr bool HasOverload(MyStruct)
    {
        return true;
    }
    
    static_assert(HasOverload(MyStruct{}));
    

    Now let's go back to example in the question, slightly modified:

    #include <type_traits>
    
    struct MyStruct {};
    
    constexpr bool Checker(...)
    {
        return false;
    }
    
    template <typename T>
    constexpr std::enable_if_t<requires { Foo(T()); }, bool> Checker(T)
    {
        return true;
    }
    
    static_assert(!Checker(MyStruct()));
    void Foo(MyStruct) {}
    static_assert(Checker(MyStruct()));
    

    When Checker() is first called here, the function template's signature would fail to compile if it were instantiated. Due to SFINAE, the compiler avoids instantiating the template. Our only existing Checker() at this point is still Checker(...).

    When Checker() is called the second time, after Foo() is declared, the compiler looks at candidates again. This time, the function template's signature can be instantiated without introducing compilation errors, so it now instantiates the template. Now, this template instantiation is the best candidate, so it is used, and Check() returns true.

    It's important to distinguish choosing the best overload candidate, which happens every time the function is called, from function template instantiation, which will happen only once. Once that template instantiation happens, there is no going back; that new function is now a candidate. Instancing a function template with the same template parameters will yield the same function, since it's already been instanced.

    For example, suppose we were to invert Checker, so the base case returns true, and the overload uses SFINAE to check for the absence of Foo():

    #include <type_traits>
    
    struct MyStruct {};
    
    constexpr bool Checker(...)
    {
        return true;
    }
    
    template <typename T>
    constexpr std::enable_if_t<!requires { Foo(T()); }, bool> Checker(T)
    {
        return false;
    }
    
    static_assert(!Checker(MyStruct()));
    void Foo(MyStruct) {}
    // Note how this hasn't changed.
    static_assert(!Checker(MyStruct()));
    

    Once the template is instantiated, there's no putting the genie back in the bottle. It now exists, and it will be considered as a candidate for Foo(), This means that if Checker() ever returns false with a type, it will always return false with that type.

    To summarize, template instantiation and function overload resolution are two separate things. Instantiation happens once; overload resolution happens at each function call. Checker_SFINAE() introduces new overload candidates once Foo() is declared, so it will change its return value. Checker_requires() is just a function template, so once it's instantiated, it's locked into that value. The requires keyword was a red herring--it had no bearing on the behavior of the function beyond allowing the Foo() check to live in the body of the function template.