1. Зачем нужны стандартные concepts
Когда начинаешь писать шаблоны, хочется верить в сказку: «Один раз написал template <typename T> — и работает для всего на свете». Реальность быстро возвращает в мир людей: внутри шаблона вы делаете x / 2 или x.size(), а потом кто-то вызывает вашу функцию со std::string или std::vector, и компилятор устраивает вам распечатку на 200 строк (и, конечно, на английском, потому что компилятор тоже разработчик).
Стандартные concepts нужны не чтобы «усложнить жизнь», а чтобы сделать код честнее: если функция предназначена только для целых чисел — пусть это будет видно прямо в сигнатуре. Если алгоритм смысл имеет только для вещественных — то же самое. Это и про читаемость, и про понятные ошибки, и про правильный контракт API. Исторически идеи «стандартных library concepts» закреплялись отдельными предложениями и решениями комитета, и в документах WG21 вы регулярно встретите упоминания “Standard library concepts” как отдельного блока работы.
Как constraints “фильтруют” типы
Когда вы видите template <std::integral T>, важно помнить: это не runtime-проверка и не if. Компилятор не запускает программу, чтобы проверить тип. Он просто решает: “эта функция вообще существует для данного T или нет”.
Вот полезная mental-модель:
flowchart TD
A[Есть вызов функции-шаблона] --> B[Компилятор подбирает T]
B --> C{Проверка constraints}
C -->|выполнены| D[Инстанцирование тела шаблона]
C -->|не выполнены| E[Функция не подходит / ошибка компиляции]
Смысл в том, что std::integral, std::floating_point и std::ranges::range — это “врата”, через которые тип либо проходит, либо нет.
2. std::integral: только целые числа
Когда мы говорим «целое число» в C++, мы обычно думаем про int. Но в языке их целая семья: short, long, long long, все unsigned-варианты, плюс char (да-да) и даже bool (и это иногда неожиданно). В шаблонах вы часто хотите ограничить функцию именно такими типами: например, для индексов, счётчиков, битовых операций, арифметики с %, целочисленного деления и так далее.
std::integral — это стандартный concept из заголовка <concepts>, который означает: «T — целочисленный тип». В учебной практике воспринимайте это как категорию типов, а не «проверку на int». То есть std::integral<int> и std::integral<long long> будут истинны, и это как раз то, что нам нужно.
Небольшая шпаргалка по смыслу:
| Concept | Про что | Типичные представители |
|---|---|---|
|
«целые числа» | |
|
«вещественные числа» | |
Минимальный пример: функция только для целых
Начнём с максимально простого и «честного» примера. Пусть у нас есть функция, которая считает количество “шагов”, и по смыслу должна работать только с целыми.
#include <concepts>
#include <iostream>
void print_twice(std::integral auto x) {
std::cout << (x * 2) << '\n'; // например: 14
}
int main() {
print_twice(7);
// print_twice(3.14); // не компилируется: 3.14 не integral
}
Обратите внимание на форму std::integral auto x. Это «ограниченный placeholder»: читается почти как обычная функция, но контракт встроен прямо в тип параметра.
Пример в DataBox: читаем ровно N значений
Давайте закрепим идею на приложении. Представим, что по ходу курса мы делаем консольную утилиту DataBox — маленький анализатор данных. Он читает числа и печатает статистику. До concepts мы обычно писали int-версию и double-версию отдельно, а теперь хотим аккуратно обобщить.
Для начала введём функцию, которая читает ровно N значений. N — это число элементов, и это классический кандидат на std::integral.
#include <concepts>
#include <iostream>
#include <vector>
std::vector<int> read_n_values(std::integral auto n) {
std::vector<int> v;
for (int i = 0; i < n; ++i) {
int x = 0;
std::cin >> x;
v.push_back(x);
}
return v;
}
Да, внутри мы всё ещё используем int i, и да, тут есть нюансы (например, если n — long long). Но сейчас нам важен смысл: параметр количества должен быть целым, и это видно из сигнатуры.
Нюанс: bool тоже integral
В какой-то момент вы можете удивиться: «Почему моя функция принимает true?» А потому что bool относится к целочисленным типам. И это не баг: в C++ исторически так сложилось, и concept отражает реальность языка.
В практическом коде это означает простую дисциплину: если функция принимает «целое число, но не bool», то одного std::integral мало — нужно добавлять дополнительные ограничения (но это уже тема про комбинации constraints и более точные требования; сегодня мы держим фокус на стандартных кирпичиках).
3. std::floating_point: для вещественных чисел
Вещественные числа — это те, у которых есть дробная часть и связанные с ней радости жизни: погрешности, NaN, бесконечности и «почему 0.1 + 0.2 не равно 0.3». Вы уже работали с double и обсуждали точность, и поэтому логично иногда говорить компилятору: «Сюда только вещественные».
std::floating_point — стандартный concept из <concepts>, который означает: «тип — float, double или long double (с учётом cv-квалификаторов)». Это очень полезно, когда вы делаете среднее значение, дисперсию, нормализацию, проценты, работу с корнями и степенями — то есть всё, где целочисленное деление сломает смысл.
Пример: среднее значение только для вещественных
Среднее — идеальная демонстрация: на целых числах (1 + 2) / 2 даёт 1, а не 1.5. Иногда это ожидаемо, а иногда — тихая логическая ошибка.
#include <concepts>
#include <iostream>
std::floating_point auto mean2(std::floating_point auto a,
std::floating_point auto b) {
return (a + b) / 2; // 2 здесь тоже floating в контексте выражения
}
int main() {
std::cout << mean2(1.0, 2.0) << '\n'; // 1.5
// std::cout << mean2(1, 2) << '\n'; // не компилируется
}
Здесь мы сделали интересную вещь: и параметры, и тип результата — через constrained auto. Это читается почти как “псевдокод”, и в этом его сила.
Пример в DataBox: среднее по данным
Теперь давайте подумаем как авторы DataBox. Мы хотим уметь считать среднее по std::vector<double>.
#include <concepts>
#include <iostream>
#include <vector>
double mean(const std::vector<double>& v) {
double sum = 0.0;
for (double x : v) {
sum += x;
}
return v.empty() ? 0.0 : (sum / v.size());
}
Пока это не шаблон. Но мысль такая: «Среднее» почти всегда просит вещественный тип. Если мы потом захотим обобщить на T, то std::floating_point будет естественным ограничением.
4. Когда integral и floating_point дают разный смысл
Ограничения становятся особенно понятными, когда вы видите две реализации рядом. Это как две двери: “Вход для целых” и “Вход для вещественных”. И компилятор — ваш охранник (не самый дружелюбный, но справедливый).
Представим, что DataBox умеет делать “нормализацию на 100” (например, перевод в проценты). На целых числах это странно: вы быстро потеряете дробную часть. На вещественных — нормально.
#include <concepts>
#include <iostream>
std::floating_point auto to_percent(std::floating_point auto x) {
return x * 100.0;
}
int main() {
std::cout << to_percent(0.123) << '\n'; // 12.3
// std::cout << to_percent(5) << '\n'; // не компилируется
}
И вот тут наступает тот момент, когда constraints не “запрещают”, а защищают смысл. Вы буквально говорите: «Если ты передал сюда int, то, дружище, давай остановимся и подумаем».
5. std::ranges::range: всё, по чему можно пройтись
До ranges вы уже много раз обходили std::vector циклом for, а позже узнали про range-for: for (auto& x : v). На уровне ощущений это воспринимается так: “если по объекту можно пробежаться в range-for, значит он контейнер (или что-то похожее)”. В C++ это обобщают словом range: объект, у которого можно получить начало и конец (условно, begin и end).
std::ranges::range — стандартный concept из <ranges>. Он означает: «для объекта можно получить итераторы начала и конца (через механизмы ranges)». То есть это не обязательно std::vector; это может быть std::array, std::string, std::list, какой-то view, или даже ваш собственный тип, если вы сделали ему begin/end.
В документах WG21 и в обсуждениях LWG регулярно фигурируют “range concepts” и требования к ranges::begin/ranges::end — это фундаментальная часть современного STL.
Простая функция: посчитать элементы в любом range
Да, у контейнеров часто есть .size(). Но у range в общем случае .size() может и не быть. Поэтому начнём с универсального подхода: просто пройтись и посчитать.
#include <cstddef>
#include <ranges>
std::size_t count_elements(const std::ranges::range auto& r) {
std::size_t n = 0;
for (const auto& x : r) {
(void)x;
++n;
}
return n;
}
Здесь важны две мысли. Во-первых, std::ranges::range auto — это прямой контракт: “дай мне то, что можно обходить”. Во-вторых, мы не требуем ничего лишнего: ни size(), ни случайного доступа, ни индексирования.
Пример в DataBox: превью для любого range
Сделаем следующий шаг: напишем функцию, которая печатает первые несколько элементов любого range. Это удобно для отладки DataBox: мы прочитали данные — хотим быстро убедиться, что они “похожи на правду”.
#include <iostream>
#include <ranges>
void print_preview(const std::ranges::range auto& r, int limit) {
int shown = 0;
for (const auto& x : r) {
if (shown >= limit) break;
std::cout << x << ' ';
++shown;
}
std::cout << '\n';
}
Заметьте: limit мы оставили int. Здесь мы ещё не “полируем” всё до идеала — нам важно увидеть пользу std::ranges::range в реальном коде.
6. Мини-версия DataBox с constraints
Сейчас мы сделаем маленький, но цельный кусок приложения, который вы можете расширять дальше. Он будет читать N, затем N чисел, печатать превью и сумму. Мы специально выберем тип данных параметром шаблона: int или double. И ограничим этот тип стандартными concepts.
Функция чтения: только числа
Мы хотим разрешить только “числовые типы”, и на текущем этапе курса мы можем понимать это как “целые или вещественные”. Запишем constraint прямо в объявлении.
#include <concepts>
#include <iostream>
#include <vector>
template <class T>
requires (std::integral<T> || std::floating_point<T>)
std::vector<T> read_n(std::integral auto n) {
std::vector<T> v;
for (int i = 0; i < n; ++i) {
T x{};
std::cin >> x;
v.push_back(x);
}
return v;
}
Обратите внимание на аккуратный баланс. Мы отдельно ограничили T как “число”, а n как “целое”. Это два разных смысла, и их полезно выражать разными constraints, а не одной большой кашей.
Сумма для range: принимаем любой range чисел
Теперь хотим суммировать что угодно, лишь бы это было range, и элементы можно было складывать. Мы сегодня держим фокус на стандартных concepts, поэтому не будем писать свой Addable — просто ограничим типы данных “числами” (integral или floating). Это честно для DataBox.
#include <concepts>
#include <ranges>
template <class R>
requires std::ranges::range<R>
auto sum_range(const R& r) {
using T = std::ranges::range_value_t<R>;
static_assert(std::integral<T> || std::floating_point<T>,
"sum_range: range elements must be integral or floating_point");
T sum{};
for (const auto& x : r) {
sum += x;
}
return sum;
}
Да, тут мы слегка “подглядели” в std::ranges::range_value_t<R>. Но по смыслу это просто “тип элемента range”. Если воспринимать это как “T — тип того, что лежит внутри”, будет достаточно для понимания.
main: связываем всё в один сценарий
Соберём короткий main, который можно реально запустить.
#include <iostream>
#include <vector>
int main() {
int n = 0;
std::cin >> n;
auto data = read_n<double>(n);
print_preview(data, 5); // например: 1.1 2.2 3.3 4.4 5.5
std::cout << sum_range(data) << '\n'; // например: 16.5
}
Это уже похоже на маленькую утилиту: “прочитал → показал → посчитал”. И самое приятное: если кто-то попробует сделать read_n<std::string>(n), компилятор остановит это раньше, чем вы успеете сказать “ну я просто попробовал”.
7. Типичные ошибки
Ошибка №1: путать std::integral с “это только int”.
Такое мышление обычно тянется из самых первых задач, где вы всё делали на int. В реальном C++ целых типов много, и std::integral честно принимает их все. Если в вашем API вы действительно хотите только int, то нужно писать int, а не concept. Concept — это именно “категория типов”.
Ошибка №2: использовать std::floating_point «на всякий случай», а потом удивляться, что int не проходит.
Это классическая история: вы писали формулу, она “вроде математическая”, значит “пусть будет double”. А потом выясняется, что параметр — это индекс, количество, размер, номер страницы, и это должно быть целое. Concepts хороши тем, что заставляют вас честно назвать смысл параметра.
Ошибка №3: думать, что std::ranges::range означает “у объекта есть .size()”.
Range — это про возможность получить начало и конец и пройтись. .size() может быть, а может не быть. Если вы пишете универсальный код, не подменяйте понятия. Если вам принципиально нужен размер, это уже другое ограничение (и его обычно выражают отдельными range-концептами или дополнительными требованиями).
Ошибка №4: забыть подключить нужные заголовки и обвинить concepts в “капризности”.
Для std::integral и std::floating_point нужен <concepts>. Для std::ranges::range нужен <ranges>. Если вы забудете заголовок, компилятор честно скажет, что не знает таких имён — и будет прав. Иногда это выглядит как “сломалось всё”, но на деле это просто дисциплина include’ов.
Ошибка №5: сделать ограничение на аргумент, но не проверить тип элементов range.
Например, вы написали sum_range(const std::ranges::range auto& r) и внутри делаете sum += x. Range-то есть, но элементы могут быть чем угодно — хоть std::string. Если вы заранее знаете, что хотите суммировать только числа, добавляйте проверку на тип элементов (как мы сделали через static_assert). Это не “паранойя”, это превращение требований в контракт.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