Обзор новой версии языка Swift 5.5

Состоялся официальный релиз iOS 15, а значит разработчикам стала доступна новая версия Xcode под номером 13, а вместе с ним и новая версия языка Swift - 5.5.

Обзор новой версии языка Swift 5.5

В этой версии языка, разработчики из Apple добавили очень много долгожданных изменений. Самые большие из них связаны с механизмом параллельного выполнения задач(concurrency).

Async/Await

Для начала стоит вспомнить, что сейчас для работы с асинхронным кодом в основном используется отложенный вызов closure. И иногда, если работа подразумевает много вызовов асинхронного кода, то это может превратиться в большую, запутанную, нечитаемую массу - Pyramid of doom.

Давайте рассмотрим простой пример. Для начала мы должны получить от сервера список id, затем выполнить какую-то сложную работу с этими ids и загрузить результат обратно на сервер.

func fetchIds(completion: @escaping ([Int]) -> Void) { DispatchQueue.global().async { let results = (1...10_000).map { $0 } completion(results) } } func performSomeWork(for ids: [Int], completion: @escaping (String) -> Void) { DispatchQueue.global().async { // Perform some heavy weight work here let workResult = "Very important result" completion(workResult) } } func upload(result: String, completion: @escaping (Bool) -> Void) { DispatchQueue.global().async { // Upload result to server completion(true) } } // Pyramid of Doom. The begining fetchIds { ids in performSomeWork(for: ids) { workResult in upload(result: workResult) { uploadResult in print("Done") } } }

Как видите вызов 3-х асинхронных методов уже превращается в не очень красивый и потенциально опасный код. С выходом Swift 5.5 и появлением async/await разработчикам станет намного проще писать код такого типа. Давайте переделаем наш код под новую парадигму.

func fetchIds() async -> [Int] { return (1...10_000).map { $0 } } func performSomeWork(for ids: [Int]) async -> String { return "Very important result" } func upload(result: String) async -> String { "Done" } let ids = await fetchIds() let workResult = await performSimeWork(for: ids) let someSyncWork = foo() let response = await upload(result) print(response) // Done

Как можно заметить код стал намного чище и понятнее, вызовы похожи на вызов синхронного кода, за исключением await.

Теперь немного разберемся как это все работает.

  1. Когда мы доходим до первого await fetchIds() Swift приостановит текущий поток и будет ждать пока завершится вызов асинхронного кода, в это время он может нагрузить наш поток какой-либо другой работой.
  2. Как только мы получаем результат из fetchIds() , мы переходим к следующему асинхронному вызову и повторяется шаг 1.
  3. После получения результата из performSomeWork(for:) у нас идет обычный синхронный код foo(), который выполнится в текущем потоке и ничего приостанавливаться не будет.
  4. Затем на вызове await upload() мы опять приостанавливаем наш поток, до тех пор пока не будет получен результат. После получения результата мы продолжим выполнение нашей прграммы.

Как видите, все достаточно просто и прямолинейно, самую сложную работу Swift делает под капотом. Но есть конечно же и ограничения, такой асинхронный код может быть вызван только в нескольких местах:

  • В других асинхронных функциях
  • В функции main() помеченной @main других структур, классов или enum-ов.
  • В новых структурах Task доступных в iOS 15.

Поэтому, можно сделать вывод что вроде как async/await фича языка, но скорее всего пользоваться ими получится в ограниченных местах, если таргет вашего приложения не iOS 15.

Так же async функции прекрасно работают с механизмом try/catch, достаточно пометить функцию как throws.

enum CustomError: Error { case badFunction } func foo() async throws -> Int { if Bool.random() { return Int.random() } else { throw CustomError.badFunction } } func bar() async { do { let result = try await foo() print(result) } catch { print("Error!") } }

Как видите, все достаточно просто. Но стоит упомянуть о некоторых правилах/особенностях:

  • Синхронные функции можно вызвать в асинхронном контексте.
  • Если у вас есть две функции различающиеся только параметром async , то Swift будет вызывать ту, которая больше подходит по контексту. Если контекст асинхронный, то вызовется асинхронная, и наоборот. Удобно.

Вызов нескольких асинхронных функций одновременно

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

func upload(_ file: String) async -> String { await Task.sleep(UInt64.random(in: 0...5) * 1_000_000_000) // nanoseconds return "\(file) Uploaded" } async let firstTask = await upload("first") async let secondTask = await upload("second") async let thirdTask = await upload("third") let result = await [firstTask, secondTask, thirdTask] print(result) // ["second Uploaded", "first Uploaded", "third Uploaded"]

В отличии от предыдущего примера, здесь все три вызова upload() не помечены ключевым словом await , а это значит, что если у системы достаточно свободных ресурсов, все они могут начать выполняться параллельно и не ждать друг друга. Поток приостановится только на строчке с await и будет ждать пока все 3 задачи выполнятся, и только потом продолжит свое выполнение дальше.

Asynchronous Sequences

Стоит упомянуть о новых протоколах, которые были добавлены в новой версии языка - AsyncSequence и AsyncIteratorProtocol. Например, у нас есть какой-то код, который читает текстовый файл, и для того чтобы не ждать пока мы прочтем весь файл, мы можем читать его кусками и выполнять работу над этими кусками.

struct FileReader: AsyncSequence { typealias Element = String struct AsyncIterator: AsyncIteratorProtocol { var current = "" mutating func next() async -> String? { defer { // read next batch } return current } } func makeAsyncIterator() -> AsyncIterator { AsyncIterator() } } func readFile() async { for await fileBatch in FileReader() { print(fileBatch) } }

Единственное отличие от привычного нам for-in loop это ключевое слово await для асинхронных последовательностей нужно использовать for-await-in. Бонус пойнтом к этому всему идет доступность high order functions из коробки.

Асинхронный контекст

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

@main

Это специальный атрибут, которым можно пометить функцию main() ваших классов, структур и enum-ов, внутри этого метода вы сможете вызывать асинхронный код.

@main struct UberHardWorkNeedToBeDone { static func main() async throws { let hardWorkResult = try await performSomeHardWork() print("Result is \(hardWorkResult)") } }

Task и TaskGroup

Это новые типы которые, sad but true, доступны только с @available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *), но тем не менее они очень важны, так как они очень напоминают старые добрые Operation, которые используются повсеместно.

Task представляет собой элементарный кусок работы, которая ваша программа может выполнить асинхронно. Мы уже встречались с ним когда использовали async let который под капотом создает Task и выполняет его. Стоит упомянуть, что код переданный в Task начнет выполнятся как только процесс создания объекта будет завершен. Для получения результата можно обратиться к свойству value , если же вы не ожидаете никакого результата, то можете просто создать объект и забыть про него, работа выполнится сама.

Task { () -> Void in print("Нам не важен результат этой работы, мы просто хотим чтобы она была выполнена") } let task = Task { _ -> String in return someImportantWork() } let value = await task.value

Closure который захватывает Task является non-escaping , потому что он начинает выполняться моментально, поэтому нет необходимости использовать self , если задача была создана внутри класса или структуры.

При создании Task можно задать приоритет high, medium, low, background, userInitiated, utility.

По аналогии с операциями, задачи можно прерывать вызовом метода cancel в таком случае, задача прекратиться и вернет ошибку CancellationError, или nil, или часть выполненной работы, в зависимости от того, как вы обработаете вызов.

TaskGrup же нужна для объединения нескольких задач в группу и получения результата, когда все задачи будут выполнены. При добавлении задачи в группу, возвращаемый тип у всех задач должен совпадать. Чтобы обойти это ограничение можно использовать свой enum или воспользоваться конструкцией async let.

func upload(_ file: String) async -> String { await Task.sleep(UInt64.random(in: 0...5) * 1_000_000_000) // nanoseconds return "\(file) Uploaded" } let result = await withTaskGroup(of: String.self) { group -> String in group.addTask { await upload("first" ) } group.addTask { await upload("second" ) } group.addTask { await upload("third" ) } var result: [String] = [] for await value in group result.append(value) } return result.joined(separator: " ") } print(result) // "second Uploaded first Uploaded third Uploaded"

Так же можно создавать группы, которые можно закэнселить или группы, которые могут выдать ошибку.

Actors

Еще очень одно большое добавление в этом релизе. Давайте немного окунемся в теорию, для чего вообще нужны акторы и какую проблему они решают?

Думаю, многие из вас знают про такой термин, как гонка состояний(Race Conditions). Предположим, у нас есть вот такой вот NotificationCenter, в который можно добавлять/удалять каких-либо Observer. Проблемы могут возникнуть когда мы попытаемся добавить/удалить Observer'a из разных потоков.

class UnsafeNotificationsCenter { var observers: Array<Observer> = [] func add(observer: Observer) { guard !observers.contains(observer) else { return } observers.append(observer) } func remove(observer: Observer) { guard let observerIndex = observers.firstIndex(of: observer) else { return } observers.remove(at: observerIndex) } }

Раньше мы решали эту проблему с помощью различных локов или очередей, которые синхронизируют доступ к массиву observers. Теперь у нас появились акторы. Они создаются с помощью специального слова actor.

actor SafeNotificationsCenter { var observers: Array<Observer> = [] func add(observer: Observer) { guard !observers.contains(observer) else { return } observers.append(observer) } func remove(observer: Observer) { guard let observerIndex = observers.firstIndex(of: observer) else { return } observers.remove(at: observerIndex) } }

Теперь мы точно знаем, что доступ к переменной observers будет защищен, а каким образом это будет сделано, с помощью локов или очередей, нам не интересно, это детали реализации. Работать с таким типом можно только из асинхронного контекста, с помощью ключевых слов await или async let

let task = Task { let center = SafeNotificationsCenter() await center.add(observer: ...) await center.remove(observer: ...) }

Так же появились Global Actors, как можно догадаться это тоже акторы, которые должны решить проблему с RaceConditions. Мы можем создавать своих и использовать их так же как и Property Wrappers. Сейчас в SDK есть несколько различных акторов, но самый полезный из них это MainActor, как несложно догадаться из названия, он нужен для того, чтобы быть уверенным, что код выполнится в главном потоке, очень удобно для работы с UI. Но не стоит забывать, что обращение к функциям/переменным осуществляется через await или async let.

Другие изменения

#if для postfix операторов

Это изменение призвано решить распространенный паттерн использующийся в SwiftUI, когда в зависимости от различных условий окружения(операционной системы, флагов компиляции и тд), нам нужны различные модификаторы для наших View.

Text("Hello world!") #if os(iOS) .font(.body) #if DEBUG .background(Color.red) #endif #else .font(.largeTitle) #endif

Думаю применений этой фиче можно найти немало, не только в SwiftUI.

Codable для enum с associated type

С каждым релизом, компилятор становится все умнее и умнее и генерирует за нас все больше и больше кода, и теперь еще один кейс с генерацией парсеров решен.

enum Component: Codable { case label(text: String) case loader(progress: Double) case stepper(count: Int) } let screen: [Component] = [ .label(text: "Header"), .loader(progress: 0.5), .stepper(count: 20) ] do { let encodedScreen = try JSONEncoder().encode(screen) let jsonString = String(decoding: encodedScreen, as: UTF8.self) print(jsonString) // [{"label":{"text":"Header"}},{"loader":{"progress":0.5}},{"stepper":{"count":20}}] } catch { print(error.localizedDescription) }

PropertyWrappers в функциях

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

Заключение

Изменений на самом деле намного больше, я выбрал только те, которые по моему мнению являются важными, со всем изменениям вы можете ознакомиться в официальном блоге.

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

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

88
Начать дискуссию