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("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. Такой стиль банально проще для мозга и отладки.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