JavaRush /Курсы /Swift SELF /Enum результата парсинга: CommandParseResult

Enum результата парсинга: CommandParseResult

Swift SELF
26 уровень , 4 лекция
Открыта

1. Идея CommandParseResult и ParseError

Если вы пишете парсер впервые, рука сама тянется сделать функцию parseCommand(...) -> Command?. Логика кажется железной: «получилось — вернули команду, не получилось — вернули nil». Но потом наступает день, когда пользователь вводит "remove abc", и вы понимаете, что nil — это не диагноз, а просто грустное молчание.

Проблема не в том, что Optional плохой. Проблема в том, что Optional сообщает слишком мало: только «успех/не успех», без ответа на вопрос «почему?». Пустая строка? Неизвестная команда? Не хватает аргумента? Аргумент неправильного типа? Всё это превращается в один и тот же nil, а дальше начинается любимая игра программиста: «угадай, где сломалось».

Представьте, что вы пришли к врачу, а он вам вместо диагноза говорит: «Ну… вы как бы не здоровы». Спасибо, доктор. Очень информативно.

Мы хотим, чтобы парсер возвращал не только факт успеха, но и понятную причину ошибки.

Идея такая:

  • если строка успешно распарсилась — возвращаем команду;
  • если нет — возвращаем ошибку парсинга, причём с контекстом.

Как устроен CommandParseResult

Это естественно моделируется через enum со связанными значениями:

  • .success(Command) — в успешном случае храним команду;
  • .failure(ParseError) — в неуспешном случае храним ошибку парсинга.

Похожий подход есть и в стандартной библиотеке Swift: Result тоже устроен как enum с кейсами успеха и ошибки. Но в этой лекции мы не используем стандартный Result — мы делаем свой, учебный и максимально прозрачный.

Сначала объявим базовые типы.

import Foundation

enum Command {
    case add(title: String)
    case remove(index: Int)
    case list
}

enum ParseError {
    case emptyInput
    case unknownCommand(String)
}

enum CommandParseResult {
    case success(Command)
    case failure(ParseError)
}

Обратите внимание на важную мелочь: CommandParseResult не заменяет Command. Он описывает результат попытки построить Command.

Почему это лучше, чем Command?

С точки зрения компилятора оба подхода рабочие. Разница в том, насколько удобно вам будет строить понятную программу и объяснять пользователю, что происходит.

Подход Что возвращаем Плюсы Минусы
Command?
команда или
nil
просто написать
nil
не объясняет причину; ошибки смешиваются; сложнее сделать хороший UX
CommandParseResult
.success(command)
или
.failure(error)
причина ошибки — часть результата; обработка через
switch
читаемая; проще печатать подсказки
чуть больше кода на объявление типов

Здесь «чуть больше кода» — это плата за то, чтобы приложение не выглядело как молчаливый сфинкс.

Расширяем ParseError контекстом

Если ошибка — это просто .unknownCommand, пользователю всё равно будет не очень понятно, что он ввёл. Поэтому ошибки парсинга обычно хранят контекст: например, какую команду ввели или какой аргумент не удалось распарсить.

Сделаем более практичную версию:

import Foundation

enum ParseError {
    case emptyInput
    case unknownCommand(String)
    case missingArgument(name: String)
    case invalidInt(String)
}

Смысл в том, что ошибка — это не «не получилось», а конкретный сценарий, который можно объяснить человеку.

3. Функция parseCommand: возвращаем CommandParseResult

Когда мы возвращали Command?, мы обычно писали так:

  • если не получилось — return nil;
  • если получилось — return .add(...).

Теперь вместо nil возвращаем .failure(...).

Ниже — компактный, но уже полезный парсер для команд "add", "remove", "list" (как каркас нашего CLI-приложения «мини-библиотека»).

Каркас парсера

import Foundation

func parseCommand(_ line: String) -> CommandParseResult {
    let parts = line.split(separator: " ")
    guard let first = parts.first else { return .failure(.emptyInput) }

    let name = String(first).lowercased()

    switch name {
    case "list":
        return .success(.list)
    default:
        return .failure(.unknownCommand(name))
    }
}

Пока парсер умеет только "list", но структура уже правильная: результат всегда либо success, либо failure. Никаких «может nil, может не nil».

Команда add <title>

