Что нового в Swift 5.5: async/await. Часть 1

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Для влюблённых в своё дело iOS разработчиков ежегодная конференция WWDC служит чем-то вроде второго дня рождения или Нового года. Пару раз мы в Postindustria даже организовывали мини-вечеринку с просмотром презентации. Всегда интересно, какие новые игрушки припасли в Купертино и что мы будем использовать где-то через год-два, когда менеджеры проекта созреют до того, чтоб бросить поддержку очередной старой версии iOS.

Не стала исключением и WWDC 2021, новинок очень много, хотя, мой личный фаворит вот.

AttributedString(markdown: "This is **text**, and even [link](https://dou.ua) is possible")

Но рассказывать о всех новинках библиотек и фреймворков можно до следующего WWDC (и не успеть), поэтому будем по совету мудрых есть слона по кускам, и начнём рассмотрение с изменений в самом языке Swift, который подрос до версии 5.5, тем более что их там очень много.

Разумеется, основным стало движение в сторону безопасной асинхронности. Расписать все нюансы в рамках одной статьи вряд ли получится, поэтому пройдусь немного «по верхам». Swift 5.5 только начинает движение к светлому идеалу Swift 6: сделать так, чтоб любой код, который компилируется, мог безопасно работать в многопоточной среде. Чтоб сделать переход более плавным, разработчики начали с добавления возможностей с сохранением обратной совместимости. «Новая асинхронность» в Swift 5.5 стоит на трёх китах.

Операторы async/await

Пожалуй, самая заметная возможность языка, о которой активно говорили ещё с прошлого года. Теперь, чтоб попробовать её, можно не возиться со скачиванием снапшотов тулчейна, достаточно просто стянуть бету 13 Xcode. Впрочем, с учётом «веса» в 12 с лишним гигабайт не сказать что это сильно проще. Хотя для первой беты новый Xcode производит приятное впечатление, он и выглядит свежей, в стиле Big Sur, ощущается приятней с местами поумневшими дополнениями.

Сама концепция async-await не нова и давно присутствует во многих языках, от JS до Python, но, представим себе что есть люди, настолько погружённые в Swift что им некогда смотреть по сторонам, и вкратце рассмотрим эту парочку, добавленную в SE-0296.

Как мы писали асинхронный код раньше (ну, те из нас, кто по тем или иным причинам не использовал Rx, promises и другие удобные вещи)?

private func fetchData(completion: (Result<[Int], Error>) -> Void) {
    let someData = (1...100_000).map { _ in Int.random(in: (1...100)) }
    completion(.success(someData))
}

private func calculateMinMax(forData array: [Int], completion: (Result<(Int, Int), DataError>) -> Void) {
    guard let first = array.first else {
        completion(.failure(.emptyArray))
        return
    }

    var min = first
    var max = first

    for item in array.dropFirst() {
        if min > item {
            min = item
        }
        if max < item {
            max = item
        }
    }

    completion(.success((min, max)))
}

private func doJob() {
    fetchData { fetchResult in
        switch fetchResult {
        case let .success(data):
            calculateMinMax(forData: data) { calcResult in
                switch calcResult {
                case let .success((min, max)):
                    textLabel.text = "min is \(min) and max is \(max)"
                 case let .failure(error):
                    print(error.localizedDescription)
                }
            }
        case let .failure(error):
            print(error.localizedDescription)
        }
    }
}

Пример, конечно, синтетический, как сыр в магазинчике возле дома, но для понимания вопроса подходит. Я мог бы придумать что-то с загрузкой данных из сети, но интернет и так переполнен мусорными запросами, давайте не будем их преумножать.

Представим себе, что fetchData откуда-то асинхронно загружает данные, а calculateMinMax осуществляет какую-то их длительную обработку. Оба метода работают асинхронно, и для сообщения о завершении работы используют callback. Метод doJob прекрасно демонстрирует проблемы последовательного вызова нескольких подобных методов: начиная со знаменитой «pyramid of doom» и заканчивая проблемой правильного сочетания вызовов колбеков и операторов return (например, в guard метода getMinMax). При этом тут только два метода, а если их 3 и больше? Понятно, что это можно улучшить разбив вызовы по отдельным методам, использовать те же promises, но теперь есть способ лучше, для начала — просто приведу код. Кстати, Xcode теперь умеет автоматически рефакторить методы с колбеками в асинхронные.

private func fetchData() async -> [Int] {
    let someData = (1...100_000).map { _ in Int.random(in: (1...100)) }
    return someData
}

private func calculateMinMax(forData array: [Int]) async throws -> (Int, Int) {
    guard let first = array.first else {
        throw DataError.emptyArray
    }

    var min = first
    var max = first

    for item in array.dropFirst() {
        if min > item {
            min = item
        }
        if max < item {
            max = item
        }
    }

    return (min, max)
}

private func doJob() async {
    do {
        let data = await fetchData()
        let (min, max) = try await calculateMinMax(forData: data)
        textLabel.text = "min is \(min), max is \(max)"
    } catch {
        print(error.localizedDescription)
    }
}

