concepts и rangesconcepts and ranges

concepts

Именованное, читаемое ограничение на шаблонный параметр — template<std::integral T> T add(T a, T b); вместо страницы SFINAE ради того же результата.A named, readable constraint on a template parameter — template<std::integral T> T add(T a, T b); instead of a page of SFINAE to achieve the same result.

архитектурный сдвиг стандартаan architectural shift for the standard частый вопрос: чем отличается от SFINAE/enable_ifcommon question: how it differs from SFINAE/enable_if

Что этоWhat it is

Concept — именованный предикат на этапе компиляции, накладывающий требования на шаблонный параметр: какие операции должны быть валидны, каким другим concept'ам должен удовлетворять тип, какие типы должны существовать. Concept объявляется через concept, обычно на основе requires-выражения, и применяется как ограничение шаблона — либо вместо class/typename, либо в отдельном requires-предложении.A concept is a named, compile-time predicate that places requirements on a template parameter: which operations must be valid, which other concepts the type must satisfy, which nested types must exist. A concept is declared with concept, usually built on a requires-expression, and applied as a template constraint — either in place of class/typename, or in a separate requires clause.

до C++20 — SFINAE + enable_ifbefore C++20 — SFINAE + enable_if
template <class T, typename = typename std::enable_if<std::is_integral<T>::value>::type>
T add(T a, T b) { return a + b; }
// ошибка при неподходящем типе — нечитаемая простыня из шаблонов// error on a bad type — an unreadable wall of template internals
C++20 — именованный conceptC++20 — a named concept
template <std::integral T>
T add(T a, T b) { return a + b; }
// ошибка при неподходящем типе — понятное сообщение "T does not satisfy integral"// error on a bad type — a clear "T does not satisfy integral" message

Зачем ввелиWhy it was introduced

SFINAE решала задачу ограничения шаблонов с самого начала generic-программирования в C++, но ценой читаемости и качества диагностики:SFINAE had solved the template-constraint problem since the earliest days of generic programming in C++, but at the cost of readability and diagnostic quality:

01

Ошибки компиляции SFINAE-кода нечитаемыSFINAE compile errors are unreadable

Когда шаблон, ограниченный через enable_if, не подходит для типа, компилятор выдаёт страницы вложенных сообщений о том, что подстановка не удалась где-то в глубине служебных метафункций — сама причина (например, "тип не integral") тонет в шуме реализации ограничения, а не самого требования.When a template constrained via enable_if doesn't match a type, the compiler produces pages of nested substitution-failure messages buried deep inside helper metafunctions — the actual reason (say, "the type isn't integral") gets lost in the noise of how the constraint was implemented, not what it actually required.

02

Ограничения нельзя было переиспользовать по имениConstraints couldn't be reused by name

Каждое использование одного и того же логического ограничения ("тип должен быть сравним на равенство и хешируем") требовало заново выписывать enable_if-выражение целиком — не было способа дать этому ограничению имя и переиспользовать его в десятке шаблонов, как обычную функцию.Every use of the same logical constraint ("the type must be equality-comparable and hashable") required re-writing the whole enable_if expression from scratch — there was no way to give that constraint a name and reuse it across a dozen templates, the way an ordinary function can be reused.

03

Перегрузки по ограничению конфликтовали неочевидным образомOverloads constrained differently clashed in non-obvious ways

Две перегрузки одной функции, ограниченные разными enable_if-условиями, могли неожиданно оказаться неоднозначными или, наоборот, не находиться компилятором вовсе — правила частичного упорядочивания шаблонов не были рассчитаны на такой стиль ограничений и вели себя непредсказуемо в пограничных случаях.Two overloads of the same function, constrained by different enable_if conditions, could unexpectedly become ambiguous, or conversely fail to be found by the compiler at all — the rules for partial template ordering weren't designed around this style of constraint and behaved unpredictably at the edges.

Как это работает под капотомHow it works under the hood

