6
\$\begingroup\$

I'm implementing apply_each for tuple-like object as a general function which may be a combined version of for_each and tuple_transform in some implementations for tuple iteration.

As the name suggests, the first argument which is invocable will apply each element of the tuple.


Function Prototype of apply_each:

template <typename F, typename... Tuples>
constexpr decltype(auto) apply_each(F&& f, Tuples&&... ts);

As usual, you can iterate over the tuple

std::tuple tup_1 {1, 2.5, "three", 'F'};
gold::apply_each([](const auto& elem) {
  std::cout << elem << ' ';
}, tup_1);

Output:

1 2.5 three F

Tuple-like object can also be used

gold::apply_each([](const auto& elem) {
  std::cout << elem << ' ';
}, std::array{1, 2, 3, 4});

Output:

1 2 3 4

How about two or more tuples? Sure! No problem

The function apply_each takes the first parameter that is callable and the arity should match with the number of elements from the tuple. There is internally a zipper function that zips the function so that the tuples that are zipped can be traversed together.

You don't need to worry about the length of every tuple because it automatically selects the minimum size of the tuples and truncates it.

std::tuple tup_1 {1, 2, 3, 4};
std::tuple tup_2 {1, 2, 3, 4, 5};
std::tuple tup_3 {1, 2, 3};

gold::apply_each([i = 1](const auto&... args) mutable {
  if (i == 1) std::cout << "Taking the sum of each element..." << '\n';
  std::cout << "Iteration " << i++ << ": " << (args + ...) << '\n';
}, tup_1, tup_2, tup_3);

Output:

Taking the sum of each element...
Iteration 1: 3
Iteration 2: 6
Iteration 3: 9

Well you can still use the parameter list: const auto& arg1, const auto& arg2, const auto& arg3.


What about the return type? Sure!

std::tuple tup_1 {1, 2, 3, 4};
std::tuple tup_2 {1, 2, 3, 4, 5};
std::tuple tup_3 {1, 2, 3};

auto sum_tuple = gold::apply_each([](const auto&... args) mutable {
  return (args + ...);
}, tup_1, tup_2, tup_3);

static_assert(std::is_same_v<std::tuple<int, int, int>, decltype(sum_tuple)>); // passed!

gold::apply_each([i = 1](const auto& elem) mutable {
  std::cout << "Sum " << i++ << ": " << elem << '\n';
}, sum_tuple);

Output:

Sum 1: 3
Sum 2: 6
Sum 3: 9

What about mutating them? Hmmm...

Well...

std::tuple<int, double> tup_1;

gold::apply_each([](auto& elem){
  if constexpr (std::is_integral_v<std::decay_t<decltype(elem)>>) {
    elem = 3;
  } else {
    elem = 3.14159; 
  }
}, tup_1);

gold::apply_each([](const auto& elem){
  std::cout << elem << ' ';
}, tup_1);

It seems to be compiled, but! The tuple doesn't change...

0 0

Why? Well, it's difficult for me to make communicate with references when that invocable object is passed, and I can't figure out easily the argument type of the function because it may be a template or not. So... I used a reference_wrapper!, because that's the only one who saved me.

Let's see...

gold::apply_each([](auto& elem){
  if constexpr (std::is_integral_v<std::decay_t<decltype(elem)>>) {
    elem = 3;
  } else {
    elem = 3.14159;
  }
}, std::ref(tup_1));

Oops!! It seems the compiler is very angry. Well, the elem is still actually a reference_wrapper so the error is about operator= mismatch, and is not converted into primitive reference because it is a generic lambda.

So, I find another solution and overload is the key!

gold::apply_each(gold::overload(
  [](int& elem){ elem = 3; },
  [](double& elem) { elem = 3.14159; }
), std::ref(tup_1));

The compiler seems very happy now :)

A reference wrapper is implicitly converted into reference type because the argument type is known which is int& and double&

What is the other way for passing a generic lambda? So I created a struct overload_unref!

gold::apply_each(gold::overload_unref(
  [](auto& elem) {
    if constexpr (std::is_integral_v<std::decay_t<decltype(elem)>>) {
      elem = 3;
    } else {
      elem = 3.14159;
    }
  }
), std::ref(tup_1));

