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. Це не бюрократія, а спосіб зробити код передбачуваним для читача.
Порівняймо чотири варіанти однієї й тієї самої ідеї — «отримати рядок»:
| Сигнатура | Що обіцяє | Що вимагає від виклику |
|---|---|---|
|
повертає одразу, помилок немає | нічого додаткового |
|
повертає одразу, але може завершитися помилкою | try + обробка |
|
поверне пізніше, помилок немає | await |
|
поверне пізніше і може завершитися помилкою | 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. Такий стиль банально простіший для мозку та налагодження.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