JavaRush /Курси /C++ SELF /std::from_chars — парсинг чисел із кодами помилок

std::from_chars — парсинг чисел із кодами помилок

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

1. Що таке std::from_chars і для чого він потрібен

Коли новачок уперше стикається із завданням «прочитати число з рядка», рука часто тягнеться до std::stoi. Це нормально: назва зрозуміла, прикладів в інтернеті багато, та й сама функція працює швидко. Але у stoi є свій характер: за некоректних вхідних даних вона може кидати винятки (а try/catch ми ще не проходили). Крім того, вона працює у світі рядків, де інколи зʼявляються зайві речі на кшталт додаткових перевірок, локалей і перетворень.

std::from_chars — це більш «низькорівневий», але дуже передбачуваний інструмент. Він не кидає винятків, не виділяє памʼять, працює з діапазоном символів [begin, end) і повертає результат із двома важливими підказками: «чому не вийшло» та «на якому символі зупинилися». У заголовку <charconv> зібрано «primitive numeric conversions», серед яких є й to_chars/from_chars.

Де міститься from_chars і як виглядає виклик

Зараз зробимо невеликий крок у бік «програмування як конструктора»: замість того, щоб сподіватися, ніби рядок «хороший», будемо акуратно розбирати його як послідовність символів.

Для std::from_chars вам майже завжди знадобляться такі заголовки:

#include <charconv>     // std::from_chars
#include <string_view>  // std::string_view
#include <system_error> // std::errc

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

Мінімальний приклад — рівно 7 рядків, без зайвих спецефектів:

#include <charconv>
#include <iostream>
#include <string_view>

int main() {
    std::string_view s = "123";
    int x = 0;
    auto r = std::from_chars(s.data(), s.data() + s.size(), x);
    std::cout << x << '\n'; // 123 (але успіх ми ще не перевірили!)
}

Зверніть увагу на важливу деталь: навіть якщо x вивівся як 123, це ще не означає успіху. У from_chars усе визначається не за принципом «ну, наче схоже», а за кодом помилки в поверненому результаті.

from_chars_result: два поля ec і ptr

Якщо сприймати std::from_chars як мініавтомат, то він робить просту річ: рухається за символами зліва направо, доки може зібрати число. Потім зупиняється й повідомляє, що сталося.

Результат має тип std::from_chars_result. У нього є два важливі поля:

Поле Що це означає простими словами
ec
код результату (
std::errc{}
— успіх, інакше — причина помилки)
ptr
вказівник на символ, на якому парсер зупинився

Тепер приклад, у якому ми перевіряємо саме те, що потрібно:

#include <charconv>
#include <iostream>
#include <string_view>
#include <system_error>

int main() {
    std::string_view s = "123";
    int x = 0;
    auto r = std::from_chars(s.data(), s.data() + s.size(), x);

    std::cout << (r.ec == std::errc{}) << '\n'; // 1 (успіх)
}

Порівняння r.ec == std::errc{} — це стандартний спосіб перевірити, що «все гаразд». Так, виглядає трохи дивно: наче ми порівнюємо з «порожньою помилкою». Але зміст саме такий: «помилки немає».

Про ptr найзручніше думати як про «курсор». Він вказує на те місце, де парсер перестав розбирати вхідні дані.

ptr як критерій строгого парсингу

Тепер — головний практичний момент: вам треба визначити політику парсингу. Наприклад, рядок "12kg" — це число чи помилка?

Якщо ви пишете калькулятор, це, ймовірно, помилка. Якщо ж ви пишете парсер на кшталт «число на початку рядка», це може бути цілком нормальним варіантом. from_chars дає змогу побачити обидва сценарії, бо ptr покаже, докуди вдалося дійти.

Приклад «число + хвіст»:

#include <charconv>
#include <iostream>
#include <string_view>
#include <system_error>

