Implementing a concept for Pair with C++20 concepts

Viewed 636

To print any kind of std::pair we can implement the following method:

template<typename First, typename Second>
void printPair(const std::pair<First, Second>& p) {
    std::cout << p.first << ", " << p.second << std::endl;
}

But suppose we want to implement a method that can print a pair of any kind, not necessarily std::pair, based on the following requirements:

  • it has a first and second public fields
  • it has a first_type and second_type public inner types
  • type of first == first_type
  • type of second == second_type

Having a concept, let's call it Pair, could allow writing a method like:

void printPair(const Pair auto& p) {
    std::cout << p.first << ", " << p.second << std::endl;
}

How would such a concept be defined?

4 Answers

There are some interesting subtleties here.

template<class P>
concept Pair = requires(P p) {
    typename P::first_type;
    typename P::second_type;
    p.first;
    p.second;
    requires std::same_as<decltype(p.first), typename P::first_type>;
    requires std::same_as<decltype(p.second), typename P::second_type>;
};

The first four lines are somewhat redundant, but can help produce better error messages. The remaining lines should be self-explanatory. Note that the use of decltype on a plain class member access produces the declared type of the data member.

The last two lines could also be written as

    { p.first } -> std::same_as<typename P::first_type&>;
    { p.second } -> std::same_as<typename P::second_type&>;

Here, the compound-requirement applies the type-constraint to decltype((p.first)). That expression is an lvalue, so the produced type is an lvalue reference type. Note that this version would accept both first_type first; and first_type& first;.

Following @Nicol Bolas comment on the original question I agree that narrowing the concept to allow only Pair that conforms to std::pair requirements is not the best design, it would be better to allow all the following:

  • std::pair and similar classes with first and second fields
  • std::tuple of size 2, std::array of size 2 and similar classes

It is true that std::pair falls in both categories, as it proposes tuple-like syntax, however we wish to be able to accommodate user types that expose first and second fields but do not implement a tuple-like syntax.

For that we can implement two separate concepts and then use conjunction to create a third one:


1. SimplePair concept

template<class P>
concept SimplePair = requires(P p) {
    p.first;
    p.second;
};

2. TuplePair concept

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

^ above supports also std::array


3. Pair concept

template<class P>
concept Pair = TuplePair<P> || SimplePair<P>;

Now we can have a generic printPair, using requires clause inside if constexpr:

void printPair(const Pair auto& p) {
    if constexpr( SimplePair<decltype(p)> ) {
        std::cout << p.first << ", " << p.second << std::endl;
    }
    else {
        std::cout << std::get<0>(p) << ", " << std::get<1>(p) << std::endl;
    }
}

usage example

struct MyPair {
    int first = 5;
    const char* second = "six";
};

int main() {
    printPair(std::make_tuple(1, "two")); // 1, two
    printPair(std::make_pair(3, 4));      // 3, 4
    printPair(MyPair{});                  // 5, six
    printPair(std::array{7, 8});          // 7, 8
    // not allowed, compilation error:
    // printPair(std::array{9, 10, 11});
    // printPair(std::make_tuple("one"));
    // printPair(std::make_tuple(1, 2, 3));
}

Code: https://godbolt.org/z/MXgqu3

Old Syntax - for Historical Purposes

The code below was valid at a certain point of time with early versions of Concepts Technical Specification, and compiles with experimental implementation, but was changed in later versions of the TS and is no longer valid with the C++20 spec. It is kept here for historical reasons and as a note on the change in the spec.


Old version of Concepts TS had the following syntax:

template<typename _pair>
concept Pair = requires(_pair p) {
    { p.first } -> typename _pair::first_type;
    { p.second } -> typename _pair::second_type;
};

Above syntax is not valid in C++20. For the valid C++20 syntax see the other answers for this question.


This would allow a generic printPair to work for std::pair as well as for any other user "pair" that conforms with Pair requirements:

void printPair(const Pair auto& p) {
    std::cout << p.first << ", " << p.second << std::endl;
}

struct UserPair {
    int first = 1;
    const char* second = "hello";
    using first_type = decltype(first);
    using second_type = decltype(second);
};

int main() {
    printPair(std::make_pair(1, 3));
    printPair(UserPair{});
}

A working code example with old version of the TS: https://godbolt.org/z/x6f76D

I really liked this question and the discussions around it, specially the solution from T.C (I don't have 50 points to comment there, so I'll post comments as another solution). I just passed from a similar situation, where a pair concept is need, but also needed the library to work on both C++17 and C++20.

This solution from T.C. works for both c++17 and c++20.

template<class P>
concept bool Pair = requires(P p) {
    typename P::first_type;
    typename P::second_type;
    p.first;
    p.second;
    requires my_same_as<decltype(p.first), typename P::first_type>;
    requires my_same_as<decltype(p.second), typename P::second_type>;
};

where my_same_as is defined as std::same_as from c++20:

template<class Me, class Other>
  concept bool my_same_as = std::is_same_v<Me, Other> && std::is_same_v<Other, Me>;

I've tried several "implementations of pairs" and interesting point is that fields first and second may vary from reference or not-reference types.

T.C. mentioned that we could replace fields with:

    { p.first } -> my_same_as<typename P::first_type&>;
    { p.second } -> my_same_as<typename P::second_type&>;

I found out that this only works on c++20, strangely not on c++17 (it compiles fine, but doesn't match concept!). Somehow it doesn't match either reference or non-reference (requiring complex implementation with an || and std::remove_reference_t).

One portable solution that I found for both c++17 and c++20 was:

template<typename P>
concept bool Pair = requires(P p)
{   
    typename P::first_type;
    typename P::second_type;
    { p.first } -> my_convertible_to<typename P::first_type>;
    { p.second } -> my_convertible_to<typename P::second_type>;
};

where my_convertible_to is equivalent to std::convertible_to from c++20:

template <class From, class To>
concept bool my_convertible_to =
  std::is_convertible_v<From, To> &&
  requires(std::add_rvalue_reference_t<From> (&f)()) {
    static_cast<To>(f());
  };

I cannot explain why this subtle behavior changed from c++17 to c++20 (on is_same_v logic), but I'm posting here as it may help others in a similar situation. I used g++-8 for c++17 and g++-10.1 for c++20. Thanks for all the learning!

Related