C++ Metaprogramming: Using Concepts

Concepts in C++ are a feature introduced in C++20 that provide a way to specify constraints on template parameters. They allow you to define requirements or expectations for types used in templates, making your code more expressive and easier to debug by ensuring that templates are only instantiated with types that meet specific criteria.

Concepts make templates safer and more readable by allowing you to specify conditions that types must satisfy at compile time, rather than relying on obscure compiler errors if a type doesn’t meet the template’s requirements.

A concept is a predicate that defines a set of requirements (constraints) for a type or template parameter. You can define a concept using the requires clause, which specifies the operations and properties that a type must have. For example:

#include <iostream>
#include <type_traits>

// Define a concept that checks if a type is integral
template <typename T>
concept Integral = std::is_integral_v<T>;

The concept definition here checks to see if the template type T is an integral type (like int, char, etc.), using the std::is_integral type trait. Once it is defined we can use it two write a template function that only accepts integral types.

#include <iostream>
#include <iomanip>
#include <type_traits>

using namespace std;

// Define a concept for integral types
template <typename T>
concept Integral = std::is_integral_v<T>;

// A function template that only accepts integral types
template <Integral T>
T mult(T a, T b) {
    return a * b;
}

int main() {
    cout << mult(5, 10) << endl;          // Works: int is integral
    // cout << mult(3.14, 1.618) << endl; // Error: double is not integral
    return 0;
}

Here the add() function is constrained to only allow integral type for its template parameter by the Integral concept. If you try to pass non-integral values, like doubles, the compiler will not match the template and generate an error.

There is an alternate syntax that uses the requires keyword to constrain template parameters.

#include <iostream>
#include <iomanip>
#include <type_traits>

using namespace std;

// Define a concept for integral types
template <typename T>
concept Integral = std::is_integral_v<T>;

// A function template that only accepts integral types
template <typename T>
requires Integral<T>
T mult(T a, T b) {
    return a * b;
}

int main() {
    cout << mult(5, 10) << endl;          // Works: int is integral
    // cout << mult(3.14, 1.618) << endl; // Error: double is not integral
    return 0;
}

The requires syntax allows more flexibility to create complex restrictions on template parameters.

Built-in Concepts in C++20

C++20 includes several built-in concepts for commonly used type checks, such as:

std::integral: Checks if a type is an integral type (like int, char, bool, etc.).
std::floating_point: Checks if a type is a floating-point type (float, double, long double).
std::signed_integral: Checks if a type is a signed integral type.
std::unsigned_integral: Checks if a type is an unsigned integral type.
std::convertible_to: Checks if one type can be converted to another.
std::same_as: Checks if two types are the same.

d#include <iostream>
#include <iomanip>
#include <type_traits>

using namespace std;

// A function template that only accepts floating point types
template <std::floating_point T>
T fp_mult(T a, T b) {
    return a * b;
}

// A function template that only accepts types that can be converted to a double
template <std::convertible_to<double> T>
T double_mult(T a, T b) {
    return a * b;
}

// A function template that only accepts chars
template <std::same_as<char> T>
std::string char_cat(T a, T b) {
    return std::string(1, a) + std::string(1, b);
}

int main() {
    // cout << fp_mult(5, 10) << endl;           // Error: int is not floating point
    cout << fp_mult(3.14, 1.618) << endl;        // Works: double is floating point
    // cout << double_mult("5", "10") << endl;   // Error: no conversion to floating point
    cout << double_mult(3.14F, 1.618F) << endl;  // Works: float is convertible to double
    // cout << char_cat(65, 66) << endl;         // Error: ints passed
    cout << char_cat('a', 'b') << endl;          // Works: chars passed
    return 0;
}

In this example we used the standard concepts convertible_to<> to constrain a template parameter to types that have conversions to double defined. We also used the standard concept same_as<> to constraint a template parameter to a specific type only. The next example has a number of more complex concepts. See if you can figure out what each one is doing.

#include <iostream>
#include <iomanip>
#include <type_traits>

using namespace std;

