Search code examples
c++c++17sfinaetype-traits

Have difficulty understanding the syntax of generic lambdas for SFINAE-based traits


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)


Solution

    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.