In C++20, the introduction of concepts and constraints revolutionizes how developers write generic code. Concepts provide a declarative way to specify template requirements, improving readability, error messages, and compile-time checks. This article explores the core ideas behind concepts, demonstrates common patterns, and shows how to use constraints effectively in real-world scenarios.
What Are Concepts?
A concept is a compile-time predicate that can be applied to a type or a set of types. It is expressed as a bool-valued expression that depends on template parameters. For example:
template <typename T>
concept Integral = std::is_integral_v <T>;
Here, Integral is satisfied only by integral types (int, long, etc.). Concepts can also combine multiple requirements:
template <typename T>
concept Incrementable = requires(T x) {
{ ++x } -> std::same_as<T&>;
{ x++ } -> std::same_as <T>;
};
The requires clause lists expressions that must be valid for T. The trailing -> specifies the expected return type of each expression.
Using Concepts in Function Templates
Previously, we would rely on static_assert or SFINAE tricks to restrict template parameters. Concepts allow us to place constraints directly in the template parameter list:
template <Incrementable T>
void increment_all(std::vector <T>& v) {
for (auto& e : v) ++e;
}
If a type that does not satisfy Incrementable is passed, the compiler produces a clear diagnostic that the concept is not satisfied.
Example: A Generic Sort Function
template <typename RandomIt>
requires std::sortable <RandomIt>
void quick_sort(RandomIt first, RandomIt last) {
if (first == last) return;
auto pivot = *first;
RandomIt left = first, right = last - 1;
while (left < right) {
while (*left <= pivot) ++left;
while (*right > pivot) --right;
if (left < right) std::iter_swap(left, right);
}
std::iter_swap(first, right);
quick_sort(first, right);
quick_sort(right + 1, last);
}
std::sortable is a standard concept that checks whether the iterator type can be sorted with operator<. If you try to call quick_sort with a container that doesn’t meet this requirement, the compiler will emit a concise error.
Constraints with requires Clauses
Constraints can also be applied to entire function bodies using requires expressions:
template <typename T>
void foo(T t)
requires std::default_initializable <T> && std::copy_constructible<T>
{
T a; // default-constructible
T b = a; // copy-constructible
}
This syntax is useful when multiple templates or overloads share the same constraint logic but you want to keep the primary signature clean.
Practical Tips
-
Start with Built-in Concepts
`: `std::integral`, `std::floating_point`, `std::default_initializable`, `std::sortable`, etc. Use them before writing custom ones.
C++20 provides a set of standard concepts in ` -
Write Small, Reusable Concepts
Break complex constraints into smaller concepts. For instance, separateComparableandSwappablebefore combining them into aSortableconcept. -
Document Concepts Clearly
When you create a concept, add comments describing its intent. IDEs can display these comments as tooltips, improving maintainability. -
Leverage
requiresExpressions for Overload Disambiguation
Constraints can resolve ambiguity between overloaded templates by selecting the most constrained candidate. -
Test with Edge Cases
Compile your code with types that intentionally fail constraints to ensure diagnostic messages are informative.
Example: A Generic Hash Table
Below is a simplified hash table that uses concepts to enforce requirements on the key type:
#include <vector>
#include <string>
#include <iostream>
#include <concepts>
#include <functional>
template <typename K>
concept Hashable = requires(const K& k, std::size_t h) {
{ std::hash <K>{}(k) } -> std::convertible_to<std::size_t>;
};
template <Hashable K, typename V>
class HashTable {
public:
HashTable(std::size_t sz = 16) : table(sz) {}
void insert(const K& key, const V& value) {
std::size_t idx = std::hash <K>{}(key) % table.size();
table[idx].push_back({key, value});
}
V* find(const K& key) {
std::size_t idx = std::hash <K>{}(key) % table.size();
for (auto& [k, v] : table[idx]) {
if (k == key) return &v;
}
return nullptr;
}
private:
std::vector<std::vector<std::pair<K, V>>> table;
};
int main() {
HashTable<std::string, int> ht;
ht.insert("foo", 42);
if (auto p = ht.find("foo")) std::cout << *p << '\n';
}
Because the Hashable concept restricts K to types that can be hashed, any attempt to instantiate HashTable with an unsupported key type results in a compile-time error with a clear message.
Conclusion
Concepts and constraints bring a new level of clarity and safety to generic C++ programming. By defining precise, readable requirements, developers can catch errors early, improve compiler diagnostics, and write more expressive code. Embrace these features early in your projects to reap the benefits of modern C++ metaprogramming.