Course outline

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.

About 4 min readContent reviewed
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.

cpp Constrain types and validate values
#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";
}
Expected output
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.

cpp A minimal observable interface requirement
#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";
}
Expected output
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.

Official references