int main() {
    std::string_view s = "12kg";
    int x = 0;
    auto r = std::from_chars(s.data(), s.data() + s.size(), x);

    if (r.ec == std::errc{}) {
        std::cout << x << '\n';      // 12
        std::cout << *r.ptr << '\n'; // k
    }
}

Якщо ви хочете строгий парсинг — тобто щоб рядок містив лише число й нічого більше, — тоді критерій успіху стає подвійним:

  1. r.ec == std::errc{}
  2. r.ptr == end (тобто дійшли до кінця рядка)

Ось як це виглядає:

#include <charconv>
#include <iostream>
#include <string_view>
#include <system_error>

int main() {
    std::string_view s = "12kg";
    int x = 0;
    auto r = std::from_chars(s.data(), s.data() + s.size(), x);

    bool ok = (r.ec == std::errc{}) && (r.ptr == s.data() + s.size());
    std::cout << ok << '\n'; // 0
}

Це вже по-справжньому «доросла» перевірка: вона не дає випадково прийняти сміття за коректні дані.

2. Помилки парсингу та пробіли

Два базові коди помилок

Щоб ваші повідомлення користувачеві або гілки if/else були осмисленими, потрібно розрізняти принаймні дві причини неуспіху.

std::errc::invalid_argument означає: «число навіть не почалося». Типовий приклад — рядок "abc".

std::errc::result_out_of_range означає: «схоже на число, але воно не вміщується в тип». Наприклад, якщо ви парсите в int, а рядок містить щось космічне на кшталт "999999999999999999999".

Приклад, де ми розрізняємо ці випадки:

#include <charconv>
#include <iostream>
#include <string_view>
#include <system_error>

int main() {
    std::string_view s = "999999999999999999999";
    int x = 0;
    auto r = std::from_chars(s.data(), s.data() + s.size(), x);

    std::cout << (r.ec == std::errc::result_out_of_range) << '\n'; // 1
}

Це особливо корисно в навчальних CLI-програмах: ви можете чесно сказати користувачеві «це не число» або «число надто велике для int» замість розмитого «щось пішло не так».

from_chars не пропускає пробіли

На відміну від std::cin >> x, який зазвичай пропускає пробіли перед числом, from_chars читає рівно те, що йому дали. Якщо рядок починається з пробілу, як-от " 123", то в простому сценарії ви отримаєте invalid_argument.

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

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

3. Зручні обгортки для застосунку

Строгий парсер у std::optional

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

Зробімо функцію parse_int_strict, яка приймає std::string_view і повертає std::optional<int>: або число, або «не вийшло».

Спочатку схема, щоб не заплутатися:

flowchart TD
    A[рядок-токен] --> B["from_chars(begin,end,value)"]
    B --> C{"ec == errc{} ?"}
    C -- ні --> D[nullopt]
    C -- так --> E{ptr == end ?}
    E -- ні --> D
    E -- так --> F["optional(value)"]

Тепер реалізація. Зверніть увагу: код короткий, перевірка читається зліва направо, жодної «магії» тут немає.

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

