JavaRush /Курсы /Swift SELF /async throws и порядок try await

async throws и порядок try await

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

1. Асинхронные функции часто становятся async throws

Асинхронность в реальных программах почти всегда связана с тем, что мы ждём что-то «из внешнего мира»: диск, сеть, базу данных, ввод пользователя, таймер, системный сервис. А внешний мир, как мы знаем, стабилен примерно как настроение компилятора при обновлении Xcode.

Синхронная функция может «сломаться» и бросить ошибку — мы это давно умеем выражать через throws. Асинхронная операция может сломаться точно так же, просто не сразу, а через некоторое время ожидания. В Swift это выражается прямолинейно: async throws. То есть «может приостанавливаться» + «может выйти по ошибке».

Давайте на супер-бытовом примере. Мы пишем учебный LibraryCLI (наш консольный менеджер библиотеки) и хотим «подгрузить информацию о книге». Сегодня мы не лезем в сеть (это будет позже), но мы можем честно смоделировать задержку, чтобы мозг привык к форме кода.

import Foundation

enum BookLookupError: Error {
    case notFound
}

func fetchBookTitle(id: Int) async throws -> String {
    try await Task.sleep(nanoseconds: 200_000_000) // 0.2s
    if id == 42 { return "The Hitchhiker's Guide" }
    throw BookLookupError.notFound
}

Обратите внимание на ощущение: функция выглядит «почти как обычная», но у неё два эффекта. Она может приостановиться (async) и может бросить ошибку (throws).

2. Как читать сигнатуру async throws

Сигнатура функции — это контракт. В Swift контракт стараются делать максимально «честным»: если внутри функции вы делаете что-то, что может кинуть ошибку — это должно быть видно по throws. Если внутри вы ждёте что-то асинхронное — это должно быть видно по async. Это не бюрократия: это способ заставить код быть предсказуемым для читающего.

Сравним четыре варианта одной и той же идеи — «получить строку»:

Сигнатура Что обещает Что требует от вызова
() -> String
возвращает сразу, ошибок нет ничего
() throws -> String
возвращает сразу, но может упасть с ошибкой try + обработка
() async -> String
вернёт позже, ошибок нет await
() async throws -> String
вернёт позже и может упасть try await

Вот почему сигнатуры в Swift такие «болтливые». Они спасают от сюрпризов.

Кстати, порядок async throws в объявлении тоже фиксирован. Это сделано специально, чтобы не было бесконечных «религиозных войн» в стиле “а у нас в команде принято throws async”. В Swift принято async throws. И да, это специально закреплено в модели языка.

3. Правило вызова: try await

Сейчас будет ключевая мысль лекции: если функция async throws, то в месте вызова вы обязаны явно написать и try, и await. Причём порядок строгий: try await, а не await try.

Это правило языка, а не вкусовщина. Более того, компилятор прямо ругается, если написать наоборот, и даже подсказывает, как правильно. В спецификации async/await это оговорено буквально: если try и await относятся к одному и тому же подвыражению, то await должно следовать после try.

Почему так? Потому что try и await — про разные риски:

await — «мы можем поставить выполнение на паузу, и между “до” и “после” могло пройти время».

try — «мы можем внезапно выйти из текущего блока по ошибке и перепрыгнуть в catch».

Чтобы глаза читали это слева направо как предупреждения, в Swift решили: сначала мы отмечаем риск ошибки (try), потом риск ожидания (await).

Мини‑пример (представим, что мы уже внутри async‑контекста):

func demoLookup() async {
    do {
        let title = try await fetchBookTitle(id: 42)
        print("Found:", title) // Found: The Hitchhiker's Guide
    } catch {
        print("Error:", error)
    }
}

Здесь одна строка сообщает всё: и «может бросить», и «может ждать».

Нюанс: почему нельзя await try, но иногда можно через скобки

Иногда новички пытаются «логически» сказать: сначала подождём, потом попробуем. И пишут await try fetchBookTitle(...). Компилятор скажет «нельзя». Но есть интересная деталь: если вы очень захотите, вы можете поменять группировку скобками.

То есть нельзя так:

// let title = await try fetchBookTitle(id: 42) // ❌

Но можно так:

func demoWeirdStyle() async {
    do {
        let title = await (try fetchBookTitle(id: 42)) // но странно
        print(title)
    } catch {
        print(error)
    }
}

Это не «рекомендация», а демонстрация того, что правило — про синтаксис и однозначность чтения. В официальном описании await-выражений прямо показано, что await try ... запрещено, а вариант со скобками разрешён как другая группировка.

Практический вывод простой: пишите try await и не усложняйте жизнь ни себе, ни ревьюеру.

4. do/catch в асинхронном коде

Когда люди впервые видят async throws, у них часто возникает ощущение, что «ошибки в асинхронности обрабатываются как-то иначе». Нет. Обрабатываются так же. Swift здесь очень последовательный: do/catch остаётся основным механизмом обработки ошибок, просто внутри do у вас появляется try await.

Представим, что в LibraryCLI есть команда «показать название книги по id», и мы хотим печатать понятное сообщение.

func showBookTitle(id: Int) async {
    do {
        let title = try await fetchBookTitle(id: id)
        print("Book:", title)
    } catch BookLookupError.notFound {
        print("No such book id:", id)
    } catch {
        print("Unexpected error:", error)
    }
}

Здесь важно именно разветвление catch: мы можем различать наши доменные ошибки (в учебном проекте — ошибки «библиотеки») и все остальные. Это хороший стиль: пользователю — понятное, разработчику — всё остальное.

Небольшая схема потока выполнения помогает новичкам не путаться:

flowchart TD
    A["do { ... }"] --> B["try await fetch..."]
    B -->|успех| C["продолжаем код, печатаем результат"]
    B -->|ошибка| D["прыжок в catch"]
    D --> E["печатаем сообщение/обрабатываем"]

5. Короткие формы: try? и try! в асинхронности

try? await и ??: превращаем ошибку в nil

Когда вы пишете try?, вы говорите: «Если будет ошибка — я не хочу её обрабатывать как ошибку, я хочу получить nil». Это мощный инструмент, но он требует зрелости. В учебном CLI он особенно полезен для «неважных» данных: например, если мы хотим показать заголовок книги, но при ошибке можем показать заглушку и продолжить.

Сделаем функцию, которая возвращает строку «или заглушку»:

func bookTitleOrPlaceholder(id: Int) async -> String {
    let title = (try? await fetchBookTitle(id: id)) ?? "<unknown title>"
    return title
}

Семантически это значит: «ошибка — это допустимый исход», и вы сознательно её проглотили.

Здесь есть тонкость: как только вы увидели try?, вы должны мысленно спросить себя: «А мне правда всё равно, почему это сломалось?» Если да — окей. Если нет — значит нужен do/catch.

try! await: как устроить себе внезапный краш

try! — это контракт вида «если тут будет ошибка, я хочу, чтобы программа упала». В учебных задачах это иногда используют, чтобы не писать do/catch, но в настоящем коде это почти всегда мина. В асинхронности — особенно: внешний мир ошибается чаще, чем хочется.

Покажем пример, который компилируется, но как жизненная стратегия — сомнителен:

func riskyDemo() async {
    let title = try! await fetchBookTitle(id: 1)
    print(title)
}

Если id не 42, мы получим notFound, и программа аварийно завершится. Для CLI это означает: пользователь ввёл обычный id, а программа такая «я обиделась» и закрылась.

В нормальной архитектуре try! оставляют для ситуаций, где ошибка означает «внутренняя поломка, которую нельзя восстановить». Пользовательский ввод и внешние операции — почти никогда не такие.

6. Как эффекты «распространяются» по сигнатурам

В Swift есть железное правило: если внутри вашей функции появился await, то функция должна стать async. Если внутри появился try без обработки — функция должна стать throws. Если появилось try await — обычно станет async throws (или вы обработаете ошибку внутри).

Это важный момент, потому что он заставляет дизайн кода быть аккуратным. Нельзя «спрятать» асинхронность и ошибки глубоко внутри и сделать вид, что снаружи всё синхронно и безопасно. Язык заставляет вас принять решение: либо вы обрабатываете внутри, либо честно прокидываете наружу.

Сравните два варианта: один «пробрасывает», второй «переводит в значение».

Вариант A: пробрасываем ошибку наверх

func loadAndFormatTitle(id: Int) async throws -> String {
    let title = try await fetchBookTitle(id: id)
    return "Book: \(title)"
}

Вариант B: превращаем ошибку в заглушку

func loadAndFormatTitleSafe(id: Int) async -> String {
    let title = (try? await fetchBookTitle(id: id)) ?? "<missing>"
    return "Book: \(title)"
}

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

7. Читаемость: один await на выражение и стиль «в два шага»

В теории Swift позволяет писать компактно: один await может покрывать целое выражение, внутри которого есть несколько асинхронных вызовов. Официальное описание await это допускает и даже приводит пример, где await покрывает вложенный async‑вызов.

Но теория и жизнь (особенно новичковая) — разные вещи. В жизни сложные выражения хуже читаются и сложнее отлаживаются.

Сравните:

func buildLabelCompact(id: Int) async -> String {
    let label = "Book: \((try? await fetchBookTitle(id: id)) ?? "<missing>")"
    return label
}

Код рабочий, но глаза устают.

То же самое, но «в два шага»:

func buildLabelReadable(id: Int) async -> String {
    let title = (try? await fetchBookTitle(id: id)) ?? "<missing>"
    let label = "Book: \(title)"
    return label
}

Практическое правило: пока вы учитесь, пишите развернуто. Компилятор не платит вам за минимальное количество строк.

8. Мини‑встраивание в LibraryCLI

Чтобы не объяснять async throws в вакууме, давайте аккуратно «приклеим» тему к нашему учебному приложению. У нас уже есть привычка отделять слой «получение данных» от слоя «печать пользователю». Сегодня мы добавим простенький сервис, который умеет либо вернуть данные, либо сообщить об ошибке, и делает это асинхронно.

Начнём с модели:

struct BookPreview {
    let id: Int
    let title: String
}

Теперь сервис:

enum LibraryServiceError: Error {
    case invalidID
    case notFound
}

struct LibraryService {
    func preview(id: Int) async throws -> BookPreview {
        guard id > 0 else { throw LibraryServiceError.invalidID }
        try await Task.sleep(nanoseconds: 150_000_000)
        if id == 42 { return BookPreview(id: id, title: "The Hitchhiker's Guide") }
        throw LibraryServiceError.notFound
    }
}

И функция, которая «готовит сообщение пользователю». Она, по сути, наша будущая обработка команды:

func printPreview(id: Int, service: LibraryService) async {
    do {
        let p = try await service.preview(id: id)
        print("[\(p.id)] \(p.title)")
    } catch {
        print("Cannot load book:", error)
    }
}

Заметьте, как естественно это читается: «попробуй дождаться, если получится — распечатай, иначе — обработай». Именно ради такого чтения async/await и придумали.

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

Ошибка №1: писать await try и спорить с компилятором “ну так логичнее”.
Это частая ловушка, потому что мозг пытается прочитать как «подожди, потом попробуй». Но try и await в Swift — это не “порядок действий”, а метки эффектов выражения. Язык требует try await как единый устойчивый шаблон, и это закреплено правилом синтаксиса. В реальном проекте спор с компилятором обычно проигрывает человек, причём быстро.

Ошибка №2: добавить try await внутри функции и забыть обновить сигнатуру на async throws.
Такое случается, когда вы «чуть-чуть дописали» код и ожидаете, что всё само соберётся. Но Swift заставляет эффекты быть видимыми снаружи. Поэтому либо вы ловите ошибку внутри do/catch (тогда throws не нужен), либо честно добавляете throws. То же самое с await: раз он появился, функция должна стать async.

Ошибка №3: использовать try? await “чтобы не возиться”, а потом удивляться, что отладка стала невозможной.
try? превращает все ошибки в nil. Это значит, что вы теряете причину: была ли проблема в данных, в логике, в отмене задачи или в чём-то ещё. try? хорош, когда у вас реально есть понятный дефолт, и причина ошибки не важна. Если причина важна — лучше do/catch, хотя бы с печатью ошибки.

Ошибка №4: использовать try! await на пользовательском вводе и радоваться, что “всё работает”.
Работает ровно до первого неправильного ввода или нестабильного внешнего условия — а потом падает. В CLI‑приложениях падение особенно неприятно: пользователь не получает нормального сообщения и не понимает, что делать. try! стоит применять только там, где ошибка означает внутреннюю поломку программы, а не нормальный жизненный сценарий.

Ошибка №5: прятать try await в огромные выражения и терять читабельность.
Swift позволяет делать выражения очень плотными, но новичку это обычно вредит: вы перестаёте видеть, где именно ожидание, где именно возможна ошибка, и что именно пошло не так. Разбивайте на две строки: сначала let value = try await ..., потом используйте value. Такой стиль банально проще для мозга и отладки.

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