`enable_if` проти концептів C++20

enable_if та концепти C++20 — це два способи обмеження шаблонів для певних типів. Давайте порівняємо ці два методи.

Розпочнемо з простого програмного коду, який виводить різні числа. Щоб уникнути втрати точності та витрат на перетворення між числовими типами, ми використовуємо шаблони для цієї функції.

#include <iostream>

template <typename T> 
void print(T a){  
    std::cout << "Це число: " << a << std::endl;  
}  

int main(){  
    const int a = 2;  
    const long b = 3;  
    const float c = 4;  
    const double d = 5;  

    print(a);  
    print(b);  
    print(c);  
    print(d);  
}

Компіляція за допомогою g++ main.cpp і виконання дає результат:

Це число: 2  
Це число: 3  
Це число: 4  
Це число: 5

Якщо ми хочемо змінити поведінку для цілих та з плаваючою точкою чисел, ми повинні обмежити шаблони для цих типів.
До C++20 ми використовували enable_if таким чином.

template <  
    typename T,  
    std::enable_if_t<std::is_integral<T>::value, bool> dummyparam = true // Можна опустити ім’я dummyparam  
> void print(T a){  
    std::cout << "Це ціле число: " << a << std::endl;  
}  

template <  
    typename T,  
    std::enable_if_t<std::is_floating_point<T>::value, bool> dummyparam = true // Можна опустити ім’я dummyparam  
> void print(T a){  
    std::cout << "Це число з плаваючою точкою: " << a << std::endl;  
}

Компіляція та повторний запуск дають наступний результат.

Це ціле число: 2  
Це ціле число: 3  
Це число з плаваючою точкою: 4  
Це число з плаваючою точкою: 5

Як C++ визначає, який шаблон використовувати для кожного типу? Магія криється в другому шаблонному параметрі.
Для першого шаблону перший шаблонний параметр — це наш знайомий T, але далі ми маємо ось таку штуку.

std::enable_if_t<std::is_integral<T>::value, bool> dummyparam = true

Давайте розглянемо процес підстановки для int або float. Для int шаблон std::is_integral (див. https://en.cppreference.com/w/cpp/types/is_integral) створює структуру з членом static const bool на ім'я value, який дорівнює true (для float він буде false). is_integral_v дає нам std::is_integral<T>::value, тому це спрощується до

std::enable_if_t<std::is_integral<T>::value, bool> dummyparam = true // для int  
std::enable_if_t<std::is_integral<T>::value, bool> dummyparam = true // для float

Далі розглянемо, що робить enable_if (див. https://en.cppreference.com/w/cpp/types/enable_if). Він створює структуру на основі двох шаблонних параметрів. Перший — це bool, а другий — тип.
Якщо перший аргумент дорівнює true, то створюється структура з обмеженим typedef під ім'ям type, який встановлений у другий шаблонний параметр. Отже, при підстановці для int std::enable_if<T>::type буде bool. _t дає нам те ж саме, що й ::type. Тому вираз спрощується до

bool dummyparam = true

dummyparam — це необов'язковий другий шаблонний параметр зі значенням за замовчуванням, яке дорівнює true. Але зверніть увагу, що dummyparam насправді не використовується в самій дефініції шаблону. В більшості випадків у коді, що використовує enable_if, ім'я цього параметра взагалі опускається. Крім того, його тип навіть не має значення. Головне, щоб він відповідав значенню за замовчуванням, що також не важливо. Ми могли б так само легко оголосити

std::enable_if_t<char> = 'a'

і це працювало б так само. Шаблон має просто розв'язатися, коли підставляється int.

Щоб зрозуміти, чому цей невикористовуваний шаблонний параметр корисний, розглянемо випадок з float.
std::enable_if створює порожню структуру без жодного typedef. Тому компілятор не може розв'язати ::type, і підстановка шаблону не вдалася, оскільки ми не знаємо тип dummyparam. Це викликає відому проблему SFINE (substitution failure is not an error — невдача підстановки не є помилкою). Коли компілятор намагається й не може підставити тип у шаблон, це не призводить до помилки компіляції. Замість цього компілятор намагається знайти іншу дефініцію шаблону, яка підходить.
У цьому випадку float не пройде інстанціацію шаблону для першого шаблону, але успішно підійде для версії з числовими типами з плаваючою точкою.

enable_if може бути складним, але ми можемо досягти того ж результату за допомогою концептів.

template <typename T>  
requires std::is_integral_v<T>  
void print(T a){  
 std::cout << "Це ціле число: " << a << std::endl;  
}  

template <typename T>  
requires std::is_floating_point_v<T>  
void print(T a){  
 std::cout << "Це число з плаваючою точкою: " << a << std::endl;  
}

Компілюючи за допомогою g++ -std=c++20 -fconcepts main.cpp (переконайтеся, що ваша версія компілятора достатньо нова) і повторно запустивши, отримаємо той самий результат, але з більш читабельним кодом.
Умови requires можуть використовувати більш складні багаторазові метафункції, визначені як concept (концепти).

 // концепт для цілісних типів  
template <typename T>  
concept integer_concept = std::is_integral_v<T>;  

template <typename T>  
requires integer_concept<T>  
void print(T a){  
 std::cout << "Це ціле число: " << a << std::endl;  
}  

// концепт для типів з плаваючою точкою  
template <typename T>  
concept float_concept = std::is_floating_point_v<T>;  

template <typename T>  
requires float_concept<T>  
void print(T a){  
 std::cout << "Це число з плаваючою точкою: " << a << std::endl;  
}

pic

У цьому випадку концепти є тривіальними обгортками для std::is_integral та std::is_floating_point, але загалом концепти дозволяють реалізовувати більш складну логіку.
Як бонус, якщо ви зробите помилку, повідомлення про помилки компілятора будуть значно кориснішими, ніж при використанні enable_if.

Перекладено з: enable_if versus c++20 Concepts

Leave a Reply

Your email address will not be published. Required fields are marked *