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

2

6

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.

Concierge answered 19/5, 2018 at 22:14 Comment(0)
D
6

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 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
}
Dharma answered 19/5, 2018 at 22:29 Comment(7)
Woah! That's a lot more complex than I would've expected it to be! Thank you so much for writing this out - I could learn a whole lot from what you've showed me here :)Concierge
It's an extremely powerful and flexible feature - the standard library uses it to distinguish between different types of iterators with different feature sets via the iterator_categoryDharma
If you're using C++17, you can also use if constexpr rather overloaded functions for implParabola
That is possible, but would require an additional template for the tag type, at which point I think it is cleaner to just let the compiler do the type equality checks. With C++20 we'll get concepts (maybe), which will hopefully allow us to do this without the entire dispatching part.Dharma
why do you use C-style cast in decltype(tag_for((T*) nullptr))?Henrik
@AndriyTylychko Bad old habit of mine. Replaced it with static_castDharma
@Parabola Unfortunately for me, I can't find a compiler with support for over c++11 :( I'm sure if I could, I could find a lot of more elegant ways to do things when it comes to templates!Concierge
P
3

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>.

Parabola answered 19/5, 2018 at 23:19 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.