Concept — это переменная-шаблон особого вида (constexpr bool, определяемый на этапе компиляции), проверяющая, что requires-выражение истинно для данного набора типов. Requires-выражение перечисляет требования — валидные выражения, требуемые типы, требуемые compile-time условия — и вычисляется в true/false без выполнения перечисленного кода, только проверкой его валидности.A concept is a special kind of variable template (constexpr bool, evaluated at compile time) that checks whether a requires-expression holds for a given set of types. A requires-expression lists requirements — valid expressions, required nested types, required compile-time conditions — and evaluates to true/false without executing the listed code, only checking its validity.

template <class T>
concept Addable = requires(T a, T b) {
    { a + b } -> std::convertible_to<T>;  // выражение a+b должно быть валидно// the expression a+b must be valid
                                             // и приводимо к T// and convertible to T
};

template <Addable T>   // concept вместо class/typename — ограничение прямо в объявлении// concept instead of class/typename — the constraint lives right in the declaration
T sum(T a, T b) {
    return a + b;
}

// эквивалентная форма с отдельным requires-предложением:// an equivalent form with a separate requires clause:
template <class T>
requires Addable<T>
T sum2(T a, T b) { return a + b; }

Ключевое отличие от SFINAE: при неудаче проверки concept компилятор точно знает, какое именно требование не выполнено (например, конкретно "выражение a + b не валидно для этого T"), и сообщает об этом напрямую — вместо того чтобы репортить провал подстановки где-то в недрах вспомогательной метафункции.The key difference from SFINAE: when a concept check fails, the compiler knows exactly which requirement wasn't met (for instance, specifically "the expression a + b is not valid for this T"), and reports that directly — instead of reporting a substitution failure buried somewhere inside a helper metafunction.

Requires-выражение поддерживает четыре вида требований одновременно — простые (выражение валидно), составные (выражение валидно и его результат удовлетворяет ограничению), типовые (существует такой вложенный тип) и вложенные (произвольное compile-time условие):A requires-expression supports four kinds of requirements at once — simple (an expression is valid), compound (an expression is valid and its result satisfies a constraint), type (a nested type exists), and nested (an arbitrary compile-time condition):

template <class T>
concept Container = requires(T c) {
    typename T::value_type;             // требование типа — должен существовать T::value_type// type requirement — T::value_type must exist
    c.begin();                          // простое требование — выражение должно быть валидно// simple requirement — the expression must be valid
    c.end();
    { c.size() } -> std::convertible_to<std::size_t>;  // составное требование// compound requirement
    requires std::default_initializable<T>;      // вложенное требование — ссылка на другой concept// nested requirement — referencing another concept
};

// составление нескольких concepts через && — обычное булево выражение:// composing several concepts with && — an ordinary boolean expression:
template <class T>
concept SortableContainer = Container<T> && std::totally_ordered<typename T::value_type>;

Примеры из реального кодаReal-world examples

типичные сценарии использованияtypical usage scenarios
01

Ограничение шаблонной функции сериализации числовыми типамиConstraining a template serialization function to numeric types

Функция сериализации в бинарный протокол, которая должна принимать только числа (не строки, не указатели) — раньше делалось через enable_if с is_arithmetic, теперь читается как обычная сигнатура.A binary-protocol serialization function that should accept only numbers (not strings, not pointers) — used to be enable_if with is_arithmetic, now reads like an ordinary signature.

template <std::floating_point T>
void write_price(std::ostream& out, T value) {
    char buf[32];
    auto [ptr, ec] = std::to_chars(buf, buf + sizeof(buf), value);
    out.write(buf, ptr - buf);
}
02

Перегрузка по категории итератора вместо теговOverloading by iterator category instead of tags

До concepts выбор между алгоритмом для random-access и forward итераторов делался через tag dispatch (пустые struct-теги + перегрузка по ним). С concepts — прямое ограничение на сигнатуре.Before concepts, choosing between an algorithm for random-access and forward iterators was done via tag dispatch (empty struct tags plus overloading on them). With concepts — a direct constraint on the signature.

template <std::random_access_iterator It>
void advance_fast(It& it, std::ptrdiff_t n) { it += n; }  // O(1)// O(1)

template <std::forward_iterator It>
void advance_fast(It& it, std::ptrdiff_t n) {
    while (n--) ++it;                          // O(n), но компилируется для любого forward-итератора// O(n), but compiles for any forward iterator
}
03

Собственный concept для "структуры с полями x/y" в геометрическом кодеA custom concept for "a struct with x/y fields" in geometry code

Функция, работающая с любым типом точки (своя структура, структура из внешней библиотеки, structured binding-совместимый tuple) без наследования от общего базового класса.A function that works with any point-like type (your own struct, a struct from an external library, a structured-binding-compatible tuple) without inheriting from a shared base class.

template <class P>
concept Point2D = requires(P p) {
    { p.x } -> std::convertible_to<double>;
    { p.y } -> std::convertible_to<double>;
};

template <Point2D P>
double distance(P a, P b) {
    return std::hypot(a.x - b.x, a.y - b.y);
}
04

requires-предложение на методе класса, а не только на свободной функцииA requires clause on a class method, not just a free function

Метод шаблонного класса, доступный только для типов, поддерживающих сравнение — например, метод sort() контейнера, инстанциируемого и для несравнимых типов, но с sort() под ограничением.A method of a template class available only for types that support comparison — for instance, a container's sort() method, where the container is instantiable for non-comparable types too, but sort() itself is constrained.

template <class T>
class Bucket {
    std::vector<T> items_;
public:
    void add(T v) { items_.push_back(std::move(v)); }

    void sort() requires std::totally_ordered<T> {  // доступен, только если T сравним// available only if T is comparable
        std::ranges::sort(items_);
    }
};

ГраблиGotchas

Concepts — не типы и не проверка семантики, только синтаксисConcepts aren't types, and they don't check semantics — only syntax

Concept проверяет, что операция валидна с точки зрения компиляции (выражение компилируется, тип существует) — он не проверяет, что операция ведёт себя ожидаемым образом семантически. Тип с перегруженным operator+, который на самом деле вычитает, формально удовлетворит concept Addable из примера выше — concepts документируют синтаксический контракт, не гарантируют корректность поведения.A concept checks that an operation is valid from the compiler's point of view (the expression compiles, the type exists) — it does not check that the operation behaves as semantically expected. A type with an overloaded operator+ that actually subtracts will formally satisfy the Addable concept from the example above — concepts document a syntactic contract, they don't guarantee correct behavior.

Порядок ограничения перегрузок работает по «наиболее ограниченному», но не всегда очевидно, что именно более ограниченоOverload constraint ordering picks the "most constrained," but it's not always obvious which one that is

Если две перегрузки применимы одновременно, компилятор выбирает ту, чей concept логически подразумевает concept другой (subsumption) — но subsumption вычисляется по структуре булевых выражений внутри concept, а не по «интуитивной» вложенности требований. Composить concepts через &&/|| напрямую внутри определения (а не через ссылку на другой именованный concept) иногда ломает ожидаемое отношение subsumption.If two overloads are simultaneously viable, the compiler picks the one whose concept logically subsumes the other's — but subsumption is computed from the boolean structure of the concept's definition, not from the "intuitive" nesting of requirements. Composing concepts with &&/|| directly inline (rather than by referencing another named concept) sometimes breaks the expected subsumption relationship.

Concept сам по себе — не замена static_assert для произвольных runtime-условийA concept by itself isn't a replacement for static_assert on arbitrary runtime conditions

Concepts ограничивают именно шаблонные параметры на этапе вывода типов и разрешения перегрузок — они не заменяют static_assert внутри тела функции для проверки условий, не связанных напрямую с типом параметра (см. cpp11/static-assert.html). Оба механизма сосуществуют и решают разные задачи.Concepts specifically constrain template parameters during type deduction and overload resolution — they don't replace a static_assert inside a function body for checking conditions unrelated to the parameter's type (see cpp11/static-assert.html). Both mechanisms coexist and solve different problems.