// Concept that checks if two types can be added using operator+
template <typename T>
concept Addable = requires(T a, T b) {
    { a + b } -> std::convertible_to<T>;
};

// Concept that checks if a type supports == and +
template <typename T>
concept AddableAndSubtractable = requires(T a, T b) {
    { a - b } -> std::convertible_to<bool>;
    { a + b } -> std::same_as<T>;
};

// Concept that checks if a type supports both ++ and --
template <typename T>
concept IncrementableAndDecrementable = requires(T t) {
    { ++t } -> std::same_as<T&>;  // Prefix increment
    { t++ } -> std::same_as<T>;   // Postfix increment
    { --t } -> std::same_as<T&>;  // Prefix decrement
    { t-- } -> std::same_as<T>;   // Postfix decrement
};

// Concept that checks if a type supports indexing with []
template <typename T>
concept Indexable = requires(T t, std::size_t i) {
    { t[i] } -> std::convertible_to<typename T::value_type>;
};

// Concept for detecting arrays
template <typename T>
concept ArrayLike = std::is_array_v<T> || std::is_pointer_v<T>;

// Concept that checks if a type behaves like a container
template <typename T>
concept ContainerLike = requires(T t) {
    t.begin();
    t.end();
    t.size();
};

// Concept that checks if a type is callable with specific arguments
template <typename F, typename... Args>
concept CallableWith = requires(F f, Args... args) {
    { f(args...) } -> std::convertible_to<void>;
};

template <Addable T>
T add(T a, T b) {
    return a + b;
}

template <AddableAndSubtractable T>
T addOrSub(T a, T b) {
    if (b ^ 0x1) {
        return a - b;
    } else {
        return a + b;
    }
}

template <IncrementableAndDecrementable T>
T incr(T a) {
    return ++a;
}

template <IncrementableAndDecrementable T>
T decr(T a) {
    return --a;
}

template <Indexable T>
typename T::value_type getFirst(T& t) {
    return t[0];
}

// Template function that works for both arrays and containers
template <typename T>
requires ContainerLike<T>
void printElements(const T& container) {
    std::cout << "Container elements: ";
    for (const auto& elem : container) {
        std::cout << elem << " ";
    }
    std::cout << std::endl;
}

template <typename T>
requires ArrayLike<T*>
void printElements(const T* arr, std::size_t size) {
    std::cout << "Array elements: ";
    for (std::size_t i = 0; i < size; ++i) {
        std::cout << arr[i] << " ";
    }
    std::cout << std::endl;
}

// Function template that requires a callable object
template <CallableWith<int, int> F>
void invokeWithInts(F func) {
    func(5, 10);  // Invoke with two integers
}

int main() {
    int a = 5;
    int b = 10;
    int arr[] = {1, 2, 3, 4, 5};
    std::vector<int> vec = {10, 20, 30, 40};
    // A lambda that takes two integers
    auto myLambda = [](int x, int y) {
        std::cout << "Lambda called with: " << x << ", " << y << std::endl;
    };

    // cout << add(&a, &b) << endl;              // Error: pointers are not addable
    cout << add(a, b) << endl;                   // Works: int is addable
    // cout << addOrSub(&a, &b) << endl;         // Error: pointers are not addable/subtractable
    cout << addOrSub(a, b) << endl;              // Works: int is addable and subtractable
    cout << incr('a') << endl;                   // Works: char is incrementable
    cout << decr(3.14) << endl;                  // Works: double is decrementable
    cout << incr(&a) << endl;                    // Works: pointers are incrementable
    // cout << getFirst(arr) << endl;            // Error: array ref is not indexable   
    cout << getFirst(vec) << endl;               // Works: pointer is indexable
    printElements(arr, 5);                       // Works: array is array-like
    printElements(vec);                          // Works: vector is container-like
    invokeWithInts(myLambda);                    // Works: lambda is callable with two ints
    return 0;
}

In most of these concept definitions we use the syntax { expression; } -> constraint which specifies an expression that must compile with the template type followed by a constraint on the resulting expression.