C++ / Advanced / Lesson 13
Concepts and generic contracts
Constrain generic interfaces with C++20 concepts, distinguish checked syntax from semantic laws, and keep diagnostics close to the caller.
On this page
Lesson goals
- Write constrained template parameters and requires expressions.
- Explain why satisfying syntax does not prove semantics.
- Design constraints around operations actually used.
Reject unsupported types at the interface
An unconstrained template may fail deep inside its body when a required operation is absent. C++20 concepts move suitable requirements into the declaration, so overload resolution can exclude unsuitable candidates earlier. A concept is a named compile-time predicate, and a requires-clause associates constraints with a declaration. Constraints describe what an implementation needs; they should not become a catalog of unrelated type traits. Prefer standard concepts when they match the intended abstraction, and add a named domain concept only when it improves the contract a caller sees.
A type constraint still needs value validation
std::integral accepts integral types, including bool. The bounded doubling function explicitly excludes bool because a Boolean doubling operation does not fit its intended numeric meaning. It checks the value against half of its type limits before multiplying, preventing signed overflow and unsigned wrap for accepted inputs. The concept establishes a type family; the runtime checks establish a value domain. Both are needed. An invalid type is rejected during compilation, while an out-of-range value throws the documented exception at runtime. The two example calls use ordinary int and unsigned int.
#include <concepts>
#include <iostream>
#include <limits>
#include <stdexcept>
#include <type_traits>
template<std::integral T> requires (!std::same_as<std::remove_cv_t<T>, bool>)
T twice_checked(T value) {
if (value > std::numeric_limits<T>::max() / 2 ||
value < std::numeric_limits<T>::min() / 2) {
throw std::out_of_range("doubling out of range");
}
return static_cast<T>(value * 2);
}
int main() {
std::cout << twice_checked(4) << " " << twice_checked(5u) << "\n";
}
8 10
Both values fit their type limits. The multiplication is performed only after the check; no overflowing result is used to detect overflow.
Describe operations without executing them
A requires expression checks whether expressions and nested requirements are well-formed. The size-reporting concept below asks whether a const object has a size expression convertible to std::size_t. This checks the interface shape, not that the result is truthful, cheap, or stable under concurrent mutation. Requiring const access matches the actual function body and avoids accidentally promising mutable-only types. Do not use the same concept name for incompatible notions across modules. Small named requirements can compose into larger interfaces while keeping diagnostic messages understandable.
#include <concepts>
#include <cstddef>
#include <iostream>
#include <string>
#include <vector>
template<class T>
concept HasConstSize = requires(const T& value) {
{ value.size() } -> std::convertible_to<std::size_t>;
};
template<HasConstSize T>
std::size_t report_size(const T& value) { return value.size(); }
int main() {
const std::string text{"code"};
const std::vector<int> numbers{1, 2, 3};
std::cout << report_size(text) << " " << report_size(numbers) << "\n";
}
4 3
The const string and vector satisfy the size expression. The result counts elements in each type, not a universal notion of text length.
State the laws the compiler cannot prove
Many standard concepts impose semantic requirements beyond expressions compiling. A comparison used for ordering must obey ordering laws; a user-defined equality operation must behave consistently with the abstraction. A requires expression cannot prove these laws for every value. Property tests can probe representative cases such as equality reflexivity and comparison transitivity, while documentation defines the full obligation. Avoid advertising a concept as a complete correctness proof. Generic code needs value-domain checks, lifetime rules, exception guarantees, and complexity expectations just as concrete code does.
Keep constrained overloads coherent
Constrained overloads can select a more specific supported operation, but overlapping ad hoc Boolean expressions are difficult to order and maintain. Reuse named concepts rather than spelling near-duplicate requirements everywhere; constraint identity and subsumption have formal rules. Keep the same semantic operation across overloads, and test accepted and rejected types intentionally. A compile-only rejection test belongs in a build test configuration, not a public runnable example that pretends to produce output. Verify the compiler implements the C++20 concept features you use before diagnosing a library call as unsupported.
Common errors and fixes
A concept is satisfied but sorting is invalid.
- Why it happens
- The comparator compiles but violates ordering laws.
- How to fix it
- Repair the comparison semantics and test representative laws.
- Prevent it next time
- Document semantic requirements alongside constraints.
An integral interface accidentally accepts bool.
- Why it happens
- std::integral includes Boolean types.
- How to fix it
- Exclude bool if the domain requires numeric counts.
- Prevent it next time
- Review concept membership with edge-case types.
A constrained function still overflows.
- Why it happens
- Only types were checked, not runtime values.
- How to fix it
- Validate limits before the dangerous operation.
- Prevent it next time
- Separate type contracts from value contracts.
Check your understanding
1. Should HasConstSize guarantee that size takes constant time? Explain how you would communicate such a requirement if the caller depends on it.
Show worked solution
No. The requires expression only checks expression validity and result convertibility. Document a constant-time complexity requirement separately, choose a suitable standard abstraction where available, and test performance on intended models. A custom type can satisfy this syntax while traversing an entire linked sequence. The type constraint alone cannot measure its algorithm.
2. For twice_checked<int>, classify zero, max()/2, and max(). Explain why checking the product afterward is unsafe.
Show worked solution
Zero is accepted and returns zero. max()/2 is accepted and doubles to a representable even value. max() is rejected before multiplication with out_of_range. Computing value * 2 first would already execute signed overflow for max(), so a later comparison cannot repair the undefined behavior. Add symmetric negative boundary tests for signed types.
Key takeaways
- Concepts improve interface selection and diagnostics.
- Syntax, semantic laws, and value domains are separate obligations.
- Constrain the actual operations your implementation uses.