JavaRush /Курси /Swift SELF /async

async / await: як змінюється стиль коду

Swift SELF
Рівень 64 , Лекція 4
Відкрита

1. Асинхронність у повсякденні: «результат буде пізніше»

Якщо ви раніше думали, що асинхронність — це щось «для гіків і серверів», то маю для вас не дуже добру новину. У звичайному застосунку ми постійно чекаємо на щось зовнішнє: відповідь мережі, читання файлу, таймер, реакцію користувача. Ми не можемо змусити інтернет відповідати швидше, але можемо зробити так, щоб наш код залишався читабельним і передбачуваним.

Коли ми пишемо синхронний код, він виконується як послідовність команд: рядок за рядком. Але в асинхронних операціях результат зʼявляється пізніше. І тут виникає суперечність: ми хочемо писати код лінійно, а виконується він із паузами. async/await — це спосіб чесно визнати ці паузи й показати їх у коді.

Для порівняння: стиль із зворотними викликами зазвичай виглядає так. Це лише ескіз псевдокоду, щоб показати ідею; дослівно його не копіюйте:

// Псевдокод: тут видно проблему вкладеності
loadUser { user in
    loadBooks(userID: user.id) { books in
        render(books)
    }
}

Чим більше кроків, тим глибшим стає відступ вправо, складнішою — обробка помилок, а зрозуміти сценарій дедалі важче. Важко й побачити, що станеться в разі дострокового виходу. async/await вирішує саме це: робить паузи явними, а стиль — лінійним.

2. async у сигнатурі: контракт на паузу

Коли у сигнатурі функції ви бачите async, це не прикраса і не «прискорювач». Це контракт: функція може призупинятися й продовжуватися пізніше. У документації Swift це формулюється через поняття suspension point — потенційної точки призупинення виконання.

Найважливіше для новачка: async не означає, що ця функція паралельна. Вона може бути повністю послідовною, просто всередині неї є очікування. І це, чесно кажучи, нормально: навіть якщо ви готуєте вечерю «асинхронно», ви все одно спершу ріжете овочі, а вже потім кладете їх у каструлю.

Міні-приклад:

func fetchGreeting() async -> String {
    "Привіт"
}

func showGreeting() async {
    let text = await fetchGreeting()
    print(text) // Привіт
}

Тут ми поки не робимо реального очікування, але важливий сам принцип: якщо fetchGreeting() позначена як async, то її виклик потребує await. Це правило повʼязане з тим, що виклик async-функції вважається потенційною точкою призупинення.

3. await: маркер suspension point

Слово await читається як «очікуй». Але корисніше думати так: await — це маркер у коді, який говорить: між рядком «до» і рядком «після» міг минути час.

Причому await не додає жодної додаткової магії до обчислення: результат виразу залишається тим самим типом і значенням, просто обчислення може бути відкладене. У пропозиції Swift Evolution про async/await окремо підкреслюється: await за змістом схожий на try — він не змінює значення, а лише робить ефект видимим.

Щоб відчути це на практиці, зробімо маленьку утиліту затримки. Так, усередині вона використовує Task.sleep, який технічно throws, але ми поки свідомо «ховаємо» цю деталь, щоб зосередитися на await як на позначці очікування. Про async throws і порядок try await поговоримо в наступній лекції.

func delay(milliseconds: UInt64) async {
    let ns = milliseconds * 1_000_000
    try? await Task.sleep(nanoseconds: ns)
}

Тепер приклад, де видно, що час минає:

import Foundation

func demoSuspension() async {
    let start = Date()

    await delay(milliseconds: 200)

    let elapsed = Date().timeIntervalSince(start)
    print(elapsed) // ~0.2 (приблизно, залежить від системи)
}

Тут ключове не в числах. Ключове в тому, що після await ваш код продовжується, але не обовʼязково відразу. У цей момент краще бути подумки готовими до того, що світ міг змінитися: стан програми, дані, доступність ресурсу, навіть порядок подій.

Саме тому Swift вимагає явно писати await у місцях, де можлива призупинка: це допомагає побачити, де розривається атомарність.

4. Як async/await змінює стиль порівняно зі зворотними викликами

Коли ви переходите на async/await, найприємніший ефект — ви знову починаєте читати код «згори вниз», як звичайний сценарій. Swift Evolution окремо наголошує: асинхронна функція може передати виконання на await-точці й продовжити пізніше, але логіка залишається послідовною, якщо ви спеціально не додаєте паралельні конструкції.

Порівняймо відчуття у вигляді маленької таблиці — так легше сприймати:

Що відбувається Callbacks async/await
«Спочатку зроби A, потім B» B записується всередині обробника завершення A A і B стоять поруч, B починається після await
Обробка помилок часто розкидана по вкладених замиканнях виглядає як звичайний do/catch (але про це буде в наступній лекції)
Читабельність швидко погіршується зі зростанням сценарію зазвичай залишається лінійною
Де «пауза» схована між поверненням і викликом обробника завершення явно позначена await