Как видите, для метода загрузки данных почти ничего не изменилось кроме добавления async в декларации и использования явного возврата значения. Это ожидаемо, так как реально никакой асинхронной работы он не делает. Слегка упростился метод обработки: мы можем непосредственно выкидывать исключения. Но вот doJob максимально наглядно демонстрирует достоинство нового подхода. Если упростить, функции, помеченные как async в силу тех или иных причин могут вернуть свой результат не сразу, а оператор await позволяет вызывающей асинхронной функции «дождаться» результата. Если в свою очередь какая-то другая функция в нашем примере будет ожидать завершения doJob, она будет вынуждена дождаться и завершения всех вызовов внутри doJob. Гипотетически, можно провести параллель между асинхронной функцией и promise, но разработчики Swift решили не идти в эту сторону.

Ну и да, для безопасности надо не забыть вернуть управление на основной поток перед модификацией текста метки. Можно это сделать привычным образом, а можно использовать новую «фишку».

private func doJob() async {
    do {
        async let data = fetchData()
        let (min, max) = try await calculateMinMax(forData: data)
        updateLabel(with: min, and: max)
    } catch {
        print(error.localizedDescription)
    }
}

@MainActor func updateLabel(with min: Int, and max: Int) {
    textLabel.text = "min is \(min), max is \(max)"
}

На самом деле, actor вообще, и MainActor в частности — куда более широкая тема, нас в данном случае интересует то, что updateLabel будет гарантированно отрабатывать на главном потоке.

Дополнительные proposals

Дополнительно к основному swift evolution proposal по async/await, в релиз подвезли несколько вспомогательных.

SE-0298 вводит протокол AsyncSequence, похожий на обычный Sequence, но с методом next() помеченным как асинхронный. Используется он как-то так.

struct AsyncRandGenerator: AsyncSequence {
    typealias Element = Int

    private var count: Int

    init(count: Int) {
        self.count = count
    }

    struct AsyncIterator: AsyncIteratorProtocol {
        private var count: Int

        init(count: Int) {
            self.count = count
        }

        mutating func next() async -> Int? {
            if count > 0 {
                count -= 1
                return Int.random(in: 0...255)
            } else {
                return nil
            }
        }
    }

    func makeAsyncIterator() -> AsyncIterator {
        AsyncIterator(count: count)
    }
}

В свою очередь, мы можем работать с такими последовательностями используя новый формат оператора for.

private func doJob() async {
    for await num in AsyncRandGenerator(count: 3) {
        print(num)
        cnt += 1
    }
}

Ожидаемо AsyncSequence содержит асинхронные реализации многих привычных методов, поэтому можно написать что-то вроде такого.

let areWeLucky = await AsyncRandGenerator(count: 100)
        .map { $0 + 1}
        .contains(7)

SE-0310 добавляет read-only properties поддержку модификаторов async и throws.

Теперь можно делать что-то вроде таких классов.

struct AsyncImage {
    private let url: URL

    init(url: URL) {
        self.url = url
    }

    var image: UIImage {
        get async throws {
            let (data, response) = try await URLSession.shared.data(from: url)
            guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
                throw DataError.invalidResponse
            }

            guard let image = UIImage(data: data) else {
                throw DataError.invalidImageData
            }

            return image
        }
    }
}

Весьма ожидаемо, URLSession получил целую охапку асинхронных методов на все случаи жизни.

Используется это как-то так.

private func doJob() async {
    let url = URL(string: "https://picsum.photos/200")!
    let image = try? await AsyncImage(url: url).image
}

Асинхронный контекст и ограничения

Надо понимать, функции, помеченные как асинхронные имеют одну, но важную особенность: их нельзя вызывать из обычных функций. То есть если мы напишем что-то вроде

let button = UIButton(primaryAction: UIAction(title: "Do It!") { _ in
        self.doJob()
})

Компилятор любезно сообщит нам про ошибку.

'async' call in a function that does not support concurrency

Строгое правило асинхронности — функции, помеченные как async можно вызывать только из асинхронного контекста. В обратную сторону это ограничение не действует, асинхронные функции с лёгкостью вызывают обычные. Как быть? Синтаксически, самый простой способ выглядит так.

let button = UIButton(primaryAction: UIAction(title: "Do It!") { [self] _ in
    async {
        await self.doJob()
    }
}) 

Но что это за конструкция и как она работает, мы рассмотрим в следующей статье, если, конечно, тема будет интересна читателям.

Прежде чем закончить, пара финальных замечаний, которые я не придумал как контекстно вставить в основной текст.

Компилятор позволяет иметь пару одинаковых функций, одна из которых будет асинхронной, а вторая — обычной. Вызываться будет та, которая подходит по текущему контексту.

Кроме того, на самом деле пара async/await сами по себе не обеспечивают никакой параллельности, многопоточности и прочего. Фактически, эти операторы это просто синтаксический сахар, скрывающий всё те же колбеки, промисы и остальные паттерны передачи управления. А для реального «прыжка» в мир параллельности в Swift 5.5 припасён новый подход, описанный в SE-0304 Structured concurrency, но разговор о нём и многих других интересных вещах тоже пойдёт в следующей части.