The mechanics of overload_unref is that it will unwrap the argument if the type is reference_wrapper.

Here is the full implementation of apply_each together with the helper tuple_zip function:

namespace gold {

  namespace detail {
    template <typename>
    struct is_ref_wrapper_ : std::false_type {};

    template <typename T>
    struct is_ref_wrapper_<std::reference_wrapper<T>> : std::true_type {};

    template <typename T>
    inline constexpr bool is_ref_wrapper_v_ = is_ref_wrapper_<T>::value;

    template <typename T>
    struct maybe_unwrap_ref_wrapper_ {
      using type = std::conditional_t<is_ref_wrapper_v_<T>, std::unwrap_reference_t<T>, T>;
    };

    template <typename T>
    using maybe_unwrap_ref_wrapper_t_ = typename maybe_unwrap_ref_wrapper_<T>::type;

    template <typename T>
    using get_ref_wrap_type_t_ = typename T::type;

    template <std::size_t I, typename... Tuple>
    using zip_tuple_at_index_t_ = std::tuple<
      std::conditional_t<
        is_ref_wrapper_v_<std::decay_t<Tuple>>,
          std::reference_wrapper<
            std::conditional_t<
              std::is_const_v<std::experimental::detected_or_t<Tuple, get_ref_wrap_type_t_, Tuple>>,
              const std::tuple_element_t<I, std::decay_t<maybe_unwrap_ref_wrapper_t_<Tuple>>>,
              std::tuple_element_t<I, std::decay_t<maybe_unwrap_ref_wrapper_t_<Tuple>>>
            >
          >,
        std::tuple_element_t<I, std::decay_t<maybe_unwrap_ref_wrapper_t_<Tuple>>>
      >...
    >;

    template <std::size_t I, typename... Tuple>
    constexpr zip_tuple_at_index_t_<I, Tuple...> zip_tuple_at_index_(Tuple&&... ts) {

      return { []<typename T>(T&& t) {
        if constexpr (is_ref_wrapper_v_<std::remove_cvref_t<T>>) {
          using type_ = std::remove_reference_t<typename T::type>; // reference_wrapper<T>::type
          if constexpr (std::is_const_v<type_>)
            return std::cref(std::get<I>(std::forward<T>(t).get()));
          else
            return std::ref(std::get<I>(std::forward<T>(t).get()));
        } else
           return std::get<I>(std::forward<T>(t));
      }(std::forward<Tuple>(ts)) ... };

    }

    template <typename... Tuple, std::size_t... Is>
    constexpr std::tuple<zip_tuple_at_index_t_<Is, Tuple...>...>
    tuple_zip_impl_(std::index_sequence<Is...>, Tuple&&... ts) {
        return { zip_tuple_at_index_<Is>(std::forward<Tuple>(ts)...)... };
    }
  
  } // namespace detail

  // tuple_zip
  template <typename... Tuple> requires (sizeof...(Tuple) > 0)
  constexpr decltype(auto) tuple_zip(Tuple&&... ts) {
    constexpr auto min_size_ = std::ranges::min({
      std::tuple_size_v<std::decay_t<detail::maybe_unwrap_ref_wrapper_t_<Tuple>>>...
    });
    return detail::tuple_zip_impl_(
      std::make_index_sequence<min_size_>{},
      std::forward<Tuple>(ts)...
    );
  }

  namespace detail {
    
    template <typename F, typename... Tuple, std::size_t... Is> requires (sizeof...(Tuple) > 0)
    constexpr decltype(auto) apply_each_impl_(std::index_sequence<Is...>, F&& f, Tuple&&... ts) {
      decltype(auto) zipped_ = tuple_zip(std::forward<Tuple>(ts)...);
      using result_t_ = decltype([&]{
        return (std::apply(std::forward<F>(f), std::get<Is>(zipped_)), ...);
      }());

      if constexpr (std::is_void_v<result_t_>) {
        return (
          std::apply(std::forward<F>(f), std::get<Is>(zipped_)), ...
        );
      } else {
        return std::make_tuple(
          std::apply(
            std::forward<F>(f), std::get<Is>(zipped_)
          )...
        );
      }
    }

  } // namespace detail

