Использование концепций для разрешения перегрузки функций (вместо SFINAE)

Пытаюсь попрощаться со СФИНАЕ.

Можно ли использовать concepts для различения функций, чтобы компилятор мог сопоставить правильную функцию в зависимости от того, соответствует ли отправленный параметр concept ограничениям?

Например, перегрузка этих двух:

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

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

Поэтому при вызове компилятор будет соответствовать правильной функции для каждого вызова:

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

Связанный вопрос: Заменит ли Concepts SFINAE?


person Amir Kirsh    schedule 26.02.2020    source источник
comment
@ Jarod42 Я никогда не уверен, пока языковой юрист не одобрит   -  person Amir Kirsh    schedule 26.02.2020
comment
@ Jarod42 en.cppreference.com/w/cpp/language/ ?   -  person L. F.    schedule 26.02.2020


Ответы (1)


Да concepts предназначены для этой цели. Если отправленный параметр не соответствует требуемому концептуальному аргументу, функция не будет учитываться в списке разрешения перегрузки, что позволит избежать двусмысленности.

Более того, если отправленный параметр соответствует нескольким функциям, будет выбрана более конкретная.

Простой пример:

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

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

Вышеупомянутые print функции представляют собой допустимую перегрузку, которая может существовать вместе.

  • Если мы отправим нецелой тип, он выберет первый
  • Если мы отправим цельный тип, он предпочтет второй

например, вызов функций:

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

Никаких двусмысленностей - эти две функции могут прекрасно работать вместе, бок о бок.

Нет необходимости в каком-либо коде SFINAE, например enable_if - он уже применен (очень красиво скрыт).


Выбор между двумя концепциями

В приведенном выше примере показано, как компилятор предпочитает ограниченный тип (std :: integers auto) неограниченному типу (просто auto). Но правила также применимы к двум конкурирующим концепциям. Компилятор должен выбрать более конкретный, если он более конкретный. Конечно, если обе концепции соблюдены и ни одна из них не является более конкретной, это приведет к двусмысленности.

Что же делает концепцию более конкретной? если он основан на другом 1.

Общая концепция - GenericTwople:

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

Более конкретная концепция - Двое:

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

Обратите внимание, что Twople требуется для соответствия требованиям GenericTwople, поэтому он более конкретен.

Если вы замените в нашем Twople строку:

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

с фактическими требованиями, предъявляемыми этой строкой, Twople по-прежнему будет иметь те же требования, но больше не будет более конкретным, чем GenericTwople. Это, наряду с повторным использованием кода, конечно, поэтому мы предпочитаем определять Twople на основе GenericTwople.


Теперь мы можем играть со всевозможными перегрузками:

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;
}

И назовите это с помощью:

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)

Мы можем пойти дальше, поскольку представленная выше концепция Twople работает также с полиморфизмом:

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";
    }
};

добавьте следующую перегрузку:

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

и вызовите его (пока все другие перегрузки все еще присутствуют) с помощью:

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

Код: https://godbolt.org/z/3-O1Gz


К сожалению, C ++ 20 не допускает концептуальную специализацию, иначе мы пошли бы еще дальше:

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

Что могло бы добавить хороший возможный ответ на этот SO вопрос, однако специализация понятий не допускается.


1 Фактические правила для частичного упорядочивания ограничений более сложны, см .: cppreference / Спецификация C ++ 20.

person Amir Kirsh    schedule 26.02.2020
comment
Отличный ответ. Я пытаюсь понять, как сделать аналогичную специализацию с классами, а не функциями: C<T> получает одну реализацию, если T с плавающей запятой, другую, если T является целым, и ошибку компилятора, если T - что-то еще. Если возможно, это не так просто, как все примеры, которые я вижу для функций. - person Adrian McCarthy; 20.06.2021
comment
@AdrianMcCarthy Я нашел хороший пост SO по этому поводу: - ) - person Amir Kirsh; 21.06.2021