Что нового в Swift 5.5: async/await. Часть 1
Усі статті, обговорення, новини про Mobile — в одному місці. Підписуйтеся на телеграм-канал!
Для влюблённых в своё дело 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, но разговор о нём и многих других интересных вещах тоже пойдёт в следующей части.
16 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів