JavaRush /Курсы /Swift SELF /throw как часть валидации

throw как часть валидации

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

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:

  1. жили в одном enum (или в паре чётко разделённых enum, но сегодня сделаем один — так проще удержать в голове);
  2. имели предсказуемое человекочитаемое сообщение;
  3. при необходимости хранили контекст (что ввели, какой диапазон ожидали).

Сделаем это без будущих тем (без LocalizedError, без «слоёв ошибок», без Result). Только то, что уже умеем: enum, associated values, switch, computed properties.

LibraryValidationError: один кейс = одно правило

Самая частая ошибка новичка — сделать один кейс .failed и пытаться запихнуть туда строку «что-то пошло не так». Формально это работает, но практической пользы мало: вы не можете нормально ветвиться, не можете нормально тестировать, и со временем сообщения превращаются в «коллекцию случайных фраз».

Вместо этого мы делаем так: одно правило — один кейс.

Таблица правил (как проектировать ошибки)

Правило валидации Что не так Какой кейс ошибки
Команда пуста нечего парсить
.emptyCommand
Неизвестная команда пользователь ошибся в слове
.unknownCommand(name: ...)
Не хватает аргументов формат команды неверный
.missingArgument(name: ...)
Год не число
Int(...)
не сработал
.yearNotANumber(input: ...)
Год вне диапазона число есть, но не подходит
.yearOutOfRange(value:min:max)
Заголовок пустой после trim пусто
.emptyTitle

Реализация 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».

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