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("Знайдено:", title) // Знайдено: The Hitchhiker's Guide
    } catch {
        print("Помилка:", 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("Книга:", title)
    } catch BookLookupError.notFound {
        print("Немає книги з id:", id)
    } catch {
        print("Неочікувана помилка:", 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)) ?? "<невідома назва>"
    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 "Книга: \(title)"
}

Варіант B: перетворюємо помилку на заглушку

func loadAndFormatTitleSafe(id: Int) async -> String {
    let title = (try? await fetchBookTitle(id: id)) ?? "<відсутня>"
    return "Книга: \(title)"
}

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

7. Читабельність: один await на вираз і стиль «у два кроки»

У теорії Swift дозволяє писати компактно: один await може покривати цілий вираз, усередині якого є кілька асинхронних викликів. Офіційний опис await це дозволяє і навіть наводить приклад, де await покриває вкладений async-виклик.

Але теорія і життя, особливо життя новачка, — різні речі. У житті складні вирази гірше читаються і складніше налагоджуються.

Порівняйте:

func buildLabelCompact(id: Int) async -> String {
    let label = "Книга: \((try? await fetchBookTitle(id: id)) ?? "<відсутня>")"
    return label
}

Код робочий, але очі втомлюються.

Те саме, але «у два кроки»:

func buildLabelReadable(id: Int) async -> String {
    let title = (try? await fetchBookTitle(id: id)) ?? "<відсутня>"
    let label = "Книга: \(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("Не вдалося завантажити книгу:", 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. Такий стиль банально простіший для мозку та налагодження.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