Jonathan Boccara's blog

How to Define A Variadic Number of Arguments of the Same Type – Part 4

Published January 7, 2020 - 0 Comments

This is a guest post by Tobias Loew. Tobias is programming now for over 35 years and started with C++ over 20 years ago. Got a PhD in maths and work for steag developing thermodynamic simulation tools (EBSILON). He likes to spend his spare time with his wife and their bunnies and writing code like hop.

In January and February 2019 the series “How to Define A Variadic Number of Arguments of the Same Type” Part 1 – 3 was published on Fluent C++. Those posts showed different attempts to define C++ functions with a variadic number of arguments that are all of the same type. In the third part of the series the following solution for a function foo accepting an arbitrary number of ints was presented:

template<typename... Ts>
using AllInts = typename std::conjunction<std::is_convertible<Ts, int>...>::type;

template<typename... Ts, typename = std::enable_if_t<AllInts<Ts...>::value, void>>
void foo(Ts&& ... ts) {} // (A)

We can call it with integers and types that implicitly convert to int:

foo(1); // (1)
foo(1,2,3); // (2)
foo(0.5f, -2.4f); // (3)
foo(1.5f, 3); // (4)

Let’s create another variadic homogeneous overload of foo, this time for floats:

template<typename... Ts>
using AllFloats = typename std::conjunction<std::is_convertible<Ts, float>...>::type;

template<typename... Ts, typename = std::enable_if_t<AllFloats<Ts...>::value, void>>
void foo(Ts&& ... ts) {} // (B)

Now, let’s assume both overloads of foo are visible to the compiler: which overload will the compiler select for calls (1) – (4)?

My naive expectation was:

  • (1) and (2) call (A)
  • (3) calls (B)
  • (4) is ambiguous and won’t compile

but, surprisingly (at least for myself), all of them are ambiguous!

Taking a closer look at (A) and (B), we see that both accept a parameter-pack of forwarding-references (Ts&&... ts), so they’re both (equivalent) perfect matches.

The SFINAE condition is just for deciding whether the overload is viable, but since int and float implicitly convert to each other, (A) and (B) are both viable for (1) – (4) and equivalent in overload resolution, thus ambiguous.

Thus, we can’t simply overload two homogeneous variadic functions that use forwarding-references, somehow their SFINAE conditions have to know of each other.

We’re close to C++20 where we get concepts, but unfortunately they won’t help here: According to eel.is/c++draft/over.match.best#2.6, we would need a partial ordering on the constraints for (A) and (B). This may be feasible for two different types but would introduce an unnatural asymmetry between (A) and (B). For three or more different types, the situation quickly would get very messy. Anyway, concepts aren’t available yet, so we won’t follow this path.

Merging variadic homogeneous overloads

Two or more homogeneous overloads in the way presented above with implicitly convertible types are always ambiguous. So, the only way around is combining the two or more SFINAE conditions to just one condition and using just a single function.

What we need is a SFINAE condition that tests for given types T1, ..., Tn whether there exists a best viable overload among all the respective variadic homogeneous function overloads. If we don’t want to limit to a certain finite number of arguments (and we don’t !) those overload sets grow infinitely large.

The way around this dilemma is to create the set of test-functions for each call on demand: then the number of arguments is known a-priori and for each type T1, ..., Tn we only need to create a single test-function with the same arity as the call.

The following small library provides such a feature:

// requires C++17 and Boost.Mp11
namespace impl {
    using namespace boost::mp11;

    // (3)
    template <size_t _Idx, class _Ty>
    struct _single_overload;

    template <size_t _Idx, class... _Tys>
    struct _single_overload<_Idx, mp_list<_Tys...>> {
        constexpr std::integral_constant<size_t, _Idx> test(_Tys...) const;
    };

    // (2)
    template <size_t _arg_count, class _Indices, class... _Types>
    struct _overload_set;

    template <size_t _arg_count, size_t... _Indices, class... _Types>
    struct _overload_set<_arg_count, std::index_sequence<_Indices...>, _Types...>
        : _single_overload<_Indices, mp_repeat_c<mp_list<_Types>, _arg_count>>... {
        using _single_overload<_Indices, mp_repeat_c<mp_list<_Types>, _arg_count>>::test...; // (2.1)
    };

    template <class _OverloadList, size_t _arg_count>
    struct overload_set;

    template <class... _OverloadList, size_t _arg_count>
    struct overload_set<mp_list<_OverloadList...>, _arg_count>
        : impl::_overload_set<_arg_count, std::index_sequence_for<_OverloadList...>, _OverloadList...> {
        using impl::_overload_set<_arg_count, std::index_sequence_for<_OverloadList...>, _OverloadList...>::test;
    };
}
// (1)
template<class _OverloadList, typename... _Tys>
constexpr decltype(impl::overload_set<_OverloadList, sizeof...(_Tys)>{}.test(std::declval<_Tys>()...)) enable();