В простейшем варианте будем считать, что title — это второй токен. Да, это ограничение (заголовки из нескольких слов мы разберём в теме токенизации с кавычками позже по курсу), а сейчас нам важнее научиться возвращать структурированный результат.

import Foundation

func parseCommand(_ line: String) -> CommandParseResult {
    let parts = line.split(separator: " ")
    guard let first = parts.first else { return .failure(.emptyInput) }

    let name = String(first).lowercased()

    switch name {
    case "add":
        guard parts.count >= 2 else { return .failure(.missingArgument(name: "title")) }
        let title = String(parts[1])
        return .success(.add(title: title))

    case "list":
        return .success(.list)

    default:
        return .failure(.unknownCommand(name))
    }
}

Заметьте стиль: мы не пытаемся «дотянуть» ошибку до конца функции. Мы ловим её там, где она возникает, и возвращаем .failure(...) сразу. Это делает код линейным: читается сверху вниз, как инструкция.

Команда remove <index>

Тут добавляется ещё одна причина ошибки: индекс может быть не числом.

import Foundation

func parseCommand(_ line: String) -> CommandParseResult {
    let parts = line.split(separator: " ")
    guard let first = parts.first else { return .failure(.emptyInput) }

    let name = String(first).lowercased()

    switch name {
    case "remove":
        guard parts.count >= 2 else { return .failure(.missingArgument(name: "index")) }
        let raw = String(parts[1])
        guard let index = Int(raw) else { return .failure(.invalidInt(raw)) }
        return .success(.remove(index: index))

    case "list":
        return .success(.list)

    default:
        return .failure(.unknownCommand(name))
    }
}

Обратите внимание: Int(raw) возвращает Int?, и мы обрабатываем это через guard. Никаких ! — парсер не должен падать от ввода пользователя. Парсер вообще существует именно потому, что пользователь часто вводит «не то».

4. Обработка результата парсинга через switch

Сделаем мини-обработчик результата, который:

  • если успех — работает с командой;
  • если ошибка — печатает понятное сообщение.

Сообщение для пользователя: ParseError.message

Мы можем сделать преобразование ошибки в текст как функцию, или как computed property внутри ParseError. Так как методы и computed properties в enum вы уже видели, сделаем свойство message.

import Foundation

enum ParseError {
    case emptyInput
    case unknownCommand(String)
    case missingArgument(name: String)
    case invalidInt(String)

    var message: String {
        switch self {
        case .emptyInput:
            return "Пустой ввод. Попробуйте, например: list"
        case let .unknownCommand(name):
            return "Неизвестная команда: \(name)"
        case let .missingArgument(name: argName):
            return "Не хватает аргумента: \(argName)"
        case let .invalidInt(raw):
            return "Ожидалось число, но получили: \(raw)"
        }
    }
}

Обработка CommandParseResult

Теперь основная обработка результата очень читабельна: switch сообщает вам, что оба сценария обязательны.

import Foundation

func handleParseResult(_ result: CommandParseResult) {
    switch result {
    case let .success(command):
        print("Команда распознана: \(command)")
    case let .failure(error):
        print("Ошибка ввода: \(error.message)")
    }
}

Да, print(command) даст технический вывод enum. Это нормально для учебного шага. Позже мы сделаем более дружелюбный вывод команд, но сегодня не распыляемся.

5. Мини-пример: «мини-библиотека»

Чтобы не было ощущения «мы делаем типы ради типов», соберём простой каркас приложения. Пусть у нас есть массив книг books: [String]. Команды:

  • add <title> добавляет;
  • remove <index> удаляет;
  • list печатает.

Сделаем функцию выполнения команды, а парсинг оставим отдельно. Это важная архитектурная привычка: парсер не должен иметь побочных эффектов.

import Foundation

func execute(_ command: Command, books: inout [String]) {
    switch command {
    case let .add(title):
        books.append(title)
        print("Добавили: \(title)") // Добавили: Milk

    case let .remove(index):
        guard index >= 0 && index < books.count else {
            print("Нет книги с индексом \(index)")
            return
        }
        let removed = books.remove(at: index)
        print("Удалили: \(removed)")

    case .list:
        print("Книги: \(books)") // Книги: ["Milk"]
    }
}

И теперь связываем всё вместе в «однопроходный» сценарий: прочитали строку, распарсили, обработали.

import Foundation

var books: [String] = []

let line = readLine() ?? ""
let result = parseCommand(line)

