5

I am reading some examples of SFINAE-based traits, but unable to make sense out of the one related to generic lambdas in C++17 (isvalid.hpp).

I can understand that it roughly contains some major parts in order to implement a type trait such as isDefaultConstructible or hasFirst trait (isvalid1.cpp):

1. Helper functions using SFINAE technique:

#include <type_traits>

// helper: checking validity of f(args...) for F f and Args... args:
template<typename F, typename... Args,
         typename = decltype(std::declval<F>()(std::declval<Args&&>()...))>
std::true_type isValidImpl(void*);

// fallback if helper SFINAE'd out:
template<typename F, typename... Args>
std::false_type isValidImpl(...);

2. Generic lambda to determine the validity:

// define a lambda that takes a lambda f and returns whether calling f with args is valid
inline constexpr
auto isValid = [](auto f) {
                 return [](auto&&... args) {
                          return decltype(isValidImpl<decltype(f),
                                                      decltype(args)&&...
                                                     >(nullptr)){};
                        };
               };

3. Type helper template:

// helper template to represent a type as a value
template<typename T>
struct TypeT {
    using Type = T;
};

// helper to wrap a type as a value
template<typename T>
constexpr auto type = TypeT<T>{};

// helper to unwrap a wrapped type in unevaluated contexts
template<typename T>
T valueT(TypeT<T>);  // no definition needed

4. Finally, compose them into isDefaultConstructible trait to check whether a type is default constructible:

constexpr auto isDefaultConstructible
    = isValid([](auto x) -> decltype((void)decltype(valueT(x))()) {
        });

It is used like this (Live Demo):

struct S {
    S() = delete;
};

int main() {
    std::cout << std::boolalpha;
    std::cout << "int: " << isDefaultConstructible(type<int>) << std::endl;    // true
    std::cout << "int&: " << isDefaultConstructible(type<int&>) << std::endl;  // false
    std::cout << "S: " << isDefaultConstructible(type<S>) << std::endl;        // false

    return 0;
}

However, some of the syntax are so complicated and I cannot figure out.

My questions are:

  • With respect to 1, as for std::declval<F>()(std::declval<Args&&>()...), does it mean that it is an F type functor taking Args type constructor? And why it uses forwarding reference Args&& instead of simply Args?

  • With respect to 2, as for decltype(isValidImpl<decltype(f), decltype(args)&&...>(nullptr)){} , I also cannot understand why it passes forwarding reference decltype(args)&& instead of simply decltype(args)?

  • With respect to 4, as for decltype((void)decltype(valueT(x))()), what is the purpose of (void) casting here? ((void) casting can also be found in isvalid1.cpp for hasFirst trait) All I can find about void casting is Casting to void to avoid use of overloaded user-defined Comma operator, but it seems it is not the case here.

Thanks for any insights.


P.S. For one who wants more detail could check C++ Templates: The Complete Guide, 2nd - 19.4.3 Using Generic Lambdas for SFINAE. The author also mentioned that some of the techniques are used widely in Boost.Hana, so I also listen to Louis Dionne's talk about it. Yet, it only helps me a little to understand the code snippet above. (It is still a great talk about the evolution of C++ metaprogramming)

Lucien
  • 61
  • 1
  • 5
  • 3
    try to focus on one question. Sometimes it takes more than one question to tackle a bigger thing. – 463035818_is_not_an_ai Jun 01 '23 at 14:16
  • 2
    A reason for casting to `void` here is that you really don't want the type, just know if there *is* a type. And if you have bad luck, it could be a type that *exists*, but cannot be returned by a function. – BoP Jun 01 '23 at 14:39
  • One question per post. Someone might know answer to some part of your question but not to other. – Jason Jun 01 '23 at 15:34

1 Answers1

1
  1. F is a function object callable with Args... For the sake of mental model, picture std::declval<F>() as a "fully constructed object of type F". std::declval is there just in case F is not default-constructible and still needs to be used in unevaluated contexts. For a default-constructible type this would be equivalent: F()(std::declval<Args&&>()...); In essence it's a call to F's constructor and then call to its operator() with forwarded Args. But imagine one type is constructible with int, another one is default-constructible, yet another one requires a string. Without some unevaluated constructor-like metafunction it would be impossible to cover all those cases. You can read more on that in Alexandrescu's Modern C++ Design: Generic Programming and Design Patterns Applied.

  2. Adding && to the argument type is effectively perfect-forwarding it. It may look obscure, but it's just a shorthand for decltype(std::forward<decltype(args)>(args)). See the implementation of std::forward and reference collapsing rules for more details. Keep in mind though, that this snippet adds rvalue-reference that collapses to the correct one when combined with the original type, not a forwarding one.

  3. As it was stated in the comments: the type is not really needed, possibilty exists it cannot be returned, its presence there is just to check expression's correctness, afterwards it can be discarded.

alagner
  • 3,448
  • 1
  • 13
  • 25
  • For 1., I try to replace `std::declval()` with simply `F()`, but the now the output would be incorrect for int type even it is constructible: ([Demo](https://godbolt.org/z/59o8bW63o)). I cannot fully understand the reason for this. Is this also because of the necessity of unevaluated context? – Lucien Jun 04 '23 at 06:31
  • 1
    Notice that a lambda is passed to `isValid` and that its type is then passed to `isValidImpl` as a template argument, and lambdas are not default constructible, therefore you're hitting the "sink-everything" function. Comment out the sink and it becomes clear https://godbolt.org/z/vETnjcxvr – alagner Jun 04 '23 at 07:47