JavaRush /Курси /C++ SELF /[[nodiscard]]: чому не варто ігнорувати результат

[[nodiscard]]: чому не варто ігнорувати результат

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

1. Чому важливо не ігнорувати результат

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

Уявіть типову ситуацію: ви написали парсер числа, який повертає std::optional<int>. Ви викликаєте його, але забуваєте присвоїти результат змінній або присвоюєте його, але не перевіряєте. А далі робите вигляд, що все гаразд. Це не «складна помилка C++», а цілком людська ситуація: просто забули один рядок. І от тут [[nodiscard]] відіграє роль правила «пристібайтеся паском безпеки»: він не замінить здоровий глузд, але відчутно зменшить шанс вилетіти в кювет уже на першому повороті.

Атрибути в C++: що таке [[...]] і чому компілятор до них дослухається

Атрибути в C++ — це своєрідні «наліпки» на код. Вони не змінюють логіку напряму, зате дають компілятору підказку. Вона може бути м’якою або доволі наполегливою: наприклад, «ей, не ігноруй результат, це підозріло». Синтаксис у них єдиний: подвійні квадратні дужки [[...]]. Сьогодні нас цікавить [[nodiscard]].

Важливо правильно розуміти очікування: атрибут — це не гарантія того, що компілятор поводитиметься абсолютно однаково в усіх середовищах. Зазвичай [[nodiscard]] породжує попередження (warning), а чи вважатиметься воно помилкою, залежить від налаштувань збирання. Але навіть попередження — це вже величезна допомога: IDE підсвітить проблему, CI може «насварити», а ви помітите її одразу, а не після трьох годин налагодження.

До речі, [[nodiscard]] — настільки практична річ, що її активно використовують у стандартній бібліотеці. Водночас навколо неї час від часу точаться дискусії: де атрибут варто залишити, а де — прибрати. Інструмент сильний і корисний, але якщо застосовувати його надто широко, він створюватиме зайвий шум із попереджень.

2. [[nodiscard]] у функціях і повернених значеннях

[[nodiscard]] на функції: сигнал «перевір мене»

Найпоширеніший сценарій — позначити функцію, результат якої майже завжди треба перевіряти. Зазвичай це функції, що повертають bool («успіх/неуспіх»), або функції, що повертають std::optional<T> («значення є/немає»). Тоді, якщо ви викликали функцію й просто викинули результат, компілятор має повне моральне право запитати: «Ви впевнені, що це не помилка?»

Подивімося на невеликий приклад. Ми пишемо функцію читання цілого числа з std::cin. Тут важливе ось що: функція повертає bool, і цей bool не варто ігнорувати.

#include <iostream>

[[nodiscard]] bool read_int(int& out) {
    return static_cast<bool>(std::cin >> out);
}

int main() {
    int x = 0;
    read_int(x);                 // <- компілятор може попередити: результат проігноровано
    std::cout << x << '\n';
}

Сенс простий: якщо read_int повернув false, змінна x може залишитися зі старим значенням або нулем — як пощастить. А застосунок тим часом продовжить роботу в «напівзламаному» стані. [[nodiscard]] не дає вам випадково зробити вигляд, ніби все гаразд.

Правильне використання виглядає так: спочатку перевірка, потім — використання.

#include <iostream>

[[nodiscard]] bool read_int(int& out) {
    return static_cast<bool>(std::cin >> out);
}

int main() {
    int x = 0;
    if (!read_int(x)) {
        std::cout << "Некоректне введення\n";   // Некоректне введення
        return 0;
    }
    std::cout << "x=" << x << '\n';   // x=...
}

Зверніть увагу на «психологію коду»: [[nodiscard]] підштовхує одразу писати гілку оброблення помилки, а не відкладати це на потім.

[[nodiscard]] + std::optional: для парсерів і пошуку

std::optional сам по собі вже робить відсутність результату явною, але проблема нікуди не зникає: ви однаково можете випадково проігнорувати optional, який повернула функція. Особливо коли пишете ланцюжки викликів на кшталт «прочитали рядок → розпарсили число → знайшли елемент → змінили».

Почнімо зі строгого парсингу рядка — за мотивами теми про std::from_chars. Ми загортаємо логіку «строге число без хвоста» у функцію й позначаємо її атрибутом [[nodiscard]].

#include <charconv>
#include <optional>
#include <string_view>
#include <system_error>

[[nodiscard]] std::optional<int> parse_int_strict(std::string_view sv) {
    int value = 0;
    auto res = std::from_chars(sv.data(), sv.data() + sv.size(), value);

    if (res.ec != std::errc{}) return std::nullopt;
    if (res.ptr != sv.data() + sv.size()) return std::nullopt;
    return value;
}

Чому це саме той випадок, коли результат треба перевіряти? Бо інакше можна зробити ось так:

#include <string_view>

// parse_int_strict визначена вище

int main() {
    parse_int_strict("123");  // <- ніби «викликали», але нічого не зробили. Майже завжди це баг.
}

Якщо ви бачите такий рядок у проєкті, це майже завжди означає, що програміст просто відволікся на чай. [[nodiscard]] саме для того й потрібен, щоб компілятор повернув вас від чаю назад до коду.

Правильне використання:

#include <iostream>
#include <string>

// parse_int_strict визначена вище

int main() {
    std::string s;
    std::getline(std::cin, s);

    auto value = parse_int_strict(s);
    if (!value) {
        std::cout << "Це некоректне ціле число\n";   // Це некоректне ціле число
        return 0;
    }
    std::cout << "ok: " << *value << '\n';  // ok: ...
}

Так само зручно позначати [[nodiscard]] функції пошуку. Раніше ви могли повертати -1, а тепер — optional<size_t>. Ігнорувати індекс знайденого елемента зазвичай безглуздо, тож [[nodiscard]] тут цілком доречний:

#include <cstddef>
#include <optional>
#include <vector>

[[nodiscard]] std::optional<std::size_t> find_index(const std::vector<int>& v, int value) {
    for (std::size_t i = 0; i < v.size(); ++i) {
        if (v[i] == value) return i;
    }
    return std::nullopt;
}

Як свідомо ігнорувати результат

Іноді результат справді не потрібен — і це нормально. Проблема в тому, що «іноді» і «випадково забув» виглядають однаково: в обох випадках результат проігноровано. Тому в хороших кодових базах діє просте правило: якщо ви навмисно ігноруєте результат із [[nodiscard]], зробіть це явно. Тоді читач одразу зрозуміє: «так, тут усе задумано саме так».

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

#include <optional>
#include <string_view>

// parse_int_strict визначена вище

int main() {
    (void)parse_int_strict("123"); // свідомо ігноруємо (але, чесно кажучи, навіщо?)
}

Чому тут так і хочеться запитати «але навіщо?» Бо в контексті введення, парсингу й пошуку ігнорування результату майже завжди означає логічну помилку: ви викликали функцію заради результату, а не заради побічного ефекту. І [[nodiscard]] якраз допомагає відрізнити «описався/забув» від «зробив це свідомо».

[[nodiscard]] на типах: коли корисно позначати цілі типи «результатів операції»

[[nodiscard]] можна ставити не лише на функції, а й на типи. Це зручно, коли ви створюєте невеликий тип результату: наприклад, структуру «успіх + повідомлення». Тоді хочеться, щоб будь-яке створення такого об’єкта «підштовхувало» код, який його отримує, перевірити, що саме всередині.

У межах навчальних прикладів можна зробити просту структуру для результату операції — без винятків і без expected:

#include <string>

struct [[nodiscard]] OpStatus {
    bool ok = false;
    std::string message;
};

Тепер функція може повертати OpStatus, і якщо ви його проігноруєте, компілятор може видати попередження.

#include <string>

struct [[nodiscard]] OpStatus {
    bool ok = false;
    std::string message;
};

[[nodiscard]] OpStatus validate_name(const std::string& name) {
    if (name.empty()) return {false, "Імʼя порожнє"};
    return {true, "ok"};
}

int main() {
    validate_name(""); // <- статус проігноровано: це підозріло
}

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

3. Приклад: [[nodiscard]] у міні-застосунку TaskBox

Тепер зберімо все в одну зрозумілу історію, щоб це були не окремі розрізнені приклади, а щось ближче до реального коду. Нехай у нас є консольний міні-застосунок TaskBox: список завдань, у якому ми можемо додавати завдання й позначати їх виконаними. Ми вже вміємо зберігати дані в std::vector, робити пошук і друк, а сьогодні зробимо введення й парсинг дисциплінованішими.

Спершу задамо модель:

#include <string>

struct Task {
    int id = 0;
    std::string title;
    bool done = false;
};

Тепер напишемо пошук завдання за id. Для новачка набагато зручніше повертати не «сирий вказівник» і не -1, а std::optional<std::size_t> — індекс або його відсутність. І це чудовий кандидат для [[nodiscard]].

#include <cstddef>
#include <optional>
#include <vector>