👍НравитсяПонравилось17
В избранноеВ избранном6
LinkedIn
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Спасибо, познавательно.
А вызов

guard let first = array.first

происходит быстрее, чем

guard array.count > 0

?

Только мне жаль, что эта асинхронщина похожа на JS а не идет путем промисов?

Только мне жаль, что эта асинхронщина похожа на JS а не идет путем промисов?

хм это интересно а как бы вы хотели лучше чтобы было? можете один из примеров пере писать для примера пож.?

Как-то так с промисами:

gist.github.com/...​14209e1072e9524b1d29c396e

    private func fetchData()  -> Promise<[Int]> {
        return Promise { seal in
            let someData = (1...100_000).map { _ in Int.random(in: (1...100)) }
            seal.fulfill(someData)
        }
    }
    
    private func calculateMinMax(forData array: [Int]) -> Promise<(Int, Int)> {
        return Promise { seal in
            guard let first = array.first else {
                seal.reject(DataError.emptyArray)
                return
            }
            
            var min = first
            var max = first
            
            for item in array.dropFirst() {
                if min > item {
                    min = item
                }
                if max < item {
                    max = item
                }
            }
            seal.fulfill((min, max))
        }
    }
    
    private func updateUI(with data: (Int, Int)) -> Promise<Void> {
        return Promise { seal in
            self.textLabel.text = "min is \(data.0), max is \(data.1)"
            seal.fulfill(())
        }
    }

    private func doJob() {
        fetchData()
            .then { array in self.calculateMinMax(forData: array) }
            .then (on: .main) { (min, max) in
                self.updateUI(with: (min, max))
            }.catch { error in
                print(error.localizedDescription)
            }
    }

чтобы ограничиться чем-то можно сравнить только основную функцию

private func doJob() {
    fetchData { fetchResult in
        switch fetchResult {
        case let .success(data):
            calculateMinMax(forData: data) { calcResult in
                switch calcResult {
                case let .success((min, max)):
                    textLabel.text = "min is \(min) and max is \(max)"
                 case let .failure(error):
                    print(error.localizedDescription)
                }
            }
        case let .failure(error):
            print(error.localizedDescription)
        }
    }
}
private func doJob() {
    fetchData()
        .then { array in self.calculateMinMax(forData: array) }
        .then (on: .main) { (min, max) in
            self.updateUI(with: (min, max))
        }.catch { error in
            print(error.localizedDescription)
        }
}

интересно не думал надо подумать что интересно во втором варианте выглядит короче но в первом к.м.к. читабельнее хотя с другой стороны может просто привыкнуть к синтаксису

ЗЫ: а вот теперь вроде и второй понятный ))

происходит быстрее, чем

скорее всего нет, так как второй вызов это просто проверка счётчика, быстрая операция. а в первом случае происходит ещё и биндинг переменной
но тут вопрос в том что надо потом делать, явно же после второго guard с массивом что-то будет происходить

Только мне жаль, что эта асинхронщина похожа на JS а не идет путем промисов?

не знаю, для меня что промисы, что async/await это примерно одно и то же с разным синтаксисом. собственно асинхронная функция это и есть промис, который в момет await заресолвится либо вернув значение либо выкинув исключение
просто синтаксис с async/await выглядит более разгруженно

Ну только это не binding, а наверное conditional unwrapping :)

как минимум иногда встречается и optional binding как название для действия. например
medium.com/...​ing-in-swift-ed15335372e7

согласен, по-разному называют

а насчет промисов: ну да, это все одно и то же. если копаться до кишок, то и промисы, и асинк/авейты — все равно реализованы на коллбеках, которые старательно упрятали. Но! Промисы — это монады, и они гениально ложатся в общую парадигму функционального программирования. Это было во-первых. А во-вторых: перемудрили они с контекстом. Вот в том примере ^^ с

primaryAction

у кнопки — я могу там запустить тело промиса на исполнение. А здесь у них всплывает трудно понимаемая абстракция контекста, о которой я должен «помнить». Тогда как в промисах неявную, но очевидную роль этого контекста выполняет синтаксис каскадирования промисов

.then {} .then {}

Но я не спорю, это все мое глубокое ИМХО

а что про неё помнить... если кто забыл — компилятор напомнит что надо в async обернуть. ну или точнее — надо ПОДУМАТЬ какой вид таска нужен и исходя из этого его стартануть
наоборот, погроммист будет понимать что работа уходит в фон
в общем — на мой взгляд разница чисто вкусовая. напилить промисы поверх async/await — легко (и наоборот)

да, peace, friendship все дела ) еще раз спасибо за труд

Цікаво, дякую, давайте ще про нові штуки.

Дякую. Друга частина майже готова, тож скоро буде

вторая часть уже вышла: dou.ua/forums/topic/33838
третья, финальная будет где-то во вторник

Подписаться на комментарии