tl;dr
- This pattern is well-defined
std::enable_if_t<!is_fooable_v<T>,void> foo(T)
is visible during the initialization of is_fooable<T>::value
- but template argument substitution will fail for it, so
is_fooable<T>::value
will be false
- You can use a second trait class to detect both functions (e.g.
struct is_really_fooable
with the same definition as is_fooable
)
1. Disclaimer
This post only considers the C++20 standard.
I didn't check previous standards for conformance.
2. Visibility of the templated foo
function
The templated foo function (template <typename T> std::enable_if_t<!is_fooable_v<T>,void> foo(T) {}
) is visible from within is_fooable
and participates in overload resolution.
This is due to test(std::declval<T>())
being dependent on T
- so name lookup needs to consider both the context of the template definition and the context of the point of instantiation:
13.8.2 Dependent names [temp.dep] (2)
If an operand of an operator is a type-dependent expression, the operator also denotes a dependent name.
[ Note: Such names are unbound and are looked up at the point of the template instantiation ([temp.point]) in both the context of the template definition and the context of the point of instantiation ([temp.dep.candidate]). — end note ]
// [...]
template<class T>
struct is_fooable { // <-- Template definition
static std::false_type test(...);
template<class U>
static auto test(const U& u) -> decltype(foo(u), std::true_type{});
static constexpr bool value = decltype(test(std::declval<T>()))::value;
};
// is_fooable is dependent on T in this case,
// so the point of instantiation will be the point where is_fooable_v<T> is itself instantiated
template<class T> inline constexpr bool is_fooable_v = is_fooable<T>::value;
template <typename T>
// same as for is_fooable_v -
std::enable_if_t<!is_fooable_v<T>,void> foo(T) {}
int main() {
std::cout << is_fooable_v<bar>; // <-- Point of instantiation for is_fooable<bar>
std::cout << is_fooable_v<moo>; // <-- Point of instantiation for is_fooable<moo>
foo(bar{});
foo(moo{});
}
So the templated foo
function would not be visible from the template definition, but it would be visible from the point of instantiation - and since we need to look at both it will be considered for overload resolution within is_fooable
.
Note: If the expression would not be dependent on the template parameter, e.g. foo(12)
, then we would only need to consider the context of the template definition:
13.8 Name resolution [temp.res] (10)
If a name does not depend on a template-parameter (as defined in [temp.dep]), a declaration (or set of declarations) for that name shall be in scope at the point where the name appears in the template definition; the name is bound to the declaration (or declarations) found at that point and this binding is not affected by declarations that are visible at the point of instantiation.
13.8.5.1 Point of instantiation [temp.point] (7) does not apply in this case - we only have a single translation unit and only have a single instantiation point for each is_fooable<T>
- so there will be no violation of the odr rule.
Note: You still need to be careful though if you use this in multiple translation units (but this basically applies to any trait-like template).
e.g. this would be a violation of the odr rule (ill-formed, ndr):
// Translation unit 1
struct bar{};
void foo(bar) {}
template<class T> struct is_fooable { /* ... */ };
// would print 1
void test1() { std::cout << is_fooable<bar>::value << std::endl; }
// Translation unit 2
struct bar{};
// foo(bar) not defined before test2
template<class T> struct is_fooable { /* ... */ };
// would print 0
void test2() { std::cout << is_fooable<bar>::value << std::endl; }
// -> different definitions of is_fooable<bar>::value in different translation units
// -> ill-formed, ndr
3. How is_fooable<moo>::value
ends up being false
In essence this is an interesting application of constant expressions combined with SFINAE.
First we'll need to cover a few basic rules:
Accessing a variable during its own initialization is undefined behaviour. (e.g. int x = x;
)
This is due to the following two rules: (emphasis mine)
6.7.3 Lifetime [basic.life] (1)
[...] The lifetime of an object of type T begins when:
- storage with the proper alignment and size for type T is obtained, and
- its initialization (if any) is complete [...]
6.7.3 Lifetime [basic.life] (7)
[...] Before the lifetime of an object has started but after the storage which the object will occupy has been allocated [...], any glvalue that refers to the original object may be used but only in limited ways. [...] The program has undefined behavior if:
- the glvalue is used to access the object [...]
non-type template parameters must be converted constant expressions
13.4.2 Template non-type arguments[temp.arg.nontype] (2)
A template-argument for a non-type template-parameter shall be a converted constant expression ([expr.const]) of the type of the template-parameter.
- A converted constant expression must be a constant expression
7.7 Constant expressions [expr.const] (10)
A converted constant expression of type T is an expression, implicitly converted to type T, where the converted expression is a constant expression [...]
- A constant expression is a core constant expression:
7.7 Constant expressions [expr.const] (11)
A constant expression is either a glvalue core constant expression that refers to an entity that is a permitted result of a constant expression (as defined below), or a prvalue core constant expression [...]
- So to wrap it up a non-type template parameter must have a value that is a core constant expression (ignoring the conversion part)
So now we can piece it together:
- Let's start with
std::cout << is_fooable_v<moo>;
: This will instantiate is_fooable_v<moo>
, which in turn will instantiate is_fooable<moo>::value
.
- So the initialization of
is_fooable<moo>::value
begins.
- The overload resolution for
test()
takes place with both test
functions as candidates
test(...)
is straightforward and would always be a viable function (with a low priority)
test(const U& u)
would be viable and will be instanciated
- this in turn will result in the overload resolution of
foo(u)
, which also has 2 potential candidate functions: foo(bar)
and foo(T)
foo(bar)
is not viable, because moo
is not convertible to bar
foo(T)
would be viable and will be instanciated
- during argument substitution into
foo(T)
we'll encounter a problem: foo(T)
accesses is_fooable<moo>::value
- which is still uninitialized (we're currently trying to initialize it)
- this would be undefined behaviour normally - but because we're in a constantly evaluated context (non-type template arguments like the one of
std::enable_if_t
need to be converted constant expressions) a special rule applies: (emphasis mine)
7.7 Constant expressions [expr.const] (5)
An expression E is a core constant expression unless the evaluation of E, following the rules of the abstract machine ([intro.execution]), would evaluate one of the following:
[...]
- 6.7.3 Lifetime [basic.life] is between 4 Intro [intro] and 15 Preprocessing directives [cpp] so this rule applies to accessing a variable outside of its lifetime.
- Therefore the
is_fooable_v<T>
within std::enable_if_t<!is_fooable_v<T>,void>
is not a core constant expression, which the standard mandates for non-type template parameters
- So this instanciation of
foo(T)
would be ill-formed (and not undefined behaviour)
- so template argument substitution for
foo(T)
fails and will not be a viable function
- there are no viable functions that
foo(u)
could match
- template argument substitution for
U
in test(const U& u)
fails due to no viable function that could be called for foo(u)
test(const U& u)
is no longer viable due to foo(u)
being ill-formed - but test(...)
is still viable
test(...)
will be the best viable function (and the error from test(const U& u)
will be swallowed due to SFINAE)
test(...)
was picked during overload resolution, so is_fooable<moo>::value
will be initialized to false
- The initialization of
is_fooable<moo>::value
is complete
So this is completely standard-conforming due to undefined behaviour not being allowed in constant expressions (and therefore foo(T)
will always cause a substitution failure during the initialization of is_fooable<T>::value
)
This is all contained within the is_fooable
struct, so even if you first call foo(moo{});
you would get the same result, e.g.:
int main() {
foo(moo{});
std::cout << is_fooable_v<moo>; // will still be false
}
It's essentially the same sequence as above, just that you start with the function foo(T)
, which then causes the instanciation of is_fooable_v<T>
.
- (see above for the sequence of events that take place)
is_fooable_v<T>
gets initialized to false
- argument substitution of
foo(T)
succeeds
-> foo<moo>(moo{})
will be called
Note: If you comment out the test(...)
function (so SFINAE won't be able to suppress the substitution failure from test(const U& u)
) then your compiler should report this substitution error (it is ill-formed and therefore there should be a diagnostic message).
This is the result from gcc 12.1: (only the interesting parts)
godbolt
In instantiation of 'constexpr const bool is_fooable<moo>::value':
error: no matching function for call to 'is_fooable<moo>::test(moo)'
error: no matching function for call to 'foo(const moo&)'
note: candidate: 'template<class T> std::enable_if_t<(! is_fooable_v<T>), void> foo(T)'
note: template argument deduction/substitution failed:
error: the value of 'is_fooable_v<moo>' is not usable in a constant expression
note: 'is_fooable_v<moo>' used in its own initializer
note: in template argument for type 'bool'
4. Remarks
You can shorten your is_fooable
trait if you use a C++20 requires clause, e.g.:
template<class T>
constexpr bool is_fooable_v = requires(T const& t) { foo(t); };
Notice that you can't use a concept, because concepts are never instanciated.
If you want to be able to also detect foo(T)
you could do so by just defining a second trait.
The second trait would not partake in the initialization shenanigans that is_fooable
uses and therefore would be able to detect the foo(T)
overload:
godbolt
struct bar {};
void foo(bar) {}
struct moo {};
template<class T>
constexpr bool is_fooable_v = requires(T const& t) { foo(t); };
template<class T>
constexpr bool is_really_fooable_v = requires(T const& t) { foo(t); };
template <typename T>
std::enable_if_t<!is_fooable_v<T>,void> foo(T) {}
int main() {
foo(moo{});
std::cout << is_fooable_v<moo>; // 0
std::cout << is_really_fooable_v<moo>; // 1
}
And yes, if you want to you can layer those traits on top of each other, e.g.:
godbolt
struct a {};
struct b {};
struct c {};
void foo(a) { std::cout << "foo1" << std::endl; }
template<class T> inline constexpr bool is_fooable_v = requires(T const& t) { foo(t); };
template<class T> inline constexpr bool is_really_fooable_v = requires(T const& t) { foo(t); };
template<class T> inline constexpr bool is_really_really_fooable_v = requires(T const& t) { foo(t); };
template <class T, class = std::enable_if_t<std::is_same_v<T, b>>>
std::enable_if_t<!is_fooable_v<T>,void> foo(T) { std::cout << "foo2" << std::endl; }
template <class T>
std::enable_if_t<!is_really_fooable_v<T>,void> foo(T) { std::cout << "foo3" << std::endl; }
int main() {
foo(a{});
foo(b{});
foo(c{});
std::cout << "a: "
<< is_fooable_v<a> << " "
<< is_really_fooable_v<a> << " "
<< is_really_really_fooable_v<a> << std::endl;
std::cout << "b: "
<< is_fooable_v<b> << " "
<< is_really_fooable_v<b> << " "
<< is_really_really_fooable_v<b> << std::endl;
std::cout << "c: "
<< is_fooable_v<c> << " "
<< is_really_fooable_v<c> << " "
<< is_really_really_fooable_v<c> << std::endl;
/* Output:
foo1
foo2
foo3
a: 1 1 1
b: 0 1 1
c: 1 0 1
*/
}
that will get really really confusing though, so i would not recommend it.
is_fooable
does not know aboutstd::enable_if_t<!is_fooable_v<T>,void> foo(T)
since it is declared after somoo
doesn't have afoo
to be called with in it's eyes. – Otolaryngologyvalue
has, for a given specialization of theis_fooable
class template, two points of instantiation (immediately after the class template specialiation, at namespace scope, and "[...] the point after the declaration-seq of the translation-unit is also considered a point of instantiation"), and for theis_fooable<moo>
specialization, these two have different meanings. – Unobtrusive