[[nodiscard]] std::optional<std::size_t> find_task_index_by_id(
    const std::vector<Task>& tasks, int id)
{
    for (std::size_t i = 0; i < tasks.size(); ++i) {
        if (tasks[i].id == id) return i;
    }
    return std::nullopt;
}

Далі нам потрібен строгий парсер id із рядка, і ми хочемо, щоб ніхто не забув перевірити його результат:

#include <charconv>
#include <optional>
#include <string_view>
#include <system_error>

[[nodiscard]] std::optional<int> parse_int_strict(std::string_view sv) {
    int value = 0;
    auto res = std::from_chars(sv.data(), sv.data() + sv.size(), value);

    if (res.ec != std::errc{}) return std::nullopt;
    if (res.ptr != sv.data() + sv.size()) return std::nullopt;
    return value;
}

Тепер уявімо команду done 3, де 3 — це id завдання. Ми читаємо рядок, приблизно розбиваємо його й далі маємо перевірити обидва результати: і парсинг числа, і пошук завдання.

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

// Task, find_task_index_by_id, parse_int_strict оголошені вище

void mark_done(std::vector<Task>& tasks, const std::string& arg) {
    auto id = parse_int_strict(arg);
    if (!id) {
        std::cout << "Некоректний id\n";               // Некоректний id
        return;
    }

    auto idx = find_task_index_by_id(tasks, *id);
    if (!idx) {
        std::cout << "Завдання не знайдено\n";       // Завдання не знайдено
        return;
    }

    tasks[*idx].done = true;
    std::cout << "Позначено як виконане\n";              // Позначено як виконане
}

Тут головний сенс навіть не у функціональності, а в тому, що код читається як ланцюжок контрактів: «якщо не розпарсилося — виходимо», «якщо не знайшли — виходимо», «якщо все гаразд — виконуємо дію». І саме тут [[nodiscard]] працює як страховка: якщо ви випадково забудете написати auto id = ... і просто напишете parse_int_strict(arg);, компілятор може зупинити вас попередженням.

Щоб закріпити все це, ось невелика таблиця: що ми позначаємо [[nodiscard]] і чому.

Функція/тип Що повертає Чому не можна ігнорувати
read_int(int&)
bool
Інакше ви продовжите роботу з некоректними даними
parse_int_strict(string_view)
optional<int>
Інакше ви «розпарсили в нікуди»
find_task_index_by_id(...)
optional<size_t>
Інакше пошук був би безглуздою дією
OpStatus
ok + message
Інакше помилка «губиться» й стає мовчазною

4. Типові помилки під час використання [[nodiscard]]

Помилка № 1: думати, що [[nodiscard]] обробляє помилку.
Цей атрибут не додає жодних if, не виправляє введення й не повертає вас у часі до моменту, коли виник баг. Він лише просить компілятор попередити: «ви впевнені, що не забули обробити результат?» А от саме оброблення все одно пишете ви: через if, ранній return або виведення повідомлення.

Помилка № 2: ставити [[nodiscard]] взагалі на все підряд.
Якщо позначити кожну другу функцію, ви дуже швидко звикнете ігнорувати попередження, а IDE перетвориться на червоно-жовту ялинку. Хороший критерій простий: ігнорування результату майже завжди має бути помилкою. Якщо ж «іноді це нормально», найімовірніше, [[nodiscard]] тут не потрібен.

Помилка № 3: гасити попередження замість того, щоб виправити логіку.
Іноді новачок бачить warning, додає (void)func(); і радіє тиші. Але тиша не означає коректність. Приведення до void доречне лише тоді, коли ви справді впевнені, що результат не потрібен. У контексті парсингу, пошуку й введення це радше виняток.

Помилка № 4: позначити [[nodiscard]], але не продумати зручний контракт.
Якщо ваша функція повертає bool, але не пояснює, що саме пішло не так, код, який її викликає, може почати породжувати дивні «універсальні повідомлення про помилку». У межах навчальних прикладів це розвʼязують просто: або повертають optional, або друкують зрозумілий текст на місці, або — у невеликих випадках — повертають простий OpStatus.

Помилка № 5: вважати, що попередження буде однаковим усюди.
Різні компілятори й налаштування збирання поводяться по-різному: десь [[nodiscard]] суворо підсвічується, десь м’якше, а десь попередження взагалі вимкнені. Тому ставтеся до [[nodiscard]] як до другої лінії захисту: перша лінія — це ваш акуратний код із перевірками після кожного потенційно неуспішного кроку.

1
Опитування
Помилки вводу та результати, рівень 20, лекція 4
Недоступний
Помилки вводу та результати
Помилки вводу та результати
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