switch result {
case let .success(command):
    execute(command, books: &books)
case let .failure(error):
    print("Ошибка: \(error.message)")
}

Да, это пока «одна команда за запуск». Но для учебной цели этого достаточно: у нас появилась правильная модель результата парсинга.

6. Схема: место CommandParseResult в потоке программы

Иногда полезно увидеть не код, а маршрут данных. В нашем CLI это выглядит примерно так:

flowchart TD
    A[Ввод пользователя: String] --> B["parseCommand(String)"]
    B --> C{CommandParseResult}
    C -->|"success(Command)"| D["execute(Command)"]
    C -->|"failure(ParseError)"| E[Показать сообщение об ошибке]

То есть CommandParseResult — это «развилка», но не в смысле if, а в смысле значения, которое можно передавать, сохранять, логировать и тестировать.

7. Почему это удобно, даже если кажется многословным

Поначалу может казаться, что .success(...) и .failure(...) — это бюрократия. Но на практике бюрократия — это когда вы везде проверяете if cmd == nil, печатаете «Ошибка», а потом не понимаете, почему «ошибка» происходит в половине кейсов.

С CommandParseResult у вас появляется три приятных бонуса.

Первый бонус — обязательность обработки. switch заставляет вас явно обработать и успех, и ошибку. Нельзя «забыть», как с Optional, где иногда пишут if let, а иногда просто ставят ! и надеются на лучшее.

Второй бонус — хороший UX. Ошибка не просто «есть», у неё есть форма и данные. Это позволяет печатать сообщения уровня «Не хватает аргумента index» вместо «Не получилось».

Третий бонус — тестируемость мышлением. Даже без тестов (мы их будем проходить позже), вы уже можете «прогонять» парсер в голове: «на "remove abc" должен вернуться .failure(.invalidInt("abc"))». Это очень конкретно.

8. Типичные ошибки

Ошибка №1: вернуть .failure(.unknownCommand(...)) как универсальную заглушку.
Очень хочется любую проблему назвать «неизвестная команда», потому что это самый быстрый путь заставить код компилироваться. Но тогда вы теряете смысл CommandParseResult. Если у вас не хватает аргумента или не парсится число, это другой класс проблем, и пользователю нужно другое сообщение.

Ошибка №2: продолжать использовать Command? внутри, а CommandParseResult — только «для вида».
Иногда делают так: парсер по-прежнему возвращает Command?, а потом уже где-то снаружи превращают nil в .failure(.unknownCommand("???")). Это ломает идею: причина ошибки должна появляться там, где ошибка обнаружена. Иначе вы неизбежно начнёте придумывать «примерные причины» задним числом.

Ошибка №3: превращать обработку результата в кашу из if вместо нормального switch.
CommandParseResult создан, чтобы его удобно и явно обрабатывать через switch. Когда вы начинаете писать цепочки if case без необходимости, логика становится менее очевидной. Для одного-двух точечных случаев if case нормален, но для «успех или ошибка» switch почти всегда читается лучше.

Ошибка №4: падать на вводе пользователя через !.
Если вы делаете let index = Int(parts[1])!, то любое "remove abc" превращается в аварийную остановку программы. Пользователь, конечно, виноват, но приложение всё равно упало — а значит, с точки зрения пользователя виноваты вы. Парсер должен быть самым терпеливым участком системы: его работа — принимать реальность, какой бы странной она ни была.

Ошибка №5: не добавлять контекст в ошибки.
Если ошибка invalidInt не хранит строку raw, вы потом не сможете нормально объяснить, что именно было не так. Ошибки парсинга — это не только «тип», но и «детали», иначе сообщения для пользователя будут слишком общими и бесполезными.

1
Задача
Swift SELF, 26 уровень, 4 лекция
Недоступна
Ошибка терминала
Ошибка терминала
1
Задача
Swift SELF, 26 уровень, 4 лекция
Недоступна
Чат команды
Чат команды
1
Задача
Swift SELF, 26 уровень, 4 лекция
Недоступна
Команды калькулятора
Команды калькулятора
1
Задача
Swift SELF, 26 уровень, 4 лекция
Недоступна
Список покупок
Список покупок
1
Опрос
Enum и Associated Values, 26 уровень, 4 лекция
Недоступен
Enum и Associated Values
Практика CLI-моделей на Swift
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