Lambdas and Overloads and Type Traits, Oh My!


In this post I will discuss overloading generic lambdas in C++14, using SFINAE with lambdas, using tagged dispatch for “pseudo-SFINAE”, and finally using overloading and SFINAE to create local type traits inside functions. All of these are useful when writing generic code. Overloading generic lambdas, for example, proves to be extremely useful when implementing a stream operator for std::tuple. I’m sure you can think of reasons why a type trait is useful only in the current scope and writing a struct elsewhere for it is overkill (e.g. checking for the existence of a member function of a template parameter).

Overloaded Lambdas

C++ provides us with the first ingredient needed for overloaded lambdas: the lambdas. The second ingredient that we need is a method of overloading the call operator of a lambda. Unfortunately, there is no language support for this so we must implement this ourselves using existing language facilities. If we have a class overloader that derives off some other class, B, then we can bring B’s call operator into scope with using B::operator();. Let me be concrete about what I mean:

1
2
3
4
template <class F>
struct overloader : F {
  using F::operator();
};

This gets us what we want for one non-function invokable (a lambda or function object). However, we want to be able to overload the call operator N times, so we need to use a variadic struct template—C++17 makes this problem quite easy. In C++11/14 we can use recursion with fast-tracking to implement overloader, and given that there will typically only be a handful of overloads the recursive approach is fine. Let’s see what this looks like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
template <class... Fs>
struct overloader;

template <class F1, class F2, class... Fs>
struct overloader<F1, F2, Fs...> : F1, F2, overloader<Fs...> {
  constexpr overloader(F1 f1, F2 f2, Fs... fs)
      : F1(std::move(f1)),
        F2(std::move(f2)),
        overloader<Fs...>(std::move(fs)...) {}

  using F1::operator();
  using F2::operator();
  using overloader<Fs...>::operator();
};

template <class F>
struct overloader<F> : F {
  constexpr explicit overloader(F f) : F(std::move(f)) {}

  using F::operator();
};

namespace overloader_details {
struct no_such_type;
}  // namespace overloader_details

template <>
struct overloader<> {
  void operator()(overloader_details::no_such_type const* const  /*unused*/) {}
};

More fast-tracks can easily be added, e.g. for four and eight invokables.

Alright, let’s walk through what is happening in the above code. First we declare a variadic struct template, overloader on line 1. Next, on line 4 we provide a specialization that matches for two or more Fs and derives off F1, F2, and overloader<Fs...>. This is the simplest fast-track we can have: it peels off two template parameters instead of one. The constructor receives the invokables and moves them into the base classes. Finally, we bring the call operators from F1, F2 and overloader<Fs...> into scope by using three using-declarations. Because we recurse through the parameter pack Fs..., by bringing the call operators of overloader<Fs...> into scope we also bring all of the call operators of Fs... into scope.

The second specialization on line 16 is for when there is only a single invokable. The code is straightforward compared with the fast-track case. The sentinel specialization on line 27 is different: we need to provide a call operator so that using overloader<>::operator() is a valid expression. However, this call operator overload should never be called and the best way to ensure that is to have a pointer to an undefined (but declared) type. If someone tries to create the type overloader_detail::no_such_type they will be greeted with a compiler error.

I claim that the above will do exactly what we need: allow us to overload the call operator of (generic) lambdas. Let’s make life a little easier and define the utility function make_overloader:

1
2
3
4
template <class... Fs>
constexpr overloader<Fs...> make_overloader(Fs... fs) {
  return overloader<Fs...>{std::move(fs)...};
}

The point of this utility function is to do the template argument deduction for us. That is, it deduces the template parameters to overloader, the Fs..., allowing us to simply pass them to the function. This is especially useful for lambdas whose types are not so straightforward to figure out. Okay, so now let’s see this in action!

1
2
3
4
5
const auto lambdas = make_overloader(
    [](int a) { std::cout << "int: " << a << '\n'; },
    [](std::string a) { std::cout << "string: " << a << '\n'; });
lambdas(1);
lambdas("this is a string");

This will print out

int: 1
string: this is a string

SFINAE With Overloaded Lambdas

When writing generic code we sometimes want/need to use SFINAE with a lambda. One example is when writing a stream operator for std::tuple where it is necessary to support types that do not have a stream operator defined. Let’s use SFINAE to select an overload depending on whether a class has a member func(int), for which we can easily write a type trait:

