JavaRush /Курси /C++ SELF /static_assert у шаблонах

static_assert у шаблонах

C++ SELF
Рівень 58 , Лекція 0
Відкрита

1. Чому шаблонні помилки «шумні»

Коли ви пишете шаблон, то фактично створюєте «код на майбутнє»: компілятор по-справжньому перевіряє його лише тоді, коли хтось підставляє конкретний тип. Тому помилка часто виглядає як довге простирадло на кшталт «підстановка такого-то типу», «інстанціювання такого-то шаблону», «а ось тут тип не має методу size()». Нам потрібно навчитися перетворювати це на одну коротку й зрозумілу фразу: «Ця функція працює лише для …».

Уявіть типову ситуацію: ви пишете універсальну функцію для «чисел», а хтось — або й ви самі за тиждень — випадково передає туди std::string. Компілятор чесно намагається скомпілювати тіло, натикається на x * x і починає розповідати трагічну історію всіх залучених шаблонів. З погляду компілятора він усе робить правильно. З погляду студента це вже «ААААААА».

static_assert — це наш спосіб сказати компілятору: «Якщо умову не виконано, зупинися просто тут і покажи моє повідомлення».

static_assert: базова ідея та синтаксис

static_assert — це перевірка на етапі компіляції. Вона або «мовчки проходить», або зупиняє компіляцію з помилкою. На відміну від if, який працює під час виконання, static_assert — це саме «охоронець на вході»: він не пропустить код далі, якщо контракт порушено.

Синтаксис буває у двох формах: із повідомленням і без нього. Варіант без повідомлення в навчальному коді майже ніколи не варто використовувати, бо «помилка без тексту» — це як записка «ви помилилися» без жодного пояснення.


#include <concepts>

template <class T>
T square(T x) {
    static_assert(std::integral<T> || std::floating_point<T>,
                  "square<T>: T must be an integer or floating-point type");
    return x * x;
}

Тут ідея проста: якщо хтось викличе square(std::string{"hi"}), компілятор зупиниться й покаже саме ваше повідомлення.

2. Важливе правило: static_assert спрацьовує під час інстанціювання

Коли ви пишете звичайну нешаблонну функцію, компілятор перевіряє її одразу. Коли ж ви пишете шаблон, він часто відкладає перевірку тіла до моменту використання. Через це поведінка static_assert у шаблонах спочатку може здаватися магією, але це добра магія.

Важливо уявити просту схему:

flowchart TD
    A[Шаблон оголошено] --> B{Є виклик із конкретним типом?}
    B -- ні --> C[Тіло майже не перевіряється]
    B -- так --> D[Інстанціювання]
    D --> E[Компіляція тіла + static_assert]
    E -- умова true --> F[Усе гаразд]
    E -- умова false --> G[Помилка з вашим повідомленням]

Тобто static_assert у шаблоні — це «пастка», яка спрацює саме тоді, коли шаблон справді намагаються застосувати до невідповідного типу. Саме це нам і потрібно: сама наявність шаблону не повинна «ламати» проєкт — помилка має виникати лише в разі неправильного використання.

3. Пастка: static_assert(false, "...") «про всяк випадок»

Поширена помилка новачка виглядає так: «Хочу заборонити якийсь шлях виконання — напишу static_assert(false, "…")». У звичайному коді це миттєво зупинить компіляцію — і все. У шаблонному коді це теж зупинить компіляцію, але робитиме це завжди, навіть якщо цей шаблон ніхто не викликає. Тобто ви випадково закладаєте «бомбу в заголовку».

Покажемо проблему на короткому прикладі:

template <class T>
void broken() {
    static_assert(false, "This template is forbidden"); // погано
}

Це «погано», тому що false не залежить від T. Компілятор бачить false одразу й не зобовʼязаний чекати моменту використання: він просто каже «ні» — і на цьому все.

Щоб static_assert у шаблоні був корисним, його умова зазвичай має залежати від параметрів шаблону, наприклад від T. Тоді перевірка відкладається до моменту реального використання.

4. Прийом always_false_v<T> для «забороненої гілки»

Іноді вам справді потрібно сказати: «Якщо ви сюди дійшли, отже використовуєте непідтримуваний тип». Для цього є дуже популярний і простий прийом: зробити «брехню», яка залежить від типу.

Ось мінімальна заготовка:

template <class>
inline constexpr bool always_false_v = false;

Тепер можна написати:

#include <concepts>

template <class T>
void print_value(const T&) {
    static_assert(always_false_v<T>,
                  "print_value<T>: unsupported type (add an overload or constrain T)");
}

Тут компілятор не видає помилку одразу, тому що always_false_v<T> залежить від T. Помилка зʼявиться лише тоді, якщо хтось справді викличе print_value з невідповідним типом.

Цей прийом особливо корисний у «заглушках» (fallback), коли ви робите кілька перевантажень для різних типів, а в останньому кажете: «Ні, цей тип ми не підтримуємо».

5. Де ставити static_assert: ближче до входу

Дуже хочеться поставити перевірку десь «на місці», поруч із проблемним рядком (x.size(), a + b, x * x). Але з погляду UX — і для вашого майбутнього спокою — корисніше ставити static_assert на початку функції або класу. Тоді, якщо станеться помилка, користувач побачить: «ця функція вимагає ось це», а не «десь унизу щось не зійшлося».

Порівняйте два стилі. Перший варіант менш вдалий у разі помилки, тому що перевірка стоїть далеко:

#include <concepts>

template <class T>
T add_late(T a, T b) {
    T c = a + b; // помилка тут може бути "простирадлом"
    static_assert(std::integral<T> || std::floating_point<T>,
                  "add_late<T>: T must be numeric");
    return c;
}

Другий стиль зазвичай кращий:

#include <concepts>

template <class T>
T add_early(T a, T b) {
    static_assert(std::integral<T> || std::floating_point<T>,
                  "add_early<T>: T must be numeric");
    return a + b;
}

Так, компілятор і без того міг би здогадатися, що проблема в a + b. Але ми допомагаємо йому сформулювати помилку так, щоб людині було легше її зрозуміти.

6. static_assert разом із concept: пояснюємо вимоги

Обмеження через concept і requires часто вже дають доволі якісну діагностику. Але інколи хочеться пояснити помилку власними словами, особливо якщо ви пишете навчальну бібліотеку або внутрішній код команди, де зрозумілість важить дуже багато.

Один із практичних підходів — обмежити інтерфейс концептом, а всередині все одно залишити static_assert: для уточнення або для дружнішого тексту. Важливо не перетворювати це на дублювання без потреби: якщо повідомлення компілятора й так ідеальне, зайвий static_assert може лише додати шуму.

Приклад у дусі навчального застосунку — CLI-трекера задач: ми хочемо розбирати числовий id з рядка.

#include <charconv>
#include <concepts>
#include <string_view>

template <std::integral T>
T parse_int(std::string_view s) {
    T value{};
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
    static_assert(std::integral<T>, "parse_int<T>: T must be integral"); // пояснення
    (void)ptr; (void)ec;
    return value;
}

З погляду чистої логіки static_assert тут надлишковий, адже T уже std::integral. Але в навчальних цілях інколи корисно залишити його як «маячок»: студент бачить, що функція справді очікує цілий тип, а не «щось числове взагалі».

Частіше корисніший інший варіант: концепт виконує грубу фільтрацію, а static_assert уточнює контракт. Наприклад: «тип має бути числовим, і ми очікуємо, що ділення матиме сенс».

#include <concepts>

template <class T>
T average2(T a, T b) {
    static_assert(std::floating_point<T>,
                  "average2<T>: use floating point type (e.g. double) to avoid integer division");
    return (a + b) / static_cast<T>(2);
}

7. Практичний приклад: поліпшуємо діагностику в TaskBoard

Щоб static_assert не залишався «абстрактною філософією», уявімо, в дусі курсу, що ми розвиваємо один застосунок — консольний трекер задач TaskBoard. На попередніх етапах ми вже зберігали задачі в std::vector, читали команди рядками, розбирали їх через std::stringstream і перевіряли введення. Тепер хочемо зробити невелику утиліту розбору, яку можна використовувати повторно.

Нехай у нас є команда такого вигляду:

add 101 "Buy milk"

101 — це id. І тут нам потрібні дві речі: по-перше, зручна узагальнена функція розбору; по-друге, зрозуміла помилка компіляції, якщо програміст випадково спробує використати її «не так».

Зробімо невелику функцію parse_id<T>, призначену лише для цілих типів:

