JavaRush /Курси /Swift SELF /Способи парсингу: split vs Scanner і CharacterSet

Способи парсингу: split vs Scanner і CharacterSet

Swift SELF
Рівень 33 , Лекція 0
Відкрита

1. Токенізація в CLI: навіщо вона потрібна

Коли ви вперше пишете CLI, дуже хочеться зробити так: line.split(separator: " ") — і готово, парсер уже народився. А потім користувач вводить два пробіли підряд, табуляцію або випадково вставляє перенос рядка — і ваш «парсер» починає поводитися як кіт у пакеті: гучно й непередбачувано.

Тому корисно знати два базові підходи: швидкий split і більш керований послідовний розбір через Scanner + CharacterSet. Не тому, що один «правильний», а тому, що в кожного є чесні межі застосовності.

«Розділити» — ще не «зрозуміти»

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

Дуже важливо не змішувати ці рівні: токенізація не має розуміти, що таке --limit=10, і тим більше не має виконувати Int("10"). Вона відповідає на простіше запитання: «Які фрагменти тексту вважати окремими словами?».

flowchart TD
    A["Введення користувача: один рядок"] --> B["Нормалізація (trim)"]
    B --> C["Токенізація (split/Scanner)"]
    C --> D["Граматика (команда/аргументи/прапорці)"]
    D --> E["Структурований Command або помилка"]

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

2. Підхід 1: split

split — це як кухонний ніж: майже завжди під рукою, дуже швидкий і зрозумілий, але не варто чекати, що він сам приготує вечерю, помиє посуд і запише вас на йогу.

Для більшості простих CLI-команд без лапок split — чудовий старт. Головне — пам’ятати два нюанси: він повертає Substring і нічого не знає про лапки та екранування. До них ми ще дійдемо.

Нормалізація: trim і порожній ввід

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

У проєкті LibraryCLI це виглядатиме так: спочатку trim, потім перевірка на порожність і лише після цього — токенізація.

import Foundation

func normalizeCLIInput(_ line: String) -> String {
    line.trimmingCharacters(in: .whitespacesAndNewlines)
}

Ця функція проста, але корисна: ви перестаєте ловити загадкові кейси на кшталт «користувач увів лише пробіли, а ми чомусь вирішили, що це команда list (ну, мало що…)».

split повертає Substring

Коли ви робите split, Swift віддає вам не нові рядки, а «зрізи» вихідного рядка — Substring. Це заощаджує пам’ять і працює швидко, але накладає практичне правило: якщо токени житимуть довше, ніж сам вихідний рядок (а в CLI це майже завжди так), краще перетворити їх на String.

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

let line = "add milk 2"
let parts = line.split(separator: " ")
print(parts) // ["add", "milk", "2"]  // але це все ще [Substring]

Якщо хочемо «довгоживучі» токени:

let line = "add milk 2"
let tokens = line.split(separator: " ").map { String($0) }
print(tokens) // ["add", "milk", "2"]

Токенізація для LibraryCLI

Давайте зробимо першу корисну функцію для нашого застосунку: токенізатор «без лапок». Він використовуватиме split(whereSeparator:), щоб роздільниками вважалися будь-які пробільні символи — пробіл, табуляція, перенос рядка. Це трохи стійкіше, ніж split(separator: " ").

import Foundation

func tokenizeWithSplit(_ line: String) -> [String] {
    let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
    if trimmed.isEmpty { return [] }

    return trimmed
        .split(whereSeparator: { $0.isWhitespace })
        .map { String($0) }
}

Перевірмо на реальному прикладі:

let tokens = tokenizeWithSplit("   list\t\t--all   ")
print(tokens) // ["list", "--all"]

Зверніть увагу: це все ще проста токенізація. Вона не розуміє, що --all — це прапорець. Вона лише чесно віддає слова. Саме тому її легко тестувати й легко пояснювати користувачеві.

3. Підхід 2: Scanner і CharacterSet

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

Цей підхід часто легше розширювати, коли вам потрібно додати спеціальні правила, але ви ще не готові писати повноцінну машину станів.

Щоб Scanner був корисним, нам потрібен другий герой — CharacterSet. Це тип із Foundation, який представляє «набір символів», наприклад «усі пробіли й переноси рядків».

CharacterSet як набір роздільників

CharacterSet — чудовий спосіб сказати: «роздільником є не конкретний символ, а цілий клас символів». На практиці в CLI найчастіше потрібен набір .whitespacesAndNewlines.

Іноді корисно додати до нього ще щось — наприклад, кому, якщо ви робите введення на кшталт tags: sci-fi,fantasy classics. Це ще не наша сьогоднішня граматика, але сам прийом корисний.

import Foundation

let delimiters = CharacterSet.whitespacesAndNewlines

Чому це зручно? Тому що ваш код починає говорити людською мовою: «пропусти пробіли», а не «якщо ch == " " або ch == "\t" або…».

Базовий цикл Scanner

Тепер зберемо токенізацію через Scanner. Тут буде два ключові виклики: scanUpToCharacters(from:) — «прочитай до роздільника» і scanCharacters(from:) — «пропусти роздільники». Обидва повертають Optional, тож ми автоматично тренуємося жити в реальному світі, де дані можуть закінчитися. Swift, до речі, цю думку дуже любить. Іноді навіть занадто.

import Foundation

func tokenizeWithScanner(_ line: String) -> [String] {
    let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
    if trimmed.isEmpty { return [] }

    let scanner = Scanner(string: trimmed)
    var result: [String] = []

    while let token = scanner.scanUpToCharacters(from: .whitespacesAndNewlines) {
        result.append(token)
        _ = scanner.scanCharacters(from: .whitespacesAndNewlines)
    }

    return result
}

