JavaRush /Курсы /C++ SELF /Простейший concept и requires

Простейший concept и requires

C++ SELF
57 уровень , 3 лекция
Открыта

1. Откуда берётся боль: «шаблон съел всё подряд»

Когда вы только освоили шаблоны, хочется написать универсальную функцию и больше никогда не думать о типах. Это нормальная стадия: мы все через неё проходили. Но у шаблонов есть характерная “шутка юмора”: пока вы не вызываете шаблон с проблемным типом, всё выглядит идеально — компиляция проходит, а баги прячутся, как кот под диваном.

Рассмотрим мини-пример. Он выглядит невинно, но в нём уже спрятана проблема: требование к типу (operator+) находится не в интерфейсе, а “спрятано” в теле.

#include <iostream>
#include <vector>

template <typename T>
T add(T a, T b) {
    return a + b; // Требуется, чтобы у T был operator+
}

int main() {
    std::cout << add(2, 3) << '\n'; // 5

    std::vector<int> x{1, 2};
    std::vector<int> y{3, 4};

    // std::cout << add(x, y) << '\n'; // Ошибка компиляции (и она будет не короткой)
}

Проблема не в том, что компилятор “плохой”. Он честно пытается сгенерировать версию add<std::vector<int>>, доходит до a + b и говорит: “у std::vector<int> нет такого сложения”. Но сообщение об ошибке часто получается шумным, потому что в реальном коде внутри шаблона могут быть алгоритмы, итераторы, куча обобщённого кода, и первопричина тонет в деталях.

И вот тут возникает идея: а можно ли сказать компилятору заранее: “add работает только для типов, которые можно складывать”?

2. Constraint, concept и два разных requires

Чтобы не путаться, давайте договоримся о словах. Слово “constraint” (ограничение) означает правило на этапе компиляции: какие типы допускаются. Это не if и не проверка во время выполнения. Это похоже на турникет в метро: либо у вас есть жетон, либо вы не проходите — но турникет не “думает” внутри себя, он просто не пускает.

concept — это имя для ограничения. То есть вы берёте проверку “тип поддерживает нужные операции” и даёте ей понятное имя. Тогда сигнатура функции становится читаемой: прямо в заголовке видно, что ожидается от T.

А вот requires в C++ встречается в двух “ролях”, и это классическая путаница новичка.

Первый случай — requires-expression. Это выражение, которое описывает требования к операциям или выражениям, и выглядит примерно так: requires(...) { ... }. Внутри вы пишете “попробуй скомпилировать вот такие выражения”.

Второй случай — requires-clause (часть объявления). Это когда вы уже сформулировали условие, и теперь “прикрепляете” его к функции: ... requires MyConcept<T>.

Если коротко, requires-expression описывает требования, а requires-clause вешает требования на объявление.

Небольшой факт из мира стандарта: даже у комитета C++ иногда случаются чисто “грамматические” промахи в документах, и их потом правят редакционно. Например, в одном из рабочих драфтов отдельно отмечали, что в грамматике определения concept забыли обязательную точку с запятой и потом это исправили. Это хороший антистресс: если вы забыли ;, вы в клубе “ошибались даже те, кто пишет стандарт”.

3. Минимальный concept: проверяем, что выражение вообще существует

Сейчас мы соберём самый маленький “кирпичик”: свой concept, написанный через requires-expression. Это ровно тот момент, когда код становится чуть более “математическим”, но на самом деле смысл очень бытовой: “если можно написать x.size(), значит тип подходит”.

Обратите внимание: requires не вызывает size() во время выполнения. Он лишь проверяет, что такое выражение корректно на этапе компиляции. То есть это не про данные, а про форму кода.

#include <string>
#include <vector>

template <class T>
concept HasSize = requires(const T& x) {
    x.size(); // Выражение должно быть корректным
};

int main() {
    static_assert(HasSize<std::string>);
    static_assert(HasSize<std::vector<int>>);
    // static_assert(HasSize<int>); // Не скомпилируется: у int нет size()
}

Здесь важно то, что мы пишем requires(const T& x), а не requires(T x). Мы как бы говорим: “Дай мне ссылку на объект, я просто попробую написать x.size()”.

Это почти всегда самый добрый и наименее требовательный вариант, потому что ссылка не требует копирования, не требует перемещения, и не заставляет тип быть “легко создаваемым”.

4. Минимальный синтаксис: как применить concept к функции

