Using concepts for function overload resolution (instead of SFINAE)
Asked Answered
J

1

6

Trying to say goodbye to SFINAE.

Is it possible to use concepts to distinguish between functions, so the compiler can match the correct function based on whether or not a sent parameter meets concept constraints?

For example, overloading these two:

// (a)
void doSomething(auto t) { /* */ }

// (b)
void doSomething(ConceptA auto t) { /* */ }

So when called the compiler would match the correct function per each call:

doSomething(param_doesnt_adhere_to_ConceptA); // calls (a)
doSomething(param_adheres_to_ConceptA); // calls (b)

Related question: Will Concepts replace SFINAE?

Joly answered 26/2, 2020 at 12:50 Comment(1)
@Jarod42 en.cppreference.com/w/cpp/language/…?Portingale
J
15

Yes concepts are designed for this purpose. If a sent parameter doesn't meet the required concept argument the function would not be considered in the overload resolution list, thus avoiding ambiguity.

Moreover, if a sent parameter meets several functions, the more specific one would be selected.

Simple example:

void print(auto t) {
    std::cout << t << std::endl;
}

void print(std::integral auto i) {
    std::cout << "integral: " << i << std::endl;
}

Above print functions are a valid overloading that can live together.

  • If we send a non integral type it will pick the first
  • If we send an integral type it will prefer the second

e.g., calling the functions:

print("hello"); // calls print(auto)
print(7);       // calls print(std::integral auto)

No ambiguity -- the two functions can perfectly live together, side-by-side.

No need for any SFINAE code, such as enable_if -- it is applied already (hidden very nicely).


Picking between two concepts

The example above presents how the compiler prefers constrained type (std::integral auto) over an unconstrained type (just auto). But the rules also apply to two competing concepts. The compiler should pick the more specific one, if one is more specific. Of course if both concepts are met and none of them is more specific this will result with ambiguity.

Well, what makes a concept be more specific? if it is based on the other one1.

The generic concept - GenericTwople:

template<class P>
concept GenericTwople = requires(P p) {
    requires std::tuple_size<P>::value == 2;
    std::get<0>(p);
    std::get<1>(p);
};

The more specific concept - Twople:

class Any;

template<class Me, class TestAgainst>
concept type_matches =
    std::same_as<TestAgainst, Any> ||
    std::same_as<Me, TestAgainst>  ||
    std::derived_from<Me, TestAgainst>;

template<class P, class First, class Second>
concept Twople =
    GenericTwople<P> && // <= note this line
    type_matches<std::tuple_element_t<0, P>, First> &&
    type_matches<std::tuple_element_t<1, P>, Second>;

Note that Twople is required to meet GenericTwople requirements, thus it is more specific.

If you replace in our Twople the line:

    GenericTwople<P> && // <= note this line

with the actual requirements that this line brings, Twople would still have the same requirements but it will no longer be more specific than GenericTwople. This, along with code reuse of course, is why we prefer to define Twople based on GenericTwople.


Now we can play with all sort of overloads:

void print(auto t) {
    cout << t << endl;
}

void print(const GenericTwople auto& p) {
    cout << "GenericTwople: " << std::get<0>(p) << ", " << std::get<1>(p) << endl;
}

void print(const Twople<int, int> auto& p) {
    cout << "{int, int}: " << std::get<0>(p) << ", " << std::get<1>(p) << endl;
}

And call it with:

print(std::tuple{1, 2});        // goes to print(Twople<int, int>)
print(std::tuple{1, "two"});    // goes to print(GenericTwople)
print(std::pair{"three", 4});   // goes to print(GenericTwople)
print(std::array{5, 6});        // goes to print(Twople<int, int>)
print("hello");                 // goes to print(auto)

We can go further, as the Twople concept presented above works also with polymorphism:

struct A{
    virtual ~A() = default;
    virtual std::ostream& print(std::ostream& out = std::cout) const {
        return out << "A";
    }
    friend std::ostream& operator<<(std::ostream& out, const A& a) {
        return a.print(out);
    }
};

struct B: A{
    std::ostream& print(std::ostream& out = std::cout) const override {
        return out << "B";
    }
};

add the following overload:

void print(const Twople<A, A> auto& p) {
    cout << "{A, A}: " << std::get<0>(p) << ", " << std::get<1>(p) << endl;
}

and call it (while all the other overloads are still present) with:

    print(std::pair{B{}, A{}}); // calls the specific print(Twople<A, A>)

Code: https://godbolt.org/z/3-O1Gz


Unfortunately C++20 doesn't allow concept specialization, otherwise we would go even further, with:

template<class P>
concept Twople<P, Any, Any> = GenericTwople<P>;

Which could add a nice possible answer to this SO question, however concept specialization is not allowed.


1 The actual rules for Partial Ordering of Constraints are more complicated, see: cppreference / C++20 spec.

Joly answered 26/2, 2020 at 12:50 Comment(2)
Awesome answer. I'm trying to figure out how to do the analogous specialization with classes rather than functions: C<T> gets one implementation if T is floating point, another if T is integral, and a compiler error if T is anything else. If it's possible, it's not as straightforward as all the examples I see for functions.Laurielaurier
@AdrianMcCarthy I've found a nice SO post on that :-)Joly

© 2022 - 2024 — McMap. All rights reserved.