Міні-приклад лінійного стилю:

func loadUserName() async -> String {
    await delay(milliseconds: 50)
    return "Alice"
}

func loadCity() async -> String {
    await delay(milliseconds: 50)
    return "Berlin"
}

func buildMessage() async -> String {
    let name = await loadUserName()
    let city = await loadCity()
    return "\(name) з \(city)"
}

Зверніть увагу: це послідовний сценарій. Так, усередині є очікування. Але порядок дій простий: спочатку імʼя, потім місто, а потім складання рядка.

5. Де можна await і що з цього випливає

Де дозволений await: тільки в async-контексті

Обмеження «await можна тільки всередині async-функції або async-замикання» спочатку дратує. Здається, що компілятор занудствує: «Ну мені тут потрібен лише один await, хіба це так важко?!». Але причина цілком прагматична: якщо функція синхронна, вона не вміє призупинятися й продовжуватися пізніше, отже всередині неї не можна писати вирази, які можуть призупинитися.

Міні-приклад — код компілюється, але рядок із await закоментовано, бо саме він не працює без async-контексту:

func loadNumber() async -> Int { 42 }

func syncFunction() {
    // let n = await loadNumber() // ❌ не можна: немає async-контексту
    print("синхронно") // без пауз
}

Сьогодні нам важливо зафіксувати правило. Як саме увійти в async-контекст у CLI — через main() async або через Task { ... } — ми розберемо в одній із наступних лекцій, щоб не звалювати все в одну купу.

Правило поширення: await піднімає async вгору по стеку

У реальному проєкті ви не пишете async лише «на нижньому рівні». Щойно ваша функція викликає щось async і вам потрібен результат, вона мусить писати await, а отже — сама стає async. Це схоже на те, як throws піднімається вгору: якщо ви не ловите помилку всередині, вам потрібно позначити функцію як throws.

Покажімо це на міні-рефакторингу:

func fetchTitle(id: Int) async -> String {
    await delay(milliseconds: 30)
    return "Book #\(id)"
}

func makeLine(id: Int) async -> String {
    let title = await fetchTitle(id: id)
    return "• \(title)"
}

Тут немає варіанта залишити makeLine синхронною, якщо ми хочемо написати її в прямому стилі й отримати значення title. Можна, звісно, піти в callbacks або запускати задачу — але це вже інша тема. Зараз важливо відчути поширення async вгору по стеку викликів.

await не робить код паралельним

Дуже поширена хибна думка: якщо я написав await, значить усе десь виконується паралельно. Ні. В async/await за замовчуванням ви пишете послідовний код, просто з можливістю призупинятися на очікуваннях. Swift Evolution прямо наголошує: без додаткових конструкцій асинхронна функція працює як звичайна послідовна функція, просто може «віддавати потік» у точках очікування.

Погляньмо на приклад:

func loadA() async -> String {
    await delay(milliseconds: 100)
    return "A"
}

func loadB() async -> String {
    await delay(milliseconds: 100)
    return "B"
}

func demoSequential() async -> String {
    let a = await loadA()
    let b = await loadB()
    return a + b
}

demoSequential() майже напевно триватиме близько 200 мс, тому що loadB() почнеться тільки після завершення loadA(). Це нормально. Паралельність — окрема тема, і вона зʼявиться пізніше через спеціальні інструменти, а не від одного лише await.

Suspension points: чому між рядками може змінитися світ

Коли ви пишете синхронний код, вам зручно думати так: «між цими двома рядками ніхто не влізе». В async-коді це перестає бути гарантією саме через suspension points. Swift вимагає позначати потенційні точки призупинення, тому що призупинка розриває атомарність: між «до» і «після» можуть виконатися інші частини програми, і світ устигне змінитися.

Корисна ментальна модель — схема часу:

sequenceDiagram
    participant F as ваша async-функція
    participant W as "світ" (час і події)
    F->>F: робимо крок 1 (звичайний код)
    F->>W: await (точка призупинення)
    Note over W: минає час
щось інше може статися W-->>F: продовження F->>F: робимо крок 2 (після await)

Технічно await — це вираз, усередині якого має бути виклик async-функції, тобто потенційна точка призупинення. І якщо ви напишете await там, де всередині немає жодного async-виклику, Swift навіть може попередити, що ви «очікуєте» те, що й так виконується синхронно.

6. Приклад: асинхронний текст превʼю для LibraryCLI

Зараз ми не будемо підключати реальний інтернет і URLSession, тому що це майже одразу приводить до теми async throws — помилок мережі, декодування тощо. Замість цього ми зробимо чесний навчальний «муляж» мережі: сервіс, який повертає дані нібито пізніше. Це дасть змогу потренувати стиль async/await і навчитися бачити suspension points, не змішуючи все одразу.

Уявімо, що в нашому LibraryCLI є ідея «показати превʼю книжки» — наприклад, коли користувач вводить команду пошуку, ми хочемо красиво надрукувати рядок на кшталт Назва Автор. Сервіс буде асинхронним, бо в реальності це міг би бути мережевий запит.

struct BookPreview {
    let title: String
    let author: String

    var line: String { "\(title) — \(author)" }
}

Тепер зробімо «віддалене» джерело даних:

func fetchTitleFromRemote(bookID: Int) async -> String {
    await delay(milliseconds: 60)
    return "Swift for Humans #\(bookID)"
}

func fetchAuthorFromRemote(bookID: Int) async -> String {
    await delay(milliseconds: 40)
    return "A. Developer"
}

Збираємо превʼю:

func buildPreview(bookID: Int) async -> BookPreview {
    let title = await fetchTitleFromRemote(bookID: bookID)
    let author = await fetchAuthorFromRemote(bookID: bookID)
    return BookPreview(title: title, author: author)
}

І ось фінальна функція, яку згодом викличе команда CLI:

func printPreview(bookID: Int) async {
    let preview = await buildPreview(bookID: bookID)
    print(preview.line) // Swift for Humans #42 — A. Developer
}

Що важливо помітити:

  1. Код читається як звичайний покроковий рецепт.
  2. Кожне очікування видно прямо в коді — це await.
  3. «Асинхронність» поки що не робить нічого паралельно: це просто послідовні кроки з паузами.

7. Стиль: як писати await, щоб не втрачати точки очікування

Коли ви тільки починаєте, дуже хочеться писати надто компактно: «запхати все в один рядок». І Swift справді це дозволяє: await може покривати весь вираз, усередині якого є кілька потенційних suspension points. У пропозиції навіть показано, що можна вкласти один async-виклик в інший і накрити його одним await.

Але для новачка і для навчального проєкту зазвичай кращий стиль «у два кроки»: винести кожен await в окремий рядок. Тоді у вас буквально в коді «ліхтариками» підсвічені місця, де міг минути час.

Порівняйте. Більш щільний стиль, який компілюється, але спочатку читається гірше:

func denseLine(bookID: Int) async -> String {
    "\(await fetchTitleFromRemote(bookID: bookID)) — \(await fetchAuthorFromRemote(bookID: bookID))"
}

Більш читабельний стиль:

func readableLine(bookID: Int) async -> String {
    let title = await fetchTitleFromRemote(bookID: bookID)
    let author = await fetchAuthorFromRemote(bookID: bookID)
    return "\(title) — \(author)"
}

Другий варіант довший на кілька рядків, але мозок сприймає його легше. І головне — ви майже фізично бачите, де можливі паузи, а отже де треба бути обережними зі станом і логікою.

8. Типові помилки

Помилка №1: сприймати await як «прискорення» або «паралельність».
На початку дуже легко переплутати: раз слово незнайоме, значить воно робить щось складне й корисне. Насправді await лише позначає очікування й потенційну призупинку. Якщо ви написали два await підряд, ви майже напевно зробили два послідовні кроки. Паралельність зʼявиться тільки тоді, коли ви явно застосуєте спеціальні інструменти для конкурентного запуску задач.

Помилка №2: ховати await у довгі вирази й губити точки очікування.
Swift дозволяє накривати одним await весь вираз, де всередині є кілька async-викликів. Але коли ви навчаєтеся, такий стиль часто заважає: ви перестаєте бачити, де могла бути пауза, і починаєте помилятися в логіці. На перших кроках краще писати «у два кроки»: let x = await … і тільки потім використовувати x.

Помилка №3: забути, що після await міг минути час.
Навіть якщо здається, що тут лише 50 мілісекунд, логіка мови від цього не змінюється. await означає, що виконання могло призупинитися, а отже не можна автоматично вважати, що все навколо залишилося незмінним. Це особливо важливо, коли ви тримаєте якісь проміжні значення й після очікування використовуєте їх так, ніби нічого не могло змінитися.

Помилка №4: дивуватися, що async «розповзається» вгору по функціях.
Щойно всередині функції зʼявляється await, вона стає async. Це не примха компілятора, а спосіб змусити сигнатуру бути чесною: код, який викликає, має заздалегідь знати, що ця функція може призупинятися. Якщо намагатися утримати синхронність будь-якою ціною, ви зазвичай повертаєтеся до callbacks або починаєте запускати задачі без контролю — а це окремі теми, до яких ми підійдемо поступово.

Помилка №5: писати await там, де нічого чекати.
Іноді після кількох годин із async-кодом рука починає автоматично ставити await «про всяк випадок». Swift може попереджати про таке: якщо всередині await-виразу немає жодного async-виклику, це виглядає підозріло. Лікується просто: ставте await лише там, де ви справді викликаєте async-функцію або інше асинхронне очікування.

1
Опитування
Архітектура endpoint, рівень 64, лекція 4
Недоступний
Архітектура endpoint
Архітектура endpoint, мапінг, помилки
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