  // apply_each
  template <typename F, typename... Tuple>
  constexpr decltype(auto) apply_each(F&& f, Tuple&&... ts) {
    using indices_ = std::make_index_sequence<std::ranges::min({
      std::tuple_size_v<std::decay_t<detail::maybe_unwrap_ref_wrapper_t_<Tuple>>>...
    })>;
    return detail::apply_each_impl_(indices_{}, std::forward<F>(f), std::forward<Tuple>(ts)...);
  }

  // unref
  template <typename T>
  constexpr T&& unref(T&& t) {
    return std::forward<T>(t);
  }

  template <typename T>
  constexpr T& unref(std::reference_wrapper<T> t) {
    return t.get();
  }

  // overload
  template <typename... Fs>
  struct overload : Fs... {
    using Fs::operator()...;
  };

  // I have to provide this, otherwise, I would get a compilation error that I can't explain further...
  template <typename... Fs>
  overload(Fs...) -> overload<Fs...>;

  namespace requirements {
    template <typename... Ts>
    concept has_at_least_ref_wrapper_ = (detail::is_ref_wrapper_v_<std::remove_cvref_t<Ts>> || ...);
  } // namespace requirements

  // overload_unref
  template <typename... Fs>
  struct overload_unref : overload<Fs...> {

    overload_unref(Fs&&... fs)
    : overload<Fs...>{ std::forward<Fs>(fs) ... } {}

    using overload<Fs...>::operator();

    template <typename... Ts>
      requires requirements::has_at_least_ref_wrapper_<Ts...>
    constexpr auto operator()(Ts&&... args) {
       return (*this)(unref(std::forward<Ts>(args))...);
    }

  };
 
} // namespace gold

Current problems:

  • The definition of alias zip_tuple_at_index_t_ is over complicated even though I'm the one who wrote this but I can't further simplify this one.
  • Same to zip_tuple_at_index_.
  • I don't know if it's safe to use std::experimental::detected_or_t even if it's experimental.
  • I'm writing decltype(auto) for no reason but I don't know if that affects the value category of the type.
  • There will be a potential compiler error for GCC 11 maybe.

Demo: https://gcc.godbolt.org/z/hz94faWad

  • Another problem is that, the code will be compiled fine in GCC 10.3, but not in GCC 11 higher.
\$\endgroup\$

1 Answer 1

6
+200
\$\begingroup\$

I see two main issues with the design.

  1. That you need to use std::ref to operate on the tuples themselves and not their copies. This isn't good. It is used this way in thread and similar cases but there it creates an object and knowledge whether a copy is needed to be a kept or a reference is extremely important. Here you invoke it immediately - so why copy at all?

I remember implementing similar code and it was a straight forward recursion - it was called visit.

namespace details
{
    template<size_t puIndex=0, typename PTuple, typename... PTuples, typename PFunc, 
                std::enable_if_t<puIndex == std::tuple_size_v<std::remove_reference_t<std::remove_const_t<PTuple>>>,int> = 0>
    void visit(PFunc&& func, PTuple&& tuple, PTuples&&... tuples)
    {}

    template<size_t puIndex=0, typename PTuple, typename... PTuples, typename PFunc, 
                std::enable_if_t<puIndex != std::tuple_size_v<std::remove_reference_t<std::remove_const_t<PTuple>>>,int> = 0>
    void visit(PFunc&& func, PTuple&& tuple, PTuples&&... tuples)
    {
        func(std::get<puIndex>(tuple), std::get<puIndex>(tuples)...);
        visit<puIndex+1>(std::forward<PFunc>(func), std::forward<PTuple>(tuple), std::forward<PTuples>(tuples)...);
    }
}

template<typename PFunc, typename PTuple, typename... PTuples>
void visit(PFunc&& func, PTuple&& tuple, PTuples&&... tuples)
{
    details::visit(std::forward<PFunc>(func), std::forward<PTuple>(tuple), std::forward<PTuples>(tuples)...);
}

I didn't bother making it safe for various sizes nor I needed to deal with return types... which brings me to the second point:

  1. I don't think this is a good idea to restrict the application to the minimal size of the tuples.

Imagine user wanted to simply work with tuples of the same size and by mistake put a tuple with smaller size. It will cause runtime errors that he will have hard time finding with all the template complexity.

