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?
С точки зрения компилятора оба подхода рабочие. Разница в том, насколько удобно вам будет строить понятную программу и объяснять пользователю, что происходит.
| Подход | Что возвращаем | Плюсы | Минусы |
|---|---|---|---|
|
команда или |
просто написать | не объясняет причину; ошибки смешиваются; сложнее сделать хороший UX |
|
или |
причина ошибки — часть результата; обработка через читаемая; проще печатать подсказки |
чуть больше кода на объявление типов |
Здесь «чуть больше кода» — это плата за то, чтобы приложение не выглядело как молчаливый сфинкс.
Расширяем 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, вы потом не сможете нормально объяснить, что именно было не так. Ошибки парсинга — это не только «тип», но и «детали», иначе сообщения для пользователя будут слишком общими и бесполезными.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