Маленька перевірка:

let tokens = tokenizeWithScanner("add   milk\t2")
print(tokens) // ["add", "milk", "2"]

З погляду результату ми отримали те саме, що й split. Але з погляду механіки це вже послідовний прохід. І коли пізніше ви додаватимете складніші правила, такий підхід іноді розвивається легше, ніж split.

Як об’єднувати набори символів

Іноді хочеться, щоб роздільниками були і пробіли, і кома. Наприклад, користувач вводить: import tags sci-fi,fantasy classics. Це ще не наша сьогоднішня граматика, але сам прийом корисний.

import Foundation

let separators = CharacterSet.whitespacesAndNewlines
    .union(CharacterSet(charactersIn: ","))

print(separators.contains(",")) // true

І тоді Scanner можна налаштувати на такі роздільники, замість .whitespacesAndNewlines. Важливо: це все ще проста токенізація. Ми не вчимо Scanner розуміти лапки — просто розширюємо поняття роздільника.

4. Порівняння split і Scanner

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

Критерій split Scanner + CharacterSet
Складність коду Мінімальна: 1–2 рядки Трохи більше «ритуалу»: цикл, Optional
Читабельність для новачка Дуже висока Середня: потрібно зрозуміти ідею «курсора»
Контроль над процесом Низький: усе відбувається відразу Високий: ви керуєте читанням і пропусканням
Роздільники Зручно для простих випадків (isWhitespace) Дуже зручно для класів символів (CharacterSet)
Лапки та екранування Не вміє У базовому вигляді теж не вміє — потрібна додаткова логіка
Тип результату [Substring] → частіше треба String(...) Одразу [String]
Помилки формату Майже нічого не дає для діагностики Можна розширювати й робити діагностику крок за кроком

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

До речі, в офіційних матеріалах і прикладах split часто показують як базову операцію «отримали частини — обробили в циклі», і це нормальна, очікувана модель.

5. Спільний інтерфейс токенізації в LibraryCLI

Дуже легко зробити так, що в main.swift у вас з’явиться десять рядків зі split, потім ще десять — зі Scanner, потім ви додасте «швидку правку» — і вже через тиждень не зможете самі пояснити, чому list працює, а add раптом ні (це не баг, а «історія становлення» вашого коду).

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

import Foundation

enum TokenizerKind {
    case split
    case scanner
}

func tokenize(_ line: String, using kind: TokenizerKind) -> [String] {
    switch kind {
    case .split:
        return tokenizeWithSplit(line)
    case .scanner:
        return tokenizeWithScanner(line)
    }
}

Тепер у точці входу (умовно main.swift) ми можемо зробити маленьке налагодження: читаємо рядок і друкуємо токени. Це не «фінальний CLI», а зручний спосіб побачити, що токенізація взагалі робить.

import Foundation

let kind: TokenizerKind = .split

if let line = readLine() {
    let tokens = tokenize(line, using: kind)
    print(tokens) // Наприклад: ["add", "milk", "2"]
}

Так, поки це виглядає як «програма, що друкує масив». Але насправді це важливий крок: ви отримали детермінований, придатний до тестування результат етапу токенізації. У наступних лекціях цього блоку цей масив перетвориться на ParsedLine, а потім — на Command. І от там уже почнеться справжня магія — та, що компілюється.

6. Типові помилки під час роботи з split і Scanner

Помилка № 1: вважати, що split уже є парсером команди.
Зазвичай це виглядає так: ви робите split, а потім одразу намагаєтеся визначити, де прапорці, де аргументи, де --key=value, і паралельно викликаєте Int(...). У підсумку «невідома команда» може з’явитися через те, що не вдалося розібрати число, а користувач дивитиметься на все це з виразом «ви там серйозно?». Правильніше тримати етапи окремо: спочатку токени, потім граматика, потім типи.

Помилка № 2: забути, що split повертає Substring, і зберегти Substring у модель.
Substring — це зріз вихідного рядка. Він може утримувати пам’ять усього рядка, навіть якщо ви зберегли лише маленький шматок. Для локальної обробки це нормально, але для результату токенізації зазвичай варто робити String($0), щоб токени були незалежними й довгоживучими. У CLI це особливо доречно, тому що токени — це й є вхідні дані, які ви далі розбиратимете.

Помилка № 3: токенізація лише за " " та ігнорування табуляції й переносів рядка.
Користувач може вставити текст із документа, термінал може підставити табуляцію, а ви раптом отримаєте дивні токени, бо вважали роздільником лише один символ " ". split(whereSeparator: { $0.isWhitespace }) або .whitespacesAndNewlines у Scanner зазвичай надійніші.

Помилка № 4: очікувати, що Scanner самостійно розбере лапки.
Scanner у базовому вигляді не обробляє "green tea" як один токен. Він чесно бачить пробіл і вважає його роздільником. Це не недолік Scanner, а просто межа задачі. У наступних лекціях цього блоку ми додамо правила лапок і екранування, але сьогодні фіксуємо: обидва підходи — split і Scanner — це проста токенізація без лапок.

Помилка № 5: «перевірю порожність після split» і пропустити випадок «рядок із самих пробілів».
Якщо ви робите let tokens = line.split(...) без нормалізації, то рядок " " дасть порожній масив токенів, і ви потім можете випадково видати невідома команда, хоча за змістом це «порожній ввід». Лікується тривіально: trim + окрема політика обробки порожнього вводу (у наступних лекціях цього блоку це стане частиною TokenizeError.emptyInput).

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