Когда concept определён, следующий шаг — “приклеить” его к функции. В C++ есть несколько способов, и они похожи по смыслу, но отличаются читаемостью. Здесь важно не запоминать всё как таблицу заклинаний, а просто понять: это три разных записи одного и того же контракта.

Ограниченный параметр шаблона: template <HasSize T>

Этот стиль часто самый читаемый: вы буквально говорите “T должен быть HasSize”.

#include <cstddef>
#include <string>

template <class T>
concept HasSize = requires(const T& x) { x.size(); };

template <HasSize T>
std::size_t len(const T& x) {
    return x.size();
}

int main() {
    std::string s = "hello";
    (void)len(s);

    // (void)len(10); // Не подходит: int не HasSize
}

requires-clause: … requires HasSize<T>

Этот стиль полезен, когда у вас сложная сигнатура, или вы хотите сначала объявить типы как обычно, а потом “дописать контракт”.

#include <cstddef>
#include <string>

template <class T>
concept HasSize = requires(const T& x) { x.size(); };

template <class T>
std::size_t len2(const T& x) requires HasSize<T> {
    return x.size();
}

int main() {
    std::string s = "abc";
    (void)len2(s);
}

Ограниченный placeholder: HasSize auto&

Это выглядит необычно, но для маленьких функций бывает очень удобно. Мы как бы говорим: “параметр — это что-то, что удовлетворяет HasSize”.

#include <cstddef>
#include <string>

template <class T>
concept HasSize = requires(const T& x) { x.size(); };

std::size_t len3(const HasSize auto& x) {
    return x.size();
}

int main() {
    std::string s = "world";
    (void)len3(s);
}

Чтобы не держать это в голове как хаос, полезно один раз увидеть мини-таблицу (и потом забыть, пока не понадобится):

Запись Как читается Когда удобна
template <HasSize T>
“T обязан быть HasSize” чаще всего, когда шаблонная функция простая
template <class T> ... requires HasSize<T>
“функция доступна, если…” когда условий несколько или вы любите “как в математике”
HasSize auto&
“параметр ограничен concept’ом” когда функция короткая, почти как inline-утилита

Пример: утилита print_size для консольного приложения

Чтобы всё это не осталось “в вакууме”, давайте представим, что у нас к этому месту курса уже есть простое консольное приложение (пусть будет MiniStats): оно хранит числа в std::vector<double>, печатает их, считает сумму и среднее. В какой-то момент мы захотели написать “универсальные” функции, чтобы переиспользовать их и для строк, и для векторов, и для любых контейнеров.

Начнём с очень практичной задачи: хотим печатать длину объекта, если она у него есть. Для std::string и std::vector — да, для int — нет.

#include <iostream>
#include <string>
#include <vector>

template <class T>
concept HasSize = requires(const T& x) { x.size(); };

template <HasSize T>
void print_size(const T& x) {
    std::cout << "size = " << x.size() << '\n';
}

int main() {
    std::string s = "abc";
    print_size(s);              // size = 3

    std::vector<int> v{10, 20};
    print_size(v);              // size = 2

    // int n = 5;
    // print_size(n);           // Не скомпилируется: нет size()
}

Здесь происходит важная педагогическая магия: ошибка у вас будет не “где-то внутри”, а прямо на вызове print_size(n). То есть контракт всплывает в интерфейсе, а не в глубине тела.

5. Addable: “можно сложить”

Следующий популярный сценарий — “тип можно складывать”. Это нужно, если вы хотите писать обобщённые суммирования, конкатенации и так далее. Опять же: важно требовать ровно то, что вы реально используете.

Сделаем Addable, который проверяет, что выражение a + b корректно.

#include <string>

template <class T>
concept Addable = requires(const T& a, const T& b) {
    a + b; // Должно компилироваться
};

template <Addable T>
T add(T a, T b) {
    return a + b;
}

int main() {
    (void)add(1, 2);
    (void)add(std::string{"ab"}, std::string{"cd"});
}

Обратите внимание, что в requires мы взяли const T&, а не “значения”. Это не просто эстетика. Это защита от случайного требования “тип должен копироваться”. И сейчас мы увидим почему.

6. Ловушка requires(T x): лишние требования к типу

В requires(...) вы объявляете “фиктивные” параметры, с которыми проверяете выражения. И очень легко написать так, что вы не хотели.

Если вы пишете requires(T x), вы тем самым часто заставляете компилятор проверить, можно ли создать объект T “как значение”. А это может означать требования к копированию или перемещению. Для многих типов-владельцев ресурсов копирование запрещено, и тогда ваш concept неожиданно начинает отвергать типы, которые вообще-то могли бы пройти по смыслу.