#include <charconv>
#include <concepts>
#include <string_view>

template <class T>
T parse_id(std::string_view s) {
    static_assert(std::integral<T>,
                  "parse_id<T>: T must be an integral type (int, long long, ...)");
    T id{};
    std::from_chars(s.data(), s.data() + s.size(), id);
    return id;
}

Тепер у коді обробки команди можна явно вказати потрібний тип:

#include <iostream>
#include <string>

int main() {
    std::string token = "101";
    int id = parse_id<int>(token);
    std::cout << "id=" << id << '\n'; // id=101
}

Якщо хтось спробує зробити auto id = parse_id<std::string>("101");, компілятор зупиниться саме на вашому повідомленні. Не на «а чому не можна from_chars для string?», не на «немає відповідного перевантаження», а на зрозумілому: «T must be integral».

Зверніть увагу на важливий нюанс формулювання повідомлення: воно має відповідати на два запитання. Перше — що саме не так, наприклад «T must be integral». Друге — що робити замість цього, наприклад «use int/long long» або «write another overload». Без другої частини static_assert легко перетворюється на «ну, ви самі винні», а нам важливо допомогти людині виправити помилку.

Concept vs static_assert: хто за що відповідає

Іноді студенти починають плутатися: «Так… якщо є concepts, навіщо static_assert?» Це нормальне запитання, бо обидва інструменти стосуються обмежень. Але роль у них усе-таки різна.

Інструмент Де живе Що робить найкраще Який зазвичай вигляд має помилка
concept / requires
У сигнатурі Відсіяти невідповідний тип ще до аналізу тіла й зробити контракт видимим під час читання API «не задовольняє обмеження …» (часто вже непогано)
static_assert
Усередині шаблону, часто на початку Дати ваше повідомлення, уточнити вимоги, зробити «одну зрозумілу причину» замість каскаду Текст, який ви написали

У навчальному коді часто доречно мати обидва інструменти: обмеження роблять API чесним, а static_assert — діагностику дружньою. Але в реальному коді static_assert зазвичай використовують помірковано, щоб не дублювати й без того добрі обмеження.

8. Типові помилки під час використання static_assert у шаблонах

Помилка № 1: static_assert(false, "...") усередині шаблону і здивування «чому не компілюється взагалі нічого».
Так стається, бо false не залежить від параметрів шаблону, і компілятор має право зупинити компіляцію одразу, навіть якщо ви ніколи не викликаєте цей шаблон. Правильний шлях — робити умову залежною від T, наприклад через always_false_v<T> або через реальну перевірку на кшталт std::integral<T>.

Помилка № 2: повідомлення в стилі "Type error" або "Bad type".
Формально воно начебто «людське» — там же є слова! — але на практиці марне: не пояснює, що саме очікувалося і як це виправити. Значно краще писати у форматі «імʼя функції: вимога + підказка», наприклад: "parse_id<T>: T must be integral (use int/long long)".

Помилка № 3: static_assert надто глибоко в коді, і користувач спершу бачить «простирадло», а вже потім ваше повідомлення.
Якщо ви ставите перевірку після кількох рядків складної шаблонної логіки, компілятор може встигнути «накопати» купу вторинних помилок. У підсумку static_assert уже не рятує UX. Найпростіший спосіб поліпшити ситуацію — ставити static_assert на початку функції або класу, як перевірку вхідного контракту.

Помилка № 4: надмірно суворі вимоги «про всяк випадок».
Іноді хочеться вимагати від T одразу все: і додавання, і множення, і size(), і щоб тип можна було копіювати, і щоб він друкувався в cout. Так ви різко звужуєте застосовність шаблону, а потім самі ж страждаєте, коли начебто відповідний тип не проходить фільтр. У вдалому формулюванні static_assert — і в обмеженнях теж — треба вимагати лише те, що справді потрібне тілу функції або контракту API.

Помилка № 5: плутанина в очікуваннях — спроба використати static_assert як перевірку під час виконання.
static_assert працює лише на етапі компіляції. Він не перевірить користувацьке введення, не спіймає ситуацію «рядок порожній», не врятує від from_chars, який повернув помилку. Він захищає вас від неправильного типу та порушеного контракту використання. Для введення і даних є перевірки стану потоків, optional/expected, коди помилок і звичайні if — але це вже інша частина інструментарію.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