Instead, the default apply_each should require all tuples to be of the same size and have a special version of apply_each for the case when the tuples are of varied sizes.

Besides, even in one of cases you've shown some would argue that it should've called the function for the maximal size of the tuples.


About return types... do you really want to deal with that? What if the tuples are in fact arrays and you return a tuple while user wanted an array as output? What to do when some calls return void? It's a mess.

User should just pass an additional tuple to the call as an output parameter and fill it. I don't think overcomplicating the function is worthwhile if the work around is better and quite straightforward.

But if you really need it I'd recommend you write function with a slightly different name to avoid any possible ambiguity. When I think about it - it isn't much more complicated to implement than the visit above... here is an example:

namespace details
{
    template<size_t I, typename Func, typename Tuple, typename... Tuples>
    auto call_i(Func&& f, Tuple&& tpl, Tuples&&... tuples)
    {
       return f(std::get<I>(tpl), std::get<I>(tuples)...);
    }
    
    template<typename Func, typename Tuple, typename... Tuples, size_t... I>
    auto apply_each_impl(std::index_sequence<I...>, Func&& f, Tuple&& tpl, Tuples&&... tuples)
    {
       return std::make_tuple(call_i<I>(std::forward<Func>(f), std::forward<Tuple>(tpl), std::forward<Tuples>(tuples)...)...);
    }

}

template<typename Func, typename Tuple, typename... Tuples>
auto apply_each(Func&& f, Tuple&& tpl, Tuples&&... tuples)
{
    return details::apply_each_impl(std::make_index_sequence<std::tuple_size_v<std::remove_reference_t<std::remove_const_t<Tuple>>>>{}, std::forward<Func>(f), std::forward<Tuple>(tpl), std::forward<Tuples>(tuples)...);
}

But this doesn't work when some of the return types are void - as one cannot create tuple with void template arguments. As @G.Sliepen noticed the call order is not properly defined per C++ standard and GCC calls them in reverse order.

So I believe the only reasonable way to get output without randomized call-order is to literally apply the work-around I proposed earlier but do so automatically. First deduce the output arguments and create the tuple, then apply the visit with lambda function that calls f and sets the output values into the output tuple and then return the output tuple. Something like this:

template<typename Func, typename Tuple, typename... Tuples>
auto apply_each_ord(Func&& f, Tuple&& tpl, Tuples&&... tuples)
{
    using outputTuple = decltype(apply_each(std::forward<Func>(f), std::forward<Tuple>(tpl), std::forward<Tuples>(tuples)...));
    
    outputTuple out;
    
    visit([&f](auto& outV, auto&&... args)
    {
        outV = f(args...);
    }, out, std::forward<Tuple>(tpl), std::forward<Tuples>(tuples)...);
    
    return out;
}
\$\endgroup\$
5
  • \$\begingroup\$ But how would you implement tuple_zip using recursion? \$\endgroup\$
    – G. Sliepen
    Sep 11, 2021 at 10:27
  • 1
    \$\begingroup\$ @G.Sliepen I wouldn't be implementing tuple_zip at all - it is just a helper to apply_each and not part of the main functionality. If you refer to implementation of the case when variables are returned - I wouldn't implement it with recursion. I have added example of an implementation... \$\endgroup\$
    – ALX23z
    Sep 11, 2021 at 11:02
  • 1
    \$\begingroup\$ Looks good, although it seems to call the function in reverse order on the tuple elements when using GCC (see godbolt.org/z/Wx1Kcn77r). \$\endgroup\$
    – G. Sliepen
    Sep 11, 2021 at 12:06
  • \$\begingroup\$ @G.Sliepen oh... that's not good. I don't see a decent way to deal with this lack of control - I begin to understand why for... was not accepted into C++ standard. Then I believe the only reasonable way to get output without randomized call-order is to literally apply the work-around I proposed earlier but do so automatically. First deduce the output arguments and create the tuple, then apply the visit with lambda function that calls f and sets the output values into the output tuple and then return the output tuple. \$\endgroup\$
    – ALX23z
    Sep 11, 2021 at 12:44
  • 1
    \$\begingroup\$ @G.Sliepen added apply_each_ord implementation that uses apply_each for type deduction and visit for the call. \$\endgroup\$
    – ALX23z
    Sep 11, 2021 at 13:07

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.