Сравним две версии “можно ли прибавить 1” — плохую и хорошую.

#include <memory>

template <class T>
concept BadConcept = requires(T x) {
    x; // Уже тут могут появиться лишние требования к созданию/копированию
};

template <class T>
concept GoodConcept = requires(const T& x) {
    x; // Для ссылки требования намного мягче
};

int main() {
    using Ptr = std::unique_ptr<int>;

    // static_assert(BadConcept<Ptr>);  // может оказаться ложным из-за лишних требований
    static_assert(GoodConcept<Ptr>);    // гораздо вероятнее будет истинным
}

Да, пример специально “на грани”: в реальной жизни ошибка проявляется иначе — вы пишете concept для операции, а он внезапно требует копируемость и начинает ругаться на std::unique_ptr, std::mutex и другие “некопируемые, но нормальные” типы.

Мораль простая: для проверки операций чаще всего начинайте с requires(const T& x) или requires(T& x).

В рабочих документах стандарта термин requires-expression даже отдельно фигурирует как часть грамматики языка (то есть это не “синтаксический сахар”, а полноценная конструкция языка). И раз это часть грамматики, то мелочи вроде “какие параметры вы объявили” реально меняют смысл проверки.

7. concept — это контракт интерфейса, а не “проверка данных”

Очень хочется (особенно после if и while) думать, что requires — это что-то вроде “условия”. Но это не “условие на значения”, а “условие на корректность кода”. То есть HasSize не проверяет, что size() возвращает “хорошее число” или что контейнер “не пустой”. Он проверяет только: можно ли написать x.size().

Это даёт правильную привычку проектирования: concept должен описывать ровно тот набор операций, который нужен вашей функции. Не больше. Если вы потребуете лишнего, вы ограничите повторное использование. Если потребуете меньше, вы получите ошибку уже внутри тела (то есть вернётесь к старой боли).

8. Типичные ошибки

Ошибка №1: путать requires-expression и requires-clause.
Это выглядит как одно и то же слово, но используется в двух смыслах. В concept вы обычно пишете requires(...) { ... }, и это описание требований. А в объявлении функции вы можете написать ... requires HasSize<T>, и это уже “прикрепление” требования к функции. Если перепутать, вы начнёте писать странные конструкции и удивляться ошибкам компилятора.

Ошибка №2: писать requires(T x) и случайно требовать копируемость/создаваемость типа.
На старте кажется, что “ну я же просто объявляю переменную”. Но для concept’ов это не просто переменная: это часть проверки. Из-за этого ваш concept внезапно начинает отвергать вполне адекватные типы вроде std::unique_ptr. Почти всегда лучше начинать с requires(const T& x) и проверять именно выражения, которые вам нужны.

Ошибка №3: делать один огромный concept “на все случаи жизни”.
Когда вы впервые почувствовали силу constraints, возникает соблазн сделать UniversalType = requires(...) { ... 20 требований ... };. Потом вы пытаетесь использовать его в трёх местах и понимаете, что он слишком строгий, и половина типов “не проходит”, хотя функция по смыслу могла бы работать. Лечится это просто: один смысл — один небольшой concept, как маленький строительный блок.

Ошибка №4: ожидать, что concept даст вам “проверку значений”.
concept не проверит, что контейнер не пустой, и не проверит, что число положительное. Он проверяет только наличие операций и корректность выражений. Для значений у нас остаются обычные if и проверка данных во время выполнения — но это уже другая история.

Ошибка №5: забыть точку с запятой после определения concept.
Определение concept — это объявление на уровне языка, и оно заканчивается ;. Это тот самый случай, когда даже в рабочей документации стандарта когда-то отдельно отмечали, что “в грамматике забыли ; и исправили”. Так что, если компилятор пишет что-то вроде expected ';', знайте: вы не одиноки.

1
Задача
C++ SELF, 57 уровень, 3 лекция
Недоступна
Длина инвентаря
Длина инвентаря
1
Задача
C++ SELF, 57 уровень, 3 лекция
Недоступна
Безопасное сложение
Безопасное сложение
1
Задача
C++ SELF, 57 уровень, 3 лекция
Недоступна
Три записи ограничения
Три записи ограничения
1
Задача
C++ SELF, 57 уровень, 3 лекция
Недоступна
Описание объекта
Описание объекта
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