std::optional<int> parse_int_strict(std::string_view s) {
    int value = 0;
    auto r = std::from_chars(s.data(), s.data() + s.size(), value);

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

Тепер приклад використання в обробнику команди. Припустімо, команда видалення завдання має вигляд "del 3", де 3 — це id. Ми отримали токен "3" і хочемо його розпарсити.

#include <iostream>
#include <optional>
#include <string_view>

std::optional<int> parse_int_strict(std::string_view s);

int main() {
    auto id = parse_int_strict("3");
    if (!id) {
        std::cout << "Некоректний id\n";
        return 0;
    }
    std::cout << "Видаляємо завдання #" << *id << '\n'; // Видаляємо завдання #3
}

Зауважте стиль: спочатку перевірили, потім використали *id. Це саме та дисципліна, яку ми тренували на std::optional.

Розширений результат зі статусом

Іноді optional недостатньо: хочеться відрізняти «не число» від «числа з хвостом» і від «надто великого числа». optional за визначенням каже лише «є/немає».

Але нам ніхто не забороняє повернути трохи більше інформації — наприклад, пару «значення + код помилки». Поки що ми не вводимо складних типів, тож можна зробити дуже простий struct.

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

struct ParseIntResult {
    int value = 0;
    std::errc ec = std::errc::invalid_argument;
    bool full = false; // чи дійшли до кінця рядка
};

ParseIntResult parse_int(std::string_view s) {
    ParseIntResult out{};
    auto r = std::from_chars(s.data(), s.data() + s.size(), out.value);
    out.ec = r.ec;
    out.full = (r.ptr == s.data() + s.size());
    return out;
}

А тепер — «людське» повідомлення користувачеві:

#include <iostream>
#include <string_view>
#include <system_error>

struct ParseIntResult;
ParseIntResult parse_int(std::string_view s);

int main() {
    auto r = parse_int("12kg");

    if (r.ec == std::errc::invalid_argument) {
        std::cout << "Це не число\n";
    } else if (r.ec == std::errc::result_out_of_range) {
        std::cout << "Число надто велике\n";
    } else if (!r.full) {
        std::cout << "Після числа є зайві символи\n";
    } else {
        std::cout << "Гаразд: " << r.value << '\n';
    }
}

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

4. Шпаргалка з перевірок

Іноді корисно мати перед очима невелику «шпаргалку», щоб не плутатися.

Перевірка Що означає Типовий приклад введення
r.ec == std::errc{}
парсер зміг прочитати число
"123"
r.ec == std::errc::invalid_argument
число не почалося
"abc"
r.ec == std::errc::result_out_of_range
число надто велике/замале для типу
"999999999999"
для
int
r.ptr == end
рядок повністю складається з числа (строгий ввід)
"123"
r.ptr != end
число прочитали, але далі є сміття (хвіст)
"12kg"

Найприємніше, що ця логіка не залежить від примх середовища виконання. Вона працює передбачувано. У документах WG21 from_chars/to_chars якраз належать до «примітивних числових перетворень» у <charconv>, і навколо них навіть обговорювали точкові зміни, наприклад constexpr для інтегральних типів.

5. Типові помилки під час роботи з std::from_chars

Помилка № 1: вважати, що якщо змінна змінилася, то парсинг успішний.
Те, що from_chars змінив змінну, ще не означає успіху. Критерій тут один: перевірка r.ec. Якщо забути про неї, ви отримаєте сценарій «працює на моїх даних» і «ламається на даних користувача» — класична історія.

Помилка № 2: забути про перевірку ptr і випадково прийняти «12kg» за коректні дані.
Багато початківців перевіряють лише r.ec, радіють і йдуть далі. А потім дивуються, чому команда "del 10abc" раптом видаляє завдання 10. Якщо ваша політика — строгий ввід, то перевірка r.ptr == end обовʼязкова, інакше ви дозволяєте хвіст.

Помилка № 3: передавати неправильні межі діапазону [first, last).
from_chars не знає, що таке «рядок» — він знає лише вказівники. Тому межі потрібно задавати правильно: begin = s.data(), end = s.data() + s.size(). Помилка в + size() часто перетворюється на сценарій «читаємо зайву памʼять» або «обрізаємо введення», а тоді ви ловите дивні ефекти, які важко налагоджувати.

Помилка № 4: очікувати, що from_chars пропускатиме пробіли, як оператор потокового вводу.
Він їх не пропускає. Якщо ви передаєте йому рядок із пробілами, результатом може бути invalid_argument. Це не баг, а контракт. Або нормалізуйте рядок заздалегідь, або виділяйте токени так, щоб пробілів у них не було.

Помилка № 5: парсити в надто вузький тип і не обробляти result_out_of_range.
Якщо ви парсите в int, то маєте бути готові до того, що користувач уведе число, яке не вміщується. І це не «рідкісний хакерський кейс», а звична реальність. У хорошому коді ви розрізняєте «не число» і «надто велике число» та обробляєте обидва сценарії.

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