1
2
3
4
5
6
7
8
9
10
11
12
template <class...>
using void_t = void;

template <class T, class = void_t<>>
struct has_func : std::false_type {};
template <class T>
struct has_func<T,
                void_t<decltype(std::declval<T>().func(std::declval<int>()))>>
    : std::true_type {};
    
template <class T>
constexpr bool has_func_v = has_func<T>::value;

I’m assuming you are familiar with writing type traits like this, but if not then I explain a litte bit of what’s happening with the decltype() call on line 8 in the section “From Overloaded Lambdas to Type Traits” below. To use the type trait for SFINAE of an overloaded lambda we use the trailing return type syntax as follows:

1
2
3
4
5
6
7
8
9
10
11
template <class T>
void check_for_func_member(T t) {
  constexpr auto my_lambdas = make_overloader(
      [](auto s) -> std::enable_if_t<has_func_v<decltype(s)>> {
        std::cout << "Has func(int) member using SFINAE\n";
      },
      [](auto s) -> std::enable_if_t<not has_func_v<decltype(s)>> {
        std::cout << "Has no func(int) member using SFINAE\n";
      });
  my_lambdas(t);
}

The return type is always void but which function is called depends on whether the first or second overload has a substitution failure in the return type. The resulting behavior is the same as using SFINAE with any regular function.

There is another clever way to have what I call “pseudo-SFINAE” with lambdas. In this case we write:

1
2
3
4
5
6
7
8
9
10
11
template <class T>
void check_for_func_member_overload(T t) {
  constexpr auto my_lambdas = make_overloader(
      [](auto s, std::true_type /*meta*/) {
        std::cout << "Has func(int) member using pseudo-SFINAE\n";
      },
      [](auto s, std::false_type /*meta*/) {
        std::cout << "Has no func(int) member using pseudo-SFINAE\n";
      });
  my_lambdas(t, typename has_func<T>::type{});
}

We select which overload we call based on what type typename has_func<T>::type is. If it is true_type we select the first, and if it is false_type we select the second. This is effectively tagged dispatch–using metaprogramming to select which function to resolve to. This approach has the major advantage that it is, in my opinion, much easier to understand than the SFINAE case.

Understanding The Overloaded Lambdas

We are almost ready to get to the fun stuff—metaprogramming. Before that we need to understand a little bit more about generic lambdas and overloading. One thing you may not be aware of is that you can use the trailing return type syntax to specify the return type of a lambda. What I mean is you can write []() -> double { return 1; } and the return type will be double, not int. We will now (ab)use this to write local type traits. First let’s write a trait that checks for the existence of a member function func(int). Here is the implementation of the type trait:

1
2
3
4
5
constexpr auto has_func = make_overloader(
    [](auto t, int) -> decltype(
        (void)std::declval<decltype(t)>().func(std::declval<int>()),
        std::true_type{}) { return std::true_type{}; },
    [](auto...) { return std::false_type{}; });

You are probably wondering what is happening here. First, we are creating an overloader of two lambdas. Let’s talk about the lambda on line 5 first. The lambda

1
[](auto...) { return std::false_type{}; }

translates to

1
2
3
4
struct LAMBDA_SECOND {
  template <class... Ts>
  std::false_type operator()(Ts...) const { return std::false_type{}; }
};

Similarly, the lambda on lines 2-4,

1
2
3
[](auto t,
   int) -> decltype((void)std::declval<decltype(t)>().func(std::declval<int>()),
                    std::true_type{}) { return std::true_type{}; }

translates to

1
2
3
4
5
6
7
8
struct LAMBDA_FIRST {
  template <class T>
  auto operator()(T t, int)
      -> decltype((void)std::declval<T>().func(std::declval<int>()),
                  std::true_type{}) const {
    return std::true_type{};
  }
};

Thus, the resulting overloaded lambda is

1
2
3
4
5
6
7
8
9
10
11
struct LAMBDA_OVERLOADER {
  template <class T>
  auto operator()(T t, int)
      -> decltype((void)std::declval<T>().func(std::declval<int>()),
                  std::true_type{}) const {
    return std::true_type{};
  }

  template <class... Ts>
  std::false_type operator()(Ts...) const { return std::false_type{}; }
};

Hopefully it is now clear how to think about the overloaded lambdas.

