Что нового в Swift 5.5: async/await. Часть 1
Для влюблённых в своё дело 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 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів