Привет, Хабр!

Каждый C++‑разработчик хоть раз в жизни получал ошибку компиляции шаблона на 200 строк. Вы передали std::string в функцию, которая ожидала тип с operator<. Компилятор не сказал «у string нет operator< с этим типом». Он выдал стену текста про какой‑то detail::move_if_noexcept_cond внутри реализации std::sort, о которой вы не просили.

Concepts в C++ все это решили. Concept — именованный набор требований к типу, проверяемый на этапе компиляции. Но concepts — это не только про ошибки, он про читаемость, перегрузку, документацию кода. Разберём.

Проблема: что такое T?

template<typename T>
T max(T a, T b) {
    return a > b ? a : b;
}

Что такое T? Любой тип? Нет, только тип с operator>. Но это неявное требование. Компилятор узнает о нём только при инстанцировании шаблона. Пользователь шаблона узнает о нём только из ошибки компиляции (или из документации, если повезёт).

С concepts:

template<typename T>
concept Comparable = requires(T a, T b) {
    { a > b } -> std::convertible_to<bool>;
    { a < b } -> std::convertible_to<bool>;
    { a == b } -> std::convertible_to<bool>;
};

template<Comparable T>
T max(T a, T b) {
    return a > b ? a : b;
}

Теперь требование явное. Comparable — concept, который говорит: «тип должен поддерживать >, < и == с результатом, конвертируемым в bool». Если передать тип без operator> ошибка будет на уровне concept, а не где‑то в кишках реализации.

Синтаксис: четыре способа использовать concept

// 1. Constrained template parameter (самый чистый)
template<Comparable T>
T max(T a, T b);

// 2. requires-clause после template
template<typename T>
    requires Comparable<T>
T max(T a, T b);

// 3. Trailing requires (после параметров)
template<typename T>
T max(T a, T b) requires Comparable<T>;

// 4. Abbreviated function template (самый краткий)
auto max(Comparable auto a, Comparable auto b) {
    return a > b ? a : b;
}

Все четыре эквивалентны. Первый и четвёртый на мой взгляд самые читаемые. Четвёртый хорош для простых функций, даже template<> писать не нужно.

Если хотите быстро понять, насколько уверенно ориентируетесь в современном C++, начните с короткого теста. ➦ [Пройти тест]

requires‑выражения: описываем требования

requires — ключевое слово для описания набора требований к типу:

template<typename T>
concept Container = requires(T c) {
    // Простое требование: выражение должно быть валидным
    c.begin();
    c.end();
    c.size();
    
    // Требование вложенного типа
    typename T::value_type;
    typename T::iterator;
    
    // Составное требование: выражение валидно И возвращает конкретный тип
    { c.size() } -> std::convertible_to<std::size_t>;
    { *c.begin() } -> std::same_as<typename T::value_type&>;
    
    // Вложенное требование: другой constraint
    requires std::default_initializable<T>;
};

Четыре вида требований внутри requires:

  • Простое — выражение компилируется: c.begin().

  • Типовое — тип существует: typename T::value_type.

  • Составное — выражение компилируется и возвращает нужный тип: { c.size() } -> std::convertible_to<std::size_t>.

  • Вложенное — другое ограничение выполняется: requires std::default_initializable<T>.

Стандартные concepts: не изобретайте велосипед

Заголовок <concepts> содержит набор готовых concepts:

#include <concepts>

// Базовые
std::same_as<T, U>          // T и U — один тип
std::derived_from<T, Base>   // T наследует Base
std::convertible_to<T, U>    // T конвертируется в U

// Арифметика
std::integral<T>             // целочисленный тип
std::floating_point<T>       // тип с плавающей точкой
std::signed_integral<T>      // знаковый целый

// Сравнение
std::equality_comparable<T>  // поддерживает == и !=
std::totally_ordered<T>      // поддерживает <, >, <=, >=, ==, !=

// Объекты
std::movable<T>              // move-конструктор + move-присваивание
std::copyable<T>             // copy + move
std::regular<T>              // copyable + default_initializable + equality_comparable

// Вызываемые
std::invocable<F, Args...>   // F можно вызвать с Args
std::predicate<F, Args...>   // F(Args...) возвращает bool

Используйте стандартные concepts вместо написания своих, где возможно:

// Плохо: свой concept для того, что уже есть
template<typename T>
concept MyIntegral = std::is_integral_v<T>;

// Хорошо: стандартный
template<std::integral T>
T absolute(T value) {
    return value < 0 ? -value : value;
}

Композиция: concepts из concepts

Concepts комбинируются через && и ||:

template<typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;

template<typename T>
concept Printable = requires(std::ostream& os, T value) {
    { os << value } -> std::same_as<std::ostream&>;
};

template<typename T>
concept PrintableNumeric = Numeric<T> && Printable<T>;

void display(PrintableNumeric auto value) {
    std::cout << "Value: " << value << "\n";
}

Можно уточнять (refine) — более специфичный concept включает менее специфичный:

template<typename T>
concept SignedNumeric = Numeric<T> && std::is_signed_v<T>;

Перегрузка через concepts

С Concepts дают можно делать лаконичную перегрузку:

template<std::integral T>
std::string serialize(T value) {
    return std::to_string(value);
}

template<std::floating_point T>
std::string serialize(T value) {
    std::ostringstream oss;
    oss << std::fixed << std::setprecision(2) << value;
    return oss.str();
}

template<typename T>
    requires requires(T t) { { t.toString() } -> std::convertible_to<std::string>; }
std::string serialize(T value) {
    return value.toString();
}

serialize(42);        // integral → "42"
serialize(3.14159);   // floating_point → "3.14"
serialize(myObj);     // has toString() → myObj.toString()

Компилятор выбирает наиболее специфичный concept. Если тип удовлетворяет нескольким — выбирается тот, чей concept «сильнее. std::signed_integral сильнее std::integral, потому что включает его как часть определения.»

Concept для своей библиотеки

Допустим, пишем библиотеку сериализации. Определяем concept для типов, которые мы умеем сериализовать:

template<typename T>
concept Serializable = requires(T value, std::ostream& out, std::istream& in) {
    { value.serialize(out) } -> std::same_as<void>;
    { T::deserialize(in) } -> std::same_as<T>;
    typename T::SerializationTag;  // Маркерный тип для версионирования
};

template<Serializable T>
void saveToFile(const T& obj, const std::string& path) {
    std::ofstream file(path, std::ios::binary);
    obj.serialize(file);
}

template<Serializable T>
T loadFromFile(const std::string& path) {
    std::ifstream file(path, std::ios::binary);
    return T::deserialize(file);
}

// Пользовательский тип
struct Config {
    using SerializationTag = struct V1;
    std::string name;
    int version;

    void serialize(std::ostream& out) const {
        out.write(name.c_str(), name.size());
        out.write(reinterpret_cast<const char*>(&version), sizeof(version));
    }

    static Config deserialize(std::istream& in) {
        // ...
    }
};

Config cfg{"app", 1};
saveToFile(cfg, "config.bin");  // OK: Config удовлетворяет Serializable
// saveToFile(42, "x.bin");     // Ошибка: int не удовлетворяет Serializable

Concept — это документация и контракт одновременно. Читатель видит Serializable и понимает требования. Компилятор проверяет их при каждом использовании.

constexpr + concepts: проверки на этапе компиляции

Concepts работают в constexpr if и static_assert:

template<typename T>
void process(T value) {
    if constexpr (std::integral<T>) {
        // Целочисленная логика
        auto result = value << 2;
    } else if constexpr (std::floating_point<T>) {
        // Дробная логика
        auto result = std::sqrt(value);
    } else {
        static_assert(Printable<T>, "T must be at least Printable");
        std::cout << value;
    }
}

constexpr if с concepts — замена длинных цепочек std::enable_if и SFINAE‑трюков. Читается как обычный if, но компилируется только нужная ветка.

Ошибки: до и после

До concepts (SFINAE):

error: no matching function for call to 'sort(std::vector<MyType>&)'
note: candidate template ignored: substitution failure [with T = MyType]:
no member named 'operator<' in 'MyType'
... (ещё 50 строк внутренностей STL)

После concepts:

error: no matching function for call to 'sort'
note: constraints not satisfied
note: because 'MyType' does not satisfy 'sortable'
note: because 'a < b' would be invalid: invalid operands to binary expression

Concepts — фундаментальное улучшение вашего кода: читаемые требования к типам, понятные ошибки, чистая перегрузка, самодокументирующийся код.

Тем, кому уже мало просто рабочего кода и важно лучше контролировать сложность, архитектуру и поведение программы, стоит присмотреться к курсу «C++‑разработчик. Продвинутый уровень».

До 31 марта действует дополнительная скидка 10% по промокоду birthday — и она суммируется с другими скидками. Так что курс можно взять на более выгодных условиях уже сейчас. ➦ [Забрать курс со скидкой]

А если хотите глубже погрузиться в практику современного C++ и посмотреть, как эти знания применяются в реальных задачах разработки, можно начать с открытых уроков:

  • 30 марта, 20:00 — "Основы 2D и 3D‑графики на C++ в Linux"
    Разберётесь, как устроена графика в Linux‑приложениях на C++.[Записаться]

  • 13 апреля, 20:00 — "Паттерны проектирования в C++"
    Поймёте, как делать код более управляемым и расширяемым.[Записаться]

  • 23 апреля, 20:00 — "Многопоточность в C++"
    Узнаете, как писать быстрые и безопасные многопоточные приложения.[Записаться]