Is it ok to define a function using SFINAE that requires non-existance of the function?
Asked Answered
S

2

12

The code in this question is based on this answer. I am a little confused about how this produces the output it does, and whether it is all well defined

#include <type_traits>
#include <iostream>
#include <vector>

struct bar {};
void foo(bar) {}
struct moo {};

template<class T>
struct is_fooable {
    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;
};
template<class T> inline constexpr bool is_fooable_v = is_fooable<T>::value;

template <typename T>
std::enable_if_t<!is_fooable_v<T>,void> foo(T) {}

int main() {
    std::cout << is_fooable_v<bar>;
    std::cout << is_fooable_v<moo>;
    foo(bar{});
    foo(moo{});   
}

Output with gcc (same with clang and msvc):

10

If is_fooable_v<moo> is false then SFINAE does not discard the foo template and then moo is "fooable", though is_fooable_v<moo> is false nevertheless.

I find it confusing that the trait has only limited use, because it cannot tell if moo is "fooable" after it was used to define foo<T> with T==moo. Irrespective of that potential confusion, is the code well defined?

Is it ok to define a function based on a trait that tests if the function does exist?

Shiri answered 10/8, 2022 at 15:30 Comment(8)
Pretty sure this isn't UB, it's just that is_fooable does not know about std::enable_if_t<!is_fooable_v<T>,void> foo(T) since it is declared after so moo doesn't have a foo to be called with in it's eyes.Otolaryngology
@Otolaryngology its probably a poor question, I can't really put my finger on what could be wrong with the code. Though I was rather surprised to see this work. I found it highly suspicious, but if theres nothing wrong with it its actually rather cool :)Shiri
Of the top of my head, GCC had some issues with that. Encountered this while trying to writing a neibloid-like function object. It was supposed to do ADL-for customisation, and fallback on a "default" in case that one didn't exist. Not fun times.Glaucous
Recusion in type traits didn't occur to me (when I wrote the answer being reflected upon) and I'm quick to throw a type trait into the mix so.... your question is brilliant. I have a feeling that it alls falls out naturally as long as a trait is not depending on itself. Looking forward to answers to this question!Verditer
Isn't this IFNDR due to [temp.point]/7? The static data member value has, for a given specialization of the is_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 the is_fooable<moo> specialization, these two have different meanings.Unobtrusive
@Unobtrusive not sure if that applies, because the paragraph starts with "A specialization for a class template has at most one point of instantiation within a translation unit." so the way I understand it the last sentence (about specializations with different meanings only applies to different translation units. Though I guess it was a Q&A about that very section I had remembered when I saw this code.Shiri
@463035818_is_not_a_number Note that the whole /7 paragraph starts with "A specialization [...] of a [...] static data member of a class template may have multiple points of instantiations within a translation unit, and in addition to the points of instantiation described above," and ends with "A specialization for any template may have points of instantiation in multiple translation units. If two different points of instantiation give a template specialization different meanings according to the one-definition rule, the program is ill-formed, no diagnostic required.".Unobtrusive
... As I read it, the note on class templates that you quote simply mentions that class template themselves only have at most one point of instantiation, but that does not apply to other templated entities as e.g. static data members of class templates. But temp.point is tricky, so I'm not sure.Unobtrusive
S
12

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.

Sundown answered 23/8, 2022 at 2:42 Comment(3)
...... madre mia ..... One of these days I'm going to learn how to read the specs. I may have to retire to have the time. I'm glad you came to the conclusion that my initial type trait was well formed though :-)Verditer
What's the difference between b & c? Why different behavior?Salinger
@Salinger The second foo has std::enable_if_t<std::is_same_v<T, b>> as a defaulted template parameter type, that causes the difference between b and c.Sundown
F
-2

Before I give the answer let me do a recap on why the code works like that:


The "problem" lies in these two lines:

template<class T> inline constexpr bool is_fooable_v = is_fooable<T>::value;

template <typename T> std::enable_if_t<!is_fooable_v<T>,void> foo(T) {}

Now imagine the foo(moo) is being created by the second line. It has to execute std::enable_if<!is_fooable_v<T>, void> which has in turn execute !is_fooable_v<T>. What is the value of such expression with T = moo? Of course to find that out we have to instantiate is_fooable_v<moo>. After some calculations the compiler instantiates this boolean to false.

And that's it: now the is_fooable_v<moo> since that point is now equal false in every other evaluation of that expression (temp.inst#7). So when you use it in the main() function:

std::cout << is_fooable_v<moo>;

it became "fixed" to that value.

To prove that look what if you add to those two lines this extra line:

template<> inline constexpr bool is_fooable_v<moo> = true;

this of course makes the foo(moo{}); line unable to compile, but it makes also both lines with std::cout printing ones.

Look for yourself.


As for the question:

Is it ok to define a function based on a trait that tests if the function does exist?

Well, the answer depends if you can actually find some use for this "trick".

If, for example, you are making a library then using this technique you can make a default implementation of a function based on if the user of your library actually provides his/her own. Of course there is a catch, I wanted to show in my recap above: you can't use the is_fooable_v<> template in your code to test for existence of such user-provided function.

Floret answered 20/8, 2022 at 20:17 Comment(3)
Excuse me, but I don't see how this is an answer to the question, considering that it just repeats the analysis that was implied by the question and the comments below it, and, most of all, considering that there's a language-lawyer tag.Disgust
Also this question is tagged language-lawyer.Jeroboam
maybe you misunderstood the question. I admit it could be phrased better, though " Irrespective of that potential confusion, is the code well defined?" means: I am aware that the trait evaluates to false and that this causes the SFINAEd function to be defined. I am also aware that one might expect (including myself) once the function is defined, the trait does evaluate to true. However, I could be fine with this confusion, so lets put that aside. The question is: Is the code legal? And the language-lawyer tag implies: Can you point me to some sections in the standard that are relevantShiri

© 2022 - 2024 — McMap. All rights reserved.