1. Валидация через throw: базовый паттерн guard-style
Валидация — это момент, когда программа проверяет: данные не просто «есть», а соответствуют правилам. И вот тут у новичков часто включается режим: «если что — вернём nil» или «если что — напечатаем “ошибка” и поедем дальше». Проблема в том, что валидация — это не редкая катастрофа, а повседневная реальность: пользователь ошибся, строка пустая, число вне диапазона.
Когда вы используете throw для валидации, вы говорите очень честно: «дальше по этому сценарию идти нельзя, потому что данные невалидны, и вот точная причина». Это контролируемый выход, а не падение программы. И он особенно хорош там, где мы хотим сохранить причину ошибки, чтобы выше по уровню принять решение: что показать пользователю, что записать в лог, что попросить повторить.
Сравните по смыслу:
- nil — это «не получилось», но почему — непонятно.
- throw SomeError.reason(...) — это «не получилось, потому что …», и причина типизирована.
И вот это «потому что» — ваш главный друг, когда проект становится чуть больше, чем «прочитал число — вывел число».
Guard-style как линейная цепочка правил
Когда проверок много, код легко превращается в «лес if’ов»: один if внутри другого, потом ещё два, потом вы забыли сделать return/throw, и программа продолжила жить в неправильном состоянии (как зомби‑данные: вроде ходят, но уже мёртвые).
guard хорош тем, что заставляет вас оформить проверку как правило допуска: если условие не выполнено — мы обязаны выйти из текущего пути. Это прям часть идеи guard: блок else должен завершать текущий путь (return, throw, break, continue и т.д.). Именно это делает guard не «перевёрнутым if», а отдельным инструментом читаемости.
В валидации это превращается в очень удобную структуру: вы пишете несколько guard, и код читается как список требований. Если все требования прошли — вы спокойно идёте дальше, уже имея корректные данные.
Небольшая схема мышления:
Входные данные
↓
guard правило №1 (иначе throw)
↓
guard правило №2 (иначе throw)
↓
guard правило №3 (иначе throw)
↓
Дальше работаем с валидными значениями
Мини‑пример: «не пустая строка» как правило
import Foundation
enum ValidationError: Error {
case emptyText
}
func requireNonEmpty(_ text: String) throws -> String {
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw ValidationError.emptyText
}
return text
}
Обратите внимание на важную деталь: после throw нет смысла писать код «на всякий случай». throw завершает текущую функцию немедленно.
2. Единый формат ошибок в LibraryCLI
Когда вы пишете маленькие упражнения, можно позволить себе «в одном месте print, в другом return nil, в третьем — просто break». Но как только вы строите CLI‑приложение (а наш учебный проект как раз в эту сторону и идёт), хаос в ошибках становится реальной проблемой.
Почему CLI особенно чувствителен к формату ошибок? Потому что у пользователя нет кнопочек и подсветки полей. У него есть текст. И если ваш текст ошибок каждый раз разный — пользователь начинает играть в угадайку: что вводить, куда смотреть, почему сегодня «Invalid input», а завтра «bad year».
Поэтому цель этого раздела — договориться о дисциплине.
Мы хотим, чтобы все ошибки валидации в нашем LibraryCLI:
- жили в одном enum (или в паре чётко разделённых enum, но сегодня сделаем один — так проще удержать в голове);
- имели предсказуемое человекочитаемое сообщение;
- при необходимости хранили контекст (что ввели, какой диапазон ожидали).
Сделаем это без будущих тем (без LocalizedError, без «слоёв ошибок», без Result). Только то, что уже умеем: enum, associated values, switch, computed properties.
LibraryValidationError: один кейс = одно правило
Самая частая ошибка новичка — сделать один кейс .failed и пытаться запихнуть туда строку «что-то пошло не так». Формально это работает, но практической пользы мало: вы не можете нормально ветвиться, не можете нормально тестировать, и со временем сообщения превращаются в «коллекцию случайных фраз».
Вместо этого мы делаем так: одно правило — один кейс.
Таблица правил (как проектировать ошибки)
| Правило валидации | Что не так | Какой кейс ошибки |
|---|---|---|
| Команда пуста | нечего парсить | |
| Неизвестная команда | пользователь ошибся в слове | |
| Не хватает аргументов | формат команды неверный | |
| Год не число | не сработал |
|
| Год вне диапазона | число есть, но не подходит | |
| Заголовок пустой | после trim пусто | |
Реализация enum и стандартного текста
import Foundation
enum LibraryValidationError: Error {
case emptyCommand
case unknownCommand(name: String)
case missingArgument(name: String)
case emptyTitle
case yearNotANumber(input: String)
case yearOutOfRange(value: Int, min: Int, max: Int)
var message: String {
switch self {
case .emptyCommand:
return "Пустая команда. Введите команду, например: add \"Clean Code\" 2008"
case .unknownCommand(let name):
return "Неизвестная команда: \\(name). Попробуйте: add"
case .missingArgument(let name):
return "Не хватает аргумента: \\(name). Пример: add \"Clean Code\" 2008"
case .emptyTitle:
return "Название книги не может быть пустым."
case .yearNotANumber(let input):
return "Год должен быть числом, но получено: '\\(input)'."
case .yearOutOfRange(let value, let min, let max):
return "Год \\(value) вне диапазона \\(min)...\\(max)."
}
}
}
Здесь message — это наш «единый формат» на уровне смысла: у каждой ошибки есть стандартный текст. Чуть позже мы добавим ещё и единый формат вывода (например, всегда с "Error:").
3. Парсинг команды add в guard-style
Сейчас будет очень жизненная сцена: пользователь вводит строку. Мы хотим добавить книгу. Нам надо вытащить из ввода команду, заголовок и год. И мы не хотим использовать !, потому что ! в таких местах — это «я надеюсь, что пользователь всегда идеален». Пользователь, конечно, старается… но нет.
Пусть у нас будет простая модель ввода: пользователь вводит строку вида:
add CleanCode 2008
Да, в реальности хочется поддержать кавычки, пробелы в названии и прочее — но это будет позже, в днях про CLI‑парсинг. Сегодня наша цель — валидация и throw, поэтому держим формат простым и предсказуемым.
Токенизация и базовая проверка
import Foundation
func tokenize(_ line: String) throws -> [String] {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw LibraryValidationError.emptyCommand }
return trimmed.split(separator: " ").map(String.init)
}
Обратите внимание: мы не «возвращаем пустой массив токенов и надеемся, что дальше разберутся». Мы сразу говорим: пустая команда — это ошибка валидации.
Парсим именно команду add
import Foundation
func parseAddCommand(_ tokens: [String]) throws -> (title: String, year: Int) {
guard tokens.first == "add" else {
throw LibraryValidationError.unknownCommand(name: tokens.first ?? "")
}
guard tokens.count >= 3 else {
throw LibraryValidationError.missingArgument(name: "title/year")
}
let title = tokens[1]
let yearText = tokens[2]
guard let year = Int(yearText) else {
throw LibraryValidationError.yearNotANumber(input: yearText)
}
return (title, year)
}
Да, тут есть момент: если tokens.first равен nil, мы подставили "". В идеале можно отдельно бросать .emptyCommand, но у нас tokenize уже не пропускает пустой ввод, так что tokens в нормальном сценарии не пустой.
4. Throwing-init как гарантия корректности: Year и Book
Теперь сделаем следующий шаг взросления кода: пусть «год» перестанет быть просто Int, а станет типом Year. Это то, о чём мы говорили в теме про value objects: когда есть правила, тип должен их хранить рядом с собой.
Важно: сегодня нас интересует не «value objects как философия», а то, как throw помогает сделать инвариант железным: если Year создан — он корректен.
Year: guard-style проверка диапазона
import Foundation
struct Year {
let value: Int
init(_ value: Int) throws {
let min = 1450
let max = 2100
guard (min...max).contains(value) else {
throw LibraryValidationError.yearOutOfRange(value: value, min: min, max: max)
}
self.value = value
}
}
Заметьте, мы сознательно используем тот же LibraryValidationError, а не отдельный YearError. Это и есть «единый формат ошибок» в миниатюре: в проекте не плодятся десятки enum’ов на каждую мелочь, пока вы ещё на раннем этапе. Когда проект станет больше, можно будет разделять ошибки по слоям — но это не тема сегодняшней лекции.
Book как валидная сущность
Сделаем минимальный Book (без ID, без авторов — не усложняем):
import Foundation
struct Book {
let title: String
let year: Year
init(title: String, year: Year) throws {
let cleanTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleanTitle.isEmpty else {
throw LibraryValidationError.emptyTitle
}
self.title = cleanTitle
self.year = year
}
}
Обратите внимание на стиль: Book не «надеется», что title уже проверили где-то снаружи. Он сам защищает свою корректность. Это уменьшает дублирование проверок и количество мест, где можно забыть правило.
5. Единая точка вывода ошибок
Очень хочется (и это прям человеческое желание) делать print("Ошибка!") в том месте, где ошибка обнаружилась. Но это ломает архитектуру даже маленького приложения: низкоуровневый код начинает решать, как общаться с пользователем, хотя у него нет контекста.
Правильнее так: низкий уровень бросает ошибку, средний уровень при необходимости добавляет контекст или переводит ошибку, а верхний уровень (условный main) решает, что именно напечатать. Так у вас появляется единый стиль сообщений, и вы не получаете «зоопарк» формулировок.
Собираем сценарий runLibraryCLI()
import Foundation
func runLibraryCLI() throws {
let line = readLine() ?? ""
let tokens = try tokenize(line)
let parsed = try parseAddCommand(tokens)
let year = try Year(parsed.year)
let book = try Book(title: parsed.title, year: year)
print("Добавили книгу: \\(book.title), \\(book.year.value)") // пример вывода
}
Здесь красиво видно «цепочку валидаторов»: каждый шаг либо даёт корректное значение, либо бросает понятную ошибку.
Top‑level обработка: единый формат текста
import Foundation
do {
try runLibraryCLI()
} catch let e as LibraryValidationError {
print("Error: \\(e.message)")
} catch {
print("Error: неожиданная ошибка: \\(error)")
}
Вот это — ключевой момент лекции: единый формат. Все наши ошибки валидации превращаются в предсказуемую строку вида "Error: ...".
И да, мы отдельно ловим LibraryValidationError, а все остальные (которые мы не ожидали) отправляем в общий catch. Это честно: «ожидаемые ошибки пользователя» и «неожиданные ошибки программы» — разные звери, но сегодня мы не углубляемся в политику ошибок, просто делаем минимум разумного разделения.
6. Блок‑схема потока ошибок в CLI
Чтобы мозг не воспринимал всё это как «магические слова throws/try», полезно держать в голове простой маршрут данных:
flowchart TD
A["readLine()"] --> B[tokenize]
B -->|ok| C[parseAddCommand]
B -->|throw| E[catch -> Error message]
C -->|ok| D[Year.init]
C -->|throw| E
D -->|ok| F[Book.init]
D -->|throw| E
F -->|ok| G[print success]
F -->|throw| E
Смысл: ошибка может возникнуть на любом шаге, и это нормально. Главное — что она доезжает до одного места, где превращается в понятный текст.
7. Типичные ошибки
Ошибка №1: смешивать валидацию и вывод пользователю в одной функции.
Очень легко начать печатать ошибки внутри Year.init или parseAddCommand, потому что «ну а где ещё». Но так вы теряете контроль над единым форматом сообщений: в одном месте будет print("Bad year"), в другом print("Ошибка ввода"), в третьем вообще ничего. Гораздо чище бросать ошибку вниз по коду и печатать её в одном верхнем месте.
Ошибка №2: делать один кейс .failed(String) вместо набора причин.
Сначала кажется удобным: «ну я же могу положить туда любой текст». Потом выясняется, что вы не можете нормально ветвиться по причинам, не можете нормально сравнивать сценарии в тестах (когда они появятся), и начинаете парсить свои же строки через contains. Лучше один кейс — одна причина, а контекст хранить через associated values.
Ошибка №3: использовать if вместо guard, а потом забывать завершить ветку ошибки.
Классика: вы проверили условие, напечатали «ошибка», но код пошёл дальше, и теперь у вас частично заполненные данные. Guard‑style как раз и хорош тем, что заставляет: если условие не выполнено — вы должны выйти (throw). Это снижает шанс «продолжили выполнение в неверном состоянии».
Ошибка №4: превращать любую неудачу в nil, а потом не понимать, что случилось.
Optional — отличный инструмент, но когда вам нужно объяснить «почему не получилось» (пусто? не число? вне диапазона?), nil слишком молчалив. Валидация часто требует причины, и throw как раз даёт эту причину в типизированном виде.
Ошибка №5: хранить слишком мало контекста в ошибке, а потом печатать бесполезное сообщение.
Если вы делаете .yearOutOfRange без фактического значения и без границ, то сообщение получится в стиле «год неверный» — и пользователю придётся угадывать. Associated values существуют ровно для того, чтобы ошибка была информативной: «получили 3021, ожидали 1450...2100».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