I’ll quickly point out that the (void) on line 3 of the first code block in this section is necessary to avoid code injection from overloaded comma operators. That is, if someone were to define void operator,(int, std::true_type) {} then the expression passed to the decltype() operator on line 2 and 3 of the first block in this section will be ill-formed.

From Overloaded Lambdas to Type Traits

Now that we have the required code, let’s see what happens when we use the type trait has_func. The simplest thing we can do is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct my_type1 {
  int func(int a) { return 2 * a; }
};
struct my_type2 {};

template <class Trait, class... Type>
constexpr bool local_trait_v =
    decltype(std::declval<Trait>()(std::declval<Type>()..., 0))::value;

template <class T>
void local_type_trait_example1(T /*t*/) {
  constexpr auto has_func_impl = make_overloader(
      [](auto t, int) -> decltype(
          (void)std::declval<decltype(t)>().func(std::declval<int>()),
          std::true_type{}) { return std::true_type{}; },
      [](auto...) { return std::false_type{}; });
  using has_func_member = decltype(has_func_impl);

  std::cout << "Has func(int) member function: " << std::boolalpha
            << local_trait_v<has_func_member, T> << "\n";
}
int main() {
  local_type_trait_example1(my_type1{});
  local_type_trait_example1(my_type2{});
}

which prints out

Has func(int) member function: true
Has func(int) member function: false

First I’ll explain local_trait_v (line 7), which is simply a helper constexpr variable used to reduce the amount of typing necessary to evaluate local type traits. It takes the trait to evaluate as its first template parameter and the types to pass to the trait as the parameter pack. Because lambdas cannot appear in an unevaluated context we must declare the variable has_func_impl rather than having the make_overloader call be inside the decltype() call on line 17.

You should now be able to follow the call to local_trait_v on line 20 back to overload resolution of the call operator. So how does the compiler actually decide which return type to deduce from the call operator? Well, if the expression to decltype() in the trailing return type on line 13 is evaluable, i.e. decltype(t) has a member function func(int), then the first overload is preferred if and only if the second argument to the call operator is an int. This is why local_trait_v calls (type, 0): the 0 matches int, which is a better match than auto... in the call operator on line 16. However, if the call were (type, 0L), the 0L would match auto... because then there is no implicit cast to from long to int. If you have written a lot of type traits then this trick will be familiar, though frequently C-style variadic functions are used, which I’m intentionally avoiding.

Another Local Type Trait Example

Another interesting class of type traits are the is_, for example is_std_map. Let’s write that one and use it! Here is the implementation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void local_type_trait_example2() {
  constexpr auto is_std_map = make_overloader(
      [](auto t, int) -> std::enable_if_t<
          std::is_same<decltype(t),
                       std::map<typename decltype(t)::key_type,
                                typename decltype(t)::mapped_type,
                                typename decltype(t)::key_compare,
                                typename decltype(t)::allocator_type>>::value,
          std::true_type> { return std::true_type{}; },
      [](auto...) { return std::false_type{}; });

  std::map<int, double> b;
  std::unordered_map<int, double> c;
  std::vector<int> d;
  std::cout << "Is a map: " << decltype(is_std_map(b, 0))::value << "\n";
  std::cout << "Is a map: " << decltype(is_std_map(c, 0))::value << "\n";
  std::cout << "Is a map: " << decltype(is_std_map(d, 0))::value << "\n";
}

The trait checks that the member aliases key_type, mapped_type, key_compare and allocator_type exist, and then that a std::map with the same template parameters is the same type as what was passed in to t. This is definitely not the most common way of implementing the type trait if one were using a struct, but the result is perfectly adequate and works within the restrictions of overloaded lambdas.

Summary

In this post we explored how to overload the call operator of a (generic) lambda, use SFINAE and tagged dispatch for “pseudo-SFINAE” in overloaded lambdas, and (ab)use these to implement type traits locally within functions. The result is several different ways of performing fairly complex metaprogramming tasks within a narrower scope than was previously possible. This helps to simplify the amount of code that needs to be analyzed when reasoning about what a function does. The biggest gain from these methods is for library implementers who write a lot of generic code. I have shared a complete, working example of the code snippets shown here on my GitHub in the file local-type-traits.cpp.

I hope you enjoyed the post and thanks for reading!

Back to blog

comments powered by Disqus