Before we analyse the code, let’s create homogeneous variadic overloads for int and float and re-check examples (1)-(4)

// create homogeneous variadic overloads int and float
using overloads_t = boost::mp11::mp_list<
    int,
    Float
>;

template<typename... Ts, decltype((enable<overloads_t, Ts...>()), 0) = 0 >
void foo(Ts&& ... ts) {
    using OL = decltype(enable<overloads_t, Ts...>());
    if constexpr (OL::value == 0) {
        // (A), homogenuous parameter-sets based on first type called
        std::cout << "overload: (int, ...)" << std::endl;
    } else if constexpr (OL::value == 1) {
        // (B), homogenuous parameter-sets based on second type called
        std::cout << "overload: (float, ...)" << std::endl;
    }
}

void test() {
    foo(1); // invokes code in branch (A)
    foo(1, 2, 3); // invokes code in branch (A)
    foo(0.5f, -2.4f); // invokes code in branch (B)
    //foo(1.5f, 3); // error ambiguous
}

As we can see, the overloads get selected as if we had declared appropriate homogeneous overloads for int and float.

Analysing the library

Now, let’s take a closer look at the library:

First of all, it requires C++17 and uses Boost.Mp11 for template meta programming: mp_list is the basic list-container for type and mp_repeat_c<mp_list<T>, n> is an alias for mp_list<T,...,T /* n-times */>. If you want to learn more, please visit the Mp11 webpage.

To define a function foo that uses enable (1), we have to define a type-list overloads_t containing the different types for the homogeneous overloads. That list and the actual types are then used to invoke enable, and we use its return type as SFINAE condition for foo. Furthermore, if a call to foo has a best viable overload among its test functions then enable will return the zero-based index of the selected type as std::integral_constant.

In (2) each type T from overloads_t is expanded to an mp_list<T,...,T> with the arity of the call. Here, we also use a C++17 feature: pack-expansion with a using-declaration.

At the core of the library (3) is struct _single_overload which is instantiated for each expanded type-list from (2) and declares a function test with the requested amount of arguments of type specified in mp_list<_Tys...>.

Putting it all together: if overloads_t consists of T1,…,Tn and foo is invoked with m arguments then the template instantiation of overload_set has the following test-declarations:

constexpr std::integral_constant<size_t, 0> test(T1, ..., T1) const;
                                                \ m-times /
...
constexpr std::integral_constant<size_t, 0> test(Tn, ..., Tn) const;
                                                \ m-times /

and in the SFINAE-condition of foo we use C++ overload resolution to check if there is a best viable overload. (This technique to create tests for selecting overloads can also be found in STL-implementations, where it is used to generate the converting constructors in std::variant for the type-alternatives.)

Finally, when implementing the body of foo the return-type of enable comes in handy: with if constexpr (OL::value == index-of-type ) we can separate the implementations for the different types, thus for an invocation only the code matching the correct index will be compiled.

Hop – defining homogeneous overload-sets and more

Using the ideas presented above the hop-library provides a toolbox to create all kinds of overloads. A hop-overload-set is a list of overload-definitions, where each one consist of a list of containing an arbitrary combination of

  • arbitrary C++ types T
  • repeat<T, min, max=unbounded>, pack<T>, non_empty_pack<T>, optional<T> for repetitions of T
  • templates for defining types with default values, forwarding references with or without additional SFINAE condition and even template argument deduction

An overload can also be created by adapting an function definition or a whole function overload set.

Here is an example using hop that defines a function accepting a std::string, followed by one or more doubles and an optional struct options_t at the end:

struct options_t{...};

struct init_options {
    options_t operator()() const { return options_t{...}; }
};

using overloads_t = hop::ol_list<
    hop::ol<
        std::string,
        hop::non_empty_pack<double>,
        hop::cpp_defaulted_param<options_t, init_options>
    >
>;

template<typename... Ts, decltype((hop::enable<overloads_t, Ts...>()), 0) = 0>
void foo(Ts&& ... ts) {....}

// valid invocations of foo
foo("test", 42);
foo(std::string{}, 1.1, 2.2, 3.3);
foo("test", 1.1, options_t{...});

If you want to learn more about hop, please visit my Github repo.

Summary

The aim of this post was to present a technique for creating overload sets of functions with a variadic number of arguments of the same type. Starting from a solution presented in part 3 of this series we concluded that even so it is not possible to overload on those functions, an observably equivalent solution can be achieved by using just a single function with an appropriate SFINAE condition.

Those ideas were elaborated in a small library which allows for defining the equivalent of an overload-sets of homogeneous variadic functions. Those functions behave in overload resolution as-if for every specified type the homogenous overloads for every arity was declared.

Finally, the library hop, which is based on the ideas presented above, is shortly introduced: it extends those ideas and provides a framework for defining complex overload sets.

You will also like

Don't want to miss out ? Follow:   twitterlinkedinrss
Share this post!Facebooktwitterlinkedin