Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

SFINAE - Falling back on default function if more sophisticated one fails

Say I have written a generic function called interpolate. Its signature is like so:

template<typename T>
T interpolate(T a, T b, float c);

Where a and b are the values to interpolate between, and c is a float in [0.0,1.0].

Iff T has T operator*(float) and T operator+(T) defined, I would like this to behave a certain way (linear interpolation). Otherwise, it would behave differently - in such a way that any T is useable(nearest neighbor interpolation).

How can I achieve this behavior?

For example:

interpolate<std::string>("hello","world!", 0.798); //uses nearest neighbor, as std::string does not have the necessary operators

interpolate<double>(42.0,128.0, 0.5);              //uses linear, as double has the needed operators

Note: this question is not on the implementation of these interpolation methods, just how to use templates to switch behavior of a function.

like image 602
Fuzzyzilla Avatar asked May 19 '18 22:05

Fuzzyzilla


2 Answers

This sounds like a prime use case for tag dispatching:

We create two different tag classes to distinguish between the two use cases

struct linear_tag {};
struct nn_tag {};

template <typename T>
T impl(T a, T b, float c, linear_tag) {
    // linear interpolation here
}

template <typename T>
T impl(T a, T b, float c, nn_tag) {
    // nearest neighbor interpolation here
}

Now, we need to find out the tag type from T:

template <typename T>
linear_tag tag_for(
    T* p,
    std::enable_if_t<std::is_same_v<T, decltype((*p + *p) * 0.5)>>* = nullptr
);
nn_tag tag_for(...); // Fallback

The first overload only exists if, for any T t, the expression (t + t) * 0.5f returns another T.1 The second overload always exists, but because of the C-style variadic argument, it is never used unless the first overload doesn't match.

Then, we can dispatch to either version by creating the appropriate tag:

template <typename T>
T interpolate(T a, T b, float c) {
    return impl(a, b, c, decltype(tag_for(static_cast<T*>(nullptr))){});
}

Here, decltype(tag_for(static_cast<T*>(nullptr))) gives us the right tag type (as the return type of the correct overload of tag_for).

You can add additional tag types with very little overhead, and test for arbitrarily complex conditions in the enable_if_t. This particular version is C++17 only (because of is_same_v), but you can just as easily make it C++11-compatible by using typename std::enable_if<...>::type and std::is_same<...>::value instead - it's just a bit more verbose.

1 This is what you specified in the question - but it is dangerous! If you use integers, for example, you will use nearest-neighbor interpolation because * returns float, not int. You should instead test if the expression (*t + *t) * 0.5f returns something that is convertible back to T using a test such as std::is_constructible_v<T, decltype((*t + *t) * 0.5f)>


As a bonus, here is a c++20 concepts-based implementation that doesn't need tags anymore (as briefly mentioned in the comments). Unfortunately, there is no compiler that supports requires on this level yet, and of course the draft standard is always subject to change:

template <typename T>
concept LinearInterpolatable = requires(T a, T b, float c) {
    { a + b } -> T;
    { a * c } -> T;
};

template <LinearInterpolatable T>
T interpolate(T a, T b, float c)
{
    // Linear interpolation
}

template <typename T>
T interpolate(T a, T b, float c)
{
    // Nearest-neighbor interpolation
}
like image 97
hlt Avatar answered Sep 20 '22 00:09

hlt


It's possible to provide an order of preference for overloaded functions. If the number of overloads is small, you can just use:

using prefer_overload_t = int;
using backup_overload_t = long;

template <typename T>
auto interpolate_impl(T a, T b, float c, prefer_overload_t)
    -> std::enable_if_t<
           std::is_same_v<T, decltype(a * c)>
           && std::is_same_v<T, decltype(a + b)>,
           T
       >
{
    // linear interpolation
}

template <typename T>
T interpolate_impl(T a, T b, float c, backup_overload_t)
{
    // nearest neighbor
}

template<typename T>
T interpolate(T a, T b, float c)
{
    return interpolate_impl(std::move(a), std::move(b), c, prefer_overload_t());
}

Since it requires no conversion to go from int to int, the former overload is preferred, but is SFINAE'd out when it doesn't work.


If you want to order an arbitrary number of overloads, you'd have to use some special type like this:

template <std::size_t N>
struct rank : rank<N - 1>
{};

template <>
struct rank<0>
{};

Then, rank<N> would be preferred over rank<N - 1>.

like image 36
Justin Avatar answered Sep 21 '22 00:09

Justin