Concept resolve to the unexpected function template when using std::make_signed_t

This is CWG 2369 (unfortunately not on a public list despite having been submitted years ago). I'll just copy the main text here:

The specification of template argument deduction in 13.9.2 [temp.deduct] paragraph 5 specifies the order of processing as:

  1. substitute explicitly-specified template arguments throughout the template parameter list and type;

  2. deduce template arguments from the resulting function signature;

  3. check that non-dependent parameters can be initialized from their arguments;

  4. substitute deduced template arguments into the template parameter list and particularly into any needed default arguments to form a complete template argument list;;

  5. substitute resulting template arguments throughout the type;

  6. check that the associated constraints are satisfied;

  7. check that remaining parameters can be initialized from their arguments.

This ordering yields unexpected differences between concept and SFINAE implementations. For example:

template <typename T>
struct static_assert_integral {
  using type = T;

struct fun {
  template <typename T,
    typename Requires = std::enable_if_t<std::is_integral_v<T>>>
    typename static_assert_integral<T>::type
  operator()(T) {}

Here the substitution ordering guarantees are leveraged to prevent static_assert_integral<T> from being instantiated when the constraints are not satisfied. As a result, the following assertion holds:

static_assert(!std::is_invocable_v<fun, float>);

A version of this code written using constraints unexpectedly behaves differently:

struct fun {
  template <typename T>
    requires std::is_integral_v<T>
  typename static_assert_integral<T>::type
  operator()(T) {}


struct fun {
  template <typename T>
  typename static_assert_integral<T>::type
  operator()(T) requires std::is_integral_v<T> {}

static_assert(!std::is_invocable_v<fun, float>); // error: static assertion failed: std::is_integral_v<T> 

Perhaps steps 5 and 6 should be interchanged.

This basically matches the example in OP. You think that your constraints are preventing the instantiation of make_signed_t (which requires an integral type), but actually it's substituted into before the constraints are checked.

The direction seems to be to change the order of steps above to [1, 2, 4, 6, 3, 5, 7], which would make the OP example valid (we would remove (1) from consideration once we fail the associated constraints before substituting into make_signed_t), and this would certainly be a defect against C++20. But it just hasn't happened yet.

Until then, your best bet might be to just make a SFINAE-friendly version of make_signed:

template <typename T> struct my_make_signed { };
template <std::integral T> struct my_make_signed<T> { using type = std::make_signed_t<T>; };
template <typename T> using my_make_signed_t = /* no typename necessary */ my_make_signed<T>::type;

According to [meta], make_signed mandate that the template argument is an integral type:

Mandates: T is an integral or enumeration type other than cv bool.

So make_signed is not SFINAE friendly.

Constraint fullfilment checks are performed after template argument substitution. Template argument substitution happens when establishing the set of overload candidates and constraint fullfilment check latter, when establishing which overload candidates are viable.

Taking your case as an exemple:

  1. The compiler establish the set of overload candidate, constraint are not checked here. So what is going to be used by the compiler is equivalent to:

    template <class T>
    auto test(T) -> std::make_signed_t<T>; //(1)
    template <typename T>
    auto test(T) -> int; //(2)

The compiler deduce T to be double, it substitute T in make_signed_t => Error: substitution failure does not happen in the direct context of test declaration.

The compiler stop here, compilation does not reach the second step of selection of viable candidates where the constraint would have been checked.